From ad022e84ca48850bb25d2f1c61043764c8200ac7 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:01:09 -0400 Subject: [PATCH 1/9] Refactor sandbox permissions and loop execution - Add LOOP_PERMISSION_RULESET for consistent permission handling - Improve sandbox timeout handling with hard deadlines - Add git common dir detection for worktree mounts - Remove opencode.jsonc workaround - Clean up strip-promise-tags utility --- package.json | 2 +- src/agents/architect.ts | 2 +- src/hooks/loop.ts | 14 ++------ src/hooks/sandbox-tools.ts | 26 ++++++++++----- src/index.ts | 11 +++--- src/sandbox/docker.ts | 59 ++++++++++++++++++++++----------- src/sandbox/manager.ts | 33 ++++++++++++++++-- src/services/loop.ts | 14 ++++---- src/tools/loop.ts | 25 ++++---------- src/tools/plan-execute.ts | 6 +--- src/utils/strip-promise-tags.ts | 6 ---- 11 files changed, 114 insertions(+), 84 deletions(-) delete mode 100644 src/utils/strip-promise-tags.ts diff --git a/package.json b/package.json index 17db975..fd0e38b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-manager/memory", - "version": "0.0.28", + "version": "0.0.29", "type": "module", "oc-plugin": [ "server", diff --git a/src/agents/architect.ts b/src/agents/architect.ts index 3030531..590e3bc 100644 --- a/src/agents/architect.ts +++ b/src/agents/architect.ts @@ -163,6 +163,6 @@ All execution modes require a **title** — a short descriptive label for the se | Loop (worktree) | memory-loop | true | Full self-contained plan | | Loop | memory-loop | false | Full self-contained plan | -"Full self-contained" means the plan must include every file path, implementation detail, code pattern, phase dependency, verification step, and gotcha. The receiving agent starts with zero context. Do NOT summarize, abbreviate, or include tags. +"Full self-contained" means the plan must include every file path, implementation detail, code pattern, phase dependency, verification step, and gotcha. The receiving agent starts with zero context. Do NOT summarize or abbreviate. `, } diff --git a/src/hooks/loop.ts b/src/hooks/loop.ts index 359e069..adf7f7f 100644 --- a/src/hooks/loop.ts +++ b/src/hooks/loop.ts @@ -1,11 +1,11 @@ import type { PluginInput } from '@opencode-ai/plugin' import type { OpencodeClient } from '@opencode-ai/sdk/v2' import type { LoopService, LoopState } from '../services/loop' -import { MAX_RETRIES, MAX_CONSECUTIVE_STALLS } from '../services/loop' +import { MAX_RETRIES, MAX_CONSECUTIVE_STALLS, LOOP_PERMISSION_RULESET } from '../services/loop' import type { Logger, PluginConfig, LoopConfig } from '../types' import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' import { execSync, spawnSync } from 'child_process' -import { resolve, join } from 'path' +import { resolve } from 'path' import type { createSandboxManager } from '../sandbox/manager' export interface LoopEventHandler { @@ -54,15 +54,6 @@ export function createLoopEventHandler( let cleaned = false try { - // Remove the opencode.jsonc file we wrote for permissions - don't commit it - try { - const { unlinkSync } = await import('fs') - unlinkSync(join(state.worktreeDir, 'opencode.jsonc')) - logger.log(`Loop: removed opencode.jsonc before commit`) - } catch { - // File may not exist, ignore - } - const addResult = spawnSync('git', ['add', '-A'], { cwd: state.worktreeDir, encoding: 'utf-8' }) if (addResult.status !== 0) { throw new Error(addResult.stderr || 'git add failed') @@ -347,6 +338,7 @@ export function createLoopEventHandler( const createParams = { title: state.worktreeName, directory: state.worktreeDir, + permission: LOOP_PERMISSION_RULESET, } const createResult = await v2Client.session.create(createParams) diff --git a/src/hooks/sandbox-tools.ts b/src/hooks/sandbox-tools.ts index 436444b..d1a9af4 100644 --- a/src/hooks/sandbox-tools.ts +++ b/src/hooks/sandbox-tools.ts @@ -31,6 +31,8 @@ function getSandboxContext(deps: SandboxToolHookDeps, sessionId: string) { } } +const BASH_DEFAULT_TIMEOUT_MS = 120_000 + export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['tool.execute.before'] { return async ( input: { tool: string; sessionID: string; callID: string }, @@ -43,29 +45,38 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t const { docker, containerName, hostDir } = sandbox const args = output.args - const cwd = args.workdir ? toContainerPath(args.workdir, hostDir) : undefined + + output.args = { ...args, command: 'true' } const cmd = (args.command ?? '').trimStart() - if (cmd === 'git' || cmd.startsWith('git ')) { - pendingResults.set(input.callID, 'Git is not available in sandbox mode. The worktree is managed by the loop system on the host.') - output.args = { ...args, command: 'true' } + if (cmd === 'git push' || cmd.startsWith('git push ')) { + pendingResults.set(input.callID, 'Git push is not available in sandbox mode. Pushes must be run on the host.') return } deps.logger.log(`[sandbox-hook] intercepting bash: ${args.command?.slice(0, 100)}`) + const hookTimeout = (args.timeout ?? BASH_DEFAULT_TIMEOUT_MS) + 10_000 + const cwd = args.workdir ? toContainerPath(args.workdir, hostDir) : undefined + try { - const result = await docker.exec(containerName, args.command, { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error(`sandbox hook timeout after ${hookTimeout}ms`)), hookTimeout), + ) + + const execPromise = docker.exec(containerName, args.command, { timeout: args.timeout, cwd, }) + const result = await Promise.race([execPromise, timeoutPromise]) + let dockerOutput = rewriteOutput(result.stdout, hostDir) if (result.stderr && result.exitCode !== 0) { dockerOutput += rewriteOutput(result.stderr, hostDir) } if (result.exitCode === 124) { - const timeoutMs = args.timeout ?? 120000 + const timeoutMs = args.timeout ?? BASH_DEFAULT_TIMEOUT_MS dockerOutput += `\n\n\nbash tool terminated command after exceeding timeout ${timeoutMs} ms\n` } else if (result.exitCode !== 0) { dockerOutput += `\n\n[Exit code: ${result.exitCode}]` @@ -74,10 +85,9 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t pendingResults.set(input.callID, dockerOutput.trim()) } catch (err) { const message = err instanceof Error ? err.message : String(err) + deps.logger.log(`[sandbox-hook] exec failed for callID ${input.callID}: ${message}`) pendingResults.set(input.callID, `Command failed: ${message}`) } - - output.args = { ...args, command: 'true' } } } diff --git a/src/index.ts b/src/index.ts index a3f7e6b..d870207 100644 --- a/src/index.ts +++ b/src/index.ts @@ -303,18 +303,19 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { await toolExecuteAfterHook!(input, output) }, 'permission.ask': async (input, output) => { - const req = input as unknown as { sessionID: string; permission: string; patterns: string[] } - const worktreeName = loopService.resolveWorktreeName(req.sessionID) + const worktreeName = loopService.resolveWorktreeName(input.sessionID) const state = worktreeName ? loopService.getActiveState(worktreeName) : null if (!state?.active) return - if (req.patterns.some((p) => p.startsWith('git push'))) { - logger.log(`Loop: denied git push for session ${req.sessionID}`) + const patterns = Array.isArray(input.pattern) ? input.pattern : (input.pattern ? [input.pattern] : []) + + if (patterns.some((p) => p.startsWith('git push'))) { + logger.log(`Loop: denied git push for session ${input.sessionID}`) output.status = 'deny' return } - logger.log(`Loop: auto-allowing ${req.permission} [${req.patterns.join(', ')}] for session ${req.sessionID}`) + logger.log(`Loop: auto-allowing ${input.type} [${patterns.join(', ')}] for session ${input.sessionID}`) output.status = 'allow' }, 'experimental.session.compacting': async (input, output) => { diff --git a/src/sandbox/docker.ts b/src/sandbox/docker.ts index f6ccc78..c673e7e 100644 --- a/src/sandbox/docker.ts +++ b/src/sandbox/docker.ts @@ -17,7 +17,7 @@ export interface DockerService { checkDocker(): Promise imageExists(image: string): Promise buildImage(dockerfilePath: string, tag: string): Promise - createContainer(name: string, projectDir: string, image: string): Promise + createContainer(name: string, projectDir: string, image: string, extraMounts?: string[]): Promise removeContainer(name: string): Promise exec(name: string, command: string, opts?: DockerExecOpts): Promise execPipe(name: string, command: string, stdin: string, opts?: { timeout?: number; abort?: AbortSignal }): Promise @@ -74,7 +74,7 @@ export function createDockerService(logger: Logger): DockerService { }) } - async function createContainer(name: string, projectDir: string, image: string): Promise { + async function createContainer(name: string, projectDir: string, image: string, extraMounts?: string[]): Promise { const args = [ 'run', '-d', @@ -82,13 +82,16 @@ export function createDockerService(logger: Logger): DockerService { name, '-v', `${projectDir}:/workspace`, - '-w', - '/workspace', - image, - 'sleep', - 'infinity', ] + if (extraMounts) { + for (const mount of extraMounts) { + args.push('-v', mount) + } + } + + args.push('-w', '/workspace', image, 'sleep', 'infinity') + const result = await execPromise('docker', args, { timeout: 30000 }) if (result.exitCode !== 0) { throw new Error(`Failed to create container: ${result.stderr}`) @@ -214,8 +217,10 @@ export function createDockerService(logger: Logger): DockerService { args: string[], options?: { timeout?: number; streaming?: boolean; abort?: AbortSignal }, ): Promise { - return new Promise((resolve, reject) => { - const timeout = options?.timeout ?? DEFAULT_TIMEOUT + const timeout = options?.timeout ?? DEFAULT_TIMEOUT + const cmdPreview = args.slice(-1)[0]?.slice(0, 80) ?? '' + + const inner = new Promise((resolve) => { const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], }) @@ -223,14 +228,21 @@ export function createDockerService(logger: Logger): DockerService { let stdout = '' let stderr = '' let timedOut = false - const cmdPreview = args.slice(-1)[0]?.slice(0, 80) ?? '' + let settled = false + + function settle(result: DockerExecResult): void { + if (settled) return + settled = true + clearTimeout(timeoutId) + resolve(result) + } const timeoutId = setTimeout(() => { timedOut = true logger.log(`[docker] timeout (${timeout}ms) for: ${cmdPreview}`) child.kill('SIGTERM') setTimeout(() => { - if (child.exitCode === null) { + if (!settled) { logger.log(`[docker] SIGKILL after SIGTERM for: ${cmdPreview}`) child.kill('SIGKILL') } @@ -239,13 +251,10 @@ export function createDockerService(logger: Logger): DockerService { if (options?.abort) { const onAbort = () => { - clearTimeout(timeoutId) logger.log(`[docker] abort signal for: ${cmdPreview}`) child.kill('SIGTERM') setTimeout(() => { - if (child.exitCode === null) { - child.kill('SIGKILL') - } + if (!settled) child.kill('SIGKILL') }, 3000) } if (options.abort.aborted) { @@ -264,11 +273,10 @@ export function createDockerService(logger: Logger): DockerService { }) child.on('close', (code) => { - clearTimeout(timeoutId) if (timedOut) { logger.log(`[docker] close after timeout, code=${code} for: ${cmdPreview}`) } - resolve({ + settle({ stdout, stderr, exitCode: timedOut ? 124 : (code ?? 1), @@ -276,11 +284,24 @@ export function createDockerService(logger: Logger): DockerService { }) child.on('error', (err) => { - clearTimeout(timeoutId) logger.log(`[docker] spawn error: ${err.message} for: ${cmdPreview}`) - reject(err) + settle({ + stdout, + stderr: stderr + err.message, + exitCode: 1, + }) }) }) + + const hardDeadline = timeout + 10_000 + const deadlinePromise = new Promise((resolve) => { + setTimeout(() => { + logger.log(`[docker] hard deadline (${hardDeadline}ms) hit for: ${cmdPreview}`) + resolve({ stdout: '', stderr: `Command exceeded hard deadline of ${hardDeadline}ms`, exitCode: 124 }) + }, hardDeadline) + }) + + return Promise.race([inner, deadlinePromise]) } return { diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index 61fb4d8..c2b51e6 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -1,6 +1,7 @@ import type { DockerService } from './docker' import type { Logger } from '../types' import { resolve } from 'path' +import { spawnSync } from 'child_process' export interface SandboxManagerConfig { image: string @@ -29,6 +30,26 @@ export function createSandboxManager( config: SandboxManagerConfig, logger: Logger, ): SandboxManager { + function detectGitMount(projectDir: string): string[] { + try { + const result = spawnSync('git', ['rev-parse', '--git-common-dir'], { + cwd: projectDir, + encoding: 'utf-8', + }) + if (result.status !== 0 || !result.stdout) return [] + + const gitCommonDir = resolve(projectDir, result.stdout.trim()) + + // If the git dir is already inside the project dir being mounted, no extra mount needed + if (gitCommonDir.startsWith(projectDir + '/')) return [] + + return [`${gitCommonDir}:${gitCommonDir}:ro`] + } catch { + logger.log(`[sandbox] could not detect git common dir for ${projectDir}, skipping extra mount`) + return [] + } + } + async function start(worktreeName: string, projectDir: string): Promise<{ containerName: string }> { const dockerAvailable = await docker.checkDocker() if (!dockerAvailable) { @@ -52,8 +73,12 @@ export function createSandboxManager( } const absoluteProjectDir = resolve(projectDir) + const extraMounts = detectGitMount(absoluteProjectDir) + if (extraMounts.length > 0) { + logger.log(`Sandbox: mounting git common dir: ${extraMounts[0]}`) + } logger.log(`Creating sandbox container ${containerName} for ${absoluteProjectDir}`) - await docker.createContainer(containerName, absoluteProjectDir, config.image) + await docker.createContainer(containerName, absoluteProjectDir, config.image, extraMounts) const active: ActiveSandbox = { containerName, @@ -153,8 +178,12 @@ export function createSandboxManager( } const absoluteProjectDir = resolve(projectDir) + const extraMounts = detectGitMount(absoluteProjectDir) + if (extraMounts.length > 0) { + logger.log(`Sandbox: mounting git common dir: ${extraMounts[0]}`) + } logger.log(`Creating sandbox container ${containerName} for ${absoluteProjectDir}`) - await docker.createContainer(containerName, absoluteProjectDir, config.image) + await docker.createContainer(containerName, absoluteProjectDir, config.image, extraMounts) const active: ActiveSandbox = { containerName, diff --git a/src/services/loop.ts b/src/services/loop.ts index dae3089..2c435a0 100644 --- a/src/services/loop.ts +++ b/src/services/loop.ts @@ -37,6 +37,12 @@ export const MAX_CONSECUTIVE_STALLS = 5 export const DEFAULT_MIN_AUDITS = 1 export const RECENT_MESSAGES_COUNT = 5 +export const LOOP_PERMISSION_RULESET = [ + { permission: '*', pattern: '*', action: 'allow' as const }, + { permission: 'external_directory', pattern: '*', action: 'deny' as const }, + { permission: 'bash', pattern: 'git push *', action: 'deny' as const }, +] + export interface LoopState { active: boolean sessionId: string @@ -129,13 +135,7 @@ export function createLoopService( } function redactCompletionSignal(text: string, promise: string): string { - let result = text - const inner = promise.replace(/<\/?promise>/g, '').trim() - if (inner) { - result = result.replaceAll(inner, '[SIGNAL_REDACTED]') - } - result = result.replaceAll(promise, '[SIGNAL_REDACTED]') - return result + return text.replaceAll(promise, '[SIGNAL_REDACTED]') } function buildContinuationPrompt(state: LoopState, auditFindings?: string): string { diff --git a/src/tools/loop.ts b/src/tools/loop.ts index d86d74d..5ef2ba7 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -1,7 +1,6 @@ import { tool } from '@opencode-ai/plugin' import { execSync, spawnSync } from 'child_process' -import { existsSync, writeFileSync } from 'fs' -import { join } from 'path' +import { existsSync } from 'fs' import { resolve } from 'path' import type { ToolContext } from './types' import { withDimensionWarning } from './types' @@ -9,10 +8,10 @@ import { parseModelString, retryWithModelFallback } from '../utils/model-fallbac import { slugify } from '../utils/logger' import { findPartialMatch } from '../utils/partial-match' import { formatSessionOutput, formatAuditResult } from '../utils/loop-format' -import { fetchSessionOutput, MAX_RETRIES, type LoopState, type LoopSessionOutput } from '../services/loop' +import { fetchSessionOutput, MAX_RETRIES, LOOP_PERMISSION_RULESET, type LoopState, type LoopSessionOutput } from '../services/loop' const z = tool.schema -const DEFAULT_PLAN_COMPLETION_PROMISE = 'ALL_PHASES_COMPLETE' +const DEFAULT_PLAN_COMPLETION_PROMISE = 'ALL_PHASES_COMPLETE' interface LoopSetupOptions { prompt: string @@ -56,6 +55,7 @@ async function setupLoop( const createResult = await v2.session.create({ title: options.sessionTitle, directory: projectDir, + permission: LOOP_PERMISSION_RULESET, }) if (createResult.error || !createResult.data) { @@ -85,6 +85,7 @@ async function setupLoop( const createResult = await v2.session.create({ title: options.sessionTitle, directory: worktreeInfo.directory, + permission: LOOP_PERMISSION_RULESET, }) if (createResult.error || !createResult.data) { @@ -105,21 +106,6 @@ async function setupLoop( } } - if (loopContext.worktree) { - try { - const loopConfig = JSON.stringify({ - permission: { - bash: { '*': 'allow', 'git push *': 'deny' }, - external_directory: { '*': 'deny' }, - }, - }, null, 2) - writeFileSync(join(loopContext.directory, 'opencode.jsonc'), loopConfig) - logger.log(`loop: wrote loop opencode.jsonc to ${loopContext.directory}`) - } catch (err) { - logger.error(`loop: failed to write opencode.jsonc`, err) - } - } - let sandboxContainerName: string | undefined const sandboxEnabled = config.sandbox?.mode === 'docker' && !!sandboxManager && !!options.worktree @@ -390,6 +376,7 @@ export function createLoopTools(ctx: ToolContext): Record tags from plan text`) - } + const planText = args.plan const createResult = await v2.session.create({ title: sessionTitle, diff --git a/src/utils/strip-promise-tags.ts b/src/utils/strip-promise-tags.ts deleted file mode 100644 index 2cc41b5..0000000 --- a/src/utils/strip-promise-tags.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function stripPromiseTags(text: string): { cleaned: string; stripped: boolean } { - let cleaned = text.replace(/\n*---\n\n\*\*IMPORTANT - Completion Signal:\*\*[\s\S]*?[\s\S]*?<\/promise>[\s\S]*?(?:until this signal is detected\.|$)/g, '') - cleaned = cleaned.replace(/[\s\S]*?<\/promise>/g, '') - cleaned = cleaned.trimEnd() - return { cleaned, stripped: cleaned !== text.trimEnd() } -} From 85833c607bb621010fbbc473ecec5840075088a8 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:57:12 -0400 Subject: [PATCH 2/9] loop: loop-consolidate-and-simplify-sandbox-loop-and-tui-code completed after 3 iterations --- src/cli/commands/restart.ts | 6 +- src/cli/commands/status.ts | 4 +- src/hooks/loop.ts | 29 ++++----- src/hooks/sandbox-tools.ts | 44 +++++-------- src/index.ts | 14 +---- src/sandbox/context.ts | 29 +++++++++ src/sandbox/docker.ts | 88 +++++++------------------- src/sandbox/manager.ts | 50 +++------------ src/services/loop.ts | 32 +++++++--- src/tools/index.ts | 3 +- src/tools/loop.ts | 59 ++++++------------ src/tools/sandbox-fs.ts | 20 +----- src/tui.tsx | 14 ++++- src/utils/loop-helpers.ts | 31 ++++++++++ test/loop-helpers.test.ts | 66 ++++++++++++++++++++ test/loop.test.ts | 106 ++++++++++++++++---------------- test/sandbox-context.test.ts | 79 ++++++++++++++++++++++++ test/strip-promise-tags.test.ts | 56 ----------------- 18 files changed, 382 insertions(+), 348 deletions(-) create mode 100644 src/sandbox/context.ts create mode 100644 src/utils/loop-helpers.ts create mode 100644 test/loop-helpers.test.ts create mode 100644 test/sandbox-context.test.ts delete mode 100644 test/strip-promise-tags.test.ts diff --git a/src/cli/commands/restart.ts b/src/cli/commands/restart.ts index 75fe9e0..9b3b5f5 100644 --- a/src/cli/commands/restart.ts +++ b/src/cli/commands/restart.ts @@ -1,4 +1,5 @@ import type { LoopState } from '../../services/loop' +import { buildCompletionSignalInstructions, LOOP_PERMISSION_RULESET } from '../../services/loop' import { openDatabase, confirm } from '../utils' import { findPartialMatch } from '../../utils/partial-match' import { createOpencodeClient } from '@opencode-ai/sdk/v2' @@ -194,6 +195,7 @@ export async function run(argv: RestartArgs): Promise { const createResult = await client.session.create({ title: state.worktreeName, directory, + permission: LOOP_PERMISSION_RULESET, }) if (createResult.error || !createResult.data) { @@ -231,8 +233,8 @@ export async function run(argv: RestartArgs): Promise { ) let promptText = state.prompt ?? '' - if (state.completionPromise) { - promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following phrase exactly: ${state.completionPromise}\n\nDo NOT output this phrase until every phase is truly complete. The loop will continue until this signal is detected.` + if (state.completionSignal) { + promptText += buildCompletionSignalInstructions(state.completionSignal) } try { diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 49f6707..4e2c85b 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -223,8 +223,8 @@ export async function run(argv: StatusArgs): Promise { console.log(` Started: ${new Date(startedAt).toISOString()}`) const sessionStatus = await tryFetchSessionStatus(argv.server ?? 'http://localhost:5551', state.sessionId, state.worktreeDir!) console.log(` Status: ${sessionStatus}`) - if (state.completionPromise) { - console.log(` Completion: ${state.completionPromise}`) + if (state.completionSignal) { + console.log(` Completion: ${state.completionSignal}`) } if (state.lastAuditResult) { for (const line of formatAuditResult(state.lastAuditResult)) { diff --git a/src/hooks/loop.ts b/src/hooks/loop.ts index adf7f7f..f487a56 100644 --- a/src/hooks/loop.ts +++ b/src/hooks/loop.ts @@ -4,6 +4,7 @@ import type { LoopService, LoopState } from '../services/loop' import { MAX_RETRIES, MAX_CONSECUTIVE_STALLS, LOOP_PERMISSION_RULESET } from '../services/loop' import type { Logger, PluginConfig, LoopConfig } from '../types' import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' +import { resolveLoopModel } from '../utils/loop-helpers' import { execSync, spawnSync } from 'child_process' import { resolve } from 'path' import type { createSandboxManager } from '../sandbox/manager' @@ -390,7 +391,7 @@ export function createLoopEventHandler( } let assistantErrorDetected = false - if (currentState.completionPromise) { + if (currentState.completionSignal) { const { text: textContent, error: assistantError } = await getLastAssistantInfo(currentState.sessionId, currentState.worktreeDir) if (assistantError) { assistantErrorDetected = true @@ -407,14 +408,14 @@ export function createLoopEventHandler( currentState = loopService.getActiveState(worktreeName)! } } - if (textContent && currentState.completionPromise && loopService.checkCompletionPromise(textContent, currentState.completionPromise)) { + if (textContent && currentState.completionSignal && loopService.checkCompletionSignal(textContent, currentState.completionSignal)) { const currentAuditCount = currentState.auditCount ?? 0 if (!currentState.audit || currentAuditCount >= minAudits) { if (loopService.hasOutstandingFindings(currentState.worktreeBranch)) { logger.log(`Loop: completion promise detected but outstanding review findings remain, continuing`) } else { await terminateLoop(worktreeName, currentState, 'completed') - logger.log(`Loop completed: detected ${currentState.completionPromise} at iteration ${currentState.iteration} (${currentAuditCount}/${minAudits} audits)`) + logger.log(`Loop completed: detected ${currentState.completionSignal} at iteration ${currentState.iteration} (${currentAuditCount}/${minAudits} audits)`) return } } else { @@ -490,12 +491,8 @@ export function createLoopEventHandler( logger.log(`Loop iteration ${nextIteration} for session ${activeSessionId}`) const currentConfig = getConfig() - const freshStateForModel = loopService.getActiveState(worktreeName) - const loopModel = freshStateForModel?.modelFailed - ? undefined - : (parseModelString(currentConfig.loop?.model) ?? parseModelString(currentConfig.executionModel)) - - if (freshStateForModel?.modelFailed) { + const loopModel = resolveLoopModel(currentConfig, loopService, worktreeName) + if (!loopModel) { logger.log(`Loop: configured model previously failed, using default model`) } @@ -600,14 +597,14 @@ export function createLoopEventHandler( // Always pass the full audit response to the code agent const auditFindings = auditText ?? undefined - if (currentState.completionPromise && auditText) { - if (loopService.checkCompletionPromise(auditText, currentState.completionPromise)) { + if (currentState.completionSignal && auditText) { + if (loopService.checkCompletionSignal(auditText, currentState.completionSignal)) { if (!currentState.audit || newAuditCount >= minAudits) { if (loopService.hasOutstandingFindings(currentState.worktreeBranch)) { logger.log(`Loop: completion promise detected but outstanding review findings remain, continuing`) } else { await terminateLoop(worktreeName, currentState, 'completed') - logger.log(`Loop completed: detected ${currentState.completionPromise} in audit at iteration ${currentState.iteration} (${newAuditCount}/${minAudits} audits)`) + logger.log(`Loop completed: detected ${currentState.completionSignal} in audit at iteration ${currentState.iteration} (${newAuditCount}/${minAudits} audits)`) return } } else { @@ -646,12 +643,8 @@ export function createLoopEventHandler( logger.log(`Loop iteration ${nextIteration} for session ${activeSessionId}`) const currentConfig = getConfig() - const freshStateForModel = loopService.getActiveState(worktreeName) - const loopModel = freshStateForModel?.modelFailed - ? undefined - : (parseModelString(currentConfig.loop?.model) ?? parseModelString(currentConfig.executionModel)) - - if (freshStateForModel?.modelFailed) { + const loopModel = resolveLoopModel(currentConfig, loopService, worktreeName) + if (!loopModel) { logger.log(`Loop: configured model previously failed, using default model`) } diff --git a/src/hooks/sandbox-tools.ts b/src/hooks/sandbox-tools.ts index d1a9af4..121fde7 100644 --- a/src/hooks/sandbox-tools.ts +++ b/src/hooks/sandbox-tools.ts @@ -3,6 +3,7 @@ import type { Logger } from '../types' import type { createLoopService } from '../services/loop' import type { createSandboxManager } from '../sandbox/manager' import { toContainerPath, rewriteOutput } from '../sandbox/path' +import { getSandboxForSession } from '../sandbox/context' interface SandboxToolHookDeps { loopService: ReturnType @@ -10,28 +11,10 @@ interface SandboxToolHookDeps { logger: Logger } -const pendingResults = new Map() - -function getSandboxContext(deps: SandboxToolHookDeps, sessionId: string) { - if (!deps.sandboxManager) return null - - const worktreeName = deps.loopService.resolveWorktreeName(sessionId) - if (!worktreeName) return null - - const state = deps.loopService.getActiveState(worktreeName) - if (!state?.active || !state.sandbox) return null - - const active = deps.sandboxManager.getActive(worktreeName) - if (!active) return null - - return { - docker: deps.sandboxManager.docker, - containerName: active.containerName, - hostDir: active.projectDir, - } -} +const pendingResults = new Map() const BASH_DEFAULT_TIMEOUT_MS = 120_000 +const STALE_THRESHOLD_MS = 5 * 60 * 1000 export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['tool.execute.before'] { return async ( @@ -40,7 +23,7 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t ) => { if (input.tool !== 'bash') return - const sandbox = getSandboxContext(deps, input.sessionID) + const sandbox = getSandboxForSession(deps, input.sessionID) if (!sandbox) return const { docker, containerName, hostDir } = sandbox @@ -50,7 +33,7 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t const cmd = (args.command ?? '').trimStart() if (cmd === 'git push' || cmd.startsWith('git push ')) { - pendingResults.set(input.callID, 'Git push is not available in sandbox mode. Pushes must be run on the host.') + pendingResults.set(input.callID, { result: 'Git push is not available in sandbox mode. Pushes must be run on the host.', storedAt: Date.now() }) return } @@ -82,11 +65,11 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t dockerOutput += `\n\n[Exit code: ${result.exitCode}]` } - pendingResults.set(input.callID, dockerOutput.trim()) + pendingResults.set(input.callID, { result: dockerOutput.trim(), storedAt: Date.now() }) } catch (err) { const message = err instanceof Error ? err.message : String(err) deps.logger.log(`[sandbox-hook] exec failed for callID ${input.callID}: ${message}`) - pendingResults.set(input.callID, `Command failed: ${message}`) + pendingResults.set(input.callID, { result: `Command failed: ${message}`, storedAt: Date.now() }) } } } @@ -98,11 +81,18 @@ export function createSandboxToolAfterHook(deps: SandboxToolHookDeps): Hooks['to ) => { if (input.tool !== 'bash') return - const dockerResult = pendingResults.get(input.callID) - if (dockerResult === undefined) return + const now = Date.now() + for (const [key, entry] of pendingResults) { + if (now - entry.storedAt > STALE_THRESHOLD_MS) { + pendingResults.delete(key) + } + } + + const entry = pendingResults.get(input.callID) + if (entry === undefined) return pendingResults.delete(input.callID) deps.logger.log(`[sandbox-hook] replacing bash output for callID ${input.callID}`) - output.output = dockerResult + output.output = entry.result } } diff --git a/src/index.ts b/src/index.ts index d870207..7388785 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { createTools, createToolExecuteBeforeHook, createToolExecuteAfterHook, a import { createSandboxToolBeforeHook, createSandboxToolAfterHook } from './hooks/sandbox-tools' import type { DimensionMismatchState, InitState, ToolContext } from './tools' import type { VecService } from './storage/vec-types' +import { isSandboxEnabled } from './sandbox/context' export function createMemoryPlugin(config: PluginConfig): Plugin { @@ -87,11 +88,11 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { } let sandboxManager: ReturnType | null = null - if (config.sandbox?.mode === 'docker') { + if (isSandboxEnabled(config, null)) { const dockerService = createDockerService(logger) try { sandboxManager = createSandboxManager(dockerService, { - image: config.sandbox.image || 'ocm-sandbox:latest', + image: config.sandbox?.image || 'ocm-sandbox:latest', }, logger) logger.log('Docker sandbox manager initialized') } catch (err) { @@ -201,15 +202,6 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { } } } - const sandboxAny = sandboxManager as any - if (sandboxAny.isGlobalActive?.()) { - try { - await sandboxAny.stopGlobal?.() - logger.log('Cleanup: stopped global sandbox container') - } catch (err) { - logger.error('Cleanup: failed to stop global sandbox container', err) - } - } } loopHandler.terminateAll() diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts new file mode 100644 index 0000000..9a37f70 --- /dev/null +++ b/src/sandbox/context.ts @@ -0,0 +1,29 @@ +import type { DockerService } from './docker' +import type { LoopService } from '../services/loop' +import type { PluginConfig } from '../types' + +export interface SandboxContext { + docker: DockerService + containerName: string + hostDir: string +} + +interface SandboxDeps { + sandboxManager: { docker: DockerService; getActive(name: string): { containerName: string; projectDir: string } | null } | null + loopService: { resolveWorktreeName(sessionId: string): string | null; getActiveState(name: string): { active: boolean; sandbox?: boolean } | null } +} + +export function getSandboxForSession(deps: SandboxDeps, sessionId: string): SandboxContext | null { + if (!deps.sandboxManager) return null + const worktreeName = deps.loopService.resolveWorktreeName(sessionId) + if (!worktreeName) return null + const state = deps.loopService.getActiveState(worktreeName) + if (!state?.active || !state.sandbox) return null + const active = deps.sandboxManager.getActive(worktreeName) + if (!active) return null + return { docker: deps.sandboxManager.docker, containerName: active.containerName, hostDir: active.projectDir } +} + +export function isSandboxEnabled(config: PluginConfig, sandboxManager: unknown): boolean { + return config.sandbox?.mode === 'docker' && !!sandboxManager +} diff --git a/src/sandbox/docker.ts b/src/sandbox/docker.ts index c673e7e..37bcfc3 100644 --- a/src/sandbox/docker.ts +++ b/src/sandbox/docker.ts @@ -5,6 +5,7 @@ export interface DockerExecOpts { timeout?: number cwd?: string abort?: AbortSignal + stdin?: string } export interface DockerExecResult { @@ -132,62 +133,10 @@ export function createDockerService(logger: Logger): DockerService { stdin: string, opts?: { timeout?: number; abort?: AbortSignal }, ): Promise { - return new Promise((resolve, reject) => { - const timeout = opts?.timeout ?? DEFAULT_TIMEOUT - const child = spawn('docker', ['exec', '-i', name, 'sh', '-c', command], { - stdio: ['pipe', 'pipe', 'pipe'], - }) - - let stdout = '' - let stderr = '' - let timedOut = false - - const timeoutId = setTimeout(() => { - timedOut = true - child.kill('SIGTERM') - setTimeout(() => { - if (child.exitCode === null) { - child.kill('SIGKILL') - } - }, 5000) - }, timeout) - - if (opts?.abort) { - opts.abort.addEventListener('abort', () => { - clearTimeout(timeoutId) - child.kill('SIGTERM') - setTimeout(() => { - if (child.exitCode === null) { - child.kill('SIGKILL') - } - }, 5000) - }) - } - - child.stdout.on('data', (data) => { - stdout += data.toString() - }) - - child.stderr.on('data', (data) => { - stderr += data.toString() - }) - - child.stdin.write(stdin) - child.stdin.end() - - child.on('close', (code) => { - clearTimeout(timeoutId) - resolve({ - stdout, - stderr, - exitCode: timedOut ? 124 : (code ?? 1), - }) - }) - - child.on('error', (err) => { - clearTimeout(timeoutId) - reject(err) - }) + return execPromise('docker', ['exec', '-i', name, 'sh', '-c', command], { + timeout: opts?.timeout ?? DEFAULT_TIMEOUT, + stdin, + abort: opts?.abort, }) } @@ -215,14 +164,17 @@ export function createDockerService(logger: Logger): DockerService { function execPromise( command: string, args: string[], - options?: { timeout?: number; streaming?: boolean; abort?: AbortSignal }, + options?: { timeout?: number; streaming?: boolean; abort?: AbortSignal; stdin?: string }, ): Promise { const timeout = options?.timeout ?? DEFAULT_TIMEOUT const cmdPreview = args.slice(-1)[0]?.slice(0, 80) ?? '' + let hardDeadlineId: ReturnType | undefined + const inner = new Promise((resolve) => { - const child = spawn(command, args, { - stdio: ['ignore', 'pipe', 'pipe'], + const stdioConfig: 'pipe' | 'ignore' = options?.stdin ? 'pipe' : 'ignore' + const child: any = spawn(command, args, { + stdio: [stdioConfig, 'pipe', 'pipe'], }) let stdout = '' @@ -234,6 +186,7 @@ export function createDockerService(logger: Logger): DockerService { if (settled) return settled = true clearTimeout(timeoutId) + clearTimeout(hardDeadlineId) resolve(result) } @@ -255,7 +208,7 @@ export function createDockerService(logger: Logger): DockerService { child.kill('SIGTERM') setTimeout(() => { if (!settled) child.kill('SIGKILL') - }, 3000) + }, 5000) } if (options.abort.aborted) { onAbort() @@ -264,15 +217,20 @@ export function createDockerService(logger: Logger): DockerService { } } - child.stdout.on('data', (data) => { + child.stdout.on('data', (data: Buffer) => { stdout += data.toString() }) - child.stderr.on('data', (data) => { + child.stderr.on('data', (data: Buffer) => { stderr += data.toString() }) - child.on('close', (code) => { + if (options?.stdin) { + child.stdin.write(options.stdin) + child.stdin.end() + } + + child.on('close', (code: number | null) => { if (timedOut) { logger.log(`[docker] close after timeout, code=${code} for: ${cmdPreview}`) } @@ -283,7 +241,7 @@ export function createDockerService(logger: Logger): DockerService { }) }) - child.on('error', (err) => { + child.on('error', (err: Error) => { logger.log(`[docker] spawn error: ${err.message} for: ${cmdPreview}`) settle({ stdout, @@ -295,7 +253,7 @@ export function createDockerService(logger: Logger): DockerService { const hardDeadline = timeout + 10_000 const deadlinePromise = new Promise((resolve) => { - setTimeout(() => { + hardDeadlineId = setTimeout(() => { logger.log(`[docker] hard deadline (${hardDeadline}ms) hit for: ${cmdPreview}`) resolve({ stdout: '', stderr: `Command exceeded hard deadline of ${hardDeadline}ms`, exitCode: 124 }) }, hardDeadline) diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index c2b51e6..21abfa7 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -13,9 +13,9 @@ export interface ActiveSandbox { startedAt: string } -interface SandboxManager { +export interface SandboxManager { docker: DockerService - start(worktreeName: string, projectDir: string): Promise<{ containerName: string }> + start(worktreeName: string, projectDir: string, startedAt?: string): Promise<{ containerName: string }> stop(worktreeName: string): Promise getActive(worktreeName: string): ActiveSandbox | null isActive(worktreeName: string): boolean @@ -23,13 +23,13 @@ interface SandboxManager { restore(worktreeName: string, projectDir: string, startedAt: string): Promise } -const activeSandboxes = new Map() - export function createSandboxManager( docker: DockerService, config: SandboxManagerConfig, logger: Logger, ): SandboxManager { + const activeSandboxes = new Map() + function detectGitMount(projectDir: string): string[] { try { const result = spawnSync('git', ['rev-parse', '--git-common-dir'], { @@ -50,7 +50,7 @@ export function createSandboxManager( } } - async function start(worktreeName: string, projectDir: string): Promise<{ containerName: string }> { + async function start(worktreeName: string, projectDir: string, startedAt?: string): Promise<{ containerName: string }> { const dockerAvailable = await docker.checkDocker() if (!dockerAvailable) { throw new Error('Docker is not available. Please ensure Docker is running.') @@ -83,7 +83,7 @@ export function createSandboxManager( const active: ActiveSandbox = { containerName, projectDir: absoluteProjectDir, - startedAt: new Date().toISOString(), + startedAt: startedAt ?? new Date().toISOString(), } activeSandboxes.set(worktreeName, active) @@ -153,46 +153,12 @@ export function createSandboxManager( async function restore(worktreeName: string, projectDir: string, startedAt: string): Promise { const containerName = docker.containerName(worktreeName) const running = await docker.isRunning(containerName) - if (running) { logger.log(`Sandbox container ${containerName} already running, repopulating map`) - const active: ActiveSandbox = { - containerName, - projectDir: resolve(projectDir), - startedAt, - } - activeSandboxes.set(worktreeName, active) + activeSandboxes.set(worktreeName, { containerName, projectDir: resolve(projectDir), startedAt }) } else { logger.log(`Sandbox container ${containerName} not running, starting new container`) - const dockerAvailable = await docker.checkDocker() - if (!dockerAvailable) { - throw new Error('Docker is not available. Please ensure Docker is running.') - } - - const imageExists = await docker.imageExists(config.image) - if (!imageExists) { - throw new Error( - `Docker image "${config.image}" not found. Build it first:\n` + - ` docker build -t ${config.image} container/` - ) - } - - const absoluteProjectDir = resolve(projectDir) - const extraMounts = detectGitMount(absoluteProjectDir) - if (extraMounts.length > 0) { - logger.log(`Sandbox: mounting git common dir: ${extraMounts[0]}`) - } - logger.log(`Creating sandbox container ${containerName} for ${absoluteProjectDir}`) - await docker.createContainer(containerName, absoluteProjectDir, config.image, extraMounts) - - const active: ActiveSandbox = { - containerName, - projectDir: absoluteProjectDir, - startedAt, - } - - activeSandboxes.set(worktreeName, active) - logger.log(`Sandbox container ${containerName} started`) + await start(worktreeName, projectDir, startedAt) } } diff --git a/src/services/loop.ts b/src/services/loop.ts index 2c435a0..3cb9bbe 100644 --- a/src/services/loop.ts +++ b/src/services/loop.ts @@ -36,6 +36,11 @@ export const STALL_TIMEOUT_MS = 60_000 export const MAX_CONSECUTIVE_STALLS = 5 export const DEFAULT_MIN_AUDITS = 1 export const RECENT_MESSAGES_COUNT = 5 +export const DEFAULT_COMPLETION_SIGNAL = 'ALL_PHASES_COMPLETE' + +export function buildCompletionSignalInstructions(signal: string): string { + return `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following phrase exactly: ${signal}\n\nBefore outputting the completion signal, you MUST:\n1. Verify each phase's acceptance criteria are met\n2. Run all verification commands listed in the plan and confirm they pass\n3. If tests were required, confirm they exist AND pass\n\nDo NOT output this phrase until every phase is truly complete and all verification steps pass. The loop will continue until this signal is detected.` +} export const LOOP_PERMISSION_RULESET = [ { permission: '*', pattern: '*', action: 'allow' as const }, @@ -51,7 +56,7 @@ export interface LoopState { worktreeBranch?: string iteration: number maxIterations: number - completionPromise: string | null + completionSignal: string | null startedAt: string prompt?: string phase: 'coding' | 'auditing' @@ -75,7 +80,7 @@ export interface LoopService { registerSession(sessionId: string, worktreeName: string): void resolveWorktreeName(sessionId: string): string | null unregisterSession(sessionId: string): void - checkCompletionPromise(text: string, promise: string): boolean + checkCompletionSignal(text: string, promise: string): boolean buildContinuationPrompt(state: LoopState, auditFindings?: string): string buildAuditPrompt(state: LoopState): string listActive(): LoopState[] @@ -130,8 +135,15 @@ export function createLoopService( kvService.delete(projectId, `loop-session:${sessionId}`) } - function checkCompletionPromise(text: string, promise: string): boolean { - return text.includes(promise) + function checkCompletionSignal(text: string, promise: string): boolean { + // If promise already contains tags, use exact match; otherwise wrap with tags + const pattern = promise.includes('') ? escapeRegex(promise) : `${escapeRegex(promise)}` + const regex = new RegExp(pattern, 'i') + return regex.test(text) + } + + function escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function redactCompletionSignal(text: string, promise: string): string { @@ -141,8 +153,8 @@ export function createLoopService( function buildContinuationPrompt(state: LoopState, auditFindings?: string): string { let systemLine = `Loop iteration ${state.iteration ?? 0}` - if (state.completionPromise) { - systemLine += ` | To stop: output ${state.completionPromise} (ONLY after all verification commands pass AND all phase acceptance criteria are met)` + if (state.completionSignal) { + systemLine += ` | To stop: output ${state.completionSignal} (ONLY after all verification commands pass AND all phase acceptance criteria are met)` } else if ((state.maxIterations ?? 0) > 0) { systemLine += ` / ${state.maxIterations}` } else { @@ -152,10 +164,10 @@ export function createLoopService( let prompt = `[${systemLine}]\n\n${state.prompt ?? ''}` if (auditFindings) { - const cleanedFindings = state.completionPromise - ? redactCompletionSignal(auditFindings, state.completionPromise) + const cleanedFindings = state.completionSignal + ? redactCompletionSignal(auditFindings, state.completionSignal) : auditFindings - const completionInstruction = state.completionPromise + const completionInstruction = state.completionSignal ? '\n\nAfter fixing all issues, output the completion signal.' : '' prompt += `\n\n---\nThe code auditor reviewed your changes. You MUST address all bugs and convention violations below — do not dismiss findings as unrelated to the task. Fix them directly without creating a plan or asking for approval.\n\n${cleanedFindings}${completionInstruction}` @@ -280,7 +292,7 @@ export function createLoopService( registerSession, resolveWorktreeName, unregisterSession, - checkCompletionPromise, + checkCompletionSignal, buildContinuationPrompt, buildAuditPrompt, listActive, diff --git a/src/tools/index.ts b/src/tools/index.ts index 1ec1e13..0e2e81c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,7 @@ import { createPlanExecuteTools } from './plan-execute' import { createLoopTools } from './loop' import { createSandboxFsTools } from './sandbox-fs' import type { ToolContext } from './types' +import { isSandboxEnabled } from '../sandbox/context' export { autoValidateOnLoad } from './health' export { createToolExecuteBeforeHook, createToolExecuteAfterHook } from './plan-approval' @@ -13,7 +14,7 @@ export { scopeEnum } from './types' export type { ToolContext, DimensionMismatchState, InitState } from './types' export function createTools(ctx: ToolContext): Record> { - const sandboxEnabled = ctx.config.sandbox?.mode === 'docker' && !!ctx.sandboxManager + const sandboxEnabled = isSandboxEnabled(ctx.config, ctx.sandboxManager) return { ...createMemoryTools(ctx), ...createKvTools(ctx), diff --git a/src/tools/loop.ts b/src/tools/loop.ts index 5ef2ba7..0d1ac66 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -8,16 +8,18 @@ import { parseModelString, retryWithModelFallback } from '../utils/model-fallbac import { slugify } from '../utils/logger' import { findPartialMatch } from '../utils/partial-match' import { formatSessionOutput, formatAuditResult } from '../utils/loop-format' -import { fetchSessionOutput, MAX_RETRIES, LOOP_PERMISSION_RULESET, type LoopState, type LoopSessionOutput } from '../services/loop' +import { fetchSessionOutput, MAX_RETRIES, LOOP_PERMISSION_RULESET, buildCompletionSignalInstructions, type LoopState, type LoopSessionOutput } from '../services/loop' +import { isSandboxEnabled } from '../sandbox/context' +import { formatDuration, computeElapsedSeconds } from '../utils/loop-helpers' const z = tool.schema -const DEFAULT_PLAN_COMPLETION_PROMISE = 'ALL_PHASES_COMPLETE' +const DEFAULT_COMPLETION_SIGNAL = 'ALL_PHASES_COMPLETE' interface LoopSetupOptions { prompt: string sessionTitle: string worktreeName?: string - completionPromise: string | null + completionSignal: string | null maxIterations: number audit: boolean agent?: string @@ -107,7 +109,7 @@ async function setupLoop( } let sandboxContainerName: string | undefined - const sandboxEnabled = config.sandbox?.mode === 'docker' && !!sandboxManager && !!options.worktree + const sandboxEnabled = isSandboxEnabled(config, sandboxManager) && !!options.worktree if (sandboxEnabled) { try { @@ -129,7 +131,7 @@ async function setupLoop( worktreeBranch: loopContext.branch, iteration: 1, maxIterations: maxIter, - completionPromise: options.completionPromise, + completionSignal: options.completionSignal, startedAt: new Date().toISOString(), prompt: options.prompt, phase: 'coding', @@ -146,8 +148,8 @@ async function setupLoop( logger.log(`loop: state stored for worktree=${autoWorktreeName}`) let promptText = options.prompt - if (options.completionPromise) { - promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following phrase exactly: ${options.completionPromise}\n\nBefore outputting the completion signal, you MUST:\n1. Verify each phase's acceptance criteria are met\n2. Run all verification commands listed in the plan and confirm they pass\n3. If tests were required, confirm they exist AND pass\n\nDo NOT output this phrase until every phase is truly complete and all verification steps pass. The loop will continue until this signal is detected.` + if (options.completionSignal) { + promptText += buildCompletionSignalInstructions(options.completionSignal) } const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( @@ -223,7 +225,7 @@ async function setupLoop( lines.push( `Model: ${modelInfo}`, `Max iterations: ${maxInfo}`, - `Completion promise: ${options.completionPromise ?? 'none'}`, + `Completion promise: ${options.completionSignal ?? 'none'}`, `Audit: ${auditInfo}`, '', 'The loop will automatically continue when the session goes idle.', @@ -259,7 +261,7 @@ export function createLoopTools(ctx: ToolContext): Record { - const duration = s.completedAt && s.startedAt - ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) - : 0 - const minutes = Math.floor(duration / 60) - const seconds = duration % 60 - const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const durationStr = formatDuration(computeElapsedSeconds(s.startedAt, s.completedAt)) lines.push(`${i + 1}. ${s.worktreeName}`) lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) lines.push('') @@ -517,10 +514,7 @@ export function createLoopTools(ctx: ToolContext): Record { - const elapsed = s.startedAt ? Math.round((Date.now() - new Date(s.startedAt).getTime()) / 1000) : 0 - const minutes = Math.floor(elapsed / 60) - const seconds = elapsed % 60 - const duration = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const duration = formatDuration(computeElapsedSeconds(s.startedAt)) const iterInfo = s.maxIterations && s.maxIterations > 0 ? `${s.iteration} / ${s.maxIterations}` : `${s.iteration} (unlimited)` const sessionStatus = statuses[s.sessionId]?.type ?? 'unavailable' const modeIndicator = !s.worktree ? ' (in-place)' : '' @@ -537,12 +531,7 @@ export function createLoopTools(ctx: ToolContext): Record { - const duration = s.completedAt && s.startedAt - ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) - : 0 - const minutes = Math.floor(duration / 60) - const seconds = duration % 60 - const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const durationStr = formatDuration(computeElapsedSeconds(s.startedAt, s.completedAt)) lines.push(`${i + 1}. ${s.worktreeName}`) lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) lines.push('') @@ -568,12 +557,7 @@ export function createLoopTools(ctx: ToolContext): Record 0 ? `${state.iteration} / ${state.maxIterations}` : `${state.iteration} (unlimited)` - const duration = state.completedAt && state.startedAt - ? Math.round((new Date(state.completedAt).getTime() - new Date(state.startedAt).getTime()) / 1000) - : 0 - const minutes = Math.floor(duration / 60) - const seconds = duration % 60 - const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const durationStr = formatDuration(computeElapsedSeconds(state.startedAt, state.completedAt)) const statusLines: string[] = [ 'Loop Status (Inactive)', @@ -630,10 +614,7 @@ export function createLoopTools(ctx: ToolContext): Record 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const duration = formatDuration(computeElapsedSeconds(state.startedAt)) const stallInfo = loopHandler.getStallInfo(state.worktreeName) const secondsSinceActivity = stallInfo @@ -683,7 +664,7 @@ export function createLoopTools(ctx: ToolContext): Record 0 ? [`Error count: ${state.errorCount} (retries before termination: ${MAX_RETRIES})`] : []), `Audit count: ${state.auditCount ?? 0}`, diff --git a/src/tools/sandbox-fs.ts b/src/tools/sandbox-fs.ts index 84805e2..ac608e9 100644 --- a/src/tools/sandbox-fs.ts +++ b/src/tools/sandbox-fs.ts @@ -1,28 +1,10 @@ import { tool } from '@opencode-ai/plugin' import type { ToolContext } from './types' import { toContainerPath, rewriteOutput } from '../sandbox/path' +import { getSandboxForSession } from '../sandbox/context' const z = tool.schema -function getSandboxForSession(ctx: ToolContext, sessionId: string) { - if (!ctx.sandboxManager) return null - - const worktreeName = ctx.loopService.resolveWorktreeName(sessionId) - if (!worktreeName) return null - - const state = ctx.loopService.getActiveState(worktreeName) - if (!state?.active || !state.sandbox) return null - - const active = ctx.sandboxManager.getActive(worktreeName) - if (!active) return null - - return { - docker: ctx.sandboxManager.docker, - containerName: active.containerName, - hostDir: active.projectDir, - } -} - export function createSandboxFsTools(ctx: ToolContext): Record> { return { glob: tool({ diff --git a/src/tui.tsx b/src/tui.tsx index 1593bd8..3237a8f 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -2,6 +2,13 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui' import { createEffect, createMemo, createSignal, onCleanup, Show, For } from 'solid-js' import { readFileSync, existsSync } from 'fs' + +// Mirrors LOOP_PERMISSION_RULESET from services/loop (TUI cannot import from services due to separate bundling) +const LOOP_PERMISSION_RULESET = [ + { permission: '*', pattern: '*', action: 'allow' as const }, + { permission: 'external_directory', pattern: '*', action: 'deny' as const }, + { permission: 'bash', pattern: 'git push *', action: 'deny' as const }, +] import { homedir, platform } from 'os' import { join } from 'path' import { execSync } from 'child_process' @@ -172,7 +179,7 @@ async function restartLoop(projectId: string, loopName: string, api: TuiPluginAp const directory = state.worktreeDir if (!directory) return null - const createResult = await api.client.session.create({ directory, title: loopName }) + const createResult = await api.client.session.create({ directory, title: loopName, permission: LOOP_PERMISSION_RULESET }) if (createResult.error || !createResult.data) return null const newSessionId = createResult.data.id @@ -199,8 +206,9 @@ async function restartLoop(projectId: string, loopName: string, api: TuiPluginAp ) let promptText = state.prompt ?? '' - if (state.completionPromise) { - promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following phrase exactly: ${state.completionPromise}\n\nDo NOT output this phrase until every phase is truly complete. The loop will continue until this signal is detected.` + if (state.completionSignal) { + const completionInstructions = `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following phrase exactly: ${state.completionSignal}\n\nBefore outputting the completion signal, you MUST:\n1. Verify each phase's acceptance criteria are met\n2. Run all verification commands listed in the plan and confirm they pass\n3. If tests were required, confirm they exist AND pass\n\nDo NOT output this phrase until every phase is truly complete. The loop will continue until this signal is detected.` + promptText += completionInstructions } await api.client.session.promptAsync({ diff --git a/src/utils/loop-helpers.ts b/src/utils/loop-helpers.ts new file mode 100644 index 0000000..424c218 --- /dev/null +++ b/src/utils/loop-helpers.ts @@ -0,0 +1,31 @@ +import type { PluginConfig } from '../types' +import type { LoopService } from '../services/loop' +import { parseModelString } from './model-fallback' + +export function resolveLoopModel( + config: PluginConfig, + loopService: LoopService, + worktreeName: string, +): { providerID: string; modelID: string } | undefined { + const state = loopService.getActiveState(worktreeName) + if (state?.modelFailed) return undefined + return parseModelString(config.loop?.model) ?? parseModelString(config.executionModel) +} + +export function formatDuration(seconds: number): string { + const minutes = Math.floor(seconds / 60) + const secs = seconds % 60 + return minutes > 0 ? `${minutes}m ${secs}s` : `${secs}s` +} + +export function formatElapsed(startedAt: string): string { + const elapsed = Math.round((Date.now() - new Date(startedAt).getTime()) / 1000) + return formatDuration(elapsed) +} + +export function computeElapsedSeconds(startedAt?: string, endedAt?: string): number { + if (!startedAt) return 0 + const start = new Date(startedAt).getTime() + const end = endedAt ? new Date(endedAt).getTime() : Date.now() + return Math.round((end - start) / 1000) +} diff --git a/test/loop-helpers.test.ts b/test/loop-helpers.test.ts new file mode 100644 index 0000000..c27b4ec --- /dev/null +++ b/test/loop-helpers.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'bun:test' +import { resolveLoopModel, formatDuration, computeElapsedSeconds } from '../src/utils/loop-helpers' +import type { PluginConfig } from '../src/types' + +describe('resolveLoopModel', () => { + const mockLoopService = { + getActiveState: (name: string) => name === 'failed-worktree' + ? { active: true, modelFailed: true } + : { active: true, modelFailed: false }, + } as any + + it('returns undefined when modelFailed is true', () => { + const config = { loop: { model: 'provider/model' } } as PluginConfig + const result = resolveLoopModel(config, mockLoopService, 'failed-worktree') + expect(result).toBeUndefined() + }) + + it('returns parsed model when available', () => { + const config = { loop: { model: 'provider/model' } } as PluginConfig + const result = resolveLoopModel(config, mockLoopService, 'valid-worktree') + expect(result).toEqual({ providerID: 'provider', modelID: 'model' }) + }) + + it('returns undefined when no model configured', () => { + const config = {} as PluginConfig + const result = resolveLoopModel(config, mockLoopService, 'valid-worktree') + expect(result).toBeUndefined() + }) +}) + +describe('formatDuration', () => { + it('formats seconds-only', () => { + expect(formatDuration(45)).toBe('45s') + }) + + it('formats minutes+seconds', () => { + expect(formatDuration(125)).toBe('2m 5s') + }) + + it('handles zero', () => { + expect(formatDuration(0)).toBe('0s') + }) + + it('handles exact minutes', () => { + expect(formatDuration(180)).toBe('3m 0s') + }) +}) + +describe('computeElapsedSeconds', () => { + it('handles both timestamps', () => { + const start = new Date('2024-01-01T00:00:00Z').toISOString() + const end = new Date('2024-01-01T00:01:30Z').toISOString() + expect(computeElapsedSeconds(start, end)).toBe(90) + }) + + it('handles missing start', () => { + expect(computeElapsedSeconds(undefined, new Date().toISOString())).toBe(0) + }) + + it('handles missing end (uses Date.now)', () => { + const start = new Date(Date.now() - 5000).toISOString() + const elapsed = computeElapsedSeconds(start, undefined) + expect(elapsed).toBeGreaterThanOrEqual(4) + expect(elapsed).toBeLessThanOrEqual(6) + }) +}) diff --git a/test/loop.test.ts b/test/loop.test.ts index 7bb5c1e..a4bca67 100644 --- a/test/loop.test.ts +++ b/test/loop.test.ts @@ -56,7 +56,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -87,7 +87,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -106,24 +106,24 @@ describe('LoopService', () => { expect(retrieved).toBeNull() }) - test('checkCompletionPromise matches exact phrase', () => { + test('checkCompletionSignal matches exact phrase', () => { const text = 'Some response text ALL_PHASES_COMPLETE more text' - expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE')).toBe(true) + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(true) }) - test('checkCompletionPromise returns false when phrase not present', () => { + test('checkCompletionSignal returns false when phrase not present', () => { const text = 'Some response text without the phrase' - expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE')).toBe(false) + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(false) }) - test('checkCompletionPromise returns false when phrase does not match', () => { + test('checkCompletionSignal returns false when phrase does not match', () => { const text = 'Some response NOT_COMPLETE text' - expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE')).toBe(false) + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(false) }) - test('checkCompletionPromise requires exact match', () => { + test('checkCompletionSignal requires exact match', () => { const text = 'Response ALL_PHASES_COMPLETE text' - expect(loopService.checkCompletionPromise(text, 'NOT_COMPLETE')).toBe(false) + expect(loopService.checkCompletionSignal(text, 'NOT_COMPLETE')).toBe(false) }) test('buildContinuationPrompt includes iteration number', () => { @@ -135,7 +135,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 3, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'My test prompt', phase: 'coding' as const, @@ -158,7 +158,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: 'COMPLETE_TASK', + completionSignal: 'COMPLETE_TASK', startedAt: new Date().toISOString(), prompt: 'My test prompt', phase: 'coding' as const, @@ -180,7 +180,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 10, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'My test prompt', phase: 'coding' as const, @@ -202,7 +202,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'My test prompt', phase: 'coding' as const, @@ -224,7 +224,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 5, maxIterations: 10, - completionPromise: 'PERSIST_TEST', + completionSignal: 'PERSIST_TEST', startedAt: new Date().toISOString(), prompt: 'Persistence test', phase: 'coding' as const, @@ -251,7 +251,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -276,7 +276,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -303,7 +303,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -327,7 +327,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -351,7 +351,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-worktree-1', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Active prompt 1', phase: 'coding' as const, @@ -368,7 +368,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-worktree-2', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Active prompt 2', phase: 'coding' as const, @@ -385,7 +385,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-worktree-3', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Inactive prompt', phase: 'coding' as const, @@ -414,7 +414,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-unique-worktree-name', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -441,7 +441,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -465,7 +465,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -488,7 +488,7 @@ describe('LoopService', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'In-place test prompt', phase: 'coding' as const, @@ -512,7 +512,7 @@ describe('LoopService', () => { worktreeBranch: 'develop', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -536,7 +536,7 @@ describe('LoopService', () => { worktreeBranch: 'main', iteration: 3, maxIterations: 0, - completionPromise: 'COMPLETE', + completionSignal: 'COMPLETE', startedAt: new Date().toISOString(), prompt: 'In-place prompt test', phase: 'coding' as const, @@ -560,7 +560,7 @@ describe('LoopService', () => { worktreeBranch: 'main', iteration: 2, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'In-place audit test', phase: 'coding' as const, @@ -671,7 +671,7 @@ describe('Stall Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'test', phase: 'coding', @@ -734,7 +734,7 @@ describe('Stall Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'test', phase: 'coding', @@ -806,7 +806,7 @@ describe('Stall Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'test', phase: 'coding', @@ -877,7 +877,7 @@ describe('Stall Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 0, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'test', phase: 'coding', @@ -928,7 +928,7 @@ describe('reconcileStale', () => { worktreeBranch: 'main', iteration: 3, maxIterations: 10, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1056,7 +1056,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 3, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1084,7 +1084,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1106,7 +1106,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 3, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1133,7 +1133,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1206,7 +1206,7 @@ describe('session rotation', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1290,7 +1290,7 @@ describe('session rotation', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'auditing' as const, @@ -1356,7 +1356,7 @@ describe('session rotation', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1448,7 +1448,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1522,7 +1522,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'auditing' as const, @@ -1586,7 +1586,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1655,7 +1655,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1732,7 +1732,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1794,7 +1794,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 2, maxIterations: 10, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1868,7 +1868,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1941,7 +1941,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 3, maxIterations: 10, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), terminationReason: 'cancelled', @@ -1968,7 +1968,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 5, maxIterations: 10, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), terminationReason: 'completed', @@ -1995,7 +1995,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 2, maxIterations: 10, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -2024,7 +2024,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -2054,7 +2054,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionPromise: null, + completionSignal: null, startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, diff --git a/test/sandbox-context.test.ts b/test/sandbox-context.test.ts new file mode 100644 index 0000000..13dd62c --- /dev/null +++ b/test/sandbox-context.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'bun:test' +import { getSandboxForSession, isSandboxEnabled } from '../src/sandbox/context' +import type { PluginConfig } from '../src/types' + +describe('getSandboxForSession', () => { + const mockDocker = {} as any + const mockLoopService = { + resolveWorktreeName: (sessionId: string) => sessionId === 'valid-session' ? 'test-worktree' : null, + getActiveState: (name: string) => name === 'test-worktree' ? { active: true, sandbox: true, worktreeDir: '/test' } : null, + } as any + + it('returns null when sandboxManager is null', () => { + const result = getSandboxForSession( + { sandboxManager: null, loopService: mockLoopService }, + 'valid-session' + ) + expect(result).toBeNull() + }) + + it('returns null when worktreeName not found', () => { + const mockSandboxManager = { docker: mockDocker, getActive: () => null } as any + const result = getSandboxForSession( + { sandboxManager: mockSandboxManager, loopService: { ...mockLoopService, resolveWorktreeName: () => null } }, + 'invalid-session' + ) + expect(result).toBeNull() + }) + + it('returns null when state is not active', () => { + const mockSandboxManager = { docker: mockDocker, getActive: () => null } as any + const result = getSandboxForSession( + { sandboxManager: mockSandboxManager, loopService: { ...mockLoopService, getActiveState: () => ({ active: false, sandbox: true }) } }, + 'valid-session' + ) + expect(result).toBeNull() + }) + + it('returns null when sandbox is false', () => { + const mockSandboxManager = { docker: mockDocker, getActive: () => null } as any + const result = getSandboxForSession( + { sandboxManager: mockSandboxManager, loopService: { ...mockLoopService, getActiveState: () => ({ active: true, sandbox: false }) } }, + 'valid-session' + ) + expect(result).toBeNull() + }) + + it('returns context when all conditions met', () => { + const mockSandboxManager = { + docker: mockDocker, + getActive: (name: string) => name === 'test-worktree' ? { containerName: 'test-container', projectDir: '/test/project' } : null, + } as any + const result = getSandboxForSession( + { sandboxManager: mockSandboxManager, loopService: mockLoopService }, + 'valid-session' + ) + expect(result).toEqual({ + docker: mockDocker, + containerName: 'test-container', + hostDir: '/test/project', + }) + }) +}) + +describe('isSandboxEnabled', () => { + it('returns false when mode is off', () => { + const config = { sandbox: { mode: 'off' as const } } as PluginConfig + expect(isSandboxEnabled(config, {})).toBe(false) + }) + + it('returns false when sandboxManager is null', () => { + const config = { sandbox: { mode: 'docker' as const } } as PluginConfig + expect(isSandboxEnabled(config, null)).toBe(false) + }) + + it('returns true when mode is docker and manager exists', () => { + const config = { sandbox: { mode: 'docker' as const } } as PluginConfig + expect(isSandboxEnabled(config, {})).toBe(true) + }) +}) diff --git a/test/strip-promise-tags.test.ts b/test/strip-promise-tags.test.ts deleted file mode 100644 index 7a8efc6..0000000 --- a/test/strip-promise-tags.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, test, expect } from 'vitest' -import { stripPromiseTags } from '../src/utils/strip-promise-tags' - -describe('stripPromiseTags', () => { - test('returns unchanged text when no promise tags present', () => { - const text = 'This is a normal plan without any special tags' - const { cleaned, stripped } = stripPromiseTags(text) - expect(cleaned).toBe(text) - expect(stripped).toBe(false) - }) - - test('strips bare promise tags', () => { - const text = 'Plan text here All phases of the plan have been completed successfully' - const { cleaned, stripped } = stripPromiseTags(text) - expect(cleaned).toBe('Plan text here') - expect(stripped).toBe(true) - expect(cleaned).not.toContain('') - }) - - test('strips full instruction block with promise tags', () => { - const text = `Plan text here - ---- - -**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following tag exactly: All phases of the plan have been completed successfully - -Do NOT output this tag until every phase is truly complete. The loop will continue until this signal is detected.` - const { cleaned, stripped } = stripPromiseTags(text) - expect(cleaned).toBe('Plan text here') - expect(stripped).toBe(true) - expect(cleaned).not.toContain('') - expect(cleaned).not.toContain('Completion Signal') - }) - - test('preserves plan content before promise tags', () => { - const plan = `## Phase 1 -Do something - -## Phase 2 -Do something else - -ALL_PHASES_COMPLETE` - const { cleaned, stripped } = stripPromiseTags(plan) - expect(cleaned).toContain('## Phase 1') - expect(cleaned).toContain('## Phase 2') - expect(cleaned).not.toContain('') - expect(stripped).toBe(true) - }) - - test('handles promise tags with multiline content', () => { - const text = 'Plan \nMulti\nLine\nContent\n end' - const { cleaned, stripped } = stripPromiseTags(text) - expect(cleaned).not.toContain('') - expect(stripped).toBe(true) - }) -}) From 90ed60fdbf28687a41744cef28f102ed9047622c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:56:27 -0400 Subject: [PATCH 3/9] Add ESLint configuration with TypeScript and Solid.js rules --- eslint.config.js | 54 +++ package.json | 11 +- pnpm-lock.yaml | 825 +++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 2 +- src/config.ts | 2 +- src/embedding/index.ts | 2 +- src/hooks/loop.ts | 42 +- src/index.ts | 6 +- src/sandbox/context.ts | 1 - src/sandbox/docker.ts | 2 +- src/services/kv.ts | 2 +- src/storage/vec-client.ts | 12 +- src/tools/loop.ts | 8 +- src/tools/plan-approval.ts | 2 +- src/tools/types.ts | 2 +- src/version.ts | 2 +- tsconfig.json | 6 +- 17 files changed, 936 insertions(+), 45 deletions(-) create mode 100644 eslint.config.js diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..54ef6cd --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,54 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import solidPlugin from "eslint-plugin-solid"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + extends: [ + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + ], + plugins: { + solid: solidPlugin, + }, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + parser: tseslint.parser, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "inline-type-imports", + }, + ], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/prefer-nullish-coalescing": "error", + "@typescript-eslint/prefer-optional-chain": "error", + }, + }, + { + files: ["**/*.tsx"], + rules: { + ...solidPlugin.configs.recommended.rules, + }, + }, + { + ignores: ["dist/**", "node_modules/**", "*.d.ts"], + } +); diff --git a/package.json b/package.json index fd0e38b..4dcf944 100644 --- a/package.json +++ b/package.json @@ -73,14 +73,23 @@ "sqlite-vec-windows-x64": "0.1.7-alpha.2" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@opentui/core": "0.1.92", "@opentui/solid": "0.1.92", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", "bun-types": "latest", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-solid": "^0.14.5", + "prettier": "^3.8.1", "solid-js": "^1.9.12", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "typescript-eslint": "^8.58.0" }, "scripts": { "build": "bun scripts/build.ts", + "lint": "eslint .", "postinstall": "node scripts/download-models.js", "prepublishOnly": "pnpm build", "test": "bun test", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bcca35..e09d682 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,21 +21,45 @@ importers: specifier: 0.1.7-alpha.2 version: 0.1.7-alpha.2 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.2.0) '@opentui/core': specifier: 0.1.92 version: 0.1.92(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) '@opentui/solid': specifier: 0.1.92 version: 0.1.92(solid-js@1.9.12)(stage-js@1.0.1)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@typescript-eslint/eslint-plugin': + specifier: ^8.58.0 + version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.58.0 + version: 8.58.0(eslint@10.2.0)(typescript@5.9.3) bun-types: specifier: latest version: 1.3.11 + eslint: + specifier: ^10.2.0 + version: 10.2.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.2.0) + eslint-plugin-solid: + specifier: ^0.14.5 + version: 0.14.5(eslint@10.2.0)(typescript@5.9.3) + prettier: + specifier: ^3.8.1 + version: 3.8.1 solid-js: specifier: ^1.9.12 version: 1.9.12 typescript: specifier: ^5.7.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.58.0 + version: 8.58.0(eslint@10.2.0)(typescript@5.9.3) optionalDependencies: sqlite-vec-darwin-arm64: specifier: 0.1.7-alpha.2 @@ -198,6 +222,45 @@ packages: '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.4': + resolution: {integrity: sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.4': + resolution: {integrity: sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.0': + resolution: {integrity: sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.4': + resolution: {integrity: sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.0': + resolution: {integrity: sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@huggingface/jinja@0.5.6': resolution: {integrity: sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==} engines: {node: '>=18'} @@ -205,6 +268,22 @@ packages: '@huggingface/transformers@3.8.1': resolution: {integrity: sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==} + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -558,12 +637,80 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} @@ -571,6 +718,19 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + any-base@1.1.0: resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} @@ -598,6 +758,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -616,6 +780,10 @@ packages: brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -665,6 +833,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -677,6 +849,9 @@ packages: supports-color: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -726,6 +901,61 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-solid@0.14.5: + resolution: {integrity: sha512-nfuYK09ah5aJG/oEN6P1qziy1zLgW4PDWe75VNPi4CEFYk1x2AEqwFeQfEPR7gNn0F2jOeqKhx2E+5oNCOBYWQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + typescript: '>=4.8.4' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.2.0: + resolution: {integrity: sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -737,6 +967,28 @@ packages: exif-parser@0.1.12: resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + file-type@16.5.4: resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} engines: {node: '>=10'} @@ -748,9 +1000,20 @@ packages: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + flatbuffers@25.9.23: resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -764,6 +1027,10 @@ packages: gifwrap@0.10.1: resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} @@ -794,16 +1061,50 @@ packages: html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + image-q@4.0.0: resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-html@2.0.0: + resolution: {integrity: sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jimp@1.6.0: resolution: {integrity: sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==} engines: {node: '>=18'} @@ -819,6 +1120,15 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -827,10 +1137,27 @@ packages: engines: {node: '>=6'} hasBin: true + kebab-case@1.0.2: + resolution: {integrity: sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + known-css-properties@0.30.0: + resolution: {integrity: sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + locate-path@3.0.0: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -854,6 +1181,10 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@8.0.7: resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} engines: {node: '>=16 || 14 >=14.17'} @@ -873,6 +1204,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -896,14 +1230,26 @@ packages: onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-locate@3.0.0: resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} engines: {node: '>=6'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -927,6 +1273,14 @@ packages: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -941,6 +1295,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pixelmatch@5.3.0: resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} hasBin: true @@ -966,6 +1324,15 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -974,6 +1341,10 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + readable-stream@4.7.0: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1034,6 +1405,14 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + simple-xml-to-json@1.2.4: resolution: {integrity: sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg==} engines: {node: '>=20.12.2'} @@ -1083,6 +1462,9 @@ packages: resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} engines: {node: '>=10'} + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -1097,17 +1479,38 @@ packages: tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + token-types@4.2.1: resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} engines: {node: '>=10'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1122,6 +1525,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + utif2@4.1.0: resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} @@ -1133,6 +1539,15 @@ packages: '@types/emscripten': optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + xml-parse-from-string@1.0.1: resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==} @@ -1151,6 +1566,10 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -1365,6 +1784,40 @@ snapshots: tslib: 2.8.1 optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.0)': + dependencies: + eslint: 10.2.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.4': + dependencies: + '@eslint/object-schema': 3.0.4 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.4': + dependencies: + '@eslint/core': 1.2.0 + + '@eslint/core@1.2.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.2.0)': + optionalDependencies: + eslint: 10.2.0 + + '@eslint/object-schema@3.0.4': {} + + '@eslint/plugin-kit@0.7.0': + dependencies: + '@eslint/core': 1.2.0 + levn: 0.4.1 + '@huggingface/jinja@0.5.6': {} '@huggingface/transformers@3.8.1': @@ -1374,6 +1827,17 @@ snapshots: onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4 sharp: 0.34.5 + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -1769,12 +2233,109 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + '@types/node@16.9.1': {} '@types/node@25.5.0': dependencies: undici-types: 7.18.2 + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 10.2.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + eslint: 10.2.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.0(eslint@10.2.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.2.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.0': {} + + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.0(eslint@10.2.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 10.2.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + '@webgpu/types@0.1.69': optional: true @@ -1782,6 +2343,19 @@ snapshots: dependencies: event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + any-base@1.1.0: {} await-to-js@3.0.0: {} @@ -1812,6 +2386,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.12: {} @@ -1824,6 +2400,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.12 @@ -1873,12 +2453,20 @@ snapshots: convert-source-map@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + csstype@3.2.3: {} debug@4.4.3: dependencies: ms: 2.1.3 + deep-is@0.1.4: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -1913,12 +2501,107 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-prettier@10.1.8(eslint@10.2.0): + dependencies: + eslint: 10.2.0 + + eslint-plugin-solid@0.14.5(eslint@10.2.0)(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + eslint: 10.2.0 + estraverse: 5.3.0 + is-html: 2.0.0 + kebab-case: 1.0.2 + known-css-properties: 0.30.0 + style-to-object: 1.0.14 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.2.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.4 + '@eslint/config-helpers': 0.5.4 + '@eslint/core': 1.2.0 + '@eslint/plugin-kit': 0.7.0 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + event-target-shim@5.0.1: {} events@3.3.0: {} exif-parser@0.1.12: {} + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + file-type@16.5.4: dependencies: readable-web-to-node-stream: 3.0.4 @@ -1933,8 +2616,20 @@ snapshots: dependencies: locate-path: 3.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + flatbuffers@25.9.23: {} + flatted@3.4.2: {} + fs.realpath@1.0.0: {} function-bind@1.1.2: {} @@ -1946,6 +2641,10 @@ snapshots: image-q: 4.0.0 omggif: 1.0.10 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@9.3.5: dependencies: fs.realpath: 1.0.0 @@ -1981,16 +2680,38 @@ snapshots: html-entities@2.3.3: {} + html-tags@3.3.1: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} + + ignore@7.0.5: {} + image-q@4.0.0: dependencies: '@types/node': 16.9.1 + imurmurhash@0.1.4: {} + + inline-style-parser@0.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-html@2.0.0: + dependencies: + html-tags: 3.3.1 + + isexe@2.0.0: {} + jimp@1.6.0: dependencies: '@jimp/core': 1.6.0 @@ -2027,15 +2748,38 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} json5@2.2.3: {} + kebab-case@1.0.2: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + known-css-properties@0.30.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + locate-path@3.0.0: dependencies: p-locate: 3.0.0 path-exists: 3.0.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + long@5.3.2: {} lru-cache@10.4.3: {} @@ -2052,6 +2796,10 @@ snapshots: mime@3.0.0: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + minimatch@8.0.7: dependencies: brace-expansion: 2.0.3 @@ -2066,6 +2814,8 @@ snapshots: ms@2.1.3: {} + natural-compare@1.4.0: {} + node-releases@2.0.36: {} object-keys@1.1.1: {} @@ -2091,14 +2841,31 @@ snapshots: platform: 1.3.6 protobufjs: 7.5.4 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + p-limit@2.3.0: dependencies: p-try: 2.2.0 + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-locate@3.0.0: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-try@2.2.0: {} pako@1.0.11: {} @@ -2118,6 +2885,10 @@ snapshots: path-exists@3.0.0: {} + path-exists@4.0.0: {} + + path-key@3.1.1: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -2129,6 +2900,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@4.0.4: {} + pixelmatch@5.3.0: dependencies: pngjs: 6.0.0 @@ -2148,6 +2921,10 @@ snapshots: pngjs@7.0.0: {} + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + process@0.11.10: {} protobufjs@7.5.4: @@ -2165,6 +2942,8 @@ snapshots: '@types/node': 25.5.0 long: 5.3.2 + punycode@2.3.1: {} + readable-stream@4.7.0: dependencies: abort-controller: 3.0.0 @@ -2247,6 +3026,12 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + simple-xml-to-json@1.2.4: {} solid-js@1.9.12: @@ -2292,6 +3077,10 @@ snapshots: '@tokenizer/token': 0.3.0 peek-readable: 4.1.0 + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + supports-preserve-symlinks-flag@1.0.0: {} tar@7.5.13: @@ -2307,16 +3096,40 @@ snapshots: tinycolor2@1.6.0: {} + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + token-types@4.2.1: dependencies: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tslib@2.8.1: optional: true + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-fest@0.13.1: {} + typescript-eslint@8.58.0(eslint@10.2.0)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.2.0)(typescript@5.9.3) + eslint: 10.2.0 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} undici-types@7.18.2: {} @@ -2327,12 +3140,22 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + utif2@4.1.0: dependencies: pako: 1.0.11 web-tree-sitter@0.25.10: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + xml-parse-from-string@1.0.1: {} xml2js@0.5.0: @@ -2346,6 +3169,8 @@ snapshots: yallist@5.0.0: {} + yocto-queue@0.1.0: {} + yoga-layout@3.2.1: {} zod@3.25.76: {} diff --git a/src/cli/index.ts b/src/cli/index.ts index b856ae6..0a10ffc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -58,7 +58,7 @@ const commands: Record = { }, }, upgrade: { - cli: async (args, globalOpts) => { + cli: async (_args, _globalOpts) => { const { run } = await import('./commands/upgrade') await run() }, diff --git a/src/config.ts b/src/config.ts index f222ba9..1b3037f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import type { AgentRole, AgentDefinition, AgentConfig } from './agents' -import type { PluginConfig } from './types' + const REPLACED_BUILTIN_AGENTS = ['build', 'plan'] diff --git a/src/embedding/index.ts b/src/embedding/index.ts index 190ecff..dd5270b 100644 --- a/src/embedding/index.ts +++ b/src/embedding/index.ts @@ -2,7 +2,7 @@ import { createHash } from 'crypto' import type { EmbeddingProvider } from './types' import type { EmbeddingConfig } from '../types' import { ApiEmbeddingProvider } from './api' -import { LocalEmbeddingProvider } from './local' + import { SharedEmbeddingClient } from './client' import { resolveDataDir } from '../storage/database' import type { CacheService } from '../cache/types' diff --git a/src/hooks/loop.ts b/src/hooks/loop.ts index f487a56..73a4cdf 100644 --- a/src/hooks/loop.ts +++ b/src/hooks/loop.ts @@ -2,8 +2,8 @@ import type { PluginInput } from '@opencode-ai/plugin' import type { OpencodeClient } from '@opencode-ai/sdk/v2' import type { LoopService, LoopState } from '../services/loop' import { MAX_RETRIES, MAX_CONSECUTIVE_STALLS, LOOP_PERMISSION_RULESET } from '../services/loop' -import type { Logger, PluginConfig, LoopConfig } from '../types' -import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' +import type { Logger, PluginConfig } from '../types' +import { retryWithModelFallback } from '../utils/model-fallback' import { resolveLoopModel } from '../utils/loop-helpers' import { execSync, spawnSync } from 'child_process' import { resolve } from 'path' @@ -20,7 +20,7 @@ export interface LoopEventHandler { export function createLoopEventHandler( loopService: LoopService, - client: PluginInput['client'], + _client: PluginInput['client'], v2Client: OpencodeClient, logger: Logger, getConfig: () => PluginConfig, @@ -252,9 +252,8 @@ export function createLoopEventHandler( }) } - let commitResult: { committed: boolean; cleaned: boolean } | undefined if (reason === 'completed' || reason === 'cancelled') { - commitResult = await commitAndCleanupWorktree(state) + await commitAndCleanupWorktree(state) } if (state.sandbox && state.sandboxContainerName && sandboxManager) { @@ -267,8 +266,7 @@ export function createLoopEventHandler( } } - async function handlePromptError(worktreeName: string, state: LoopState, context: string, err: unknown, retryFn?: () => Promise): Promise { - const sessionId = state.sessionId + async function handlePromptError(worktreeName: string, _state: LoopState, context: string, err: unknown, retryFn?: () => Promise): Promise { const currentState = loopService.getActiveState(worktreeName) if (!currentState?.active) { logger.log(`Loop: loop ${worktreeName} already terminated, ignoring error: ${context}`) @@ -302,7 +300,7 @@ export function createLoopEventHandler( } } - async function getLastAssistantInfo(sessionId: string, worktreeDir: string): Promise<{ text: string | null; error: string | null }> { + async function getLastAssistantInfo(sessionId: string, worktreeDir: string): Promise<{ text: string | null; error: string | null; lastMessageRole: string }> { try { const messagesResult = await v2Client.session.messages({ sessionID: sessionId, @@ -315,9 +313,14 @@ export function createLoopEventHandler( parts: Array<{ type: string; text?: string }> }> + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null const lastAssistant = [...messages].reverse().find((m) => m.info.role === 'assistant') - if (!lastAssistant) return { text: null, error: null } + if (!lastAssistant) { + const role = lastMessage?.info.role ?? 'none' + logger.log(`Loop: no assistant message found in session ${sessionId}, last message role: ${role}`) + return { text: null, error: null, lastMessageRole: role } + } const text = lastAssistant.parts .filter((p) => p.type === 'text' && typeof p.text === 'string') @@ -326,10 +329,10 @@ export function createLoopEventHandler( const error = lastAssistant.info.error?.data?.message ?? lastAssistant.info.error?.name ?? null - return { text, error } + return { text, error, lastMessageRole: 'assistant' } } catch (err) { logger.error(`Loop: could not read session messages`, err) - return { text: null, error: null } + return { text: null, error: null, lastMessageRole: 'error' } } } @@ -377,7 +380,7 @@ export function createLoopEventHandler( return newSessionId } - async function handleCodingPhase(worktreeName: string, state: LoopState): Promise { + async function handleCodingPhase(worktreeName: string, _state: LoopState): Promise { let currentState = loopService.getActiveState(worktreeName) if (!currentState?.active) { logger.log(`Loop: loop ${worktreeName} no longer active, skipping coding phase`) @@ -392,7 +395,11 @@ export function createLoopEventHandler( let assistantErrorDetected = false if (currentState.completionSignal) { - const { text: textContent, error: assistantError } = await getLastAssistantInfo(currentState.sessionId, currentState.worktreeDir) + const { text: textContent, error: assistantError, lastMessageRole } = await getLastAssistantInfo(currentState.sessionId, currentState.worktreeDir) + if (lastMessageRole !== 'assistant') { + logger.error(`Loop: assistant message not found in coding phase (last message: ${lastMessageRole}), session may not have responded yet`) + return + } if (assistantError) { assistantErrorDetected = true logger.error(`Loop: assistant error detected in coding phase: ${assistantError}`) @@ -551,7 +558,7 @@ export function createLoopEventHandler( consecutiveStalls.set(worktreeName, 0) } - async function handleAuditingPhase(worktreeName: string, state: LoopState): Promise { + async function handleAuditingPhase(worktreeName: string, _state: LoopState): Promise { // Re-fetch and validate state to catch aborts that happened during idle event processing let currentState = loopService.getActiveState(worktreeName) if (!currentState?.active) { @@ -565,7 +572,12 @@ export function createLoopEventHandler( return } - const { text: auditText, error: assistantError } = await getLastAssistantInfo(currentState.sessionId, currentState.worktreeDir) + const { text: auditText, error: assistantError, lastMessageRole } = await getLastAssistantInfo(currentState.sessionId, currentState.worktreeDir) + + if (lastMessageRole !== 'assistant') { + logger.error(`Loop: assistant message not found in auditing phase (last message: ${lastMessageRole}), session may not have responded yet`) + return + } let assistantErrorDetected = false if (assistantError) { diff --git a/src/index.ts b/src/index.ts index 7388785..49e47b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ import type { Plugin, PluginInput, Hooks } from '@opencode-ai/plugin' -import { tool } from '@opencode-ai/plugin' import { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2' import { agents } from './agents' import { createConfigHandler } from './config' @@ -16,11 +15,10 @@ import { resolveLogPath } from './storage' import { createLogger } from './utils/logger' import { createDockerService } from './sandbox/docker' import { createSandboxManager } from './sandbox/manager' -import { join } from 'path' import type { PluginConfig, CompactionConfig } from './types' -import { createTools, createToolExecuteBeforeHook, createToolExecuteAfterHook, autoValidateOnLoad, scopeEnum } from './tools' +import { createTools, createToolExecuteBeforeHook, createToolExecuteAfterHook, autoValidateOnLoad } from './tools' import { createSandboxToolBeforeHook, createSandboxToolAfterHook } from './hooks/sandbox-tools' -import type { DimensionMismatchState, InitState, ToolContext } from './tools' +import type { DimensionMismatchState, ToolContext } from './tools' import type { VecService } from './storage/vec-types' import { isSandboxEnabled } from './sandbox/context' diff --git a/src/sandbox/context.ts b/src/sandbox/context.ts index 9a37f70..31c1eb8 100644 --- a/src/sandbox/context.ts +++ b/src/sandbox/context.ts @@ -1,5 +1,4 @@ import type { DockerService } from './docker' -import type { LoopService } from '../services/loop' import type { PluginConfig } from '../types' export interface SandboxContext { diff --git a/src/sandbox/docker.ts b/src/sandbox/docker.ts index 37bcfc3..7f452ac 100644 --- a/src/sandbox/docker.ts +++ b/src/sandbox/docker.ts @@ -1,4 +1,4 @@ -import { spawn, spawnSync } from 'child_process' +import { spawn } from 'child_process' import type { Logger } from '../types' export interface DockerExecOpts { diff --git a/src/services/kv.ts b/src/services/kv.ts index d8467b9..36b4920 100644 --- a/src/services/kv.ts +++ b/src/services/kv.ts @@ -19,7 +19,7 @@ export interface KvService { listByPrefix(projectId: string, prefix: string): KvEntry[] } -export function createKvService(db: Database, logger?: Logger, defaultTtlMs?: number): KvService { +export function createKvService(db: Database, _logger?: Logger, defaultTtlMs?: number): KvService { const queries = createKvQuery(db) return { diff --git a/src/storage/vec-client.ts b/src/storage/vec-client.ts index cd7ba15..4e005bf 100644 --- a/src/storage/vec-client.ts +++ b/src/storage/vec-client.ts @@ -59,17 +59,7 @@ async function isWorkerRunning(pidPath: string, socketPath: string): Promise { - const { v2, directory, config, loopService, loopHandler, logger, sandboxManager } = ctx + const { v2, directory, config, loopService, logger, sandboxManager } = ctx const autoWorktreeName = options.worktreeName ?? `loop-${slugify(options.sessionTitle.replace(/^Loop:\s*/i, ''))}` const projectDir = directory const maxIter = options.maxIterations ?? config.loop?.defaultMaxIterations ?? 0 @@ -237,7 +237,7 @@ async function setupLoop( } export function createLoopTools(ctx: ToolContext): Record> { - const { v2, loopService, loopHandler, config, directory, logger } = ctx + const { v2, loopService, loopHandler, config, logger } = ctx return { 'memory-loop': tool({ @@ -247,7 +247,7 @@ export function createLoopTools(ctx: ToolContext): Record { + execute: async (args, _context) => { if (config.loop?.enabled === false) { return 'Loops are disabled in plugin config. Use memory-plan-execute instead.' } diff --git a/src/tools/plan-approval.ts b/src/tools/plan-approval.ts index 7fbbcef..c22fe60 100644 --- a/src/tools/plan-approval.ts +++ b/src/tools/plan-approval.ts @@ -47,7 +47,7 @@ export function createToolExecuteBeforeHook(ctx: ToolContext): Hooks['tool.execu return async ( input: { tool: string; sessionID: string; callID: string }, - output: { args: unknown } + _output: { args: unknown } ) => { const worktreeName = loopService.resolveWorktreeName(input.sessionID) const state = worktreeName ? loopService.getActiveState(worktreeName) : null diff --git a/src/tools/types.ts b/src/tools/types.ts index e7a99ec..79d30bf 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -1,6 +1,6 @@ import { tool } from '@opencode-ai/plugin' import type { Database } from 'bun:sqlite' -import type { PluginConfig, Logger, MemoryScope } from '../types' +import type { PluginConfig, Logger } from '../types' import type { EmbeddingProvider } from '../embedding' import type { VecService } from '../storage/vec-types' import type { MemoryService } from '../services/memory' diff --git a/src/version.ts b/src/version.ts index 83bb812..f009d9f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.0.28' +export const VERSION = '0.0.29' diff --git a/tsconfig.json b/tsconfig.json index 7ab45d3..ba0b3ff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,11 @@ "noEmit": true, "jsx": "react-jsx", "jsxImportSource": "@opentui/solid", - "types": ["bun-types"] + "types": ["bun-types"], + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true }, "include": ["src/**/*"] } From 99d52840d737fe2b080dbb3c5f1b4b878f404ba1 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:26:29 -0400 Subject: [PATCH 4/9] Fix post-refactor bugs: sandbox init and completion signal detection - src/index.ts:89: Use config.sandbox?.mode === 'docker' directly instead of isSandboxEnabled(config, null) which always returned false since sandboxManager was not yet created at init time - src/services/loop.ts:138-143: Revert checkCompletionSignal to simple case-insensitive includes() matching. The tag wrapping was introduced in the refactor but never wired into agent instructions, causing loops to run indefinitely - test/loop.test.ts: Update test cases to use raw signal strings and add case-insensitive test - src/tools/loop.ts: Import DEFAULT_COMPLETION_SIGNAL from services instead of duplicating, and use isSandboxEnabled() consistently in restart - src/tui.tsx:210: Sync inlined completion instructions with canonical version - src/utils/loop-helpers.ts: Remove unused formatElapsed function All 329 tests pass. TypeScript and type checking pass. --- src/index.ts | 3 +-- src/services/loop.ts | 10 ++-------- src/tools/loop.ts | 5 ++--- src/tui.tsx | 2 +- src/utils/loop-helpers.ts | 5 ----- test/loop.test.ts | 19 ++++++++++++------- 6 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/index.ts b/src/index.ts index 49e47b2..9b96006 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,6 @@ import { createTools, createToolExecuteBeforeHook, createToolExecuteAfterHook, a import { createSandboxToolBeforeHook, createSandboxToolAfterHook } from './hooks/sandbox-tools' import type { DimensionMismatchState, ToolContext } from './tools' import type { VecService } from './storage/vec-types' -import { isSandboxEnabled } from './sandbox/context' export function createMemoryPlugin(config: PluginConfig): Plugin { @@ -86,7 +85,7 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { } let sandboxManager: ReturnType | null = null - if (isSandboxEnabled(config, null)) { + if (config.sandbox?.mode === 'docker') { const dockerService = createDockerService(logger) try { sandboxManager = createSandboxManager(dockerService, { diff --git a/src/services/loop.ts b/src/services/loop.ts index 3cb9bbe..d8473e5 100644 --- a/src/services/loop.ts +++ b/src/services/loop.ts @@ -135,16 +135,10 @@ export function createLoopService( kvService.delete(projectId, `loop-session:${sessionId}`) } - function checkCompletionSignal(text: string, promise: string): boolean { - // If promise already contains tags, use exact match; otherwise wrap with tags - const pattern = promise.includes('') ? escapeRegex(promise) : `${escapeRegex(promise)}` - const regex = new RegExp(pattern, 'i') - return regex.test(text) + function checkCompletionSignal(text: string, completionSignal: string): boolean { + return text.toLowerCase().includes(completionSignal.toLowerCase()) } - function escapeRegex(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - } function redactCompletionSignal(text: string, promise: string): string { return text.replaceAll(promise, '[SIGNAL_REDACTED]') diff --git a/src/tools/loop.ts b/src/tools/loop.ts index 0d5a2b5..994f874 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -8,12 +8,11 @@ import { parseModelString, retryWithModelFallback } from '../utils/model-fallbac import { slugify } from '../utils/logger' import { findPartialMatch } from '../utils/partial-match' import { formatSessionOutput, formatAuditResult } from '../utils/loop-format' -import { fetchSessionOutput, MAX_RETRIES, LOOP_PERMISSION_RULESET, buildCompletionSignalInstructions, type LoopState, type LoopSessionOutput } from '../services/loop' +import { fetchSessionOutput, MAX_RETRIES, LOOP_PERMISSION_RULESET, buildCompletionSignalInstructions, DEFAULT_COMPLETION_SIGNAL, type LoopState, type LoopSessionOutput } from '../services/loop' import { isSandboxEnabled } from '../sandbox/context' import { formatDuration, computeElapsedSeconds } from '../utils/loop-helpers' const z = tool.schema -const DEFAULT_COMPLETION_SIGNAL = 'ALL_PHASES_COMPLETE' interface LoopSetupOptions { prompt: string @@ -392,7 +391,7 @@ export function createLoopTools(ctx: ToolContext): Record 0 ? `${minutes}m ${secs}s` : `${secs}s` } -export function formatElapsed(startedAt: string): string { - const elapsed = Math.round((Date.now() - new Date(startedAt).getTime()) / 1000) - return formatDuration(elapsed) -} - export function computeElapsedSeconds(startedAt?: string, endedAt?: string): number { if (!startedAt) return 0 const start = new Date(startedAt).getTime() diff --git a/test/loop.test.ts b/test/loop.test.ts index a4bca67..0d866cd 100644 --- a/test/loop.test.ts +++ b/test/loop.test.ts @@ -107,23 +107,28 @@ describe('LoopService', () => { }) test('checkCompletionSignal matches exact phrase', () => { - const text = 'Some response text ALL_PHASES_COMPLETE more text' - expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(true) + const text = 'Some response text ALL_PHASES_COMPLETE more text' + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(true) }) test('checkCompletionSignal returns false when phrase not present', () => { const text = 'Some response text without the phrase' - expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(false) + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(false) }) test('checkCompletionSignal returns false when phrase does not match', () => { - const text = 'Some response NOT_COMPLETE text' - expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(false) + const text = 'Some response NOT_COMPLETE text' + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(false) }) test('checkCompletionSignal requires exact match', () => { - const text = 'Response ALL_PHASES_COMPLETE text' - expect(loopService.checkCompletionSignal(text, 'NOT_COMPLETE')).toBe(false) + const text = 'Response ALL_PHASES_COMPLETE text' + expect(loopService.checkCompletionSignal(text, 'NOT_COMPLETE')).toBe(false) + }) + + test('checkCompletionSignal is case-insensitive', () => { + const text = 'Some response all_phases_complete more text' + expect(loopService.checkCompletionSignal(text, 'ALL_PHASES_COMPLETE')).toBe(true) }) test('buildContinuationPrompt includes iteration number', () => { From 0e8e04dd3136835a9ef781b37aa88f36ed1ba4a5 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:48:32 -0400 Subject: [PATCH 5/9] Simplify KV migration and strengthen type guards --- src/index.ts | 6 +++- src/services/loop.ts | 83 +++++++++++++++++++++++--------------------- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9b96006..7841175 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,7 +75,11 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { const kvService = createKvService(db, logger, config.defaultKvTtlMs) const loopService = createLoopService(kvService, projectId, logger, config.loop) - migrateRalphKeys(kvService, projectId, logger).catch(() => {}) + try { + migrateRalphKeys(kvService, projectId, logger) + } catch (err) { + logger.error('Failed to migrate ralph: KV entries', err) + } const activeSandboxLoops = loopService.listActive().filter(s => s.sandbox && s.worktreeName) diff --git a/src/services/loop.ts b/src/services/loop.ts index d8473e5..e5e565e 100644 --- a/src/services/loop.ts +++ b/src/services/loop.ts @@ -3,31 +3,31 @@ import type { Logger, LoopConfig } from '../types' import type { OpencodeClient } from '@opencode-ai/sdk/v2' import { findPartialMatch } from '../utils/partial-match' -export async function migrateRalphKeys(kvService: KvService, projectId: string, logger: Logger): Promise { - const oldEntries = await kvService.listByPrefix(projectId, 'ralph:') +export function migrateRalphKeys(kvService: KvService, projectId: string, logger: Logger): void { + const oldEntries = kvService.listByPrefix(projectId, 'ralph:') if (oldEntries.length === 0) return - logger.log(`Migrating ${oldEntries.length} ralph: KV entries to loop: prefix`) + logger.log(`Migrating ${String(oldEntries.length)} ralph: KV entries to loop: prefix`) for (const entry of oldEntries) { const newKey = entry.key.replace(/^ralph:/, 'loop:') - const data = typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data + const data = (typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data) as Record if ('inPlace' in data) { - data.worktree = !data.inPlace + data.worktree = !(data.inPlace as boolean) delete data.inPlace } - await kvService.set(projectId, newKey, data) - await kvService.delete(projectId, entry.key) + kvService.set(projectId, newKey, data) + kvService.delete(projectId, entry.key) } - const oldSessions = await kvService.listByPrefix(projectId, 'ralph-session:') + const oldSessions = kvService.listByPrefix(projectId, 'ralph-session:') for (const entry of oldSessions) { const newKey = entry.key.replace(/^ralph-session:/, 'loop-session:') - await kvService.set(projectId, newKey, entry.data) - await kvService.delete(projectId, entry.key) + kvService.set(projectId, newKey, entry.data) + kvService.delete(projectId, entry.key) } if (oldSessions.length > 0) { - logger.log(`Migrated ${oldSessions.length} ralph-session: KV entries to loop-session: prefix`) + logger.log(`Migrated ${String(oldSessions.length)} ralph-session: KV entries to loop-session: prefix`) } } @@ -109,7 +109,7 @@ export function createLoopService( function getActiveState(name: string): LoopState | null { const state = kvService.get(projectId, stateKey(name)) - if (!state || !state.active) { + if (!state?.active) { return null } return state @@ -145,12 +145,12 @@ export function createLoopService( } function buildContinuationPrompt(state: LoopState, auditFindings?: string): string { - let systemLine = `Loop iteration ${state.iteration ?? 0}` + let systemLine = `Loop iteration ${String(state.iteration)}` if (state.completionSignal) { systemLine += ` | To stop: output ${state.completionSignal} (ONLY after all verification commands pass AND all phase acceptance criteria are met)` - } else if ((state.maxIterations ?? 0) > 0) { - systemLine += ` / ${state.maxIterations}` + } else if (state.maxIterations > 0) { + systemLine += ` / ${String(state.maxIterations)}` } else { systemLine += ` | No completion promise set - loop runs until cancelled` } @@ -170,20 +170,20 @@ export function createLoopService( const outstandingFindings = getOutstandingFindings(state.worktreeBranch) if (outstandingFindings.length > 0) { const findingKeys = outstandingFindings.map((f) => `- \`${f.key}\``).join('\n') - prompt += `\n\n---\n⚠️ Outstanding Review Findings (${outstandingFindings.length})\n\nThese review findings are blocking loop completion. Fix these issues so they pass the next audit review.\n\n${findingKeys}` + prompt += `\n\n---\n⚠️ Outstanding Review Findings (${String(outstandingFindings.length)})\n\nThese review findings are blocking loop completion. Fix these issues so they pass the next audit review.\n\n${findingKeys}` } return prompt } function buildAuditPrompt(state: LoopState): string { - const taskSummary = (state.prompt?.length ?? 0) > 200 - ? `${state.prompt?.substring(0, 197)}...` + const taskSummary = state.prompt && state.prompt.length > 200 + ? `${state.prompt.substring(0, 197)}...` : (state.prompt ?? '') const branchInfo = state.worktreeBranch ? ` (branch: ${state.worktreeBranch})` : '' return [ - `Post-iteration ${state.iteration ?? 0} code review${branchInfo}.`, + `Post-iteration ${String(state.iteration)} code review${branchInfo}.`, '', `Task context: ${taskSummary}`, '', @@ -200,15 +200,19 @@ export function createLoopService( function listActive(): LoopState[] { const entries = kvService.listByPrefix(projectId, 'loop:') return entries - .map((entry) => entry.data as LoopState) - .filter((state): state is LoopState => state !== null && state.active) + .map((entry) => entry.data) + .filter((data): data is LoopState => + data !== null && typeof data === 'object' && 'active' in data && (data as LoopState).active + ) } function listRecent(): LoopState[] { const entries = kvService.listByPrefix(projectId, 'loop:') return entries - .map((entry) => entry.data as LoopState) - .filter((state): state is LoopState => state !== null && !state.active) + .map((entry) => entry.data) + .filter((data): data is LoopState => + data !== null && typeof data === 'object' && 'active' in data && !(data as LoopState).active + ) } function findByWorktreeName(name: string): LoopState | null { @@ -248,7 +252,7 @@ export function createLoopService( } setState(state.worktreeName, updated) } - logger.log(`Loop: terminated ${active.length} active loop(s)`) + logger.log(`Loop: terminated ${String(active.length)} active loop(s)`) } function reconcileStale(): number { @@ -260,7 +264,7 @@ export function createLoopService( completedAt: new Date().toISOString(), terminationReason: 'shutdown', }) - logger.log(`Reconciled stale active loop: ${state.worktreeName} (was at iteration ${state.iteration})`) + logger.log(`Reconciled stale active loop: ${state.worktreeName} (was at iteration ${String(state.iteration)})`) } return active.length } @@ -270,7 +274,7 @@ export function createLoopService( if (!branch) return findings return findings.filter((f) => { const data = f.data as Record | null - return data && data.branch === branch + return data?.branch === branch }) } @@ -303,7 +307,7 @@ export function createLoopService( } export interface LoopSessionOutput { - messages: Array<{ text: string; cost: number; tokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number } }> + messages: { text: string; cost: number; tokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number } }[] totalCost: number totalTokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number } fileChanges: { additions: number; deletions: number; files: number } | null @@ -326,18 +330,19 @@ export async function fetchSessionOutput( directory, }) - const messages = (messagesResult.data ?? []) as Array<{ + const messages = (messagesResult.data ?? []) as { info: { role: string; cost?: number; tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } } - parts: Array<{ type: string; text?: string }> - }> + parts: { type: string; text?: string }[] + }[] const assistantMessages = messages.filter((m) => m.info.role === 'assistant') const lastThree = assistantMessages.slice(-RECENT_MESSAGES_COUNT) const extractedMessages = lastThree.map((msg) => { const text = msg.parts - .filter((p) => p.type === 'text' && typeof p.text === 'string') - .map((p) => p.text as string) + .filter((p) => p.type === 'text' && p.text !== undefined) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map((p) => p.text!) .join('\n') const cost = msg.info.cost ?? 0 const tokens = msg.info.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } @@ -348,8 +353,8 @@ export async function fetchSessionOutput( input: tokens.input, output: tokens.output, reasoning: tokens.reasoning, - cacheRead: tokens.cache?.read ?? 0, - cacheWrite: tokens.cache?.write ?? 0, + cacheRead: tokens.cache.read, + cacheWrite: tokens.cache.write, }, } }) @@ -365,11 +370,11 @@ export async function fetchSessionOutput( totalCost += msg.info.cost ?? 0 const tokens = msg.info.tokens if (tokens) { - totalInputTokens += tokens.input ?? 0 - totalOutputTokens += tokens.output ?? 0 - totalReasoningTokens += tokens.reasoning ?? 0 - totalCacheRead += tokens.cache?.read ?? 0 - totalCacheWrite += tokens.cache?.write ?? 0 + totalInputTokens += tokens.input + totalOutputTokens += tokens.output + totalReasoningTokens += tokens.reasoning + totalCacheRead += tokens.cache.read + totalCacheWrite += tokens.cache.write } } From 54abed97c625f8e47b2a2ec3e95089afdb87e8aa Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:53:18 -0400 Subject: [PATCH 6/9] Fix completionSignal type in tests --- test/cli-cancel.test.ts | 23 +++--------------- test/cli-status.test.ts | 23 +++--------------- test/loop.test.ts | 38 ++++++++++++++--------------- test/plan-approval.test.ts | 50 +++++++++++++++++++------------------- test/tool-blocking.test.ts | 4 +-- 5 files changed, 52 insertions(+), 86 deletions(-) diff --git a/test/cli-cancel.test.ts b/test/cli-cancel.test.ts index d52e82b..868afda 100644 --- a/test/cli-cancel.test.ts +++ b/test/cli-cancel.test.ts @@ -3,26 +3,7 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' import { join } from 'path' import { mkdtempSync, rmSync } from 'fs' - -interface LoopState { - sessionId: string - worktreeName: string - worktreeBranch: string - worktreeDir: string - worktree: boolean - iteration: number - maxIterations: number - phase: 'coding' | 'auditing' - startedAt: string - completedAt?: string - terminationReason?: string - active: boolean - audit: boolean - errorCount: number - auditCount: number - completionPromise?: string - lastAuditResult?: string -} +import { type LoopState } from '../src/services/loop' function createTestKvDb(tempDir: string): Database { const dbPath = join(tempDir, 'memory.db') @@ -57,6 +38,8 @@ function insertLoopState(db: Database, projectId: string, worktreeName: string, audit: false, errorCount: 0, auditCount: 0, + completionSignal: null, + lastAuditResult: undefined, ...state, } diff --git a/test/cli-status.test.ts b/test/cli-status.test.ts index d6cf2ff..a2d3d8d 100644 --- a/test/cli-status.test.ts +++ b/test/cli-status.test.ts @@ -3,26 +3,7 @@ import { Database } from 'bun:sqlite' import { existsSync } from 'fs' import { join } from 'path' import { mkdtempSync, rmSync } from 'fs' - -interface LoopState { - sessionId: string - worktreeName: string - worktreeBranch: string - worktreeDir: string - worktree: boolean - iteration: number - maxIterations: number - phase: 'coding' | 'auditing' - startedAt: string - completedAt?: string - terminationReason?: string - active: boolean - audit: boolean - errorCount: number - auditCount: number - completionPromise?: string - lastAuditResult?: string -} +import { type LoopState } from '../src/services/loop' function createTestKvDb(tempDir: string): Database { const dbPath = join(tempDir, 'memory.db') @@ -57,6 +38,7 @@ function insertLoopState(db: Database, projectId: string, worktreeName: string, audit: false, errorCount: 0, auditCount: 0, + completionSignal: null, ...state, } @@ -127,6 +109,7 @@ describe('CLI Status - list-worktrees', () => { audit: false, errorCount: 0, auditCount: 0, + completionSignal: null, } db2.run( diff --git a/test/loop.test.ts b/test/loop.test.ts index 0d866cd..65a6357 100644 --- a/test/loop.test.ts +++ b/test/loop.test.ts @@ -56,7 +56,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -163,7 +163,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 0, - completionSignal: 'COMPLETE_TASK', + completionSignal: 'COMPLETE_TASK', startedAt: new Date().toISOString(), prompt: 'My test prompt', phase: 'coding' as const, @@ -173,7 +173,7 @@ describe('LoopService', () => { } const prompt = loopService.buildContinuationPrompt(state) - expect(prompt).toContain('[Loop iteration 1 | To stop: output COMPLETE_TASK (ONLY after all verification commands pass AND all phase acceptance criteria are met)]') + expect(prompt).toContain('[Loop iteration 1 | To stop: output COMPLETE_TASK (ONLY after all verification commands pass AND all phase acceptance criteria are met)]') }) test('buildContinuationPrompt includes max iterations when no promise', () => { @@ -229,7 +229,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 5, maxIterations: 10, - completionSignal: 'PERSIST_TEST', + completionSignal: 'PERSIST_TEST', startedAt: new Date().toISOString(), prompt: 'Persistence test', phase: 'coding' as const, @@ -332,7 +332,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -446,7 +446,7 @@ describe('LoopService', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -493,7 +493,7 @@ describe('LoopService', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'In-place test prompt', phase: 'coding' as const, @@ -541,7 +541,7 @@ describe('LoopService', () => { worktreeBranch: 'main', iteration: 3, maxIterations: 0, - completionSignal: 'COMPLETE', + completionSignal: 'COMPLETE', startedAt: new Date().toISOString(), prompt: 'In-place prompt test', phase: 'coding' as const, @@ -553,7 +553,7 @@ describe('LoopService', () => { const prompt = loopService.buildContinuationPrompt(inPlaceState) expect(prompt).toContain('Loop iteration 3') expect(prompt).toContain('In-place prompt test') - expect(prompt).toContain('COMPLETE') + expect(prompt).toContain('COMPLETE') }) test('buildContinuationPrompt with audit findings works with inPlace state', () => { @@ -933,7 +933,7 @@ describe('reconcileStale', () => { worktreeBranch: 'main', iteration: 3, maxIterations: 10, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1061,7 +1061,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 3, maxIterations: 0, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1089,7 +1089,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1111,7 +1111,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 3, maxIterations: 0, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1138,7 +1138,7 @@ describe('buildContinuationPrompt with outstanding findings', () => { worktreeBranch: 'opencode/loop-test', iteration: 2, maxIterations: 0, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1453,7 +1453,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1873,7 +1873,7 @@ describe('Assistant Error Detection', () => { worktreeBranch: 'main', iteration: 1, maxIterations: 5, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1946,7 +1946,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 3, maxIterations: 10, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), terminationReason: 'cancelled', @@ -1973,7 +1973,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 5, maxIterations: 10, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), terminationReason: 'completed', @@ -2000,7 +2000,7 @@ describe('Force-restart behavior', () => { worktreeBranch: 'main', iteration: 2, maxIterations: 10, - completionSignal: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts index d5e31a2..b3c28d0 100644 --- a/test/plan-approval.test.ts +++ b/test/plan-approval.test.ts @@ -82,32 +82,32 @@ Do NOT output text without also making this tool call. db.close() }) - function simulateToolExecuteAfter( - tool: string, - args: unknown, - output: { title: string; output: string; metadata: unknown }, - sessionActive = false - ) { - if (sessionActive) { - const state = { - active: true, - sessionId: sessionID, - worktreeName: 'test-worktree', - worktreeDir: '/test/worktree', - worktreeBranch: 'opencode/loop-test', - iteration: 1, - maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', - startedAt: new Date().toISOString(), - prompt: 'Test prompt', - phase: 'coding' as const, - audit: false, - errorCount: 0, - auditCount: 0, - worktree: true, + function simulateToolExecuteAfter( + tool: string, + args: unknown, + output: { title: string; output: string; metadata: unknown }, + sessionActive = false + ) { + if (sessionActive) { + const state = { + active: true, + sessionId: sessionID, + worktreeName: 'test-worktree', + worktreeDir: '/test/worktree', + worktreeBranch: 'opencode/loop-test', + iteration: 1, + maxIterations: 5, + completionSignal: 'ALL_PHASES_COMPLETE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + worktree: true, + } + loopService.setState(sessionID, state) } - loopService.setState(sessionID, state) - } if (tool === 'question') { const questionArgs = args as { questions?: Array<{ options?: Array<{ label: string }> }> } | undefined diff --git a/test/tool-blocking.test.ts b/test/tool-blocking.test.ts index 8bd79e2..745c1bc 100644 --- a/test/tool-blocking.test.ts +++ b/test/tool-blocking.test.ts @@ -57,7 +57,7 @@ describe('Tool Blocking Logic', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -87,7 +87,7 @@ describe('Tool Blocking Logic', () => { worktreeBranch: 'opencode/loop-test', iteration: 1, maxIterations: 5, - completionPromise: 'ALL_PHASES_COMPLETE', + completionSignal: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, From f8969864eb48381b98d40edb2335b31ef8a3479c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:47:36 -0400 Subject: [PATCH 7/9] Update ESLint configuration with relaxed type checking rules --- eslint.config.js | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 54ef6cd..41ac986 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,16 +7,14 @@ export default tseslint.config( ...tseslint.configs.recommended, { files: ["**/*.{ts,tsx}"], - extends: [ - ...tseslint.configs.strictTypeChecked, - ...tseslint.configs.stylisticTypeChecked, - ], plugins: { solid: solidPlugin, }, languageOptions: { parserOptions: { - projectService: true, + projectService: { + allowDefaultProject: ["scripts/*.ts", "scripts/*.js", "test/*.ts"], + }, tsconfigRootDir: import.meta.dirname, }, parser: tseslint.parser, @@ -30,16 +28,9 @@ export default tseslint.config( caughtErrorsIgnorePattern: "^_", }, ], - "@typescript-eslint/consistent-type-imports": [ - "error", - { - prefer: "type-imports", - fixStyle: "inline-type-imports", - }, - ], - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/prefer-nullish-coalescing": "error", - "@typescript-eslint/prefer-optional-chain": "error", + "@typescript-eslint/no-deprecated": "warn", + "no-useless-assignment": "warn", + "no-empty": "off", }, }, { @@ -49,6 +40,15 @@ export default tseslint.config( }, }, { - ignores: ["dist/**", "node_modules/**", "*.d.ts"], + files: ["scripts/*.js"], + languageOptions: { + globals: { + process: "readonly", + console: "readonly", + }, + }, + }, + { + ignores: ["dist/**", "node_modules/**", "*.d.ts", "test/**"], } ); From c5312d1c41c8e5fcee41536a2db58030d38c969c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:52:27 -0400 Subject: [PATCH 8/9] Fix lint errors: replace any types and remove unused variables --- src/hooks/sandbox-tools.ts | 3 +++ src/sandbox/docker.ts | 12 ++++++------ src/tools/loop.ts | 4 ++-- src/tools/sandbox-fs.ts | 2 -- src/tools/types.ts | 1 + 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/hooks/sandbox-tools.ts b/src/hooks/sandbox-tools.ts index 121fde7..4cd4a05 100644 --- a/src/hooks/sandbox-tools.ts +++ b/src/hooks/sandbox-tools.ts @@ -19,6 +19,7 @@ const STALE_THRESHOLD_MS = 5 * 60 * 1000 export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['tool.execute.before'] { return async ( input: { tool: string; sessionID: string; callID: string }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches upstream Hooks type output: { args: any }, ) => { if (input.tool !== 'bash') return @@ -76,7 +77,9 @@ export function createSandboxToolBeforeHook(deps: SandboxToolHookDeps): Hooks['t export function createSandboxToolAfterHook(deps: SandboxToolHookDeps): Hooks['tool.execute.after'] { return async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches upstream Hooks type input: { tool: string; sessionID: string; callID: string; args: any }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches upstream Hooks type output: { title: string; output: string; metadata: any }, ) => { if (input.tool !== 'bash') return diff --git a/src/sandbox/docker.ts b/src/sandbox/docker.ts index 7f452ac..c1be9d9 100644 --- a/src/sandbox/docker.ts +++ b/src/sandbox/docker.ts @@ -1,4 +1,4 @@ -import { spawn } from 'child_process' +import { spawn, type ChildProcess } from 'child_process' import type { Logger } from '../types' export interface DockerExecOpts { @@ -173,7 +173,7 @@ export function createDockerService(logger: Logger): DockerService { const inner = new Promise((resolve) => { const stdioConfig: 'pipe' | 'ignore' = options?.stdin ? 'pipe' : 'ignore' - const child: any = spawn(command, args, { + const child: ChildProcess = spawn(command, args, { stdio: [stdioConfig, 'pipe', 'pipe'], }) @@ -217,17 +217,17 @@ export function createDockerService(logger: Logger): DockerService { } } - child.stdout.on('data', (data: Buffer) => { + child.stdout!.on('data', (data: Buffer) => { stdout += data.toString() }) - child.stderr.on('data', (data: Buffer) => { + child.stderr!.on('data', (data: Buffer) => { stderr += data.toString() }) if (options?.stdin) { - child.stdin.write(options.stdin) - child.stdin.end() + child.stdin!.write(options.stdin) + child.stdin!.end() } child.on('close', (code: number | null) => { diff --git a/src/tools/loop.ts b/src/tools/loop.ts index 994f874..d8ecd3e 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -49,7 +49,7 @@ async function setupLoop( let currentBranch: string | undefined try { currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectDir, encoding: 'utf-8' }).trim() - } catch (err) { + } catch (_err) { logger.log(`loop: no git branch detected, running without branch info`) } @@ -497,7 +497,7 @@ export function createLoopTools(ctx: ToolContext): Record = {} + const statuses: Record = {} try { const uniqueDirs = [...new Set(active.map((s) => s.worktreeDir).filter(Boolean))] const results = await Promise.allSettled( diff --git a/src/tools/sandbox-fs.ts b/src/tools/sandbox-fs.ts index ac608e9..5f5d5e5 100644 --- a/src/tools/sandbox-fs.ts +++ b/src/tools/sandbox-fs.ts @@ -106,7 +106,6 @@ export function createSandboxFsTools(ctx: ToolContext): Record Date: Mon, 6 Apr 2026 10:30:35 -0400 Subject: [PATCH 9/9] Update TUI plugin documentation and add sandbox tests --- README.md | 16 +- src/services/loop.ts | 3 +- src/tui.tsx | 3 +- test/loop.test.ts | 420 ++++++++++++++++++++++++++++++++++- test/sandbox-docker.test.ts | 30 +++ test/sandbox-manager.test.ts | 199 +++++++++++++++++ test/sandbox-path.test.ts | 79 +++++++ 7 files changed, 746 insertions(+), 4 deletions(-) create mode 100644 test/sandbox-docker.test.ts create mode 100644 test/sandbox-path.test.ts diff --git a/README.md b/README.md index 14dc66a..44c47c2 100644 --- a/README.md +++ b/README.md @@ -419,10 +419,24 @@ The `Memory: Show loops` command is registered in the command palette when loops ### Setup -When installed via npm, the TUI plugin loads automatically. For local development, add the built TUI file to your `~/.config/opencode/tui.json`: +When installed via npm, the TUI plugin loads automatically when added to your TUI config. The plugin is auto-detected via the `./tui` export in `package.json`. + +Add to your `~/.config/opencode/tui.json` or project-level `tui.json`: + +```json +{ + "$schema": "https://opencode.ai/tui.json", + "plugin": [ + "@opencode-manager/memory" + ] +} +``` + +For local development, reference the built TUI file directly: ```json { + "$schema": "https://opencode.ai/tui.json", "plugin": [ "/path/to/opencode-memory/dist/tui.js" ] diff --git a/src/services/loop.ts b/src/services/loop.ts index e5e565e..a7887e8 100644 --- a/src/services/loop.ts +++ b/src/services/loop.ts @@ -141,7 +141,8 @@ export function createLoopService( function redactCompletionSignal(text: string, promise: string): string { - return text.replaceAll(promise, '[SIGNAL_REDACTED]') + const regex = new RegExp(promise.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi') + return text.replace(regex, '[SIGNAL_REDACTED]') } function buildContinuationPrompt(state: LoopState, auditFindings?: string): string { diff --git a/src/tui.tsx b/src/tui.tsx index 8e298fd..b4b51fc 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -3,7 +3,8 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plug import { createEffect, createMemo, createSignal, onCleanup, Show, For } from 'solid-js' import { readFileSync, existsSync } from 'fs' -// Mirrors LOOP_PERMISSION_RULESET from services/loop (TUI cannot import from services due to separate bundling) +// Note: LOOP_PERMISSION_RULESET is defined in services/loop but TUI cannot import from services due to separate bundling +// This duplication is intentional to avoid circular dependencies const LOOP_PERMISSION_RULESET = [ { permission: '*', pattern: '*', action: 'allow' as const }, { permission: 'external_directory', pattern: '*', action: 'deny' as const }, diff --git a/test/loop.test.ts b/test/loop.test.ts index 65a6357..09b5193 100644 --- a/test/loop.test.ts +++ b/test/loop.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test' import { Database } from 'bun:sqlite' import { createKvQuery } from '../src/storage/kv-queries' import { createKvService } from '../src/services/kv' -import { createLoopService } from '../src/services/loop' +import { createLoopService, migrateRalphKeys, buildCompletionSignalInstructions, fetchSessionOutput, type LoopState } from '../src/services/loop' const TEST_DIR = '/tmp/opencode-manager-loop-test-' + Date.now() @@ -2079,3 +2079,421 @@ describe('Force-restart behavior', () => { expect(retrieved).toBeNull() }) }) + +describe('migrateRalphKeys', () => { + let db: Database + let kvService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + }) + + afterEach(() => { + db.close() + }) + + test('migrates ralph: entries to loop: prefix', () => { + const logger = createMockLogger() + kvService.set(projectId, 'ralph:foo', { value: 'bar' }) + kvService.set(projectId, 'ralph:bar', { value: 'baz' }) + + migrateRalphKeys(kvService, projectId, logger) + + expect(kvService.get(projectId, 'loop:foo')).toEqual({ value: 'bar' }) + expect(kvService.get(projectId, 'loop:bar')).toEqual({ value: 'baz' }) + expect(kvService.get(projectId, 'ralph:foo')).toBeNull() + expect(kvService.get(projectId, 'ralph:bar')).toBeNull() + }) + + test('converts inPlace true to worktree false', () => { + const logger = createMockLogger() + kvService.set(projectId, 'ralph:test', { inPlace: true, sessionId: 'abc' }) + + migrateRalphKeys(kvService, projectId, logger) + + const migrated = kvService.get(projectId, 'loop:test') + expect(migrated).toEqual({ worktree: false, sessionId: 'abc' }) + expect((migrated as any)?.inPlace).toBeUndefined() + }) + + test('converts inPlace false to worktree true', () => { + const logger = createMockLogger() + kvService.set(projectId, 'ralph:test', { inPlace: false, sessionId: 'abc' }) + + migrateRalphKeys(kvService, projectId, logger) + + const migrated = kvService.get(projectId, 'loop:test') + expect(migrated).toEqual({ worktree: true, sessionId: 'abc' }) + expect((migrated as any)?.inPlace).toBeUndefined() + }) + + test('migrates ralph-session: entries to loop-session: prefix', () => { + const logger = createMockLogger() + kvService.set(projectId, 'ralph:dummy', { dummy: true }) + kvService.set(projectId, 'ralph-session:s1', 'worktree-1') + + migrateRalphKeys(kvService, projectId, logger) + + expect(kvService.get(projectId, 'loop-session:s1')).toBe('worktree-1') + expect(kvService.get(projectId, 'ralph-session:s1')).toBeNull() + }) + + test('no-op when no ralph entries exist', () => { + const logger = createMockLogger() + + expect(() => migrateRalphKeys(kvService, projectId, logger)).not.toThrow() + expect(kvService.listByPrefix(projectId, 'loop:').length).toBe(0) + }) + + test('logs migration count', () => { + const logs: string[] = [] + const logger = { + log: (msg: string) => logs.push(msg), + error: () => {}, + debug: () => {}, + } + kvService.set(projectId, 'ralph:foo', { value: 'bar' }) + kvService.set(projectId, 'ralph:bar', { value: 'baz' }) + + migrateRalphKeys(kvService, projectId, logger) + + expect(logs.some(log => log.includes('Migrating') && log.includes('2'))).toBe(true) + }) +}) + +describe('buildCompletionSignalInstructions', () => { + test('returns string containing the signal', () => { + const result = buildCompletionSignalInstructions('MY_SIGNAL') + expect(result).toContain('MY_SIGNAL') + }) + + test('contains verification instructions', () => { + const result = buildCompletionSignalInstructions('MY_SIGNAL') + expect(result).toContain('Verify each phase') + }) + + test('contains IMPORTANT header', () => { + const result = buildCompletionSignalInstructions('MY_SIGNAL') + expect(result).toContain('IMPORTANT') + }) +}) + +describe('terminateAll', () => { + let db: Database + let kvService: ReturnType + let loopService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + loopService = createLoopService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + function createActiveState(name: string, sessionId: string): LoopState { + return { + active: true, + sessionId, + worktreeName: name, + worktreeDir: `/tmp/${name}`, + worktreeBranch: 'main', + iteration: 1, + maxIterations: 5, + completionSignal: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + } + } + + test('marks all active loops as shutdown', () => { + const state1 = createActiveState('worktree-1', 'session-1') + const state2 = createActiveState('worktree-2', 'session-2') + + loopService.setState('worktree-1', state1) + loopService.setState('worktree-2', state2) + + loopService.terminateAll() + + const updated1 = loopService.getAnyState('worktree-1') + const updated2 = loopService.getAnyState('worktree-2') + + expect(updated1?.active).toBe(false) + expect(updated1?.terminationReason).toBe('shutdown') + expect(updated1?.completedAt).toBeDefined() + + expect(updated2?.active).toBe(false) + expect(updated2?.terminationReason).toBe('shutdown') + expect(updated2?.completedAt).toBeDefined() + }) + + test('does not affect inactive loops', () => { + const activeState = createActiveState('active', 'session-active') + const inactiveState: LoopState = { + ...createActiveState('inactive', 'session-inactive'), + active: false, + terminationReason: 'completed', + } + + loopService.setState('active', activeState) + loopService.setState('inactive', inactiveState) + + loopService.terminateAll() + + const inactive = loopService.getAnyState('inactive') + expect(inactive?.terminationReason).toBe('completed') + }) + + test('no-op with no active loops', () => { + expect(() => loopService.terminateAll()).not.toThrow() + }) +}) + +describe('listRecent', () => { + let db: Database + let kvService: ReturnType + let loopService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + loopService = createLoopService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + function createActiveState(name: string, sessionId: string): LoopState { + return { + active: true, + sessionId, + worktreeName: name, + worktreeDir: `/tmp/${name}`, + worktreeBranch: 'main', + iteration: 1, + maxIterations: 5, + completionSignal: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + } + } + + function createInactiveState(name: string, sessionId: string): LoopState { + return { + active: false, + sessionId, + worktreeName: name, + worktreeDir: `/tmp/${name}`, + worktreeBranch: 'main', + iteration: 1, + maxIterations: 5, + completionSignal: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + } + } + + test('returns only inactive states', () => { + loopService.setState('active-1', createActiveState('active-1', 'session-1')) + loopService.setState('active-2', createActiveState('active-2', 'session-2')) + loopService.setState('inactive-1', createInactiveState('inactive-1', 'session-3')) + + const recent = loopService.listRecent() + + expect(recent.length).toBe(1) + expect(recent[0].worktreeName).toBe('inactive-1') + }) + + test('returns empty array when no inactive states', () => { + loopService.setState('active-1', createActiveState('active-1', 'session-1')) + loopService.setState('active-2', createActiveState('active-2', 'session-2')) + + const recent = loopService.listRecent() + + expect(recent).toEqual([]) + }) +}) + +describe('findCandidatesByPartialName', () => { + let db: Database + let kvService: ReturnType + let loopService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + loopService = createLoopService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + function createActiveState(name: string, sessionId: string): LoopState { + return { + active: true, + sessionId, + worktreeName: name, + worktreeDir: `/tmp/${name}`, + worktreeBranch: 'main', + iteration: 1, + maxIterations: 5, + completionSignal: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + } + } + + test('returns multiple candidates for ambiguous match', () => { + loopService.setState('feature-auth', createActiveState('feature-auth', 'session-1')) + loopService.setState('feature-api', createActiveState('feature-api', 'session-2')) + + const candidates = loopService.findCandidatesByPartialName('feature') + + expect(candidates.length).toBe(2) + }) + + test('returns empty array when no matches', () => { + loopService.setState('feature-auth', createActiveState('feature-auth', 'session-1')) + + const candidates = loopService.findCandidatesByPartialName('nonexistent') + + expect(candidates).toEqual([]) + }) +}) + +describe('fetchSessionOutput', () => { + function createMockLogger() { + return { + log: () => {}, + error: () => {}, + debug: () => {}, + } + } + + const createMockV2Client = (messages: any[] = [], session: any = {}) => ({ + session: { + messages: async () => ({ data: messages }), + get: async () => ({ data: session }), + }, + } as any) + + test('returns null when directory is empty', async () => { + const mockClient = createMockV2Client() + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '', logger) + + expect(result).toBeNull() + }) + + test('returns null when sessionId is empty', async () => { + const mockClient = createMockV2Client() + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, '', '/dir', logger) + + expect(result).toBeNull() + }) + + test('extracts messages from assistant responses', async () => { + const messages = [ + { + info: { role: 'assistant', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } } }, + parts: [{ type: 'text', text: 'Hello from assistant' }], + }, + { + info: { role: 'user', cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, + parts: [{ type: 'text', text: 'User message' }], + }, + ] + const mockClient = createMockV2Client(messages) + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '/tmp/test', logger) + + expect(result).not.toBeNull() + expect(result?.messages.length).toBe(1) + expect(result?.messages[0].text).toContain('Hello from assistant') + }) + + test('calculates total cost and tokens', async () => { + const messages = [ + { + info: { role: 'assistant', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 5, write: 2 } } }, + parts: [{ type: 'text', text: 'First message' }], + }, + { + info: { role: 'assistant', cost: 0.02, tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 10, write: 4 } } }, + parts: [{ type: 'text', text: 'Second message' }], + }, + ] + const mockClient = createMockV2Client(messages) + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '/tmp/test', logger) + + expect(result?.totalCost).toBe(0.03) + expect(result?.totalTokens.input).toBe(300) + expect(result?.totalTokens.output).toBe(150) + expect(result?.totalTokens.reasoning).toBe(30) + expect(result?.totalTokens.cacheRead).toBe(15) + expect(result?.totalTokens.cacheWrite).toBe(6) + }) + + test('includes file changes from session summary', async () => { + const messages = [ + { + info: { role: 'assistant', cost: 0.01, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } } }, + parts: [{ type: 'text', text: 'Message' }], + }, + ] + const session = { + summary: { additions: 10, deletions: 5, files: 3 }, + } + const mockClient = createMockV2Client(messages, session) + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '/tmp/test', logger) + + expect(result?.fileChanges).toEqual({ additions: 10, deletions: 5, files: 3 }) + }) + + test('returns null on API error', async () => { + const mockClient = { + session: { + messages: async () => { throw new Error('API error') }, + get: async () => { throw new Error('API error') }, + }, + } as any + const logger = createMockLogger() + + const result = await fetchSessionOutput(mockClient, 'session-1', '/tmp/test', logger) + + expect(result).toBeNull() + }) +}) diff --git a/test/sandbox-docker.test.ts b/test/sandbox-docker.test.ts new file mode 100644 index 0000000..bb2e2dd --- /dev/null +++ b/test/sandbox-docker.test.ts @@ -0,0 +1,30 @@ +import { describe, test, expect } from 'bun:test' +import { createDockerService } from '../src/sandbox/docker' + +function createMockLogger() { + return { + log: () => {}, + error: () => {}, + debug: () => {}, + } +} + +describe('DockerService containerName', () => { + const logger = createMockLogger() + const docker = createDockerService(logger) + + test('containerName returns ocm-sandbox- prefixed name', () => { + const result = docker.containerName('my-worktree') + expect(result).toBe('ocm-sandbox-my-worktree') + }) + + test('containerName handles names with special characters', () => { + const result = docker.containerName('feature/test-123') + expect(result).toBe('ocm-sandbox-feature/test-123') + }) + + test('containerName handles empty string', () => { + const result = docker.containerName('') + expect(result).toBe('ocm-sandbox-') + }) +}) diff --git a/test/sandbox-manager.test.ts b/test/sandbox-manager.test.ts index ded47f6..2f9211e 100644 --- a/test/sandbox-manager.test.ts +++ b/test/sandbox-manager.test.ts @@ -18,6 +18,7 @@ function createMockDockerService() { let runningContainers = new Set() let shouldDockerBeAvailable = true let shouldImageExist = true + let shouldRemoveThrow = false const mock = { checkDocker: async () => shouldDockerBeAvailable, @@ -29,6 +30,9 @@ function createMockDockerService() { }, removeContainer: async (name: string) => { removeContainerCalls.push(name) + if (shouldRemoveThrow) { + throw new Error('Failed to remove container') + } }, exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }), execPipe: async () => ({ stdout: '', stderr: '', exitCode: 0 }), @@ -55,6 +59,9 @@ function createMockDockerService() { setImageExists: (exists: boolean) => { shouldImageExist = exists }, + setRemoveThrow: (shouldThrow: boolean) => { + shouldRemoveThrow = shouldThrow + }, } return mock } @@ -184,4 +191,196 @@ describe('SandboxManager', () => { expect(active?.startedAt).toBe(originalStartedAt) }) }) + + describe('start', () => { + test('throws when Docker is not available', async () => { + const mockDocker = createMockDockerService() + mockDocker.setDockerAvailable(false) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await expect(() => manager.start('test', '/path')).toThrow('Docker is not available') + }) + + test('throws when image does not exist', async () => { + const mockDocker = createMockDockerService() + mockDocker.setImageExists(false) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await expect(() => manager.start('test', '/path')).toThrow('not found') + }) + + test('returns early when container already running', async () => { + const mockDocker = createMockDockerService() + mockDocker.setRunning('ocm-sandbox-test', true) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + const result = await manager.start('test', '/path') + + expect(mockDocker.getCreateContainerCalls().length).toBe(0) + expect(result).toEqual({ containerName: 'ocm-sandbox-test' }) + }) + + test('creates container and populates active map', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + const result = await manager.start('test', '/path') + + expect(mockDocker.getCreateContainerCalls().length).toBe(1) + expect(manager.isActive('test')).toBe(true) + const active = manager.getActive('test') + expect(active).not.toBeNull() + expect(active?.containerName).toBe('ocm-sandbox-test') + }) + }) + + describe('stop', () => { + test('removes container and clears active map', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + await manager.stop('test') + + expect(mockDocker.getRemoveContainerCalls()).toContain('ocm-sandbox-test') + expect(manager.isActive('test')).toBe(false) + }) + + test('clears active map even when removeContainer throws', async () => { + const mockDocker = createMockDockerService() + mockDocker.setRemoveThrow(true) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + await manager.stop('test') + + expect(manager.isActive('test')).toBe(false) + }) + + test('uses containerName fallback when not in active map', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.stop('unknown') + + expect(mockDocker.getRemoveContainerCalls()).toContain('ocm-sandbox-unknown') + }) + }) + + describe('getActive and isActive', () => { + test('returns null and false for unknown worktree', () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + expect(manager.getActive('unknown')).toBeNull() + expect(manager.isActive('unknown')).toBe(false) + }) + + test('returns active sandbox after start', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + + const active = manager.getActive('test') + expect(active).not.toBeNull() + expect(manager.isActive('test')).toBe(true) + }) + + test('returns null and false after stop', async () => { + const mockDocker = createMockDockerService() + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.start('test', '/path') + await manager.stop('test') + + expect(manager.getActive('test')).toBeNull() + expect(manager.isActive('test')).toBe(false) + }) + }) + + describe('cleanupOrphans additional', () => { + test('handles empty container list', async () => { + const mockDocker = createMockDockerService() + mockDocker.setContainers([]) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + const removed = await manager.cleanupOrphans() + + expect(removed).toBe(0) + }) + + test('continues cleanup when removal fails', async () => { + const mockDocker = createMockDockerService() + mockDocker.setContainers(['ocm-sandbox-first', 'ocm-sandbox-second']) + mockDocker.setRemoveThrow(true) + const logger = createMockLogger() + const manager = createSandboxManager( + mockDocker as unknown as DockerService, + { image: 'ocm-sandbox:latest' }, + logger + ) + + await manager.cleanupOrphans() + + const calls = mockDocker.getRemoveContainerCalls() + expect(calls).toContain('ocm-sandbox-first') + expect(calls).toContain('ocm-sandbox-second') + }) + }) }) diff --git a/test/sandbox-path.test.ts b/test/sandbox-path.test.ts new file mode 100644 index 0000000..8515937 --- /dev/null +++ b/test/sandbox-path.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect } from 'bun:test' +import { toContainerPath, toHostPath, rewriteOutput } from '../src/sandbox/path' + +describe('toContainerPath', () => { + test('converts host path to container path', () => { + const result = toContainerPath('/home/user/project/src/file.ts', '/home/user/project') + expect(result).toBe('/workspace/src/file.ts') + }) + + test('returns path as-is when already a container path', () => { + const result = toContainerPath('/workspace/src/file.ts', '/home/user/project') + expect(result).toBe('/workspace/src/file.ts') + }) + + test('returns path as-is when unrelated to hostDir', () => { + const result = toContainerPath('/usr/bin/node', '/home/user/project') + expect(result).toBe('/usr/bin/node') + }) + + test('converts exact hostDir to /workspace', () => { + const result = toContainerPath('/home/user/project', '/home/user/project') + expect(result).toBe('/workspace') + }) +}) + +describe('toHostPath', () => { + test('converts container path to host path', () => { + const result = toHostPath('/workspace/src/file.ts', '/home/user/project') + expect(result).toBe('/home/user/project/src/file.ts') + }) + + test('returns absolute non-workspace paths unchanged', () => { + const result = toHostPath('/usr/bin/node', '/home/user/project') + expect(result).toBe('/usr/bin/node') + }) + + test('treats relative paths as relative to workspace', () => { + const result = toHostPath('src/file.ts', '/home/user/project') + expect(result).toBe('/home/user/project/src/file.ts') + }) + + test('converts exact /workspace to hostDir', () => { + const result = toHostPath('/workspace', '/home/user/project') + expect(result).toBe('/home/user/project') + }) +}) + +describe('rewriteOutput', () => { + test('replaces /workspace/ with hostDir/', () => { + const result = rewriteOutput('Error at /workspace/src/file.ts:10', '/home/user/project') + expect(result).toBe('Error at /home/user/project/src/file.ts:10') + }) + + test('replaces /workspace at end of line', () => { + const result = rewriteOutput('Working dir: /workspace', '/home/user/project') + expect(result).toBe('Working dir: /home/user/project') + }) + + test('handles multi-line output', () => { + const input = `Error at /workspace/src/file.ts:10 + at /workspace/lib/utils.ts:25 + Working dir: /workspace` + const expected = `Error at /home/user/project/src/file.ts:10 + at /home/user/project/lib/utils.ts:25 + Working dir: /home/user/project` + const result = rewriteOutput(input, '/home/user/project') + expect(result).toBe(expected) + }) + + test('returns empty string for empty input', () => { + const result = rewriteOutput('', '/home/user/project') + expect(result).toBe('') + }) + + test('handles multiple occurrences on same line', () => { + const result = rewriteOutput('/workspace/a and /workspace/b', '/home/user/project') + expect(result).toBe('/home/user/project/a and /home/user/project/b') + }) +})