From d86051805b48dd29a64cabd4187f1bb663d4a796 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Wed, 29 Apr 2026 14:09:47 +0000 Subject: [PATCH 1/5] feat(setup): delete scratch agent after ping-pong, simplify flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Terminal Agent" created for the connection test is now silently deleted after a successful ping. If the user chooses to chat, a new agent is auto-created as "{name}'s Terminal" — no name prompt needed. Condensed the three-line ping section into a single "Connection verified." status line. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/delete-cli-agent.ts | 74 +++++++++++++++++++++++++++++++++++++ setup/auto.ts | 31 +++++++++++++--- 2 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 scripts/delete-cli-agent.ts diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts new file mode 100644 index 000000000..8e947cb5f --- /dev/null +++ b/scripts/delete-cli-agent.ts @@ -0,0 +1,74 @@ +/** + * Delete the scratch CLI agent created during setup's ping-pong test. + * + * Removes the agent group, its messaging_group_agents wiring, any + * agent_destinations rows, and the groups// directory. Leaves the + * CLI messaging group intact so it can be reused for a new agent. + * + * Usage: + * pnpm exec tsx scripts/delete-cli-agent.ts --folder + */ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from '../src/config.js'; +import { getAgentGroupByFolder, deleteAgentGroup } from '../src/db/agent-groups.js'; +import { initDb } from '../src/db/connection.js'; +import { runMigrations } from '../src/db/migrations/index.js'; + +interface Args { + folder: string; +} + +function parseArgs(): Args { + const argv = process.argv.slice(2); + let folder = ''; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--folder' && argv[i + 1]) folder = argv[++i]; + } + if (!folder) { + console.error('usage: pnpm exec tsx scripts/delete-cli-agent.ts --folder '); + process.exit(1); + } + return { folder }; +} + +const args = parseArgs(); + +const db = initDb(path.join(DATA_DIR, 'v2.db')); +runMigrations(db); + +const ag = getAgentGroupByFolder(args.folder); +if (!ag) { + console.log(`No agent group with folder "${args.folder}" — nothing to delete.`); + process.exit(0); +} + +// Delete all rows referencing this agent group, in dependency order. +const fkTables = [ + 'messaging_group_agents', + 'agent_destinations', + 'agent_group_members', + 'pending_sender_approvals', + 'channel_registrations', + 'user_roles', + 'sessions', +]; +for (const table of fkTables) { + const exists = db + .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?") + .get(table); + if (exists) { + db.prepare(`DELETE FROM ${table} WHERE agent_group_id = ?`).run(ag.id); + } +} + +deleteAgentGroup(ag.id); + +// Remove the groups// directory. +const groupDir = path.join(process.cwd(), 'groups', args.folder); +if (fs.existsSync(groupDir)) { + fs.rmSync(groupDir, { recursive: true }); +} + +console.log(`Deleted agent group ${ag.id} (${args.folder}).`); diff --git a/setup/auto.ts b/setup/auto.ts index 392bc133f..e46a639eb 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -55,6 +55,7 @@ import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js import { emit as phEmit } from './lib/diagnostics.js'; import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; +import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -349,8 +350,8 @@ async function main(): Promise { const res = await runQuietStep( 'cli-agent', { - running: 'Bringing your assistant online…', - done: 'Assistant wired up.', + running: 'Preparing connection test…', + done: 'Ready to test.', }, ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], ); @@ -365,7 +366,7 @@ async function main(): Promise { p.log.message( brandBody( dimWrap( - "Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.", + 'Checking your assistant can respond — first startup takes 30–60 seconds.', 4, ), ), @@ -373,6 +374,10 @@ async function main(): Promise { const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); + const scratchFolder = `cli-with-${normalizeName(displayName!)}`; + spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', scratchFolder], { + stdio: 'ignore', + }); const next = ensureAnswer( await brightSelect<'continue' | 'chat'>({ message: 'What next?', @@ -390,7 +395,23 @@ async function main(): Promise { }), ) as 'continue' | 'chat'; setupLog.userInput('first_chat_choice', next); - if (next === 'chat') await runFirstChat(); + if (next === 'chat') { + const terminalAgentName = `${displayName!}'s Terminal`; + const createRes = await runQuietChild( + 'create-terminal-agent', + 'pnpm', + ['exec', 'tsx', 'scripts/init-cli-agent.ts', '--display-name', displayName!, '--agent-name', terminalAgentName], + { running: `Creating ${terminalAgentName}…`, done: `${terminalAgentName} is ready.` }, + ); + if (!createRes.ok) { + await fail( + 'create-terminal-agent', + `Couldn't create ${terminalAgentName}.`, + 'You can retry later with `pnpm exec tsx scripts/init-cli-agent.ts`.', + ); + } + await runFirstChat(); + } } else { phEmit('first_chat_failed', { reason: ping }); renderPingFailureNote(ping); @@ -592,7 +613,7 @@ async function confirmAssistantResponds(): Promise { const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${elapsed}s)`; if (result === 'ok') { - s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); + s.stop(`${k.bold(fitToWidth('Connection verified.', suffix))}${k.dim(suffix)}`); } else { const msg = result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time."; From 8c5d67cc78174d5a6f96cb692a3de4ba876625af Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Wed, 29 Apr 2026 14:27:03 +0000 Subject: [PATCH 2/5] fix(setup): dynamic FK cleanup, remove normalizeName coupling - delete-cli-agent.ts discovers tables with agent_group_id dynamically instead of hardcoding a list - cli-agent step emits FOLDER in its status block so setup/auto.ts reads it from the step result instead of re-deriving via normalizeName Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/delete-cli-agent.ts | 29 ++++++++++++----------------- setup/auto.ts | 4 +--- setup/cli-agent.ts | 5 ++++- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts index 8e947cb5f..be3d95935 100644 --- a/scripts/delete-cli-agent.ts +++ b/scripts/delete-cli-agent.ts @@ -44,23 +44,18 @@ if (!ag) { process.exit(0); } -// Delete all rows referencing this agent group, in dependency order. -const fkTables = [ - 'messaging_group_agents', - 'agent_destinations', - 'agent_group_members', - 'pending_sender_approvals', - 'channel_registrations', - 'user_roles', - 'sessions', -]; -for (const table of fkTables) { - const exists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?") - .get(table); - if (exists) { - db.prepare(`DELETE FROM ${table} WHERE agent_group_id = ?`).run(ag.id); - } +// Dynamically find every table with an agent_group_id column and delete +// matching rows. This is self-maintaining — new FK tables are picked up +// automatically without updating a hardcoded list. +const tables = db + .prepare( + `SELECT DISTINCT m.name FROM sqlite_master m + JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id' + WHERE m.type = 'table' AND m.name != 'agent_groups'`, + ) + .all() as { name: string }[]; +for (const { name } of tables) { + db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id); } deleteAgentGroup(ag.id); diff --git a/setup/auto.ts b/setup/auto.ts index e46a639eb..6ebf48662 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -55,8 +55,6 @@ import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js import { emit as phEmit } from './lib/diagnostics.js'; import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; -import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js'; - const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -374,7 +372,7 @@ async function main(): Promise { const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); - const scratchFolder = `cli-with-${normalizeName(displayName!)}`; + const scratchFolder = res.terminal?.fields.FOLDER ?? ''; spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', scratchFolder], { stdio: 'ignore', }); diff --git a/setup/cli-agent.ts b/setup/cli-agent.ts index d9a90c576..18b8e97a6 100644 --- a/setup/cli-agent.ts +++ b/setup/cli-agent.ts @@ -60,8 +60,9 @@ export async function run(args: string[]): Promise { log.info('Invoking init-cli-agent', { displayName, agentName }); + let stdout = ''; try { - execFileSync('pnpm', scriptArgs, { + stdout = execFileSync('pnpm', scriptArgs, { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', @@ -82,9 +83,11 @@ export async function run(args: string[]): Promise { process.exit(1); } + const folderMatch = stdout.match(/@ groups\/(\S+)/); emitStatus('CLI_AGENT', { DISPLAY_NAME: displayName, AGENT_NAME: agentName || displayName, + FOLDER: folderMatch?.[1] ?? '', CHANNEL: 'cli/local', STATUS: 'success', LOG: 'logs/setup.log', From 8542c484f6433db6b347c2cb12ad68fe85536ca2 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Wed, 29 Apr 2026 14:45:42 +0000 Subject: [PATCH 3/5] fix(setup): isolate scratch agent with hardcoded _ping-test folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scratch agent uses fixed folder `_ping-test` so it can never collide with a real agent on re-runs - Added --folder flag to init-cli-agent.ts and cli-agent step wrapper - Delete always targets `_ping-test` exactly — no re-derivation needed - Removed normalizeName coupling and FOLDER status field (no longer needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/delete-cli-agent.ts | 7 ++++--- scripts/init-cli-agent.ts | 8 +++++++- setup/auto.ts | 5 ++--- setup/cli-agent.ts | 17 +++++++++++------ 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts index be3d95935..01a9e33a5 100644 --- a/scripts/delete-cli-agent.ts +++ b/scripts/delete-cli-agent.ts @@ -1,9 +1,10 @@ /** * Delete the scratch CLI agent created during setup's ping-pong test. * - * Removes the agent group, its messaging_group_agents wiring, any - * agent_destinations rows, and the groups// directory. Leaves the - * CLI messaging group intact so it can be reused for a new agent. + * Dynamically finds and removes all rows referencing the agent group + * (any table with an agent_group_id column), deletes the agent group + * itself, and removes the groups// directory. Leaves the CLI + * messaging group intact so it can be reused for a new agent. * * Usage: * pnpm exec tsx scripts/delete-cli-agent.ts --folder diff --git a/scripts/init-cli-agent.ts b/scripts/init-cli-agent.ts index 4a56827bc..73fb9d150 100644 --- a/scripts/init-cli-agent.ts +++ b/scripts/init-cli-agent.ts @@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`; interface Args { displayName: string; agentName: string; + folder?: string; } function parseArgs(argv: string[]): Args { let displayName: string | undefined; let agentName: string | undefined; + let folder: string | undefined; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; @@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args { } else if (key === '--agent-name') { agentName = val; i++; + } else if (key === '--folder') { + folder = val; + i++; } } @@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args { return { displayName, agentName: agentName?.trim() || displayName, + folder, }; } @@ -95,7 +101,7 @@ async function main(): Promise { const promotedToOwner = false; // 2. Agent group + filesystem. - const folder = `cli-with-${normalizeName(args.displayName)}`; + const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`; let ag: AgentGroup | undefined = getAgentGroupByFolder(folder); if (!ag) { const agId = generateId('ag'); diff --git a/setup/auto.ts b/setup/auto.ts index 6ebf48662..0b2cfa192 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -351,7 +351,7 @@ async function main(): Promise { running: 'Preparing connection test…', done: 'Ready to test.', }, - ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], + ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'], ); if (!res.ok) { await fail( @@ -372,8 +372,7 @@ async function main(): Promise { const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); - const scratchFolder = res.terminal?.fields.FOLDER ?? ''; - spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', scratchFolder], { + spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'], { stdio: 'ignore', }); const next = ensureAnswer( diff --git a/setup/cli-agent.ts b/setup/cli-agent.ts index 18b8e97a6..73b855791 100644 --- a/setup/cli-agent.ts +++ b/setup/cli-agent.ts @@ -8,6 +8,7 @@ * Args: * --display-name (required) operator's display name * --agent-name (optional) agent persona name, defaults to display-name + * --folder (optional) explicit folder name, defaults to cli-with- */ import { execFileSync } from 'child_process'; import path from 'path'; @@ -18,9 +19,11 @@ import { emitStatus } from './status.js'; function parseArgs(args: string[]): { displayName: string; agentName?: string; + folder?: string; } { let displayName: string | undefined; let agentName: string | undefined; + let folder: string | undefined; for (let i = 0; i < args.length; i++) { const key = args[i]; @@ -34,6 +37,10 @@ function parseArgs(args: string[]): { agentName = val; i++; break; + case '--folder': + folder = val; + i++; + break; } } @@ -46,23 +53,23 @@ function parseArgs(args: string[]): { process.exit(2); } - return { displayName, agentName }; + return { displayName, agentName, folder }; } export async function run(args: string[]): Promise { - const { displayName, agentName } = parseArgs(args); + const { displayName, agentName, folder } = parseArgs(args); const projectRoot = process.cwd(); const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts'); const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName]; if (agentName) scriptArgs.push('--agent-name', agentName); + if (folder) scriptArgs.push('--folder', folder); log.info('Invoking init-cli-agent', { displayName, agentName }); - let stdout = ''; try { - stdout = execFileSync('pnpm', scriptArgs, { + execFileSync('pnpm', scriptArgs, { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', @@ -83,11 +90,9 @@ export async function run(args: string[]): Promise { process.exit(1); } - const folderMatch = stdout.match(/@ groups\/(\S+)/); emitStatus('CLI_AGENT', { DISPLAY_NAME: displayName, AGENT_NAME: agentName || displayName, - FOLDER: folderMatch?.[1] ?? '', CHANNEL: 'cli/local', STATUS: 'success', LOG: 'logs/setup.log', From 8a205808e0eeb5910afa6a8605defc7b05efff3a Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 30 Apr 2026 07:56:34 +0000 Subject: [PATCH 4/5] fix(setup): wrap scratch agent cleanup in transaction, remove session data Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/delete-cli-agent.ts | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts index 01a9e33a5..c85679fcb 100644 --- a/scripts/delete-cli-agent.ts +++ b/scripts/delete-cli-agent.ts @@ -45,21 +45,20 @@ if (!ag) { process.exit(0); } -// Dynamically find every table with an agent_group_id column and delete -// matching rows. This is self-maintaining — new FK tables are picked up -// automatically without updating a hardcoded list. -const tables = db - .prepare( - `SELECT DISTINCT m.name FROM sqlite_master m - JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id' - WHERE m.type = 'table' AND m.name != 'agent_groups'`, - ) - .all() as { name: string }[]; -for (const { name } of tables) { - db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id); -} - -deleteAgentGroup(ag.id); +const cleanup = db.transaction(() => { + const tables = db + .prepare( + `SELECT DISTINCT m.name FROM sqlite_master m + JOIN pragma_table_info(m.name) p ON p.name = 'agent_group_id' + WHERE m.type = 'table' AND m.name != 'agent_groups'`, + ) + .all() as { name: string }[]; + for (const { name } of tables) { + db.prepare(`DELETE FROM ${name} WHERE agent_group_id = ?`).run(ag.id); + } + deleteAgentGroup(ag.id); +}); +cleanup(); // Remove the groups// directory. const groupDir = path.join(process.cwd(), 'groups', args.folder); @@ -67,4 +66,10 @@ if (fs.existsSync(groupDir)) { fs.rmSync(groupDir, { recursive: true }); } +// Remove session data on disk. +const sessionsDir = path.join(DATA_DIR, 'v2-sessions', ag.id); +if (fs.existsSync(sessionsDir)) { + fs.rmSync(sessionsDir, { recursive: true }); +} + console.log(`Deleted agent group ${ag.id} (${args.folder}).`); From 3d6a9b74f3cec320b93293bff85d4872bfbeb985 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 30 Apr 2026 23:16:34 +0300 Subject: [PATCH 5/5] review: surface ping-test cleanup failures + restore copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes the post-ping `_ping-test` cleanup through `spawnQuiet` + `setupLog.step` so a non-zero exit from `delete-cli-agent.ts` lands in `logs/setup-steps/cleanup-cli-agent.log` and the progression log, and prints a one-line warn to the user. Previously the spawnSync was fire-and-forget with `stdio: 'ignore'`, leaving an orphan agent group silently if cleanup failed. Restores the original copy on the cli-agent step labels, the ping explainer paragraph, and the post-ping spinner stop line — those copy changes are out of scope for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 846baefd2..2610c2302 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -51,10 +51,11 @@ import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js'; import * as setupLog from './logs.js'; -import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; +import { ensureAnswer, fail, runQuietChild, runQuietStep, spawnQuiet } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, fmtDuration, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; + const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); @@ -320,8 +321,8 @@ async function main(): Promise { const res = await runQuietStep( 'cli-agent', { - running: 'Preparing connection test…', - done: 'Ready to test.', + running: 'Bringing your assistant online…', + done: 'Assistant wired up.', }, ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'], ); @@ -336,7 +337,7 @@ async function main(): Promise { p.log.message( brandBody( dimWrap( - 'Checking your assistant can respond — first startup takes 30–60 seconds.', + "Your assistant runs in an isolated sandbox. I'm going to send it a quick test message (ping) and wait for a reply (pong) to confirm it's responding. First startup typically takes 30–60 seconds while the sandbox warms up.", 4, ), ), @@ -344,9 +345,27 @@ async function main(): Promise { const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); - spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'], { - stdio: 'ignore', - }); + const cleanupRawLog = setupLog.stepRawLog('cleanup-cli-agent'); + const cleanupStart = Date.now(); + const cleanup = await spawnQuiet( + 'pnpm', + ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'], + cleanupRawLog, + ); + setupLog.step( + 'cleanup-cli-agent', + cleanup.ok ? 'success' : 'failed', + Date.now() - cleanupStart, + { exit_code: cleanup.exitCode }, + cleanupRawLog, + ); + if (!cleanup.ok) { + p.log.warn( + brandBody( + `Couldn't clean up the test agent — it may still appear in your agent list. See ${cleanupRawLog} for details.`, + ), + ); + } const next = ensureAnswer( await brightSelect<'continue' | 'chat'>({ message: 'What next?', @@ -580,7 +599,7 @@ async function confirmAssistantResponds(): Promise { clearInterval(tick); const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result === 'ok') { - s.stop(`${k.bold(fitToWidth('Connection verified.', suffix))}${k.dim(suffix)}`); + s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); } else { const msg = result === 'socket_error' ? "Couldn't reach the NanoClaw service." : "Your assistant didn't reply in time.";