From 4743513018b4a568f87c5b2ace16404818164073 Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 15:01:38 +0000 Subject: [PATCH 01/63] docs: add PR hygiene check to CLAUDE.md and contributing guidelines Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 12 ++++++++++++ CONTRIBUTING.md | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c9c49ff..85418fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,18 @@ Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) f Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, SKILL.md format rules, PR requirements, and the pre-submission checklist (searching for existing PRs/issues, testing, description format). +## PR Hygiene + +Before pushing or creating a PR, run these checks and show the output to the user for approval: + +```bash +git diff upstream/main --name-only HEAD +git diff upstream/main --stat HEAD +git log upstream/main..HEAD --oneline +``` + +If any personal files appear (CLAUDE.md, .claude/, personal configs, group data), remove them before pushing. Do not push until the user confirms the diff is clean. + ## Development Run commands directly—don't tell the user to run them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a7816a..3c0e6d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,7 +123,8 @@ Test your contribution on a fresh clone before submitting. For skills, run the s 1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge. 2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone. -3. **Check the right box** in the PR template. Labels are auto-applied based on your selection: +3. **Check for personal files.** Before pushing, verify no personal files are in your diff (see PR Hygiene in CLAUDE.md). +4. **Check the right box** in the PR template. Labels are auto-applied based on your selection: | Checkbox | Label | |----------|-------| From 94689fcb36f0903c3e984662deeb0c6438ab7ab7 Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 22:04:29 +0000 Subject: [PATCH 02/63] docs: consolidate PR hygiene check from 3 commands to 2 Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85418fb..7ae7555 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,6 @@ Before creating a PR, adding a skill, or preparing any contribution, you MUST re Before pushing or creating a PR, run these checks and show the output to the user for approval: ```bash -git diff upstream/main --name-only HEAD git diff upstream/main --stat HEAD git log upstream/main..HEAD --oneline ``` From ad507fa426ab1dcdda930a27f6def7b8055c482b Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 22:09:36 +0000 Subject: [PATCH 03/63] docs: clarify PR hygiene check wording Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ae7555..e5b9b7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,14 +50,14 @@ Before creating a PR, adding a skill, or preparing any contribution, you MUST re ## PR Hygiene -Before pushing or creating a PR, run these checks and show the output to the user for approval: +Before pushing or creating a PR, run these checks: ```bash git diff upstream/main --stat HEAD git log upstream/main..HEAD --oneline ``` -If any personal files appear (CLAUDE.md, .claude/, personal configs, group data), remove them before pushing. Do not push until the user confirms the diff is clean. +Show the output and wait for approval before pushing. If any personal files appear (CLAUDE.md, .claude/, personal configs, group data), remove them first. ## Development From 5ed74c3a3fe55c3b5778c62fae678ee78899581e Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 22:52:05 +0000 Subject: [PATCH 04/63] docs: scope PR hygiene check to PR creation only, improve wording Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e5b9b7f..e662afd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,14 +50,14 @@ Before creating a PR, adding a skill, or preparing any contribution, you MUST re ## PR Hygiene -Before pushing or creating a PR, run these checks: +Before creating a PR, run these checks: ```bash git diff upstream/main --stat HEAD git log upstream/main..HEAD --oneline ``` -Show the output and wait for approval before pushing. If any personal files appear (CLAUDE.md, .claude/, personal configs, group data), remove them first. +Show the output and wait for approval. Installation-specific files (group files, .claude/settings.json, local configs) should not be included. ## Development From 0c420cffca123692d7d0d73934f2f410f1072c11 Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 23:56:06 +0000 Subject: [PATCH 05/63] docs: align contributing guidelines with updated PR hygiene wording Co-Authored-By: Claude Opus 4.6 (1M context) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c0e6d5..413e542 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,7 +123,7 @@ Test your contribution on a fresh clone before submitting. For skills, run the s 1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge. 2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone. -3. **Check for personal files.** Before pushing, verify no personal files are in your diff (see PR Hygiene in CLAUDE.md). +3. **Check for installation-specific files.** Before creating a PR, verify no installation-specific files are in your diff (see PR Hygiene in CLAUDE.md). 4. **Check the right box** in the PR template. Labels are auto-applied based on your selection: | Checkbox | Label | From 7e37b13aabd0d7ed8ebdedfa96cecad8e1e89796 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 28 Apr 2026 13:26:44 +0300 Subject: [PATCH 06/63] Fix path traversal in attachment handling on channel-inbound path --- src/attachment-safety.ts | 23 ++++++++++++++ src/host-core.test.ts | 37 +++++++++++++++++++++++ src/modules/agent-to-agent/agent-route.ts | 23 ++------------ src/router.ts | 9 +++++- src/session-manager.ts | 18 ++++++++++- 5 files changed, 88 insertions(+), 22 deletions(-) create mode 100644 src/attachment-safety.ts diff --git a/src/attachment-safety.ts b/src/attachment-safety.ts new file mode 100644 index 0000000..85467f9 --- /dev/null +++ b/src/attachment-safety.ts @@ -0,0 +1,23 @@ +import path from 'path'; + +/** + * Is `name` safe to use as the last segment of a path inside an + * attachment-staging directory? Filenames originate from untrusted sources — + * channel messages from any chat participant, agent-to-agent forwards from + * a possibly-compromised peer agent — and land in `path.join(dir, name)` + * sinks on the host. Without this guard, a `..`-laden name escapes the + * inbox and writes anywhere the host process has filesystem permission. + * + * Rejects: + * - non-string / empty + * - `.` / `..` (traversal sentinels that path.basename returns as-is) + * - anything containing a path separator (`/` or `\`) or NUL + * - any value where `path.basename(name) !== name`, catching OS-specific + * separators and covering drives/prefixes on Windows runtimes + */ +export function isSafeAttachmentName(name: string): boolean { + if (typeof name !== 'string' || name.length === 0) return false; + if (name === '.' || name === '..') return false; + if (/[\\/\0]/.test(name)) return false; + return path.basename(name) === name; +} diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 9906c4b..2bb72d4 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -173,6 +173,43 @@ describe('session manager', () => { expect(getSession(session.id)!.last_active).not.toBeNull(); }); + + it('should refuse path-traversal in attachment filenames', () => { + // Regression: attachment.name comes from untrusted senders (E2EE-protected + // chat platforms can't sanitize it server-side). Without the guard, a + // `../../../tmp/pwned` filename escapes the inbox dir and writes anywhere + // the host process can reach. + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + const inboxBase = path.join(sessionDir('ag-1', session.id), 'inbox'); + const escapeTarget = path.join('/tmp', 'nanoclaw-traversal-canary'); + if (fs.existsSync(escapeTarget)) fs.rmSync(escapeTarget); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-attack', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'pwn', + attachments: [ + { + type: 'document', + name: '../../../../../../../../tmp/nanoclaw-traversal-canary', + data: Buffer.from('owned').toString('base64'), + }, + ], + }), + }); + + expect(fs.existsSync(escapeTarget)).toBe(false); + // The bytes should still land — under a synthesized safe name inside the + // inbox — so the agent doesn't lose data on a malicious filename. + const inboxDir = path.join(inboxBase, 'msg-attack'); + expect(fs.existsSync(inboxDir)).toBe(true); + const written = fs.readdirSync(inboxDir); + expect(written).toHaveLength(1); + expect(written[0]).not.toContain('/'); + expect(written[0]).not.toContain('..'); + }); }); describe('router', () => { diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 812cb8e..613a1ed 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -21,6 +21,7 @@ import fs from 'fs'; import path from 'path'; +import { isSafeAttachmentName } from '../../attachment-safety.js'; import { getAgentGroup } from '../../db/agent-groups.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; @@ -29,6 +30,8 @@ import { resolveSession, sessionDir, writeSessionMessage } from '../../session-m import type { Session } from '../../types.js'; import { hasDestination } from './db/agent-destinations.js'; +export { isSafeAttachmentName }; + export interface ForwardedAttachment { name: string; filename: string; @@ -36,26 +39,6 @@ export interface ForwardedAttachment { localPath: string; } -/** - * Is `name` safe to use as the last segment of a path inside the target - * agent's inbox directory? Filenames arrive in messages_out content from - * the source agent — under a multi-agent setup with heterogenous providers - * (or a compromised / hallucinating sub-agent) they can't be trusted. - * - * Rejects: - * - empty string - * - `.` / `..` (traversal sentinels that path.basename returns as-is) - * - anything containing a path separator (`/` or `\`) or NUL - * - any value where `path.basename(name) !== name`, catching OS-specific - * separators and covering drives/prefixes on Windows runtimes - */ -export function isSafeAttachmentName(name: string): boolean { - if (typeof name !== 'string' || name.length === 0) return false; - if (name === '.' || name === '..') return false; - if (/[\\/\0]/.test(name)) return false; - return path.basename(name) === name; -} - /** * Copy file attachments from the source agent's outbox into the target * agent's inbox. Returns attachments using the formatter's existing diff --git a/src/router.ts b/src/router.ts index 3cf0192..995496d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -289,7 +289,14 @@ export async function routeInbound(event: InboundEvent): Promise { log.warn('adapter.subscribe failed', { channelType: event.channelType, threadId: event.threadId, err }); }); } - } else if (agent.ignored_message_policy === 'accumulate') { + } else if (agent.ignored_message_policy === 'accumulate' && !(engages && (!accessOk || !scopeOk))) { + // Accumulate stores the message as silent context. We allow it when + // engagement simply didn't fire, but NOT when engagement fired and + // the access/scope gate refused — those refusals are security + // decisions about an untrusted sender, and silently storing their + // message (which also stages their attachments to disk via + // writeSessionMessage → extractAttachmentFiles) is exactly what the + // gate is meant to prevent. await deliverToAgent(agent, agentGroup, mg, event, userId, adapter?.supportsThreads === true, false); accumulatedCount++; } else { diff --git a/src/session-manager.ts b/src/session-manager.ts index 38eaa0d..996a750 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -14,6 +14,7 @@ import type Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; +import { isSafeAttachmentName } from './attachment-safety.js'; import type { OutboundFile } from './channels/adapter.js'; import { DATA_DIR } from './config.js'; import { getMessagingGroup } from './db/messaging-groups.js'; @@ -252,11 +253,26 @@ function extractAttachmentFiles( let changed = false; for (const att of attachments) { if (typeof att.data === 'string') { + // The name field is attacker-controlled: chat platforms with E2E + // attachment encryption (WhatsApp, Matrix) cannot sanitize filename + // server-side, and other adapters pass att.name through raw. Without + // this guard, `path.join(inboxDir, '../../...')` writes anywhere the + // host process has fs permission — see Signal Desktop's Nov 2025 + // attachment-fileName advisory for the same archetype. + const rawName = (att.name as string | undefined) ?? `attachment-${Date.now()}`; + const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`; + if (filename !== rawName) { + log.warn('Refused unsafe attachment filename — would escape inbox', { + messageId, + rawName, + replacement: filename, + }); + } const inboxDir = path.join(sessionDir(agentGroupId, sessionId), 'inbox', messageId); fs.mkdirSync(inboxDir, { recursive: true }); - const filename = (att.name as string) || `attachment-${Date.now()}`; const filePath = path.join(inboxDir, filename); fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64')); + att.name = filename; att.localPath = `inbox/${messageId}/${filename}`; delete att.data; changed = true; From 45d3016bcec5afa8b7e6fc21ee952165400348ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Apr 2026 10:27:34 +0000 Subject: [PATCH 07/63] =?UTF-8?q?docs:=20update=20token=20count=20to=20133?= =?UTF-8?q?k=20tokens=20=C2=B7=2067%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index f41b3e5..5a0fe82 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 133k tokens, 66% of context window + + 133k tokens, 67% of context window From c36f0c6b36436dcb7367724dc441cb1be6cced21 Mon Sep 17 00:00:00 2001 From: Gabi Simons <263580637+gabi-simons@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:35:51 +0000 Subject: [PATCH 08/63] fix(setup): wire Slack agent during setup like Discord/Telegram Slack setup previously stopped after installing the adapter, leaving users to manually discover /init-first-agent. When they DM'd the bot, the channel-approval flow silently failed because no owner existed. Now the Slack setup flow matches Discord/Telegram: - Collects the operator's Slack member ID - Opens a DM channel via conversations.open (requires im:write scope) - Runs init-first-agent to establish ownership, wiring, and welcome DM - Updates post-install note to focus on webhook URL (the only remaining step) The welcome DM is delivered via chat.postMessage (outbound), which works before Event Subscriptions are configured. The user sees the greeting immediately; inbound replies require webhooks. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-slack/SKILL.md | 2 +- setup/auto.ts | 5 +- setup/channels/slack.ts | 191 ++++++++++++++++++++++++++---- 3 files changed, 168 insertions(+), 30 deletions(-) diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index 318de7b..addbd67 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -60,7 +60,7 @@ pnpm run build 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch** 2. Name it (e.g., "NanoClaw") and select your workspace 3. Go to **OAuth & Permissions** and add Bot Token Scopes: - - `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` + - `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write` 4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`) 5. Go to **Basic Information** and copy the **Signing Secret** diff --git a/setup/auto.ts b/setup/auto.ts index 5ce2712..4dee7c8 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -510,10 +510,7 @@ function channelDmLabel(choice: ChannelChoice): string | null { case 'imessage': return 'iMessage'; case 'slack': - // Slack install doesn't wire an agent or send a welcome DM — the - // driver prints its own "finish in your Slack app" note. Falling - // through to null avoids a misleading "check your Slack DMs" banner. - return null; + return 'Slack DMs'; default: return null; } diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index f66c29a..ac31cca 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -1,24 +1,23 @@ /** * Slack channel flow for setup:auto. * - * `runSlackChannel(displayName)` walks the operator from a bare Slack - * workspace through a running bot, then stops before wiring an agent: + * `runSlackChannel(displayName)` owns the full branch from creating a + * Slack app through the welcome DM: * * 1. Walk through creating a Slack app (api.slack.com/apps) — scopes, * event subscriptions, and signing secret * 2. Paste the bot token + signing secret (clack password prompts) * 3. Validate via auth.test → resolves workspace + bot identity * 4. Install the adapter (setup/add-slack.sh, non-interactive) - * 5. Print the post-install checklist: set the public webhook URL in - * Slack's Event Subscriptions, DM the bot to bootstrap the channel, - * then `/manage-channels` to wire an agent. + * 5. Ask for the operator's Slack user ID + * 6. conversations.open to get the DM channel ID + * 7. Ask for the messaging-agent name (defaulting to "Nano") + * 8. Wire the agent via scripts/init-first-agent.ts * - * Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll), - * Slack needs a public Event Subscriptions URL for inbound events, and - * opening an unsolicited DM would need `im:write` scope we don't force - * the SKILL.md to require. Shipping a honest "here's what's left" note - * is better than a welcome DM the user won't receive until they - * configure the webhook anyway. + * The welcome DM is sent via outbound delivery (chat.postMessage), which + * works without Event Subscriptions being configured. The user sees the + * greeting in Slack immediately; inbound replies require webhooks, so the + * post-install note covers that. * * All output obeys the three-level contract. See docs/setup-flow.md. */ @@ -27,11 +26,13 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; +const DEFAULT_AGENT_NAME = 'Nano'; interface WorkspaceInfo { teamName: string; @@ -40,10 +41,7 @@ interface WorkspaceInfo { botUserId: string; } -// displayName is reserved for when we start wiring the first agent here. -// Kept to match the `runChannel(displayName)` signature every other -// channel driver uses, so auto.ts can dispatch without a branch. -export async function runSlackChannel(_displayName: string): Promise { +export async function runSlackChannel(displayName: string): Promise { await walkThroughAppCreation(); const token = await collectBotToken(); @@ -78,6 +76,47 @@ export async function runSlackChannel(_displayName: string): Promise { ); } + const ownerUserId = await collectSlackUserId(); + const dmChannelId = await openDmChannel(token, ownerUserId); + const platformId = `slack:${dmChannelId}`; + + const role = await askOperatorRole('Slack'); + setupLog.userInput('slack_role', role); + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'slack', + '--user-id', `slack:${ownerUserId}`, + '--platform-id', platformId, + '--display-name', displayName, + '--agent-name', agentName, + '--role', role, + ], + { + running: `Wiring ${agentName} to your Slack DMs…`, + done: 'Agent wired.', + }, + { + extraFields: { + CHANNEL: 'slack', + AGENT_NAME: agentName, + PLATFORM_ID: platformId, + }, + }, + ); + if (!init.ok) { + await fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/init-first-agent` in Claude Code.', + ); + } + showPostInstallChecklist(info); } @@ -89,8 +128,9 @@ async function walkThroughAppCreation(): Promise { '', ' 1. Create a new app "From scratch", name it, pick a workspace', ' 2. OAuth & Permissions → add Bot Token Scopes:', - ' chat:write, channels:history, groups:history, im:history,', - ' channels:read, groups:read, users:read, reactions:write', + ' chat:write, im:write, channels:history, groups:history,', + ' im:history, channels:read, groups:read, users:read,', + ' reactions:write', ' 3. App Home → enable "Messages Tab" and "Allow users to send', ' slash commands and messages from the messages tab"', ' 4. Basic Information → copy the "Signing Secret"', @@ -221,15 +261,120 @@ async function validateSlackToken(token: string): Promise { } } +async function collectSlackUserId(): Promise { + p.note( + [ + "To get your Slack member ID:", + '', + ' 1. In Slack, click your profile picture (top right)', + ' 2. Click "Profile"', + ' 3. Click the three dots (⋯) → "Copy member ID"', + ].join('\n'), + 'Find your Slack user ID', + ); + const answer = ensureAnswer( + await p.text({ + message: 'Paste your Slack member ID', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Member ID is required'; + if (!/^U[A-Z0-9]{8,}$/.test(t)) { + return "That doesn't look like a Slack member ID (starts with U)"; + } + return undefined; + }, + }), + ); + const id = (answer as string).trim(); + setupLog.userInput('slack_user_id', id); + return id; +} + +async function openDmChannel(token: string, userId: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Opening a DM channel…'); + try { + const res = await fetch(`${SLACK_API}/conversations.open`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ users: userId }), + }); + const data = (await res.json()) as { + ok?: boolean; + channel?: { id?: string }; + error?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (data.ok && data.channel?.id) { + s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + setupLog.step('slack-open-dm', 'success', Date.now() - start, { + DM_CHANNEL_ID: data.channel.id, + }); + return data.channel.id; + } + const reason = data.error ?? `HTTP ${res.status}`; + s.stop(`Couldn't open a DM channel: ${reason}`, 1); + setupLog.step('slack-open-dm', 'failed', Date.now() - start, { + ERROR: reason, + }); + if (reason === 'missing_scope') { + await fail( + 'slack-open-dm', + "Your Slack app is missing the im:write scope.", + 'Go to OAuth & Permissions in your Slack app settings, add the im:write scope, reinstall the app, then retry setup.', + ); + } + await fail( + 'slack-open-dm', + "Couldn't open a DM channel with you.", + `Slack said "${reason}". Check the member ID and app permissions, then retry.`, + ); + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('slack-open-dm', 'failed', Date.now() - start, { + ERROR: message, + }); + await fail( + 'slack-open-dm', + "Couldn't reach Slack.", + 'Check your internet connection and retry setup.', + ); + } +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your assistant be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} + function showPostInstallChecklist(info: WorkspaceInfo): void { p.note( wrapForGutter( [ - `The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`, + `Your agent is wired to Slack and a welcome DM is on its way.`, + `To receive replies, Slack needs a public URL for delivering events:`, '', - ' 1. A public URL so Slack can deliver events.', - ' NanoClaw serves a webhook on port 3000 by default — expose it', - ' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.', + ' 1. Expose NanoClaw\'s webhook server (port 3000) via ngrok,', + ' Cloudflare Tunnel, or a reverse proxy on a VPS.', '', ' 2. In your Slack app → Event Subscriptions:', ' • Toggle "Enable Events" on', @@ -237,10 +382,6 @@ function showPostInstallChecklist(info: WorkspaceInfo): void { ' • Subscribe to bot events: message.channels, message.groups,', ' message.im, app_mention', ' • Save, then reinstall the app when Slack prompts', - '', - ` 3. DM @${info.botName} from Slack once — that bootstraps the`, - ' messaging group. Then run `/manage-channels` in `claude` to', - ' wire an agent to it.', ].join('\n'), 6, ), From c5d02434178023724c1ff6839481b9f0fd246286 Mon Sep 17 00:00:00 2001 From: Gabi Simons <263580637+gabi-simons@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:19:44 +0000 Subject: [PATCH 09/63] fix(setup): add Interactivity & Shortcuts step to Slack setup Slack interactive buttons (channel approval cards) require Interactivity to be enabled in the app settings. Without it, button clicks silently fail to reach the host. Added the step to both the setup wizard post-install checklist and the add-slack SKILL.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-slack/SKILL.md | 8 +++++++- setup/channels/slack.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index addbd67..d09db61 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -76,7 +76,13 @@ pnpm run build 10. Under **Subscribe to bot events**, add: - `message.channels`, `message.groups`, `message.im`, `app_mention` 11. Click **Save Changes** -12. Slack will show a banner asking you to **reinstall the app** — click it to apply the new event subscriptions + +### Interactivity + +12. Go to **Interactivity & Shortcuts** and toggle **Interactivity** on +13. Set the **Request URL** to the same `https://your-domain/webhook/slack` +14. Click **Save Changes** +15. Slack will show a banner asking you to **reinstall the app** — click it to apply the new settings ### Configure environment diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index ac31cca..6d1ff56 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -381,7 +381,15 @@ function showPostInstallChecklist(info: WorkspaceInfo): void { ` • Request URL: https:///webhook/slack`, ' • Subscribe to bot events: message.channels, message.groups,', ' message.im, app_mention', - ' • Save, then reinstall the app when Slack prompts', + ' • Save Changes', + '', + ' 3. In your Slack app → Interactivity & Shortcuts:', + ' • Toggle "Interactivity" on', + ` • Request URL: https:///webhook/slack`, + ' • Save Changes', + '', + ' 4. Slack will prompt you to reinstall the app — do it to apply', + ' the new settings', ].join('\n'), 6, ), From 2bf296b04a3802b25edd08e39692d042a3d7868f Mon Sep 17 00:00:00 2001 From: Daniel Milliner Date: Tue, 28 Apr 2026 14:01:32 +0000 Subject: [PATCH 10/63] add startup circuit breaker and troubleshooting docs Backs off on rapid restarts to avoid exhausting Discord gateway identify limits and triggering Cloudflare IP bans. Resets on clean shutdown so only crashes accumulate the counter. Also adds a troubleshooting section to CLAUDE.md with the most useful diagnostic locations. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 12 ++++++- src/circuit-breaker.ts | 79 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 5 +++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/circuit-breaker.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7115c4c..6565e8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,7 +186,17 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart systemctl --user start|stop|restart nanoclaw ``` -Host logs: `logs/nanoclaw.log` (normal) and `logs/nanoclaw.error.log` (errors only — some delivery/approval failures only show up here). +## Troubleshooting + +Check these first when something goes wrong: + +| What | Where | +|------|-------| +| Host logs | `logs/nanoclaw.error.log` first (delivery failures, crash-loop backoff, warnings), then `logs/nanoclaw.log` for the full routing chain | +| Setup logs | `logs/setup.log` (overall), `logs/setup-steps/*.log` (per-step: bootstrap, environment, container, onecli, mounts, service, etc.) | +| Session DBs | `data/v2-sessions///` — `inbound.db` (`messages_in`: did the message reach the container?), `outbound.db` (`messages_out`: did the agent produce a response?) | + +Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect. ## Supply Chain Security (pnpm) diff --git a/src/circuit-breaker.ts b/src/circuit-breaker.ts new file mode 100644 index 0000000..4288eb4 --- /dev/null +++ b/src/circuit-breaker.ts @@ -0,0 +1,79 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import { log } from './log.js'; + +const CB_PATH = path.join(DATA_DIR, 'circuit-breaker.json'); +const RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900]; // index = attempt number, 6+ capped at 15min + +interface CircuitBreakerState { + attempt: number; + timestamp: string; +} + +function read(): CircuitBreakerState | null { + try { + const raw = fs.readFileSync(CB_PATH, 'utf-8'); + return JSON.parse(raw) as CircuitBreakerState; + } catch { + return null; + } +} + +function write(state: CircuitBreakerState): void { + fs.writeFileSync(CB_PATH, JSON.stringify(state, null, 2) + '\n'); +} + +function getDelay(attempt: number): number { + const idx = Math.min(attempt, BACKOFF_SCHEDULE_S.length - 1); + return BACKOFF_SCHEDULE_S[idx]; +} + +export function resetCircuitBreaker(): void { + try { + fs.unlinkSync(CB_PATH); + log.info('Circuit breaker reset on clean shutdown'); + } catch {} +} + +export async function enforceStartupBackoff(): Promise { + const now = new Date(); + const prev = read(); + + let attempt: number; + if (!prev) { + attempt = 1; + } else { + const elapsedMs = now.getTime() - new Date(prev.timestamp).getTime(); + if (elapsedMs < RESET_WINDOW_MS) { + attempt = prev.attempt + 1; + log.warn('Previous startup was not a clean shutdown', { + previousAttempt: prev.attempt, + previousTimestamp: prev.timestamp, + elapsedSec: Math.round(elapsedMs / 1000), + }); + } else { + attempt = 1; + log.info('Circuit breaker reset — last startup was over 1h ago', { + previousAttempt: prev.attempt, + previousTimestamp: prev.timestamp, + }); + } + } + + write({ attempt, timestamp: now.toISOString() }); + + const delaySec = getDelay(attempt); + if (delaySec > 0) { + const resumeAt = new Date(now.getTime() + delaySec * 1000).toISOString(); + log.warn('Circuit breaker: delaying startup due to repeated crashes', { + attempt, + delaySec, + resumeAt, + }); + await new Promise((resolve) => setTimeout(resolve, delaySec * 1000)); + log.info('Circuit breaker: backoff complete, resuming startup', { attempt }); + } +} diff --git a/src/index.ts b/src/index.ts index ea9fba6..6235525 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import path from 'path'; import { DATA_DIR } from './config.js'; +import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; @@ -58,6 +59,9 @@ import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from async function main(): Promise { log.info('NanoClaw starting'); + // 0. Circuit breaker — backoff on rapid restarts + await enforceStartupBackoff(); + // 1. Init central DB const dbPath = path.join(DATA_DIR, 'v2.db'); const db = initDb(dbPath); @@ -175,6 +179,7 @@ async function shutdown(signal: string): Promise { stopDeliveryPolls(); stopHostSweep(); await teardownChannelAdapters(); + resetCircuitBreaker(); process.exit(0); } From 336e01d2a1f1014d87da4f1d00a0f67e1e811cad Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 28 Apr 2026 22:51:11 +0300 Subject: [PATCH 11/63] fix circuit-breaker off-by-one, ENOENT, and reset-on-throw + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getDelay indexed by attempt (1-based) into a 0-indexed array, so the leading 0 was unreachable and every "after a crash" delay was shifted up one slot. Use attempt - 1 so the documented schedule (0s → 0s → 10s → 30s → 2min → 5min → 15min cap) actually holds. - enforceStartupBackoff runs before initDb (which creates DATA_DIR), so on a fresh checkout fs.writeFileSync hit ENOENT. write() now mkdirSync's DATA_DIR first. - shutdown() didn't run resetCircuitBreaker if teardownChannelAdapters threw, so a graceful exit with a teardown error would be counted as a crash on the next start. Wrap teardown in try/finally. - Adds src/circuit-breaker.test.ts: state transitions, full schedule (parameterized), reset-window expiry, malformed file, and the fresh-install path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/circuit-breaker.test.ts | 197 ++++++++++++++++++++++++++++++++++++ src/circuit-breaker.ts | 9 +- src/index.ts | 12 ++- 3 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 src/circuit-breaker.test.ts diff --git a/src/circuit-breaker.test.ts b/src/circuit-breaker.test.ts new file mode 100644 index 0000000..d8c996c --- /dev/null +++ b/src/circuit-breaker.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for the startup circuit breaker. + * + * Covers state transitions, the documented backoff schedule, and the + * fresh-install case where DATA_DIR doesn't exist yet (the breaker runs + * before initDb, so it has to create the dir itself). + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// vi.mock factories are hoisted above imports, so they can't close over local +// consts. vi.hoisted is hoisted alongside the mock and runs before any +// `import` — so it can only use globals (no path/os modules). Use require() +// inside the callback to compute the test dir. +const { TEST_DIR } = vi.hoisted(() => { + const nodePath = require('path') as typeof import('path'); + const nodeOs = require('os') as typeof import('os'); + return { TEST_DIR: nodePath.join(nodeOs.tmpdir(), 'nanoclaw-cb-test') }; +}); +const CB_PATH = path.join(TEST_DIR, 'circuit-breaker.json'); + +vi.mock('./config.js', async () => { + const actual = await vi.importActual('./config.js'); + return { ...actual, DATA_DIR: TEST_DIR }; +}); + +vi.mock('./log.js', () => ({ + log: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; + +function readState(): { attempt: number; timestamp: string } { + return JSON.parse(fs.readFileSync(CB_PATH, 'utf-8')); +} + +function seedState(attempt: number, timestamp = new Date().toISOString()): void { + fs.writeFileSync(CB_PATH, JSON.stringify({ attempt, timestamp })); +} + +beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); +}); + +afterEach(() => { + vi.useRealTimers(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); +}); + +describe('resetCircuitBreaker', () => { + it('deletes the state file', () => { + seedState(3); + expect(fs.existsSync(CB_PATH)).toBe(true); + resetCircuitBreaker(); + expect(fs.existsSync(CB_PATH)).toBe(false); + }); + + it('is a no-op when the file does not exist', () => { + expect(fs.existsSync(CB_PATH)).toBe(false); + expect(() => resetCircuitBreaker()).not.toThrow(); + }); +}); + +describe('enforceStartupBackoff — state transitions', () => { + it('first run writes attempt=1 and does not delay', async () => { + vi.useFakeTimers(); + const start = Date.now(); + await enforceStartupBackoff(); + // No timers should have been queued — clean first start is 0s. + expect(Date.now() - start).toBe(0); + expect(readState().attempt).toBe(1); + }); + + it('within reset window, attempt is incremented', async () => { + seedState(1); + vi.useFakeTimers(); + const promise = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await promise; + expect(readState().attempt).toBe(2); + }); + + it('outside reset window (>1h), attempt resets to 1', async () => { + const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + seedState(5, longAgo); + await enforceStartupBackoff(); + expect(readState().attempt).toBe(1); + }); + + it('exactly at the reset window boundary still counts as "within"', async () => { + // RESET_WINDOW_MS = 60min. Use 59min59s to stay inside even if the test + // takes a few ms to execute. + const justInside = new Date(Date.now() - (60 * 60 * 1000 - 1000)).toISOString(); + seedState(2, justInside); + vi.useFakeTimers(); + const promise = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await promise; + expect(readState().attempt).toBe(3); + }); + + it('treats a malformed state file as no prior state', async () => { + fs.writeFileSync(CB_PATH, '{ this is not json'); + await enforceStartupBackoff(); + expect(readState().attempt).toBe(1); + }); + + it('resetCircuitBreaker after a startup actually clears the counter for the next startup', async () => { + // Simulate: crash, restart (attempt=2), graceful shutdown, restart again. + seedState(1); + vi.useFakeTimers(); + const p1 = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await p1; + expect(readState().attempt).toBe(2); + + resetCircuitBreaker(); + expect(fs.existsSync(CB_PATH)).toBe(false); + + await enforceStartupBackoff(); + expect(readState().attempt).toBe(1); + }); +}); + +describe('enforceStartupBackoff — backoff schedule', () => { + /** + * Documented schedule: + * + * clean start → 1 crash → 2 crash → 3 crash → 4 crash → 5 crash → 6+ crash + * 0s → 0s → 10s → 30s → 2min → 5min → 15min cap + * + * Each row is [priorAttempt seeded in the file, expected delay this run + * produces in seconds]. priorAttempt=null = no file = very first start. + * + * To assert the *requested* delay (not just observed elapsed real time), + * we spy on global.setTimeout and look at the longest call. runAllTimersAsync + * lets the function complete so we can move on. + */ + const cases: Array<{ label: string; priorAttempt: number | null; expectedDelaySec: number }> = [ + { label: 'clean first start (no file)', priorAttempt: null, expectedDelaySec: 0 }, + { label: 'first crash (attempt=2)', priorAttempt: 1, expectedDelaySec: 0 }, + { label: 'second crash (attempt=3)', priorAttempt: 2, expectedDelaySec: 10 }, + { label: 'third crash (attempt=4)', priorAttempt: 3, expectedDelaySec: 30 }, + { label: 'fourth crash (attempt=5)', priorAttempt: 4, expectedDelaySec: 120 }, + { label: 'fifth crash (attempt=6)', priorAttempt: 5, expectedDelaySec: 300 }, + { label: 'sixth crash (attempt=7) — cap', priorAttempt: 6, expectedDelaySec: 900 }, + { label: 'far past cap (attempt=20)', priorAttempt: 19, expectedDelaySec: 900 }, + ]; + + for (const { label, priorAttempt, expectedDelaySec } of cases) { + it(`${label}: delays ${expectedDelaySec}s`, async () => { + if (priorAttempt !== null) seedState(priorAttempt); + + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + + const promise = enforceStartupBackoff(); + await vi.runAllTimersAsync(); + await promise; + + // enforceStartupBackoff only calls setTimeout when delaySec > 0. Pick + // the longest delay it requested (vitest may queue small internal + // timers we don't care about). + const requestedDelays = setTimeoutSpy.mock.calls.map((c) => c[1] ?? 0); + const maxDelayMs = requestedDelays.length ? Math.max(...requestedDelays) : 0; + + expect(maxDelayMs).toBe(expectedDelaySec * 1000); + }); + } +}); + +describe('enforceStartupBackoff — fresh install (DATA_DIR missing)', () => { + /** + * The breaker runs before initDb (which is what creates DATA_DIR). On a + * fresh checkout the dir doesn't exist yet, so write() must create it + * before writing the state file — otherwise the host crashes on its very + * first start. + */ + it('creates DATA_DIR on demand and does not throw', async () => { + fs.rmSync(TEST_DIR, { recursive: true }); + expect(fs.existsSync(TEST_DIR)).toBe(false); + + await expect(enforceStartupBackoff()).resolves.toBeUndefined(); + expect(fs.existsSync(TEST_DIR)).toBe(true); + expect(fs.existsSync(CB_PATH)).toBe(true); + expect(readState().attempt).toBe(1); + }); +}); diff --git a/src/circuit-breaker.ts b/src/circuit-breaker.ts index 4288eb4..20211f0 100644 --- a/src/circuit-breaker.ts +++ b/src/circuit-breaker.ts @@ -6,7 +6,9 @@ import { log } from './log.js'; const CB_PATH = path.join(DATA_DIR, 'circuit-breaker.json'); const RESET_WINDOW_MS = 60 * 60 * 1000; // 1 hour -const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900]; // index = attempt number, 6+ capped at 15min +// Index = number of consecutive crashes (0 = clean start, attempt 1). +// 6+ crashes capped at 15min. +const BACKOFF_SCHEDULE_S = [0, 0, 10, 30, 120, 300, 900]; interface CircuitBreakerState { attempt: number; @@ -23,11 +25,14 @@ function read(): CircuitBreakerState | null { } function write(state: CircuitBreakerState): void { + // The breaker runs before initDb (which is what creates DATA_DIR), so on a + // fresh checkout the dir may not exist yet. + fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(CB_PATH, JSON.stringify(state, null, 2) + '\n'); } function getDelay(attempt: number): number { - const idx = Math.min(attempt, BACKOFF_SCHEDULE_S.length - 1); + const idx = Math.min(attempt - 1, BACKOFF_SCHEDULE_S.length - 1); return BACKOFF_SCHEDULE_S[idx]; } diff --git a/src/index.ts b/src/index.ts index 6235525..9ded3d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -178,9 +178,15 @@ async function shutdown(signal: string): Promise { } stopDeliveryPolls(); stopHostSweep(); - await teardownChannelAdapters(); - resetCircuitBreaker(); - process.exit(0); + try { + await teardownChannelAdapters(); + } finally { + // Always reset on graceful shutdown — even if teardown threw, we got here + // via SIGTERM/SIGINT, not a crash, so the next start shouldn't be counted + // as one. + resetCircuitBreaker(); + process.exit(0); + } } process.on('SIGTERM', () => shutdown('SIGTERM')); From ede6c01da8784c1adf5cddea5443e937761ce297 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 28 Apr 2026 19:53:23 +0000 Subject: [PATCH 12/63] chore: bump version to 2.0.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ee88d92..9b3b6fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.14", + "version": "2.0.15", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 89738917aed2c82f66c89203c05fda27a734e929 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 08:18:29 +0000 Subject: [PATCH 13/63] offer to install and authenticate Claude CLI before diagnosis When setup fails and claude-assist kicks in, instead of silently skipping when the CLI is missing or unauthenticated, interactively offer to install it (via install-claude.sh) and sign in (via claude setup-token) so the user can get diagnostic help immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/lib/claude-assist.ts | 73 +++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 1651a9c..9cc3e5d 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -2,8 +2,10 @@ * Offer Claude-assisted debugging when a setup step fails. * * Flow: - * 1. Check `claude` is on PATH and has a working credential. If not, - * silently skip — pre-auth failures can't use this path. + * 1. Check `claude` is on PATH — if not, offer to install it via + * setup/install-claude.sh. Then check auth via `claude auth status` + * — if not signed in, offer to run `claude setup-token` (browser + * OAuth). If either is declined or fails, silently skip. * 2. Ask the user for consent ("Want me to ask Claude for a fix?"). * 3. Build a minimal prompt: the one-paragraph situation, the failing * step's name/message/hint, and a short list of *file references* @@ -16,7 +18,7 @@ * * Skippable with NANOCLAW_SKIP_CLAUDE_ASSIST=1 for CI/scripted runs. */ -import { execSync, spawn } from 'child_process'; +import { execSync, spawn, spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -90,7 +92,7 @@ export async function offerClaudeAssist( projectRoot: string = process.cwd(), ): Promise { if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false; - if (!isClaudeUsable()) return false; + if (!(await ensureClaudeReady(projectRoot))) return false; const want = ensureAnswer( await p.confirm({ @@ -128,15 +130,70 @@ export async function offerClaudeAssist( return true; } -function isClaudeUsable(): boolean { +function isClaudeInstalled(): boolean { try { execSync('command -v claude', { stdio: 'ignore' }); + return true; } catch { return false; } - // Availability without auth is half the story; a real query will still - // fail if the token isn't registered. We try first and surface the error - // rather than pre-checking auth with a separate round trip. +} + +function isClaudeAuthenticated(): boolean { + try { + execSync('claude auth status', { stdio: 'ignore', timeout: 5_000 }); + return true; + } catch { + return false; + } +} + +async function ensureClaudeReady(projectRoot: string): Promise { + if (!isClaudeInstalled()) { + const install = ensureAnswer( + await p.confirm({ + message: + 'Claude CLI is needed to diagnose this. Install it now?', + initialValue: true, + }), + ); + if (!install) return false; + + const code = spawnSync('bash', ['setup/install-claude.sh'], { + cwd: projectRoot, + stdio: 'inherit', + }).status; + if (code !== 0 || !isClaudeInstalled()) { + p.log.error("Couldn't install the Claude CLI."); + return false; + } + p.log.success('Claude CLI installed.'); + } + + if (!isClaudeAuthenticated()) { + const auth = ensureAnswer( + await p.confirm({ + message: + "Claude CLI isn't signed in. Sign in now? (a browser will open)", + initialValue: true, + }), + ); + if (!auth) return false; + + const code = await new Promise((resolve) => { + const child = spawn('claude', ['setup-token'], { + stdio: 'inherit', + }); + child.on('close', (c) => resolve(c ?? 1)); + child.on('error', () => resolve(1)); + }); + if (code !== 0 || !isClaudeAuthenticated()) { + p.log.error("Couldn't complete Claude sign-in."); + return false; + } + p.log.success('Claude CLI signed in.'); + } + return true; } From 93be2d15f0d1e78797f085733fba65026cdae19e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 10:18:38 +0000 Subject: [PATCH 14/63] fix claude setup-token flow for headless/remote systems Use script(1) to capture PTY output and extract OAuth token when browser-based auth isn't available, with fallback code-paste flow. Co-Authored-By: Claude Opus 4.6 --- setup/lib/claude-assist.ts | 47 ++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 9cc3e5d..dbc5082 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -5,7 +5,8 @@ * 1. Check `claude` is on PATH — if not, offer to install it via * setup/install-claude.sh. Then check auth via `claude auth status` * — if not signed in, offer to run `claude setup-token` (browser - * OAuth). If either is declined or fails, silently skip. + * OAuth with code-paste fallback for headless/remote systems). + * If either is declined or fails, silently skip. * 2. Ask the user for consent ("Want me to ask Claude for a fix?"). * 3. Build a minimal prompt: the one-paragraph situation, the failing * step's name/message/hint, and a short list of *file references* @@ -20,6 +21,7 @@ */ import { execSync, spawn, spawnSync } from 'child_process'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import * as p from '@clack/prompts'; @@ -180,14 +182,45 @@ async function ensureClaudeReady(projectRoot: string): Promise { ); if (!auth) return false; - const code = await new Promise((resolve) => { - const child = spawn('claude', ['setup-token'], { + // setup-token has an interactive TUI; reset terminal to cooked mode + // so its prompts render correctly after clack's raw-mode prompts. + spawnSync('stty', ['sane'], { stdio: 'inherit' }); + + // Run under script(1) to capture the OAuth token from PTY output + // while preserving interactive TTY for the browser OAuth flow. + // Same approach as register-claude-token.sh, but we set the env var + // instead of writing to OneCLI. + const tmpfile = path.join(os.tmpdir(), `claude-setup-token-${process.pid}`); + try { + const isUtilLinux = (() => { + try { + return execSync('script --version 2>&1', { encoding: 'utf-8' }).includes('util-linux'); + } catch { return false; } + })(); + const scriptArgs = isUtilLinux + ? ['-q', '-c', 'claude setup-token', tmpfile] + : ['-q', tmpfile, 'claude', 'setup-token']; + + spawnSync('script', scriptArgs, { + cwd: projectRoot, stdio: 'inherit', }); - child.on('close', (c) => resolve(c ?? 1)); - child.on('error', () => resolve(1)); - }); - if (code !== 0 || !isClaudeAuthenticated()) { + + if (!isClaudeAuthenticated() && fs.existsSync(tmpfile)) { + const raw = fs.readFileSync(tmpfile, 'utf-8'); + const stripped = raw + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + .replace(/[\n\r]/g, ''); + const matches = stripped.match(/(sk-ant-oat[A-Za-z0-9_-]{80,500}AA)/g); + if (matches) { + process.env.CLAUDE_CODE_OAUTH_TOKEN = matches[matches.length - 1]; + } + } + } finally { + try { fs.unlinkSync(tmpfile); } catch {} + } + + if (!isClaudeAuthenticated()) { p.log.error("Couldn't complete Claude sign-in."); return false; } From 9c8f680ca87d31fe3c273c246cd80eaec019e04e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 10:20:10 +0000 Subject: [PATCH 15/63] fix: stop dimming setup card bodies Clack's `p.note` defaults to `format: e => styleText("dim", e)`, which fades note bodies regardless of the project's stated readability stance (see comment on `dimWrap` in setup/lib/theme.ts: "prose renders at the terminal's regular weight"). The dim styling makes body copy hard to read on dark terminals and visibly washes out brand-colored segments embedded in cards (e.g. the chip + bold heading rows). Add a `note()` helper in setup/lib/theme.ts that wraps `p.note` with a pass-through formatter, and route every setup-flow `p.note` call site through it: setup/auto.ts, every setup/channels/*.ts adapter, and the two setup/lib/claude-* helpers. Pre-styled segments (brandBold, brandChip, formatPairingCard, formatCodeCard) now render at full strength instead of being faded alongside surrounding prose. --- setup/auto.ts | 14 +++++++------- setup/channels/discord.ts | 11 ++++++----- setup/channels/imessage.ts | 8 ++++---- setup/channels/signal.ts | 5 +++-- setup/channels/slack.ts | 8 ++++---- setup/channels/teams.ts | 21 +++++++++++---------- setup/channels/telegram.ts | 8 ++++---- setup/channels/whatsapp.ts | 8 ++++---- setup/lib/claude-assist.ts | 4 ++-- setup/lib/claude-handoff.ts | 4 +++- setup/lib/theme.ts | 14 ++++++++++++++ 11 files changed, 62 insertions(+), 43 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4dee7c8..ee5c369 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -52,7 +52,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; -import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js'; +import { brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -435,7 +435,7 @@ async function main(): Promise { ); } if (notes.length > 0) { - p.note(notes.join('\n'), "What's left"); + note(notes.join('\n'), "What's left"); } // "What's left" is a soft failure — we don't abort like fail(), but the // user is still stuck and a fix is exactly what claude-assist is for. @@ -467,11 +467,11 @@ async function main(): Promise { ]; const labelWidth = Math.max(...rows.map(([l]) => l.length)); const nextSteps = rows.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`).join('\n'); - p.note(nextSteps, 'Try these'); + note(nextSteps, 'Try these'); // Always-on warning goes before the "check your DMs" directive so the // caveat doesn't land after the user's already looked away at their phone. - p.note( + note( wrapForGutter( "NanoClaw runs on this machine. It's only reachable while this computer is on and connected to the internet. For always-on availability, run it on a cloud VM — or keep this machine awake.", 6, @@ -488,7 +488,7 @@ async function main(): Promise { // that the welcome-message signal was too easy to miss. Use p.note so it // renders with a visible box, cyan-bold the directive line, and put it // as the last thing before outro. - p.note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi'); + note(`${brandBold('→')} ${k.bold(`Check your ${dmTarget} — your assistant is saying hi.`)}`, 'Go say hi'); p.outro(k.green("You're set.")); } else { p.outro(k.green("You're ready! Chat with `pnpm run chat hi`.")); @@ -567,7 +567,7 @@ function renderPingFailureNote(result: PingResult): void { 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', 6, ); - p.note(body, 'Skipping the first chat'); + note(body, 'Skipping the first chat'); } /** @@ -582,7 +582,7 @@ function renderPingFailureNote(result: PingResult): void { * clearly optional. */ async function runFirstChat(): Promise { - p.note( + note( wrapForGutter( [ 'Your assistant runs in a sandbox on this machine.', diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3668686..671d920 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,6 +31,7 @@ import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; +import { note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -155,7 +156,7 @@ async function askHasBotToken(): Promise { async function walkThroughBotCreation(): Promise { const url = 'https://discord.com/developers/applications'; - p.note( + note( [ "You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.", '', @@ -184,7 +185,7 @@ function showTokenLocationReminder(hasExistingBot: boolean): void { // to find it — tokens in the Dev Portal aren't visible after first reveal, // and "Reset Token" issues a new one. if (hasExistingBot) { - p.note( + note( [ "Where to find your bot token:", '', @@ -216,7 +217,7 @@ async function walkThroughServerCreation(): Promise { // the web client and rely on the + button being visible. The steps below // are the same whether they're in the desktop app or the browser. const url = 'https://discord.com/channels/@me'; - p.note( + note( [ "A Discord server is just a private space for you and the bot. Free and takes 30 seconds.", '', @@ -392,7 +393,7 @@ async function resolveOwnerUserId( } async function promptForUserIdWithDevMode(): Promise { - p.note( + note( [ "To get your Discord user ID:", '', @@ -430,7 +431,7 @@ async function promptInviteBot( `&scope=bot` + `&permissions=${INVITE_PERMISSIONS}`; - p.note( + note( [ `@${botUsername} needs to share a server with you before it can DM you.`, '', diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index d8b129f..387f6b2 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -36,7 +36,7 @@ import * as setupLog from '../logs.js'; import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { wrapForGutter } from '../lib/theme.js'; +import { note, wrapForGutter } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -189,7 +189,7 @@ async function walkThroughFullDiskAccess(): Promise { } const nodeDir = path.dirname(nodePath); - p.note( + note( wrapForGutter( [ `iMessage needs Full Disk Access granted to the Node binary:`, @@ -222,7 +222,7 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { - p.note( + note( [ "Photon is a separate service that owns an iMessage account and", "exposes it over HTTP. NanoClaw will talk to it via its API.", @@ -264,7 +264,7 @@ async function collectRemoteCreds(): Promise { } async function askOperatorHandle(): Promise { - p.note( + note( [ "What phone number or email do you iMessage with?", "That's where your assistant will send its welcome message.", diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 9e54cb9..4e1cbfb 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -44,6 +44,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; +import { note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -139,7 +140,7 @@ async function ensureSignalCli(): Promise { if (!probe.error && probe.status === 0) return; if (process.platform === 'darwin') { - p.note( + note( [ "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", '', @@ -152,7 +153,7 @@ async function ensureSignalCli(): Promise { 'signal-cli not found', ); } else { - p.note( + note( [ "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", '', diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 6d1ff56..4ee5973 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -28,7 +28,7 @@ import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { wrapForGutter } from '../lib/theme.js'; +import { note, wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -121,7 +121,7 @@ export async function runSlackChannel(displayName: string): Promise { } async function walkThroughAppCreation(): Promise { - p.note( + note( [ "You'll create a Slack app that the assistant talks through.", "Free and stays inside the workspaces you pick.", @@ -262,7 +262,7 @@ async function validateSlackToken(token: string): Promise { } async function collectSlackUserId(): Promise { - p.note( + note( [ "To get your Slack member ID:", '', @@ -367,7 +367,7 @@ async function resolveAgentName(): Promise { } function showPostInstallChecklist(info: WorkspaceInfo): void { - p.note( + note( wrapForGutter( [ `Your agent is wired to Slack and a welcome DM is on its way.`, diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index fb4d878..e412086 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -40,6 +40,7 @@ import { } from '../lib/claude-handoff.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; +import { note } from '../lib/theme.js'; import * as setupLog from '../logs.js'; const CHANNEL = 'teams'; @@ -79,7 +80,7 @@ export async function runTeamsChannel(_displayName: string): Promise { // ─── step: intro / prereqs ────────────────────────────────────────────── function printIntro(): void { - p.note( + note( [ 'Setting up Teams is more involved than the other channels — about', '7 steps across the Azure portal and Teams admin.', @@ -93,7 +94,7 @@ function printIntro(): void { } async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { - p.note( + note( [ 'Before we start, confirm you have:', '', @@ -119,7 +120,7 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[] // ─── step: public URL ────────────────────────────────────────────────── async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise { - p.note( + note( [ "Azure Bot Service delivers messages to an HTTPS endpoint you", "control. The endpoint needs to reach this machine's webhook", @@ -175,7 +176,7 @@ async function stepAppRegistration(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ `1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`, '2. Name it (e.g. "NanoClaw")', @@ -259,7 +260,7 @@ async function stepClientSecret(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ `1. In your app registration, open "Certificates & secrets"`, '2. Click "New client secret"', @@ -328,7 +329,7 @@ async function stepAzureBot(args: { ` --appid ${args.collected.appId} \\\n` + ` ${tenantFlag}--endpoint "${endpoint}"`; - p.note( + note( [ `In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`, '', @@ -365,7 +366,7 @@ async function stepEnableTeamsChannel(args: { collected: Collected; completed: string[]; }): Promise { - p.note( + note( [ '1. Open your Azure Bot resource → Channels', '2. Click Microsoft Teams → Accept terms → Apply', @@ -435,7 +436,7 @@ async function stepSideload(args: { completed: string[]; zipPath: string; }): Promise { - p.note( + note( [ '1. Open Microsoft Teams', '2. Go to Apps → Manage your apps → Upload an app', @@ -501,7 +502,7 @@ async function finishWithHandoff( collected: Collected, completed: string[], ): Promise { - p.note( + note( [ 'The Teams adapter is live and the service is running.', '', @@ -530,7 +531,7 @@ async function finishWithHandoff( ); if (choice === 'self') { - p.note( + note( [ ' 1. Find your bot in Teams (search by name, or via the sideloaded', ' app) and send it a message ("hi" is fine)', diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df97fcf..3a86a5f 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { brandBold } from '../lib/theme.js'; +import { brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -47,7 +47,7 @@ export async function runTelegramChannel(displayName: string): Promise { // installed, or the bot's web profile if not. tg://resolve?domain= is // more direct but silently fails when the scheme isn't registered. const botUrl = `https://t.me/${botUsername}`; - p.note( + note( [ `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, '', @@ -132,7 +132,7 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { - p.note( + note( [ "Your assistant talks to you through a Telegram bot you create.", "Here's how:", @@ -240,7 +240,7 @@ async function runPairTelegram(): Promise< } else { stopSpinner("Old code expired. Here's a fresh one."); } - p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); + note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); s.start('Waiting for you to send the code from Telegram…'); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 85c9866..eb487cb 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { brandBold } from '../lib/theme.js'; +import { brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -171,7 +171,7 @@ async function askAuthMethod(): Promise { } async function askPhoneNumber(): Promise { - p.note( + note( [ "Enter your phone number the way WhatsApp expects it:", '', @@ -249,7 +249,7 @@ async function runWhatsAppAuth( } else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') { const code = block.fields.CODE ?? '????'; stopSpinner('Your pairing code is ready.'); - p.note(formatPairingCard(code), 'Pairing code'); + note(formatPairingCard(code), 'Pairing code'); s.start('Waiting for you to enter the code…'); spinnerActive = true; } else if (block.type === 'WHATSAPP_AUTH') { @@ -395,7 +395,7 @@ async function restartService(): Promise { } async function askChatPhone(authedPhone: string): Promise { - p.note( + note( [ `Authenticated with ${k.cyan('+' + authedPhone)}.`, '', diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 1651a9c..48c760e 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -24,7 +24,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { ensureAnswer } from './runner.js'; -import { fitToWidth } from './theme.js'; +import { fitToWidth, note } from './theme.js'; export interface AssistContext { stepName: string; @@ -111,7 +111,7 @@ export async function offerClaudeAssist( return false; } - p.note( + note( `${parsed.reason}\n\n${k.cyan('$')} ${parsed.command}`, "Claude's suggestion", ); diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 9c931f2..3a0c219 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -27,6 +27,8 @@ import { execSync, spawn } from 'child_process'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { note } from './theme.js'; + export interface HandoffContext { /** Channel this handoff is happening in (e.g., 'teams'). */ channel: string; @@ -69,7 +71,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise const systemPrompt = buildSystemPrompt(ctx); - p.note( + note( [ "I'm handing you off to Claude in interactive mode.", "It has the context of where you are in setup.", diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 35b5ca3..f30ebe6 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -11,6 +11,7 @@ * - COLORTERM truecolor/24bit → 24-bit ANSI (exact brand cyan) * - Otherwise → kleur's 16-color cyan (closest fallback) */ +import * as p from '@clack/prompts'; import k from 'kleur'; const USE_ANSI = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; @@ -68,6 +69,19 @@ export function dimWrap(text: string, gutter: number): string { return wrapForGutter(text, gutter); } +/** + * Wrap clack's `p.note` with the dim formatter disabled. By default + * clack renders note bodies through `styleText("dim", …)`, which the + * project's prose-readability stance (see `dimWrap` above) explicitly + * rejects. Pass-through formatter keeps body text at the terminal's + * regular weight; pre-styled segments (chips, bold, brand color) come + * through unfaded. + */ +const passthroughFormat = (s: string): string => s; +export function note(message: string, title?: string): void { + p.note(message, title, { format: passthroughFormat }); +} + const ANSI_RE = /\x1b\[[0-9;]*m/g; function visibleLength(s: string): number { From aa390b3fd0466af30e5cc19113bb9e52944d3684 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 29 Apr 2026 10:20:54 +0000 Subject: [PATCH 16/63] detect existing .env and credentials on setup re-run When re-running setup on a machine that already has a .env with channel tokens or OneCLI config, detect them early and offer to reuse instead of prompting the user to paste everything again. - Add detectExistingEnv() to parse .env and group known keys - Add detectExistingDisplayName() to read display name from v2.db - Defer display name prompt until actually needed (cli-agent or channel) - Skip cli-agent and first-chat when groups are already wired - Add token reuse checks to Telegram, Discord, Slack, Teams, iMessage Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 98 ++++++++++++++++++++++++++++++++++++-- setup/channels/discord.ts | 12 +++++ setup/channels/imessage.ts | 13 +++++ setup/channels/slack.ts | 24 ++++++++++ setup/channels/teams.ts | 22 +++++++++ setup/channels/telegram.ts | 12 +++++ setup/environment.ts | 18 +++++++ 7 files changed, 195 insertions(+), 4 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4dee7c8..01d7f3a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -46,6 +46,7 @@ import { } from './lib/setup-config-parse.js'; import { runAdvancedScreen } from './lib/setup-config-screen.js'; import { runWindowedStep } from './lib/windowed-runner.js'; +import { detectRegisteredGroups, detectExistingDisplayName } from './environment.js'; import { pollHealth } from './onecli.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-claude.js'; @@ -121,6 +122,39 @@ async function main(): Promise { } } + // Detect existing .env and offer to reuse it so the user doesn't have to + // paste credentials again on a re-run. + const existingEnv = detectExistingEnv(); + if (existingEnv) { + const lines = Object.values(existingEnv.groups).map( + (g) => ` ${k.green('✓')} ${g.label}`, + ); + p.note(lines.join('\n'), 'Found existing configuration'); + + const reuseChoice = ensureAnswer( + await brightSelect({ + message: 'Use this existing environment?', + options: [ + { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' }, + { value: 'fresh', label: 'No, start fresh' }, + ], + initialValue: 'reuse', + }), + ) as 'reuse' | 'fresh'; + setupLog.userInput('existing_env_choice', reuseChoice); + + if (reuseChoice === 'reuse') { + for (const [key, value] of Object.entries(existingEnv.raw)) { + if (!process.env[key]) process.env[key] = value; + } + if (existingEnv.groups.onecli) skip.add('onecli'); + if (detectRegisteredGroups(process.cwd())) { + skip.add('cli-agent'); + skip.add('first-chat'); + } + } + } + if (!skip.has('container')) { p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)); p.log.message( @@ -295,14 +329,17 @@ async function main(): Promise { } let displayName: string | undefined; - const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel'); - if (needsDisplayName) { - const fallback = process.env.USER?.trim() || 'Operator'; + async function resolveDisplayName(): Promise { + if (displayName) return displayName; const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim(); - displayName = preset || (await askDisplayName(fallback)); + const existing = detectExistingDisplayName(process.cwd()); + const fallback = process.env.USER?.trim() || 'Operator'; + displayName = preset || existing || (await askDisplayName(fallback)); + return displayName; } if (!skip.has('cli-agent')) { + await resolveDisplayName(); const res = await runQuietStep( 'cli-agent', { @@ -371,6 +408,9 @@ async function main(): Promise { let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { channelChoice = await askChannelChoice(); + if (channelChoice !== 'skip') { + await resolveDisplayName(); + } if (channelChoice === 'telegram') { await runTelegramChannel(displayName!); } else if (channelChoice === 'discord') { @@ -1010,6 +1050,56 @@ async function askChannelChoice(): Promise { // ─── interactive / env helpers ───────────────────────────────────────── +interface ExistingEnvGroup { + label: string; + keys: string[]; +} + +const ENV_KEY_GROUPS: Record = { + onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] }, + telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] }, + discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] }, + slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] }, + signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] }, + teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] }, + whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] }, + imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] }, +}; + +function detectExistingEnv(): { groups: Record; raw: Record } | null { + const envPath = path.join(process.cwd(), '.env'); + if (!fs.existsSync(envPath)) return null; + + let content: string; + try { + content = fs.readFileSync(envPath, 'utf-8'); + } catch { + return null; + } + + const raw: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 1) continue; + raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); + } + + if (Object.keys(raw).length === 0) return null; + + const groups: Record = {}; + for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) { + const found = def.keys.filter((key) => raw[key] !== undefined); + if (found.length > 0) { + groups[id] = { label: def.label, keys: found }; + } + } + + if (Object.keys(groups).length === 0) return null; + return { groups, raw }; +} + function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3668686..dd17bc2 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -239,6 +239,18 @@ async function walkThroughServerCreation(): Promise { } async function collectDiscordToken(): Promise { + const existing = process.env.DISCORD_BOT_TOKEN?.trim(); + if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('discord_token', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index d8b129f..89d2efe 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -222,6 +222,19 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { + const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim(); + const existingKey = process.env.IMESSAGE_API_KEY?.trim(); + if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found existing Photon credentials (${existingUrl}). Use them?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('imessage_remote_creds', 'reused-existing'); + return { serverUrl: existingUrl, apiKey: existingKey }; + } + } + p.note( [ "Photon is a separate service that owns an iMessage account and", diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 6d1ff56..cfbd988 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -151,6 +151,18 @@ async function walkThroughAppCreation(): Promise { } async function collectBotToken(): Promise { + const existing = process.env.SLACK_BOT_TOKEN?.trim(); + if (existing && existing.startsWith('xoxb-') && existing.length >= 24) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('slack_bot_token', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your Slack bot token', @@ -172,6 +184,18 @@ async function collectBotToken(): Promise { } async function collectSigningSecret(): Promise { + const existing = process.env.SLACK_SIGNING_SECRET?.trim(); + if (existing && /^[a-f0-9]{16,}$/i.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: 'Found an existing Slack signing secret. Use it?', + initialValue: true, + })); + if (reuse) { + setupLog.userInput('slack_signing_secret', 'reused-existing'); + return existing; + } + } + const answer = ensureAnswer( await p.password({ message: 'Paste your Slack signing secret', diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index fb4d878..91a91d9 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -59,6 +59,28 @@ export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; + const existingAppId = process.env.TEAMS_APP_ID?.trim(); + const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim(); + if (existingAppId && existingPassword) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, + initialValue: true, + })); + if (reuse) { + collected.appId = existingAppId; + collected.appPassword = existingPassword; + collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; + if (collected.appType === 'SingleTenant') { + collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim(); + } + setupLog.userInput('teams_credentials', 'reused-existing'); + await installAdapter(collected); + completed.push('Adapter installed and service restarted (reused existing credentials).'); + await finishWithHandoff(collected, completed); + return; + } + } + printIntro(); await confirmPrereqs({ collected, completed }); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df97fcf..4659bd6 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -132,6 +132,18 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { + const existing = process.env.TELEGRAM_BOT_TOKEN?.trim(); + if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { + const reuse = ensureAnswer(await p.confirm({ + message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, + initialValue: true, + })); + if (reuse) { + setupLog.userInput('telegram_token', 'reused-existing'); + return existing; + } + } + p.note( [ "Your assistant talks to you through a Telegram bot you create.", diff --git a/setup/environment.ts b/setup/environment.ts index 6986396..c351023 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -11,6 +11,24 @@ import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +export function detectExistingDisplayName(projectRoot: string): string | null { + const dbPath = path.join(projectRoot, 'data', 'v2.db'); + if (!fs.existsSync(dbPath)) return null; + + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const row = db + .prepare(`SELECT display_name FROM users WHERE id = 'cli:local'`) + .get() as { display_name: string } | undefined; + return row?.display_name?.trim() || null; + } catch { + return null; + } finally { + db?.close(); + } +} + export function detectRegisteredGroups(projectRoot: string): boolean { if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { return true; From a014a675561a9d8f893a02b50ee374675b3ed602 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 29 Apr 2026 10:34:58 +0000 Subject: [PATCH 17/63] fix password fields not clearing after validation error When pasting an invalid token, the old value stayed in the input field. Pasting a new token appended to the old one instead of replacing it, causing repeated validation failures. Add clearOnError: true to all 8 password prompts across setup. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 1 + setup/channels/discord.ts | 1 + setup/channels/imessage.ts | 1 + setup/channels/slack.ts | 2 ++ setup/channels/teams.ts | 1 + setup/channels/telegram.ts | 1 + setup/lib/setup-config-screen.ts | 2 +- 7 files changed, 8 insertions(+), 1 deletion(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4dee7c8..2f333a3 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -706,6 +706,7 @@ async function runPasteAuth(method: 'oauth' | 'api'): Promise { const answer = ensureAnswer( await p.password({ message: `Paste your ${label}`, + clearOnError: true, validate: (v) => { if (!v || !v.trim()) return 'Required'; if (!v.trim().startsWith(prefix)) { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3668686..74bc9af 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -242,6 +242,7 @@ async function collectDiscordToken(): Promise { const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', + clearOnError: true, validate: (v) => { const t = (v ?? '').trim(); if (!t) return 'Token is required'; diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index d8b129f..fae9fe4 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -250,6 +250,7 @@ async function collectRemoteCreds(): Promise { const keyAnswer = ensureAnswer( await p.password({ message: 'Photon API key', + clearOnError: true, validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'), }), ); diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 6d1ff56..9ae86ae 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -154,6 +154,7 @@ async function collectBotToken(): Promise { const answer = ensureAnswer( await p.password({ message: 'Paste your Slack bot token', + clearOnError: true, validate: (v) => { const t = (v ?? '').trim(); if (!t) return 'Token is required'; @@ -175,6 +176,7 @@ async function collectSigningSecret(): Promise { const answer = ensureAnswer( await p.password({ message: 'Paste your Slack signing secret', + clearOnError: true, validate: (v) => { const t = (v ?? '').trim(); if (!t) return 'Signing secret is required'; diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index fb4d878..2b892bf 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -276,6 +276,7 @@ async function stepClientSecret(args: { const answer = ensureAnswer( await p.password({ message: 'Paste the client secret Value', + clearOnError: true, validate: validateWithHelpEscape((v) => { const t = (v ?? '').trim(); if (!t) return 'Required'; diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index df97fcf..3c670e6 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -150,6 +150,7 @@ async function collectTelegramToken(): Promise { const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', + clearOnError: true, validate: (v) => { if (!v || !v.trim()) return "Token is required"; if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) { diff --git a/setup/lib/setup-config-screen.ts b/setup/lib/setup-config-screen.ts index ad8ae62..88b10d5 100644 --- a/setup/lib/setup-config-screen.ts +++ b/setup/lib/setup-config-screen.ts @@ -115,7 +115,7 @@ async function promptOne(e: Entry, values: ConfigValues): Promise { }; const ans = ensureAnswer( e.secret - ? await p.password({ message: e.label, validate }) + ? await p.password({ message: e.label, clearOnError: true, validate }) : await p.text({ message: e.label, placeholder: e.placeholder ?? e.default, From ab2d5096711833c2f0cea53f5fdfc0ed8ab14ed5 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 11:43:30 +0000 Subject: [PATCH 18/63] feat(setup): paint card and log bodies in brand cyan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `brandBody` helper in setup/lib/theme.ts that wraps prose in brand cyan (#2BB7CE), with the same TTY/NO_COLOR/truecolor gating used by `brand`/`brandBold`/`brandChip`. The helper splits multi-line input and colors each line independently so the SGR sequence doesn't bleed across clack's gutter prefix. Routing: - `note()` (the un-dim card wrapper from #2095) now passes `brandBody` as its `format` callback, so card bodies render cyan line-by-line. - Every prose `p.log.{message,info,success,step,warn}` call in the setup flow wraps its body argument in `brandBody`. Calls whose body is explicitly `k.dim(...)` (failure transcript tails, log paths, claude-assist response previews) are left alone — those are the "preview/debug" cases the dim-policy comment in theme.ts already carves out. - Spinner-finish lines in windowed-runner / claude-assist color only the message portion; the `(5s)` elapsed suffix stays dim. Brand cyan accents (chips, wordmark, inline emphasis) are unchanged. This PR only adds the body color. A follow-up will add OSC 11 dark/light detection so light-mode terminals get a brand blue (#2b6fdc) variant — opt-in upgrade with no regression for the dark-mode default. --- setup/auto.ts | 58 ++++++++++++++++++++++-------------- setup/channels/discord.ts | 4 +-- setup/channels/whatsapp.ts | 4 +-- setup/lib/claude-assist.ts | 6 ++-- setup/lib/claude-handoff.ts | 6 ++-- setup/lib/runner.ts | 4 +-- setup/lib/theme.ts | 36 +++++++++++++++++----- setup/lib/windowed-runner.ts | 6 ++-- 8 files changed, 78 insertions(+), 46 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index ee5c369..c0b5add 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -52,7 +52,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; -import { brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; +import { brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -122,11 +122,13 @@ async function main(): Promise { } if (!skip.has('container')) { - p.log.message(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4)); + p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4))); p.log.message( - dimWrap( - 'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.', - 4, + brandBody( + dimWrap( + 'The first build pulls a base image and installs a few tools. On a fresh machine this usually takes 3–10 minutes.', + 4, + ), ), ); const res = await runWindowedStep('container', { @@ -161,9 +163,11 @@ async function main(): Promise { if (!skip.has('onecli')) { p.log.message( - dimWrap( - 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', - 4, + brandBody( + dimWrap( + 'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.', + 4, + ), ), ); @@ -287,9 +291,11 @@ async function main(): Promise { await fail('service', "Couldn't start NanoClaw.", 'See logs/nanoclaw.error.log for details.'); } if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') { - p.log.warn("NanoClaw's permissions need a tweak before it can reach Docker."); + p.log.warn(brandBody("NanoClaw's permissions need a tweak before it can reach Docker.")); p.log.message( - ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`, + brandBody( + ' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' + ` systemctl --user restart ${getSystemdUnit()}`, + ), ); } } @@ -320,9 +326,11 @@ async function main(): Promise { } if (!skip.has('first-chat')) { p.log.message( - 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.", - 4, + 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.", + 4, + ), ), ); const ping = await confirmAssistantResponds(); @@ -387,9 +395,11 @@ async function main(): Promise { await runIMessageChannel(displayName!); } else { p.log.info( - wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', - 4, + brandBody( + wrapForGutter( + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', + 4, + ), ), ); } @@ -629,7 +639,7 @@ function sendChatMessage(message: string): Promise { async function runAuthStep(): Promise { if (anthropicSecretExists()) { - p.log.success('Your Claude account is already connected.'); + p.log.success(brandBody('Your Claude account is already connected.')); setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' }); return; } @@ -677,7 +687,7 @@ async function runAuthStep(): Promise { } async function runSubscriptionAuth(): Promise { - p.log.step('Opening the Claude sign-in flow…'); + p.log.step(brandBody('Opening the Claude sign-in flow…')); console.log(k.dim(' (a browser will open for sign-in; this part is interactive)')); console.log(); const start = Date.now(); @@ -696,7 +706,7 @@ async function runSubscriptionAuth(): Promise { ); } setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' }); - p.log.success('Claude account connected.'); + p.log.success(brandBody('Claude account connected.')); } async function runPasteAuth(method: 'oauth' | 'api'): Promise { @@ -919,9 +929,11 @@ async function runTimezoneStep(): Promise { tz = await resolveTimezoneViaClaude(raw); } else { p.log.warn( - wrapForGutter( - "That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.", - 4, + brandBody( + wrapForGutter( + "That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.", + 4, + ), ), ); } @@ -1086,7 +1098,7 @@ function maybeReexecUnderSg(): void { if (!/permission denied/i.test(err)) return; if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; - p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.'); + p.log.warn(brandBody('Docker socket not accessible in current group. Re-executing under `sg docker`.')); const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { stdio: 'inherit', env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 671d920..20024fe 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,7 +31,7 @@ import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { note } from '../lib/theme.js'; +import { brandBody, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -386,7 +386,7 @@ async function resolveOwnerUserId( } } else { p.log.info( - "Your bot is owned by a Developer Team, so we need your Discord user ID directly.", + brandBody("Your bot is owned by a Developer Team, so we need your Discord user ID directly."), ); } return await promptForUserIdWithDevMode(); diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index eb487cb..fe4211b 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { brandBold, note } from '../lib/theme.js'; +import { brandBody, brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -267,7 +267,7 @@ async function runWhatsAppAuth( if (spinnerActive) { stopSpinner('WhatsApp linked.'); } else { - p.log.success('WhatsApp linked.'); + p.log.success(brandBody('WhatsApp linked.')); } } else if (status === 'failed') { if (qrLinesPrinted > 0) { diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 48c760e..03d3e04 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -24,7 +24,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { ensureAnswer } from './runner.js'; -import { fitToWidth, note } from './theme.js'; +import { brandBody, fitToWidth, note } from './theme.js'; export interface AssistContext { stepName: string; @@ -106,7 +106,7 @@ export async function offerClaudeAssist( const parsed = parseResponse(response); if (!parsed) { - p.log.warn("Claude responded but I couldn't parse a command out of it."); + p.log.warn(brandBody("Claude responded but I couldn't parse a command out of it.")); p.log.message(k.dim(response.trim().slice(0, 500))); return false; } @@ -268,7 +268,7 @@ async function queryClaudeUnderSpinner( const elapsed = Math.round((Date.now() - start) / 1000); const suffix = ` (${elapsed}s)`; if (kind === 'ok') { - p.log.success(`${fitToWidth('Claude replied.', suffix)}${k.dim(suffix)}`); + p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`); resolve(payload); } else { p.log.error( diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 3a0c219..87023ef 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -27,7 +27,7 @@ import { execSync, spawn } from 'child_process'; import * as p from '@clack/prompts'; import k from 'kleur'; -import { note } from './theme.js'; +import { brandBody, note } from './theme.js'; export interface HandoffContext { /** Channel this handoff is happening in (e.g., 'teams'). */ @@ -64,7 +64,7 @@ export interface HandoffContext { export async function offerClaudeHandoff(ctx: HandoffContext): Promise { if (!isClaudeUsable()) { p.log.warn( - "Claude isn't installed yet — can't hand you off here. Finish setup first, then retry.", + brandBody("Claude isn't installed yet — can't hand you off here. Finish setup first, then retry."), ); return false; } @@ -93,7 +93,7 @@ export async function offerClaudeHandoff(ctx: HandoffContext): Promise { stdio: 'inherit' }, ); child.on('close', () => { - p.log.success("Back from Claude. Let's continue."); + p.log.success(brandBody("Back from Claude. Let's continue.")); resolve(true); }); child.on('error', () => { diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index c1599e4..cf7a86d 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -20,7 +20,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { offerClaudeAssist } from './claude-assist.js'; import { emit as phEmit } from './diagnostics.js'; -import { fitToWidth } from './theme.js'; +import { brandBody, fitToWidth } from './theme.js'; export type Fields = Record; export type Block = { type: string; fields: Fields }; @@ -390,7 +390,7 @@ export async function fail( const skipList = [ ...new Set([...existingSkip, ...setupLog.completedStepNames()]), ].join(','); - p.log.step(`Retrying from ${stepName}…`); + p.log.step(brandBody(`Retrying from ${stepName}…`)); const result = spawnSync('pnpm', ['--silent', 'run', 'setup:auto'], { stdio: 'inherit', env: { ...process.env, NANOCLAW_SKIP: skipList }, diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index f30ebe6..d313014 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -39,6 +39,29 @@ export function brandChip(s: string): string { return k.bgCyan(k.black(k.bold(s))); } +/** + * Brand body color for setup-flow prose. Used for card bodies (via the + * `note()` formatter) and `p.log.*` body arguments — anywhere the + * previous "dim" treatment was making prose hard to read or washing + * out embedded brand emphasis. + * + * Multi-line input is colored line-by-line so embedded line breaks + * don't bleed the SGR sequence across clack's gutter prefix. + */ +export function brandBody(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) { + return s + .split('\n') + .map((line) => (line.length > 0 ? `\x1b[38;2;43;183;206m${line}\x1b[39m` : line)) + .join('\n'); + } + return s + .split('\n') + .map((line) => (line.length > 0 ? k.cyan(line) : line)) + .join('\n'); +} + /** * Wrap text so it fits inside clack's gutter without the terminal's soft * wrap breaking the `│ …` bar on long lines. Works on a single string with @@ -70,16 +93,13 @@ export function dimWrap(text: string, gutter: number): string { } /** - * Wrap clack's `p.note` with the dim formatter disabled. By default - * clack renders note bodies through `styleText("dim", …)`, which the - * project's prose-readability stance (see `dimWrap` above) explicitly - * rejects. Pass-through formatter keeps body text at the terminal's - * regular weight; pre-styled segments (chips, bold, brand color) come - * through unfaded. + * Wrap clack's `p.note` so card bodies render in the brand body color + * (#2b6fdc) instead of clack's default dim. Clack runs the formatter + * on each line individually, so `brandBody` colors each line cleanly + * without bleeding across the gutter prefix. */ -const passthroughFormat = (s: string): string => s; export function note(message: string, title?: string): void { - p.note(message, title, { format: passthroughFormat }); + p.note(message, title, { format: brandBody }); } const ANSI_RE = /\x1b\[[0-9;]*m/g; diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 875aba6..6f165a4 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js'; import type { StepResult, SpinnerLabels } from './runner.js'; import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js'; import * as setupLog from '../logs.js'; -import { fitToWidth } from './theme.js'; +import { brandBody, fitToWidth } from './theme.js'; const WINDOW_SIZE = 3; const SPINNER_FRAMES = ['◒', '◐', '◓', '◑']; @@ -169,7 +169,7 @@ async function runUnderWindow( if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; - p.log.success(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`); + p.log.success(`${brandBody(fitToWidth(msg, suffix))}${k.dim(suffix)}`); } else { const failMsg = labels.failed ?? labels.running.replace(/…$/, ' failed'); p.log.error(`${fitToWidth(failMsg, suffix)}${k.dim(suffix)}`); @@ -185,7 +185,7 @@ async function handleStall( ): Promise { render.pauseRender(); p.log.warn( - `This looks stuck — no output from the ${stepName} step for the last 60 seconds.`, + brandBody(`This looks stuck — no output from the ${stepName} step for the last 60 seconds.`), ); phEmit('step_stalled', { step: stepName }); From 4c791a41b2406454ba70726ee763fdfbeb8eba22 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 12:01:35 +0000 Subject: [PATCH 19/63] feat(setup): cyan highlight on active and submitted choices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customize `brightSelect`'s render function so the focused option's label paints in brand cyan during selection and the submitted answer paints in dim cyan after the user moves on. Inactive options keep their default rendering — only the cursor and submitted state pick up the color, matching the body-text emphasis added in #2101. Also migrate the one remaining `p.select` call site (the "What next?" prompt after the first chat) to `brightSelect` so every menu in the setup flow goes through the same render path. The shape of the call matches what `brightSelect` already supports — message + options with value/label/hint — so no feature is lost in the swap. Reuses `brandBody` from #2101 for the cyan, so the prompt highlight and the body prose share one definition of the brand body color. --- setup/auto.ts | 2 +- setup/lib/bright-select.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index c0b5add..024da9f 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -337,7 +337,7 @@ async function main(): Promise { if (ping === 'ok') { phEmit('first_chat_ready'); const next = ensureAnswer( - await p.select({ + await brightSelect<'continue' | 'chat'>({ message: 'What next?', options: [ { diff --git a/setup/lib/bright-select.ts b/setup/lib/bright-select.ts index 94c4838..96c5de4 100644 --- a/setup/lib/bright-select.ts +++ b/setup/lib/bright-select.ts @@ -18,6 +18,8 @@ import { SelectPrompt } from '@clack/core'; import { isCancel } from '@clack/prompts'; import { styleText } from 'node:util'; +import { brandBody } from './theme.js'; + const BULLET_ACTIVE = '●'; const BULLET_INACTIVE = '○'; const BAR = '│'; @@ -95,7 +97,7 @@ export function brightSelect( const shown = st === 'cancel' ? styleText(['strikethrough', 'dim'], selected) - : styleText('dim', selected); + : styleText('dim', brandBody(selected)); lines.push(`${grayBar} ${shown}`); return lines.join('\n'); } @@ -104,11 +106,12 @@ export function brightSelect( options.forEach((opt, idx) => { const label = opt.label ?? String(opt.value); const hint = opt.hint ? ` ${styleText('dim', `(${opt.hint})`)}` : ''; - const marker = - idx === cursor - ? styleText('green', BULLET_ACTIVE) - : styleText('dim', BULLET_INACTIVE); - lines.push(`${bar} ${marker} ${label}${hint}`); + const isActive = idx === cursor; + const marker = isActive + ? styleText('green', BULLET_ACTIVE) + : styleText('dim', BULLET_INACTIVE); + const shownLabel = isActive ? brandBody(label) : label; + lines.push(`${bar} ${marker} ${shownLabel}${hint}`); }); lines.push(styleText(color, CAP_BOT)); return lines.join('\n'); From 26594d2c5416fc878f0c51ffe79672fd4674a5df Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 12:16:15 +0000 Subject: [PATCH 20/63] feat(setup): paint "you" green in the display-name prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `accentGreen` helper (#3fba50) with the same TTY/NO_COLOR/ truecolor gating as the rest of the palette, then wraps the word "you" in the "What should your assistant call you?" prompt so the operator parses at a glance who the question is about — the user, not the assistant. The mirror prompt that asks for the assistant's name ("What should your assistant be called?") is left for a follow-up. --- setup/auto.ts | 4 ++-- setup/lib/theme.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 024da9f..2011f34 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -52,7 +52,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau import * as setupLog from './logs.js'; import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js'; import { emit as phEmit } from './lib/diagnostics.js'; -import { brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; +import { accentGreen, brandBody, brandBold, brandChip, dimWrap, fitToWidth, note, wrapForGutter } from './lib/theme.js'; import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; @@ -976,7 +976,7 @@ async function runTimezoneStep(): Promise { async function askDisplayName(fallback: string): Promise { const answer = ensureAnswer( await p.text({ - message: 'What should your assistant call you?', + message: `What should your assistant call ${accentGreen('you')}?`, placeholder: fallback, defaultValue: fallback, }), diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index d313014..0dfa53f 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -39,6 +39,18 @@ export function brandChip(s: string): string { return k.bgCyan(k.black(k.bold(s))); } +/** + * Accent green (#3fba50) for emphasizing a single word inside prompt + * messages — currently the "you" in "What should your assistant call + * you?" so the operator parses at a glance who the question is about. + * Same TTY/NO_COLOR/truecolor gating as the rest of the palette. + */ +export function accentGreen(s: string): string { + if (!USE_ANSI) return s; + if (TRUECOLOR) return `\x1b[38;2;63;186;80m${s}\x1b[39m`; + return k.green(s); +} + /** * Brand body color for setup-flow prose. Used for card bodies (via the * `note()` formatter) and `p.log.*` body arguments — anywhere the From 46088369534b323d9c921a9e52e7a1dae7d0e788 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 12:32:25 +0000 Subject: [PATCH 21/63] feat(setup): paint "assistant" green in the agent-name prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the word "assistant" in `accentGreen` (#3fba50, added in #2103) across the six channel adapters that ask "What should your assistant be called?" — Discord, iMessage, Signal, Slack, Telegram, WhatsApp. Mirrors the green emphasis on "you" in the display-name prompt: the green word names the subject of the question (assistant vs operator) so the operator parses it at a glance. --- setup/channels/discord.ts | 4 ++-- setup/channels/imessage.ts | 4 ++-- setup/channels/signal.ts | 4 ++-- setup/channels/slack.ts | 4 ++-- setup/channels/telegram.ts | 4 ++-- setup/channels/whatsapp.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 20024fe..336fc72 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,7 +31,7 @@ import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { brandBody, note } from '../lib/theme.js'; +import { accentGreen, brandBody, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -507,7 +507,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index 387f6b2..1096618 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -36,7 +36,7 @@ import * as setupLog from '../logs.js'; import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { note, wrapForGutter } from '../lib/theme.js'; +import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -303,7 +303,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 4e1cbfb..0c5718e 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -44,7 +44,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { note } from '../lib/theme.js'; +import { accentGreen, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -347,7 +347,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 4ee5973..32c124b 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -28,7 +28,7 @@ import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { note, wrapForGutter } from '../lib/theme.js'; +import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -356,7 +356,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 3a86a5f..bc45d9e 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { brandBold, note } from '../lib/theme.js'; +import { accentGreen, brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -291,7 +291,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index fe4211b..96d23d5 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { brandBody, brandBold, note } from '../lib/theme.js'; +import { accentGreen, brandBody, brandBold, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -462,7 +462,7 @@ async function resolveAgentName(): Promise { } const answer = ensureAnswer( await p.text({ - message: 'What should your assistant be called?', + message: `What should your ${accentGreen('assistant')} be called?`, placeholder: DEFAULT_AGENT_NAME, defaultValue: DEFAULT_AGENT_NAME, }), From db1983774076490ffe81b2d3f643d795d0655161 Mon Sep 17 00:00:00 2001 From: Gabi Simons <263580637+gabi-simons@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:17:35 +0000 Subject: [PATCH 22/63] feat(permissions): richer channel-approval flow with agent selection and free-text naming Replace the hardcoded Approve/Ignore card with a multi-step flow: - Single agent: "Connect to [name]" / "Connect new agent" / "Reject" - Multiple agents: "Choose existing agent" (follow-up list) / "Connect new agent" / "Reject" - "Connect new agent" prompts for a free-text name via DM, creates immediately on reply - Add setMessageInterceptor router hook for capturing free-text replies - Add resolveChannelName optional method to ChannelAdapter interface Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/adapter.ts | 1 + .../permissions/channel-approval.test.ts | 14 +- src/modules/permissions/channel-approval.ts | 217 +++++++++---- .../db/pending-channel-approvals.ts | 6 + src/modules/permissions/index.ts | 300 +++++++++++++++--- src/router.ts | 18 ++ 6 files changed, 458 insertions(+), 98 deletions(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 82247a1..a2a7069 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -135,6 +135,7 @@ export interface ChannelAdapter { // Optional setTyping?(platformId: string, threadId: string | null): Promise; syncConversations?(): Promise; + resolveChannelName?(platformId: string): Promise; /** * Subscribe the bot to a thread so follow-up messages route via the diff --git a/src/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts index da992d2..a2e6690 100644 --- a/src/modules/permissions/channel-approval.test.ts +++ b/src/modules/permissions/channel-approval.test.ts @@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => { expect(kind).toBe('chat-sdk'); const payload = JSON.parse(content as string); expect(payload.type).toBe('ask_question'); - // Card names the target agent so the owner knows what they're wiring to. - expect(payload.question).toContain('Andy'); + // Single-agent card offers a direct "Connect to " button. + const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:')); + expect(connectOption).toBeDefined(); + expect(connectOption.label).toContain('Andy'); const { getDb } = await import('../../db/connection.js'); const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{ @@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => { }; expect(pending).toBeDefined(); - // Owner clicks approve. + // Owner clicks "Connect to Andy" (single-agent card). for (const handler of getResponseHandlers()) { const claimed = await handler({ questionId: pending.messaging_group_id, - value: 'approve', + value: 'connect:ag-1', userId: 'owner', // raw platform id — handler namespaces it channelType: 'telegram', platformId: 'dm-owner', @@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => { if (claimed) break; } - // Wiring created with MVP defaults. + // Wiring created with defaults. const mga = getDb() .prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?') .get(pending.messaging_group_id) as { @@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => { for (const handler of getResponseHandlers()) { const claimed = await handler({ questionId: pending.messaging_group_id, - value: 'approve', + value: 'connect:ag-1', userId: 'owner', channelType: 'telegram', platformId: 'dm-owner', diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index 8ab41bc..6127cea 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -5,24 +5,32 @@ * addressed to the bot (SDK-confirmed mention or DM), it calls * `requestChannelApproval` instead of silently dropping. The flow: * - * 1. Pick the target agent group we'd wire to (MVP: first by name). - * Multi-agent picker is a follow-up — see ACTION-ITEMS. + * 1. Gather all existing agent groups. * 2. Pick an eligible approver (owner / admin) and a reachable DM for * them, reusing the same primitives the sender-approval flow uses. - * 3. Deliver an Approve / Ignore card that names the target agent - * explicitly so the owner knows what they're wiring to. + * 3. Deliver a card with three action families: + * a. Connect to [agent] — one button per existing agent group. + * Single-agent installs get a one-click connect. + * b. Connect new agent — prompts for a free-text name, creates + * the agent immediately on reply. + * c. Reject — deny the channel. * 4. Record a `pending_channel_approvals` row holding the original event - * so it can be re-routed on approve. + * so it can be re-routed on connect/create. * - * On approve (handler in index.ts): - * - Create `messaging_group_agents` with MVP defaults + * On connect (handler in index.ts): + * - Create `messaging_group_agents` with defaults * (mention-sticky for groups / pattern='.' for DMs, * sender_scope='known', ignored_message_policy='accumulate') * - Add the triggering sender to `agent_group_members` so sender_scope * doesn't bounce the replayed message into a sender-approval cascade * - Delete the pending row, replay the original event * - * On ignore: + * On connect new agent (handler in index.ts): + * - Prompt for a free-text agent name via DM + * - On reply: create the agent group + filesystem, then wire + * and replay as above + * + * On reject: * - Set `messaging_groups.denied_at = now()` so the router stops * escalating on this channel until an admin explicitly re-wires * - Delete the pending row @@ -36,19 +44,81 @@ * - Approver has no reachable DM. * - Delivery adapter missing. */ -import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; -import { getAllAgentGroups } from '../../db/agent-groups.js'; -import { getMessagingGroup } from '../../db/messaging-groups.js'; +import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js'; +import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js'; +import { getChannelAdapter } from '../../channels/channel-registry.js'; +import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js'; import { getDeliveryAdapter } from '../../delivery.js'; +import { initGroupFilesystem } from '../../group-init.js'; import { log } from '../../log.js'; import type { InboundEvent } from '../../channels/adapter.js'; +import type { AgentGroup } from '../../types.js'; import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js'; -const APPROVAL_OPTIONS: RawOption[] = [ - { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, - { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, -]; +// ── Value constants (response handler in index.ts parses these) ── + +export const CONNECT_PREFIX = 'connect:'; +export const NEW_AGENT_VALUE = 'new_agent'; +export const CHOOSE_EXISTING_VALUE = 'choose_existing'; +export const REJECT_VALUE = 'reject'; + +// ── Utilities ── + +function toFolder(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unnamed' + ); +} + +// ── Card builders ── + +function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] { + const options: RawOption[] = []; + if (agentGroups.length === 1) { + options.push({ + label: `Connect to ${agentGroups[0].name}`, + selectedLabel: `✅ Connected to ${agentGroups[0].name}`, + value: `${CONNECT_PREFIX}${agentGroups[0].id}`, + }); + } else { + options.push({ + label: 'Choose existing agent', + selectedLabel: '📋 Choosing…', + value: CHOOSE_EXISTING_VALUE, + }); + } + options.push({ + label: 'Connect new agent', + selectedLabel: '🆕 Connecting new agent…', + value: NEW_AGENT_VALUE, + }); + options.push({ + label: 'Reject', + selectedLabel: '🙅 Rejected', + value: REJECT_VALUE, + }); + return options; +} + +function buildQuestionText( + isGroup: boolean, + senderName: string | undefined, + channelName: string | null, + channelType: string, +): string { + const who = senderName ?? 'Someone'; + if (isGroup) { + const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`; + return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`; + } + return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`; +} + +// ── Main flow ── export interface RequestChannelApprovalInput { messagingGroupId: string; @@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput { export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise { const { messagingGroupId, event } = input; - // In-flight dedup: don't spam the owner if the same unwired channel - // gets more mentions / DMs while a card is already pending. if (hasInFlightChannelApproval(messagingGroupId)) { - log.debug('Channel registration already in flight — dropping retry', { - messagingGroupId, - }); + log.debug('Channel registration already in flight — dropping retry', { messagingGroupId }); return; } - // MVP: pick the first agent group by name. Multi-agent systems will get - // a richer card later (user picks the target from a list). const agentGroups = getAllAgentGroups(); if (agentGroups.length === 0) { log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', { @@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) }); return; } - const target = agentGroups[0]; + // Use first agent group for approver resolution — owners and global admins + // are returned regardless of which group we pass. + const referenceGroup = agentGroups[0]; - // pickApprover takes the target agent group's id — gets scoped admins + - // global admins + owners. For fresh installs with only an owner, the - // owner is returned. - const approvers = pickApprover(target.id); + const approvers = pickApprover(referenceGroup.id); if (approvers.length === 0) { log.warn('Channel registration skipped — no owner or admin configured', { messagingGroupId, - targetAgentGroupId: target.id, + targetAgentGroupId: referenceGroup.id, }); return; } const originMg = getMessagingGroup(messagingGroupId); const originChannelType = originMg?.channel_type ?? ''; + + // Resolve channel name if not yet persisted. + if (originMg && !originMg.name) { + const channelAdapter = getChannelAdapter(originChannelType); + if (channelAdapter?.resolveChannelName) { + try { + const name = await channelAdapter.resolveChannelName(originMg.platform_id); + if (name) { + updateMessagingGroup(originMg.id, { name }); + originMg.name = name; + } + } catch { + /* non-critical */ + } + } + } + const delivery = await pickApprovalDelivery(approvers, originChannelType); if (!delivery) { log.warn('Channel registration skipped — no DM channel for any approver', { messagingGroupId, - targetAgentGroupId: target.id, + targetAgentGroupId: referenceGroup.id, }); return; } const isGroup = event.message?.isGroup ?? originMg?.is_group === 1; - // Extract sender name from the event content for a human-readable card. let senderName: string | undefined; try { const parsed = JSON.parse(event.message.content) as Record; senderName = (parsed.senderName ?? parsed.sender) as string | undefined; } catch { - // non-critical — fall through to generic wording + // non-critical } - const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; - const question = isGroup - ? senderName - ? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` - : `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` - : senderName - ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` - : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; - const options = normalizeOptions(APPROVAL_OPTIONS); + const channelName = originMg?.name ?? null; + const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message'; + const question = buildQuestionText(isGroup, senderName, channelName, originChannelType); + const options = normalizeOptions(buildApprovalOptions(agentGroups)); createPendingChannelApproval({ messaging_group_id: messagingGroupId, - agent_group_id: target.id, + agent_group_id: referenceGroup.id, original_message: JSON.stringify(event), approver_user_id: delivery.userId, created_at: new Date().toISOString(), @@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) const adapter = getDeliveryAdapter(); if (!adapter) { - log.error('Channel registration row created but no delivery adapter is wired', { - messagingGroupId, - }); + log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId }); return; } @@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) 'chat-sdk', JSON.stringify({ type: 'ask_question', - // Use messaging_group_id as the questionId — it's unique per card - // (PK on pending table dedups) and lets the response handler look - // up the pending row directly without another index. questionId: messagingGroupId, title, question, @@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) ); log.info('Channel registration card delivered', { messagingGroupId, - targetAgentGroupId: target.id, + agentGroupCount: agentGroups.length, approver: delivery.userId, }); } catch (err) { - log.error('Channel registration card delivery failed', { - messagingGroupId, - err, - }); + log.error('Channel registration card delivery failed', { messagingGroupId, err }); } } -export const APPROVE_VALUE = 'approve'; -export const REJECT_VALUE = 'reject'; +// ── Helpers for the response handler (index.ts) ── + +/** + * Build normalized options for the agent-selection follow-up card. + */ +export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] { + const options: RawOption[] = agentGroups.map((ag) => ({ + label: ag.name, + selectedLabel: `✅ Connected to ${ag.name}`, + value: `${CONNECT_PREFIX}${ag.id}`, + })); + options.push({ + label: 'Cancel', + selectedLabel: '🙅 Cancelled', + value: REJECT_VALUE, + }); + return normalizeOptions(options); +} + +/** + * Create a new agent group and initialize its filesystem. Handles + * folder-name collisions with numeric suffixes. + */ +export function createNewAgentGroup(name: string): AgentGroup { + let folder = toFolder(name); + const baseFolder = folder; + let suffix = 2; + while (getAgentGroupByFolder(folder)) { + folder = `${baseFolder}-${suffix}`; + suffix++; + } + + const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createAgentGroup({ + id: agId, + name, + folder, + agent_provider: null, + created_at: new Date().toISOString(), + }); + + const ag = getAgentGroup(agId)!; + initGroupFilesystem(ag); + return ag; +} diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts index d402074..24f7209 100644 --- a/src/modules/permissions/db/pending-channel-approvals.ts +++ b/src/modules/permissions/db/pending-channel-approvals.ts @@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean { return row !== undefined; } +export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void { + getDb() + .prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?') + .run(title, optionsJson, messagingGroupId); +} + export function deletePendingChannelApproval(messagingGroupId: string): void { getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId); } diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 83390d8..98a9463 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -16,27 +16,53 @@ * access gate is not registered and core defaults to allow-all. */ import { recordDroppedMessage } from '../../db/dropped-messages.js'; +import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js'; import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js'; import { routeInbound, setAccessGate, setChannelRequestGate, + setMessageInterceptor, setSenderResolver, setSenderScopeGate, type AccessGateResult, } from '../../router.js'; import type { InboundEvent } from '../../channels/adapter.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; +import { getDeliveryAdapter } from '../../delivery.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; -import { requestChannelApproval } from './channel-approval.js'; +import { + buildAgentSelectionOptions, + CHOOSE_EXISTING_VALUE, + CONNECT_PREFIX, + createNewAgentGroup, + NEW_AGENT_VALUE, + REJECT_VALUE, + requestChannelApproval, +} from './channel-approval.js'; import { addMember } from './db/agent-group-members.js'; -import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js'; +import { + deletePendingChannelApproval, + getPendingChannelApproval, + updatePendingChannelApprovalCard, +} from './db/pending-channel-approvals.js'; import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js'; import { hasAdminPrivilege } from './db/user-roles.js'; import { getUser, upsertUser } from './db/users.js'; import { requestSenderApproval } from './sender-approval.js'; +import { ensureUserDm } from './user-dm.js'; + +// ── Free-text name input state ── +// Tracks approvers waiting for a text reply with the agent name. Keyed by +// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart. +interface PendingNameInput { + channelMgId: string; + dmChannelType: string; + dmPlatformId: string; +} +const awaitingNameInput = new Map(); function extractAndUpsertUser(event: InboundEvent): string | null { let content: Record; @@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => { * by messaging_group_id). If no such row, return false so downstream * handlers get a shot. * - * Approve: create the wiring with MVP defaults (mention-sticky for - * groups / pattern='.' for DMs; sender_scope='known'; - * ignored_message_policy='accumulate'), add the triggering sender as a - * member so sender_scope doesn't immediately bounce them into a - * sender-approval card, then replay the original event. - * - * Deny: set `messaging_groups.denied_at = now()` so future mentions on - * this channel drop silently until an admin explicitly wires it. + * Value dispatch: + * connect: — wire to an existing agent group, replay the message + * choose_existing — send a follow-up card listing all agents + * new_agent — prompt for a free-text agent name (interceptor + * captures the reply and creates immediately) + * reject — set denied_at, delete pending row */ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise { const row = getPendingChannelApproval(payload.questionId); if (!row) return false; - // Click-auth: same pattern as sender-approval (see commit 68058cb). - // Raw platform userId → namespace with channelType → must match the - // designated approver OR have admin privilege over the target agent. const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null; const isAuthorized = clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id)); @@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< clickerId, expectedApprover: row.approver_user_id, }); - return true; // claim but take no action + return true; } const approverId = clickerId; - const approved = payload.value === 'approve'; - if (!approved) { + // ── Reject / Cancel ── + if (payload.value === REJECT_VALUE) { setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString()); deletePendingChannelApproval(row.messaging_group_id); log.info('Channel registration denied', { messagingGroupId: row.messaging_group_id, - agentGroupId: row.agent_group_id, approverId, }); return true; } - // Rehydrate the original event to know (a) whether it was a DM or group - // (chooses engage_mode default), and (b) who the triggering sender was - // (auto-member-add so sender_scope='known' doesn't bounce the replay). + // ── Choose existing agent — send agent-selection follow-up card ── + if (payload.value === CHOOSE_EXISTING_VALUE) { + const approverDm = await ensureUserDm(row.approver_user_id); + if (!approverDm) { + log.error('Channel registration: no DM channel for approver', { + messagingGroupId: row.messaging_group_id, + approverUserId: row.approver_user_id, + }); + return true; + } + + const adapter = getDeliveryAdapter(); + if (!adapter) return true; + + const agentGroups = getAllAgentGroups(); + const options = buildAgentSelectionOptions(agentGroups); + const title = '📋 Choose an agent'; + updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options)); + + try { + await adapter.deliver( + approverDm.channel_type, + approverDm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: row.messaging_group_id, + title, + question: 'Which agent should handle this channel?', + options, + }), + ); + } catch (err) { + log.error('Channel registration: agent-selection card delivery failed', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + return true; + } + + // ── Create new agent — prompt for free-text name ── + if (payload.value === NEW_AGENT_VALUE) { + const approverDm = await ensureUserDm(row.approver_user_id); + if (!approverDm) { + log.error('Channel registration: no DM channel for approver', { + messagingGroupId: row.messaging_group_id, + approverUserId: row.approver_user_id, + }); + return true; + } + + const adapter = getDeliveryAdapter(); + if (!adapter) { + log.error('Channel registration: no delivery adapter for name prompt', { + messagingGroupId: row.messaging_group_id, + }); + return true; + } + + awaitingNameInput.set(row.approver_user_id, { + channelMgId: row.messaging_group_id, + dmChannelType: approverDm.channel_type, + dmPlatformId: approverDm.platform_id, + }); + + try { + await adapter.deliver( + approverDm.channel_type, + approverDm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ text: 'Reply with the name for your new agent:' }), + ); + } catch (err) { + log.error('Channel registration: name prompt delivery failed', { + messagingGroupId: row.messaging_group_id, + err, + }); + awaitingNameInput.delete(row.approver_user_id); + } + return true; + } + + // ── Resolve target agent group (connect to existing or create new) ── + let targetAgentGroupId: string; + + if (payload.value.startsWith(CONNECT_PREFIX)) { + targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length); + const ag = getAgentGroup(targetAgentGroupId); + if (!ag) { + log.error('Channel registration: target agent group no longer exists', { + messagingGroupId: row.messaging_group_id, + targetAgentGroupId, + }); + deletePendingChannelApproval(row.messaging_group_id); + return true; + } + } else { + log.warn('Channel registration: unknown response value', { + messagingGroupId: row.messaging_group_id, + value: payload.value, + }); + return true; + } + + // ── Wire + replay (shared path for connect and create) ── let event: InboundEvent; try { event = JSON.parse(row.original_message) as InboundEvent; @@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< return true; } - // Decide engage_mode from the original event. DMs (`isMention=true` & - // not in a group) get `pattern='.'` (always respond). Group mentions - // get `mention-sticky` (respond now + follow the thread). - // - // We can't read `mg.is_group` reliably here because we only auto-create - // the mg with `is_group=0` on first sight — the adapter hasn't told us - // yet whether it's actually a group. Fall back to the InboundEvent's - // `threadId`: a non-null threadId implies a threaded platform (Slack - // channel thread, Discord thread), which we treat as a group. const isGroup = event.threadId !== null; const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern'; const engagePattern = isGroup ? null : '.'; @@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< createMessagingGroupAgent({ id: mgaId, messaging_group_id: row.messaging_group_id, - agent_group_id: row.agent_group_id, + agent_group_id: targetAgentGroupId, engage_mode: engageMode, engage_pattern: engagePattern, sender_scope: 'known', @@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< }); log.info('Channel registration approved — wiring created', { messagingGroupId: row.messaging_group_id, - agentGroupId: row.agent_group_id, + agentGroupId: targetAgentGroupId, mgaId, engageMode, approverId, }); - // Auto-admit the triggering sender. Without this, the replay below - // would bounce through sender-approval (sender_scope='known' + - // sender-is-not-a-member). const senderUserId = extractAndUpsertUser(event); if (senderUserId) { addMember({ user_id: senderUserId, - agent_group_id: row.agent_group_id, + agent_group_id: targetAgentGroupId, added_by: approverId, added_at: new Date().toISOString(), }); } - // Clear the pending row BEFORE replay so the gate check on the second - // attempt sees a wired channel (agentCount > 0) and takes the fan-out - // path normally. deletePendingChannelApproval(row.messaging_group_id); try { @@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< } registerResponseHandler(handleChannelApprovalResponse); + +// ── Free-text name interceptor ── +// Captures the next DM from an approver who clicked "Create new agent", +// creates the agent immediately, wires the channel, and replays. + +setMessageInterceptor(async (event: InboundEvent): Promise => { + const userId = extractAndUpsertUser(event); + if (!userId) return false; + + const pending = awaitingNameInput.get(userId); + if (!pending) return false; + if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false; + + awaitingNameInput.delete(userId); + + let text: string | undefined; + try { + const parsed = JSON.parse(event.message.content) as Record; + text = (typeof parsed.text === 'string' ? parsed.text : undefined)?.trim(); + } catch { + /* fall through */ + } + + if (!text) { + log.warn('Channel registration: empty name reply, ignoring', { userId }); + return true; + } + + const row = getPendingChannelApproval(pending.channelMgId); + if (!row) return true; + + const ag = createNewAgentGroup(text); + log.info('Channel registration: new agent group created', { + messagingGroupId: row.messaging_group_id, + agentGroupId: ag.id, + agentName: ag.name, + folder: ag.folder, + }); + + let originalEvent: InboundEvent; + try { + originalEvent = JSON.parse(row.original_message) as InboundEvent; + } catch (err) { + log.error('Channel registration: failed to parse stored event', { + messagingGroupId: row.messaging_group_id, + err, + }); + deletePendingChannelApproval(row.messaging_group_id); + return true; + } + + const isGroup = originalEvent.threadId !== null; + const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern'; + const engagePattern = isGroup ? null : '.'; + + const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createMessagingGroupAgent({ + id: mgaId, + messaging_group_id: row.messaging_group_id, + agent_group_id: ag.id, + engage_mode: engageMode, + engage_pattern: engagePattern, + sender_scope: 'known', + ignored_message_policy: 'accumulate', + session_mode: 'shared', + priority: 0, + created_at: new Date().toISOString(), + }); + log.info('Channel registration approved — wiring created', { + messagingGroupId: row.messaging_group_id, + agentGroupId: ag.id, + mgaId, + engageMode, + approverId: userId, + }); + + const senderUserId = extractAndUpsertUser(originalEvent); + if (senderUserId) { + addMember({ + user_id: senderUserId, + agent_group_id: ag.id, + added_by: userId, + added_at: new Date().toISOString(), + }); + } + + deletePendingChannelApproval(row.messaging_group_id); + + try { + await routeInbound(originalEvent); + } catch (err) { + log.error('Failed to replay message after channel approval', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + + const adapter = getDeliveryAdapter(); + if (adapter) { + const dm = await ensureUserDm(row.approver_user_id); + if (dm) { + adapter + .deliver( + dm.channel_type, + dm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }), + ) + .catch(() => {}); + } + } + return true; +}); diff --git a/src/router.ts b/src/router.ts index 995496d..844041e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void { senderScopeGate = fn; } +/** + * Message-interceptor hook. Runs at the very top of routeInbound, before + * messaging-group resolution. When the interceptor returns true the message + * is consumed and routing stops. Used by the permissions module to capture + * free-text replies during multi-step approval flows (e.g. agent naming). + */ +export type MessageInterceptorFn = (event: InboundEvent) => Promise; + +let messageInterceptor: MessageInterceptorFn | null = null; + +export function setMessageInterceptor(fn: MessageInterceptorFn): void { + messageInterceptor = fn; +} + /** * Channel-registration hook. Runs when the router sees a mention/DM on a * messaging group that has no wirings AND hasn't been denied. The hook is @@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender * Creates messaging group + session if they don't exist yet. */ export async function routeInbound(event: InboundEvent): Promise { + // Pre-route interceptor — lets modules consume messages before any routing + // (e.g. free-text replies during multi-step approval flows). + if (messageInterceptor && (await messageInterceptor(event))) return; + // 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram, // WhatsApp, iMessage, email) collapse threads to the channel. const adapter = getChannelAdapter(event.channelType); From 5f34e262403867dcbc21e6942f09f38e34b8bd76 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 17:02:15 +0300 Subject: [PATCH 23/63] fix(credentials): translate auth errors and require OneCLI for spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the case where credentials aren't usable: 1. Replace Claude Code's "Not logged in / Invalid API key · Please run /login" output with a host-aware message. The user can't run /login from chat, so the raw text is unhelpful. Provider gains an optional isAuthRequired() classifier; the poll-loop substitutes the message on both result-text and error paths. 2. Treat OneCLI gateway failure as a transient hard error instead of spawning a credential-less container. The catch in container-runner now propagates; router and host-sweep wrap wakeContainer to log and leave the inbound row pending so the next 60s sweep tick retries. Router also stops the typing indicator on failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 44 ++++++++++++++----- .../agent-runner/src/providers/claude.ts | 12 +++++ container/agent-runner/src/providers/types.ts | 8 ++++ src/container-runner.ts | 24 +++++----- src/host-sweep.ts | 9 +++- src/router.ts | 12 ++++- 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index bd48db2..2846337 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,6 +21,20 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +const AUTH_REQUIRED_USER_TEXT = + "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; + +function writeAuthRequiredMessage(routing: RoutingContext): void { + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: AUTH_REQUIRED_USER_TEXT }), + }); +} + export interface PollLoopConfig { provider: AgentProvider; /** @@ -171,7 +185,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing, processingIds, config.providerName); + const result = await processQuery(query, routing, processingIds, config.provider, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setContinuation(config.providerName, continuation); @@ -189,15 +203,18 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearContinuation(config.providerName); } - // Write error response so the user knows something went wrong - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Error: ${errMsg}` }), - }); + if (config.provider.isAuthRequired?.(errMsg)) { + writeAuthRequiredMessage(routing); + } else { + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); + } } // Ensure completed even if processQuery ended without a result event @@ -249,6 +266,7 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], + provider: AgentProvider, providerName: string, ): Promise { let queryContinuation: string | undefined; @@ -310,7 +328,11 @@ async function processQuery( // at all — either way the turn is finished. markCompleted(initialBatchIds); if (event.text) { - dispatchResultText(event.text, routing); + if (provider.isAuthRequired?.(event.text)) { + writeAuthRequiredMessage(routing); + } else { + dispatchResultText(event.text, routing); + } } } } diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..6dcdb5a 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -236,6 +236,14 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; */ const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; +/** + * Auth-required detection. Matches Claude Code's output when no usable + * credential is available — "Not logged in · Please run /login" or + * "Invalid API key · Please run /login". The user can't run /login from + * chat, so the poll-loop substitutes a host-aware message. + */ +const AUTH_REQUIRED_RE = /(Not logged in|Invalid API key)[\s\S]*?Please run \/login/i; + export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; @@ -259,6 +267,10 @@ export class ClaudeProvider implements AgentProvider { return STALE_SESSION_RE.test(msg); } + isAuthRequired(text: string): boolean { + return AUTH_REQUIRED_RE.test(text); + } + query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 55ab919..99833a7 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -14,6 +14,14 @@ export interface AgentProvider { * (missing transcript, unknown session, etc.) and should be cleared. */ isSessionInvalid(err: unknown): boolean; + + /** + * True if the given text/error indicates the underlying SDK or CLI has no + * usable Anthropic auth (e.g. Claude Code's "Not logged in · Please run + * /login"). The poll-loop swaps the raw output for a host-aware message + * since the user can't run /login from chat. + */ + isAuthRequired?(text: string): boolean; } /** diff --git a/src/container-runner.ts b/src/container-runner.ts index 029b5fe..dc71248 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -435,20 +435,18 @@ async function buildContainerArgs( } // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls - // are routed through the agent vault for credential injection. - try { - if (agentIdentifier) { - await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); - } - const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); - if (onecliApplied) { - log.info('OneCLI gateway applied', { containerName }); - } else { - log.warn('OneCLI gateway not applied — container will have no credentials', { containerName }); - } - } catch (err) { - log.warn('OneCLI gateway error — container will have no credentials', { containerName, err }); + // are routed through the agent vault for credential injection. Treated as + // a transient hard failure: if we can't wire the gateway, we don't spawn. + // The caller (router or host-sweep) catches the throw, leaves the inbound + // message pending, and the next sweep tick retries. + if (agentIdentifier) { + await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); } + const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); + if (!onecliApplied) { + throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials'); + } + log.info('OneCLI gateway applied', { containerName }); // Host gateway args.push(...hostGatewayArgs()); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 4dc2fb7..ff88fb0 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -168,7 +168,14 @@ async function sweepSession(session: Session): Promise { const dueCount = countDueMessages(inDb); if (dueCount > 0 && !isContainerRunning(session.id)) { log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - await wakeContainer(session); + try { + await wakeContainer(session); + } catch (err) { + // Transient spawn failure (e.g. OneCLI gateway down). Leave messages + // pending so the next sweep tick retries; don't abort the rest of + // the sweep cycle for other sessions. + log.warn('wakeContainer failed — will retry on next sweep', { sessionId: session.id, err }); + } } const alive = isContainerRunning(session.id); diff --git a/src/router.ts b/src/router.ts index 3cf0192..e429977 100644 --- a/src/router.ts +++ b/src/router.ts @@ -27,7 +27,7 @@ import { getMessagingGroupWithAgentCount, } from './db/messaging-groups.js'; import { findSessionForAgent } from './db/sessions.js'; -import { startTypingRefresh } from './modules/typing/index.js'; +import { startTypingRefresh, stopTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; @@ -450,7 +450,15 @@ async function deliverToAgent( startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); const freshSession = getSession(session.id); if (freshSession) { - await wakeContainer(freshSession); + try { + await wakeContainer(freshSession); + } catch (err) { + // Transient spawn failure (e.g. OneCLI gateway down). The inbound + // row is already persisted — host-sweep will retry the wake on its + // next tick. Don't bubble out of the channel adapter. + log.warn('wakeContainer failed — host-sweep will retry', { sessionId: freshSession.id, err }); + stopTypingRefresh(freshSession.id); + } } } } From d86051805b48dd29a64cabd4187f1bb663d4a796 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Wed, 29 Apr 2026 14:09:47 +0000 Subject: [PATCH 24/63] 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 0000000..8e947cb --- /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 392bc13..e46a639 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 25/63] 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 8e947cb..be3d959 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 e46a639..6ebf486 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 d9a90c5..18b8e97 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 26/63] 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 be3d959..01a9e33 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 4a56827..73fb9d1 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 6ebf486..0b2cfa1 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 18b8e97..73b8557 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 d5b48e474278a8dc6067944c63049a64fcd950f1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 17:51:32 +0300 Subject: [PATCH 27/63] fix(credentials): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wakeContainer now never throws — returns Promise, catches internally. Closes the regression risk for the 5 awaited callers in agent-to-agent, interactive, and approvals/response-handler that the previous version left unwrapped. Router uses the boolean to stop the typing indicator on transient failure; host-sweep just awaits. - Tighten AUTH_REQUIRED_RE: anchor to start-of-string with the specific `·` (U+00B7) separator the CLI uses, so an agent that quotes the banner mid-sentence in a normal reply doesn't trip the classifier. - Log a one-line note from writeAuthRequiredMessage so substitutions are visible when debugging "user got the credentials message but I don't see why." - Add unit tests for ClaudeProvider.isAuthRequired covering both banner variants, trailing content, mid-sentence quoting, leading-prose quoting, alternate separators, and unrelated text. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 1 + .../agent-runner/src/providers/claude.test.ts | 37 +++++++++++++++++++ .../agent-runner/src/providers/claude.ts | 8 +++- src/container-runner.ts | 24 +++++++++--- src/host-sweep.ts | 11 ++---- src/router.ts | 14 +++---- 6 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 container/agent-runner/src/providers/claude.test.ts diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 2846337..43c9cf1 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -25,6 +25,7 @@ const AUTH_REQUIRED_USER_TEXT = "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; function writeAuthRequiredMessage(routing: RoutingContext): void { + log('Auth-required detected — substituting host-aware message for the user'); writeMessageOut({ id: generateId(), kind: 'chat', diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts new file mode 100644 index 0000000..d906280 --- /dev/null +++ b/container/agent-runner/src/providers/claude.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'bun:test'; + +import { ClaudeProvider } from './claude.js'; + +describe('ClaudeProvider.isAuthRequired', () => { + const provider = new ClaudeProvider(); + + it('matches the "Not logged in" banner', () => { + expect(provider.isAuthRequired('Not logged in · Please run /login')).toBe(true); + }); + + it('matches the "Invalid API key" banner', () => { + expect(provider.isAuthRequired('Invalid API key · Please run /login')).toBe(true); + }); + + it('matches with trailing content after the banner', () => { + expect(provider.isAuthRequired('Not logged in · Please run /login\n\nstack trace …')).toBe(true); + }); + + it('does not match when the agent quotes the phrase mid-sentence', () => { + const quoted = "The error 'Invalid API key · Please run /login' means your auth has expired."; + expect(provider.isAuthRequired(quoted)).toBe(false); + }); + + it('does not match when the agent leads its reply with the phrase in prose', () => { + const prose = '"Not logged in · Please run /login" is a Claude Code error.'; + expect(provider.isAuthRequired(prose)).toBe(false); + }); + + it('does not match a different separator (defensive against typos in agent output)', () => { + expect(provider.isAuthRequired('Not logged in - Please run /login')).toBe(false); + }); + + it('does not match unrelated text', () => { + expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 6dcdb5a..11ea4b0 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -237,12 +237,16 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; /** - * Auth-required detection. Matches Claude Code's output when no usable + * Auth-required detection. Matches Claude Code's banner when no usable * credential is available — "Not logged in · Please run /login" or * "Invalid API key · Please run /login". The user can't run /login from * chat, so the poll-loop substitutes a host-aware message. + * + * Anchored to start-of-string with the specific `·` separator (U+00B7) + * the CLI uses, so an agent that quotes the phrase verbatim mid-sentence + * in a normal reply doesn't trip the classifier. */ -const AUTH_REQUIRED_RE = /(Not logged in|Invalid API key)[\s\S]*?Please run \/login/i; +const AUTH_REQUIRED_RE = /^(Not logged in|Invalid API key)\s*·\s*Please run \/login/; export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; diff --git a/src/container-runner.ts b/src/container-runner.ts index dc71248..27b0f5c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -58,7 +58,7 @@ const activeContainers = new Map>(); +const wakePromises = new Map>(); export function getActiveContainerCount(): number { return activeContainers.size; @@ -73,20 +73,32 @@ export function isContainerRunning(sessionId: string): boolean { * (the in-flight wake promise is reused). * * The container runs the v2 agent-runner which polls the session DB. + * + * Contract: never throws. Returns `true` on successful spawn, `false` on + * transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't + * need to wrap — the inbound row stays pending and host-sweep retries on + * its next tick. Callers that care (e.g. the router's typing indicator) + * can branch on the boolean. */ -export function wakeContainer(session: Session): Promise { +export function wakeContainer(session: Session): Promise { if (activeContainers.has(session.id)) { log.debug('Container already running', { sessionId: session.id }); - return Promise.resolve(); + return Promise.resolve(true); } const existing = wakePromises.get(session.id); if (existing) { log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id }); return existing; } - const promise = spawnContainer(session).finally(() => { - wakePromises.delete(session.id); - }); + const promise = spawnContainer(session) + .then(() => true) + .catch((err) => { + log.warn('wakeContainer failed — host-sweep will retry', { sessionId: session.id, err }); + return false; + }) + .finally(() => { + wakePromises.delete(session.id); + }); wakePromises.set(session.id, promise); return promise; } diff --git a/src/host-sweep.ts b/src/host-sweep.ts index ff88fb0..69a4d61 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -168,14 +168,9 @@ async function sweepSession(session: Session): Promise { const dueCount = countDueMessages(inDb); if (dueCount > 0 && !isContainerRunning(session.id)) { log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - try { - await wakeContainer(session); - } catch (err) { - // Transient spawn failure (e.g. OneCLI gateway down). Leave messages - // pending so the next sweep tick retries; don't abort the rest of - // the sweep cycle for other sessions. - log.warn('wakeContainer failed — will retry on next sweep', { sessionId: session.id, err }); - } + // wakeContainer never throws — transient spawn failures (OneCLI down, + // etc.) return false and leave messages pending for the next tick. + await wakeContainer(session); } const alive = isContainerRunning(session.id); diff --git a/src/router.ts b/src/router.ts index e429977..69d7313 100644 --- a/src/router.ts +++ b/src/router.ts @@ -450,15 +450,11 @@ async function deliverToAgent( startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); const freshSession = getSession(session.id); if (freshSession) { - try { - await wakeContainer(freshSession); - } catch (err) { - // Transient spawn failure (e.g. OneCLI gateway down). The inbound - // row is already persisted — host-sweep will retry the wake on its - // next tick. Don't bubble out of the channel adapter. - log.warn('wakeContainer failed — host-sweep will retry', { sessionId: freshSession.id, err }); - stopTypingRefresh(freshSession.id); - } + const woke = await wakeContainer(freshSession); + // wakeContainer never throws — it returns false on transient spawn + // failure (host-sweep retries). Stop the typing indicator we just + // started so it doesn't leak; the inbound row stays pending. + if (!woke) stopTypingRefresh(freshSession.id); } } } From b9d302524e8b40be7e194e866cf2dd33d853fdc7 Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 29 Apr 2026 15:01:09 +0000 Subject: [PATCH 28/63] fix(session-manager): derive attachment extension from mimeType and att.type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a channel bridge passes an attachment without an explicit `name`, extractAttachmentFiles fell back to `attachment-` with no extension. Agents could not tell whether the file was a JPEG, PDF, or audio clip, and tools keyed on extension (image viewers, exiftool, etc.) misbehaved. Two cases are now covered: 1. Channels that set `mimeType` but no `name` (Discord/Slack documents, Telegram document uploads). A small MIME-to-extension table covers the common content types — image/*, audio/*, video/*, pdf, zip, txt, json. Unknown MIMEs fall back to the unsuffixed name. 2. Channels that set `att.type` but no `mimeType` (Telegram photos, stickers, voice, animations). The chat-sdk bridge sets a coarse media-class (`photo` / `sticker` / `voice` / `video` / `animation`) which is reliable enough to derive a canonical extension. Telegram GIFs are MP4 under the hood. The existing isSafeAttachmentName security guard is preserved — the derived name still passes through it before disk I/O. The new lookup tables emit static values from internal maps and cannot construct a path-traversal payload; attacker-controlled att.name continues to flow through the same validator. --- src/session-manager.ts | 56 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/session-manager.ts b/src/session-manager.ts index 996a750..342c155 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -230,6 +230,60 @@ export function writeSessionMessage( updateSession(sessionId, { last_active: new Date().toISOString() }); } +// Map common MIME types to canonical file extensions. Used to derive a +// usable suffix when the channel bridge passes an attachment without an +// explicit `name`. Without an extension, agents (and humans) can't tell +// what kind of file landed in the inbox. +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/heic': 'heic', + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/mp4': 'm4a', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'application/json': 'json', + 'application/zip': 'zip', +}; + +// Fallback when `mimeType` is missing — Telegram photos and stickers arrive +// without an explicit MIME on the attachment object. The channel bridge sets +// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) +// which is reliable enough to derive a canonical extension. Telegram's GIFs +// are actually MP4, hence `animation: 'mp4'`. +const TYPE_TO_EXT: Record = { + image: 'jpg', + photo: 'jpg', + sticker: 'webp', + voice: 'ogg', + audio: 'mp3', + video: 'mp4', + animation: 'mp4', +}; + +function extForMime(mime: string | undefined): string { + if (!mime) return ''; + const clean = mime.split(';')[0].trim().toLowerCase(); + return MIME_TO_EXT[clean] ?? ''; +} + +function deriveAttachmentName(att: Record): string { + const explicit = att.name as string | undefined; + if (explicit) return explicit; + let ext = extForMime(att.mimeType as string | undefined); + if (!ext && typeof att.type === 'string') { + ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; + } + return ext ? `attachment-${Date.now()}.${ext}` : `attachment-${Date.now()}`; +} + /** * If message content has attachments with base64 `data`, save them to * the session's inbox directory and replace with `localPath`. @@ -259,7 +313,7 @@ function extractAttachmentFiles( // this guard, `path.join(inboxDir, '../../...')` writes anywhere the // host process has fs permission — see Signal Desktop's Nov 2025 // attachment-fileName advisory for the same archetype. - const rawName = (att.name as string | undefined) ?? `attachment-${Date.now()}`; + const rawName = deriveAttachmentName(att); const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`; if (filename !== rawName) { log.warn('Refused unsafe attachment filename — would escape inbox', { From beb5e049eda84b178bc02f10c4346e6ef99d1279 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:03:25 +0300 Subject: [PATCH 29/63] fix(credentials): move auth-required remediation message into provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a paired `authRequiredMessage()` method to AgentProvider so per-provider auth-failure remediation can differ. Claude returns the Anthropic/`claude` instruction; future providers (Codex, OpenCode, …) can return their own remediation text. The poll-loop calls `provider.authRequiredMessage?.()` and falls back to a generic message if a provider implements `isAuthRequired` without supplying its own remediation. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 17 +++++++++++------ .../agent-runner/src/providers/claude.test.ts | 10 ++++++++++ container/agent-runner/src/providers/claude.ts | 4 ++++ container/agent-runner/src/providers/types.ts | 17 +++++++++++++++-- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 43c9cf1..fb54378 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,10 +21,15 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -const AUTH_REQUIRED_USER_TEXT = - "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; +// Generic fallback for providers that classify auth failures via +// `isAuthRequired` but don't supply their own remediation text. Concrete +// providers (Claude, Codex, …) override this with a provider-specific +// message via `authRequiredMessage()`. +const GENERIC_AUTH_REQUIRED_MESSAGE = + "I can't reach my credentials right now. The operator running NanoClaw needs to re-authenticate on the host machine."; -function writeAuthRequiredMessage(routing: RoutingContext): void { +function writeAuthRequiredMessage(provider: AgentProvider, routing: RoutingContext): void { + const text = provider.authRequiredMessage?.() ?? GENERIC_AUTH_REQUIRED_MESSAGE; log('Auth-required detected — substituting host-aware message for the user'); writeMessageOut({ id: generateId(), @@ -32,7 +37,7 @@ function writeAuthRequiredMessage(routing: RoutingContext): void { platform_id: routing.platformId, channel_type: routing.channelType, thread_id: routing.threadId, - content: JSON.stringify({ text: AUTH_REQUIRED_USER_TEXT }), + content: JSON.stringify({ text }), }); } @@ -205,7 +210,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { } if (config.provider.isAuthRequired?.(errMsg)) { - writeAuthRequiredMessage(routing); + writeAuthRequiredMessage(config.provider, routing); } else { writeMessageOut({ id: generateId(), @@ -330,7 +335,7 @@ async function processQuery( markCompleted(initialBatchIds); if (event.text) { if (provider.isAuthRequired?.(event.text)) { - writeAuthRequiredMessage(routing); + writeAuthRequiredMessage(provider, routing); } else { dispatchResultText(event.text, routing); } diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts index d906280..91836e4 100644 --- a/container/agent-runner/src/providers/claude.test.ts +++ b/container/agent-runner/src/providers/claude.test.ts @@ -35,3 +35,13 @@ describe('ClaudeProvider.isAuthRequired', () => { expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); }); }); + +describe('ClaudeProvider.authRequiredMessage', () => { + const provider = new ClaudeProvider(); + + it('returns the Anthropic-specific remediation', () => { + const msg = provider.authRequiredMessage(); + expect(msg).toContain('Anthropic credentials'); + expect(msg).toContain('claude'); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 11ea4b0..89dd5cd 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -275,6 +275,10 @@ export class ClaudeProvider implements AgentProvider { return AUTH_REQUIRED_RE.test(text); } + authRequiredMessage(): string { + return "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; + } + query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 99833a7..3124c07 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -17,11 +17,24 @@ export interface AgentProvider { /** * True if the given text/error indicates the underlying SDK or CLI has no - * usable Anthropic auth (e.g. Claude Code's "Not logged in · Please run + * usable credentials (e.g. Claude Code's "Not logged in · Please run * /login"). The poll-loop swaps the raw output for a host-aware message - * since the user can't run /login from chat. + * since the user can't authenticate from chat. + * + * Paired with `authRequiredMessage()` — providers that implement one + * should implement both. The matcher is provider-specific because each + * SDK/CLI has its own auth-failure banner format. */ isAuthRequired?(text: string): boolean; + + /** + * User-facing remediation message returned when `isAuthRequired` matches. + * Provider-specific because the actionable instruction differs across + * providers (e.g. Claude vs Codex vs OpenCode each direct the operator + * to a different auth flow). Falls back to a generic message in the + * poll-loop if a provider implements `isAuthRequired` but not this. + */ + authRequiredMessage?(): string; } /** From 9889848932d5b27b6f53c3e6d3b717c41d8aabea Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 29 Apr 2026 15:07:26 +0000 Subject: [PATCH 30/63] fix(claude-provider): respect operator-set CLAUDE_CODE_AUTO_COMPACT_WINDOW Closes #1820. The container agent-runner sets CLAUDE_CODE_AUTO_COMPACT_WINDOW unconditionally on the container process env, with no way to override it per-deployment without editing source. Read process.env first and fall back to the existing 165000 literal when unset. Default behavior is unchanged for installs that do not set the env var. Operators running 1M-context models or emergency-tuning a live deployment can now raise or lower the threshold from the host env. --- container/agent-runner/src/providers/claude.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..c9478b8 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -226,8 +226,12 @@ function createPreCompactHook(assistantName?: string): HookCallback { /** * Claude Code auto-compacts context at this window (tokens). Kept here so * the generic bootstrap doesn't need to know about Claude-specific env vars. + * + * Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to + * raise or lower the threshold without editing source — useful when running + * with a 1M-context model variant or when emergency-tuning a deployment. */ -const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; +const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000'; /** * Stale-session detection. Matches Claude Code's error text when a From 70cb35f58b221a8236302cb409df6cc37b1c6cb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:13:37 +0000 Subject: [PATCH 31/63] chore: bump version to 2.0.16 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9b3b6fb..419e4b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.15", + "version": "2.0.16", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From ee165d09c2959032c789309f5c6994767fce1fd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:13:40 +0000 Subject: [PATCH 32/63] =?UTF-8?q?docs:=20update=20token=20count=20to=20134?= =?UTF-8?q?k=20tokens=20=C2=B7=2067%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 5a0fe82..cccf8c7 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 133k tokens, 67% of context window + + 134k tokens, 67% of context window @@ -15,8 +15,8 @@ tokens - - 133k + + 134k From e31a6c7e3493e00371a616eacb94d570e0174ca5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:26:04 +0300 Subject: [PATCH 33/63] revert(credentials): drop auth-required login-message handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the "Not logged in · Please run /login" detection and substitution from this PR — narrowing scope to just the OneCLI gateway transient-retry change. The login-message handling will be addressed separately. Reverts: - AgentProvider.isAuthRequired / authRequiredMessage - ClaudeProvider auth-required regex, classifier, and remediation text - poll-loop writeAuthRequiredMessage helper + call sites - claude.test.ts (auth-only test file) OneCLI/wakeContainer changes (the remaining content of the PR) are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 50 ++++--------------- .../agent-runner/src/providers/claude.test.ts | 47 ----------------- .../agent-runner/src/providers/claude.ts | 20 -------- container/agent-runner/src/providers/types.ts | 21 -------- 4 files changed, 11 insertions(+), 127 deletions(-) delete mode 100644 container/agent-runner/src/providers/claude.test.ts diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index fb54378..bd48db2 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,26 +21,6 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -// Generic fallback for providers that classify auth failures via -// `isAuthRequired` but don't supply their own remediation text. Concrete -// providers (Claude, Codex, …) override this with a provider-specific -// message via `authRequiredMessage()`. -const GENERIC_AUTH_REQUIRED_MESSAGE = - "I can't reach my credentials right now. The operator running NanoClaw needs to re-authenticate on the host machine."; - -function writeAuthRequiredMessage(provider: AgentProvider, routing: RoutingContext): void { - const text = provider.authRequiredMessage?.() ?? GENERIC_AUTH_REQUIRED_MESSAGE; - log('Auth-required detected — substituting host-aware message for the user'); - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text }), - }); -} - export interface PollLoopConfig { provider: AgentProvider; /** @@ -191,7 +171,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing, processingIds, config.provider, config.providerName); + const result = await processQuery(query, routing, processingIds, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setContinuation(config.providerName, continuation); @@ -209,18 +189,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearContinuation(config.providerName); } - if (config.provider.isAuthRequired?.(errMsg)) { - writeAuthRequiredMessage(config.provider, routing); - } else { - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Error: ${errMsg}` }), - }); - } + // Write error response so the user knows something went wrong + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); } // Ensure completed even if processQuery ended without a result event @@ -272,7 +249,6 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], - provider: AgentProvider, providerName: string, ): Promise { let queryContinuation: string | undefined; @@ -334,11 +310,7 @@ async function processQuery( // at all — either way the turn is finished. markCompleted(initialBatchIds); if (event.text) { - if (provider.isAuthRequired?.(event.text)) { - writeAuthRequiredMessage(provider, routing); - } else { - dispatchResultText(event.text, routing); - } + dispatchResultText(event.text, routing); } } } diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts deleted file mode 100644 index 91836e4..0000000 --- a/container/agent-runner/src/providers/claude.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect } from 'bun:test'; - -import { ClaudeProvider } from './claude.js'; - -describe('ClaudeProvider.isAuthRequired', () => { - const provider = new ClaudeProvider(); - - it('matches the "Not logged in" banner', () => { - expect(provider.isAuthRequired('Not logged in · Please run /login')).toBe(true); - }); - - it('matches the "Invalid API key" banner', () => { - expect(provider.isAuthRequired('Invalid API key · Please run /login')).toBe(true); - }); - - it('matches with trailing content after the banner', () => { - expect(provider.isAuthRequired('Not logged in · Please run /login\n\nstack trace …')).toBe(true); - }); - - it('does not match when the agent quotes the phrase mid-sentence', () => { - const quoted = "The error 'Invalid API key · Please run /login' means your auth has expired."; - expect(provider.isAuthRequired(quoted)).toBe(false); - }); - - it('does not match when the agent leads its reply with the phrase in prose', () => { - const prose = '"Not logged in · Please run /login" is a Claude Code error.'; - expect(provider.isAuthRequired(prose)).toBe(false); - }); - - it('does not match a different separator (defensive against typos in agent output)', () => { - expect(provider.isAuthRequired('Not logged in - Please run /login')).toBe(false); - }); - - it('does not match unrelated text', () => { - expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); - }); -}); - -describe('ClaudeProvider.authRequiredMessage', () => { - const provider = new ClaudeProvider(); - - it('returns the Anthropic-specific remediation', () => { - const msg = provider.authRequiredMessage(); - expect(msg).toContain('Anthropic credentials'); - expect(msg).toContain('claude'); - }); -}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 89dd5cd..fbb077c 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -236,18 +236,6 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; */ const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; -/** - * Auth-required detection. Matches Claude Code's banner when no usable - * credential is available — "Not logged in · Please run /login" or - * "Invalid API key · Please run /login". The user can't run /login from - * chat, so the poll-loop substitutes a host-aware message. - * - * Anchored to start-of-string with the specific `·` separator (U+00B7) - * the CLI uses, so an agent that quotes the phrase verbatim mid-sentence - * in a normal reply doesn't trip the classifier. - */ -const AUTH_REQUIRED_RE = /^(Not logged in|Invalid API key)\s*·\s*Please run \/login/; - export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; @@ -271,14 +259,6 @@ export class ClaudeProvider implements AgentProvider { return STALE_SESSION_RE.test(msg); } - isAuthRequired(text: string): boolean { - return AUTH_REQUIRED_RE.test(text); - } - - authRequiredMessage(): string { - return "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; - } - query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 3124c07..55ab919 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -14,27 +14,6 @@ export interface AgentProvider { * (missing transcript, unknown session, etc.) and should be cleared. */ isSessionInvalid(err: unknown): boolean; - - /** - * True if the given text/error indicates the underlying SDK or CLI has no - * usable credentials (e.g. Claude Code's "Not logged in · Please run - * /login"). The poll-loop swaps the raw output for a host-aware message - * since the user can't authenticate from chat. - * - * Paired with `authRequiredMessage()` — providers that implement one - * should implement both. The matcher is provider-specific because each - * SDK/CLI has its own auth-failure banner format. - */ - isAuthRequired?(text: string): boolean; - - /** - * User-facing remediation message returned when `isAuthRequired` matches. - * Provider-specific because the actionable instruction differs across - * providers (e.g. Claude vs Codex vs OpenCode each direct the operator - * to a different auth flow). Falls back to a generic message in the - * poll-loop if a provider implements `isAuthRequired` but not this. - */ - authRequiredMessage?(): string; } /** From 1452ed262b018c7f07430018cd156c6b1bc3e754 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:30:20 +0000 Subject: [PATCH 34/63] chore: bump version to 2.0.17 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 419e4b3..6287027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.16", + "version": "2.0.17", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 34f361287746a507ffa952cc1c542fe35e4b5b5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:30:22 +0000 Subject: [PATCH 35/63] =?UTF-8?q?docs:=20update=20token=20count=20to=20135?= =?UTF-8?q?k=20tokens=20=C2=B7=2067%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index cccf8c7..20b07a3 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 134k tokens, 67% of context window + + 135k tokens, 67% of context window @@ -15,8 +15,8 @@ tokens - - 134k + + 135k From 2a3be9ec7fc00b687489674258d6c8ffb35ce742 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 30 Apr 2026 09:40:44 +0300 Subject: [PATCH 36/63] extract attachment-naming, harden mimeType guard, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the MIME/type-to-extension maps and derivation helpers out of session-manager.ts into a dedicated attachment-naming module — keeps session-manager focused on session lifecycle and gives the helpers a natural home for unit tests alongside the existing attachment-safety module. Two small fixes alongside the extraction: - extForMime now guards `typeof mime !== 'string'` before .split, so a buggy bridge passing `mimeType: { ... }` (object) no longer crashes the inbound write loop. - deriveAttachmentName computes Date.now() once per call instead of twice, and tightens the explicit-name check to a string-and-truthy guard so non-string values fall through to derivation. Adds attachment-naming.test.ts with 11 cases covering MIME normalization (case + parameters), Telegram type fallback, the non-string defensive guard, and the bare-timestamp fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/attachment-naming.test.ts | 71 +++++++++++++++++++++++++++++++++++ src/attachment-naming.ts | 69 ++++++++++++++++++++++++++++++++++ src/session-manager.ts | 55 +-------------------------- 3 files changed, 141 insertions(+), 54 deletions(-) create mode 100644 src/attachment-naming.test.ts create mode 100644 src/attachment-naming.ts diff --git a/src/attachment-naming.test.ts b/src/attachment-naming.test.ts new file mode 100644 index 0000000..5ca13f1 --- /dev/null +++ b/src/attachment-naming.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; + +import { deriveAttachmentName, extForMime } from './attachment-naming.js'; + +describe('extForMime', () => { + it('returns empty for undefined / non-string / empty', () => { + expect(extForMime(undefined)).toBe(''); + expect(extForMime('')).toBe(''); + expect(extForMime({})).toBe(''); + expect(extForMime(null)).toBe(''); + expect(extForMime(42)).toBe(''); + }); + + it('maps common MIME types to canonical extensions', () => { + expect(extForMime('image/jpeg')).toBe('jpg'); + expect(extForMime('application/pdf')).toBe('pdf'); + expect(extForMime('audio/ogg')).toBe('ogg'); + }); + + it('strips parameters and is case-insensitive', () => { + expect(extForMime('image/JPEG; foo=bar')).toBe('jpg'); + expect(extForMime(' Application/PDF ')).toBe('pdf'); + expect(extForMime('text/plain; charset=utf-8')).toBe('txt'); + }); + + it('returns empty for unknown MIMEs', () => { + expect(extForMime('application/octet-stream')).toBe(''); + expect(extForMime('application/x-totally-made-up')).toBe(''); + }); +}); + +describe('deriveAttachmentName', () => { + it('returns explicit name when set, no derivation', () => { + expect(deriveAttachmentName({ name: 'photo.jpg', mimeType: 'application/pdf' })).toBe('photo.jpg'); + }); + + it('ignores empty / non-string explicit name and falls through to derivation', () => { + const out = deriveAttachmentName({ name: '', mimeType: 'application/pdf' }); + expect(out).toMatch(/^attachment-\d+\.pdf$/); + + const out2 = deriveAttachmentName({ name: 42, mimeType: 'application/pdf' }); + expect(out2).toMatch(/^attachment-\d+\.pdf$/); + }); + + it('derives extension from mimeType when no name', () => { + expect(deriveAttachmentName({ mimeType: 'application/pdf' })).toMatch(/^attachment-\d+\.pdf$/); + expect(deriveAttachmentName({ mimeType: 'image/jpeg' })).toMatch(/^attachment-\d+\.jpg$/); + }); + + it('falls back to att.type when mimeType is missing (Telegram photos/stickers)', () => { + expect(deriveAttachmentName({ type: 'photo' })).toMatch(/^attachment-\d+\.jpg$/); + expect(deriveAttachmentName({ type: 'sticker' })).toMatch(/^attachment-\d+\.webp$/); + expect(deriveAttachmentName({ type: 'voice' })).toMatch(/^attachment-\d+\.ogg$/); + expect(deriveAttachmentName({ type: 'animation' })).toMatch(/^attachment-\d+\.mp4$/); + }); + + it('case-insensitive att.type lookup', () => { + expect(deriveAttachmentName({ type: 'PHOTO' })).toMatch(/^attachment-\d+\.jpg$/); + }); + + it('returns bare timestamp when nothing matches', () => { + expect(deriveAttachmentName({})).toMatch(/^attachment-\d+$/); + expect(deriveAttachmentName({ mimeType: 'application/octet-stream' })).toMatch(/^attachment-\d+$/); + expect(deriveAttachmentName({ type: 'mystery-class' })).toMatch(/^attachment-\d+$/); + }); + + it('does not crash on non-string mimeType (defensive against buggy bridges)', () => { + expect(() => deriveAttachmentName({ mimeType: { foo: 'bar' } })).not.toThrow(); + expect(deriveAttachmentName({ mimeType: { foo: 'bar' } })).toMatch(/^attachment-\d+$/); + }); +}); diff --git a/src/attachment-naming.ts b/src/attachment-naming.ts new file mode 100644 index 0000000..2dfe8c1 --- /dev/null +++ b/src/attachment-naming.ts @@ -0,0 +1,69 @@ +/** + * Derive a safe, extensioned filename for inbound attachments when the + * channel bridge passes data without an explicit `name`. + * + * Two-step lookup: + * 1. `mimeType` → extension (Discord/Slack documents, Telegram document + * uploads — channels that set the MIME but not a filename). + * 2. `att.type` → extension (Telegram photos/stickers/voice/animations — + * coarse media-class set by the chat-sdk bridge with no MIME). + * + * Output is still passed through `isSafeAttachmentName` at the call site. + * The maps emit static values, so no derivation path can construct a + * traversal payload — only an attacker-controlled `att.name` can, and that + * goes through the safety guard unchanged. + */ + +// Map common MIME types to canonical file extensions. Without an extension, +// agents (and humans) can't tell what kind of file landed in the inbox, and +// tools keyed on extension (image viewers, exiftool, etc.) misbehave. +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/heic': 'heic', + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/mp4': 'm4a', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'application/json': 'json', + 'application/zip': 'zip', +}; + +// Fallback when `mimeType` is missing — Telegram photos and stickers arrive +// without an explicit MIME on the attachment object. The channel bridge sets +// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) +// which is reliable enough to derive a canonical extension. Telegram's GIFs +// are actually MP4, hence `animation: 'mp4'`. +const TYPE_TO_EXT: Record = { + image: 'jpg', + photo: 'jpg', + sticker: 'webp', + voice: 'ogg', + audio: 'mp3', + video: 'mp4', + animation: 'mp4', +}; + +export function extForMime(mime: unknown): string { + if (typeof mime !== 'string' || !mime) return ''; + const clean = mime.split(';')[0].trim().toLowerCase(); + return MIME_TO_EXT[clean] ?? ''; +} + +export function deriveAttachmentName(att: Record): string { + const explicit = att.name; + if (typeof explicit === 'string' && explicit) return explicit; + let ext = extForMime(att.mimeType); + if (!ext && typeof att.type === 'string') { + ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; + } + const ts = Date.now(); + return ext ? `attachment-${ts}.${ext}` : `attachment-${ts}`; +} diff --git a/src/session-manager.ts b/src/session-manager.ts index 342c155..7751fba 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -14,6 +14,7 @@ import type Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; +import { deriveAttachmentName } from './attachment-naming.js'; import { isSafeAttachmentName } from './attachment-safety.js'; import type { OutboundFile } from './channels/adapter.js'; import { DATA_DIR } from './config.js'; @@ -230,60 +231,6 @@ export function writeSessionMessage( updateSession(sessionId, { last_active: new Date().toISOString() }); } -// Map common MIME types to canonical file extensions. Used to derive a -// usable suffix when the channel bridge passes an attachment without an -// explicit `name`. Without an extension, agents (and humans) can't tell -// what kind of file landed in the inbox. -const MIME_TO_EXT: Record = { - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/webp': 'webp', - 'image/gif': 'gif', - 'image/heic': 'heic', - 'audio/ogg': 'ogg', - 'audio/mpeg': 'mp3', - 'audio/wav': 'wav', - 'audio/mp4': 'm4a', - 'video/mp4': 'mp4', - 'video/webm': 'webm', - 'video/quicktime': 'mov', - 'application/pdf': 'pdf', - 'text/plain': 'txt', - 'application/json': 'json', - 'application/zip': 'zip', -}; - -// Fallback when `mimeType` is missing — Telegram photos and stickers arrive -// without an explicit MIME on the attachment object. The channel bridge sets -// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) -// which is reliable enough to derive a canonical extension. Telegram's GIFs -// are actually MP4, hence `animation: 'mp4'`. -const TYPE_TO_EXT: Record = { - image: 'jpg', - photo: 'jpg', - sticker: 'webp', - voice: 'ogg', - audio: 'mp3', - video: 'mp4', - animation: 'mp4', -}; - -function extForMime(mime: string | undefined): string { - if (!mime) return ''; - const clean = mime.split(';')[0].trim().toLowerCase(); - return MIME_TO_EXT[clean] ?? ''; -} - -function deriveAttachmentName(att: Record): string { - const explicit = att.name as string | undefined; - if (explicit) return explicit; - let ext = extForMime(att.mimeType as string | undefined); - if (!ext && typeof att.type === 'string') { - ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; - } - return ext ? `attachment-${Date.now()}.${ext}` : `attachment-${Date.now()}`; -} - /** * If message content has attachments with base64 `data`, save them to * the session's inbox directory and replace with `localPath`. From 6e5e568da12a48e05dbfb0dcc8f10e67887936ff Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 30 Apr 2026 10:33:46 +0300 Subject: [PATCH 37/63] sanitize agent sent file names to prevent path traversal --- src/session-manager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/session-manager.ts b/src/session-manager.ts index 996a750..edd4b08 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -372,6 +372,11 @@ export function readOutboxFiles( if (!fs.existsSync(outboxDir)) return undefined; const files: OutboundFile[] = []; for (const filename of filenames) { + // Reject any name that isn't a bare basename before touching the filesystem. + if (!isSafeAttachmentName(filename)) { + log.warn('Refused unsafe outbox filename — would escape outbox', { messageId, filename }); + continue; + } const filePath = path.join(outboxDir, filename); if (fs.existsSync(filePath)) { files.push({ filename, data: fs.readFileSync(filePath) }); From 15f286b73dd9c6efe5c81d2a09b0ed942247de30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:34:23 +0000 Subject: [PATCH 38/63] chore: bump version to 2.0.18 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6287027..72020d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.17", + "version": "2.0.18", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 43f49b988ebdf202f43c25e739d0c53d127b99bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:40:16 +0000 Subject: [PATCH 39/63] =?UTF-8?q?docs:=20update=20token=20count=20to=20135?= =?UTF-8?q?k=20tokens=20=C2=B7=2068%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 20b07a3..86587f7 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 135k tokens, 67% of context window + + 135k tokens, 68% of context window From f828e2971cd22470a4f90f84ea6d0e6911dc50d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:40:21 +0000 Subject: [PATCH 40/63] chore: bump version to 2.0.19 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 72020d7..2b36863 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.18", + "version": "2.0.19", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 8a205808e0eeb5910afa6a8605defc7b05efff3a Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 30 Apr 2026 07:56:34 +0000 Subject: [PATCH 41/63] 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 01a9e33..c85679f 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 7755082a4ce7bb1276aa2694faa54e6b621b0813 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 11:58:42 +0000 Subject: [PATCH 42/63] Add root user warning gate to Linux setup pre-flight Users running setup as root hit permission issues with containers, services, and file ownership. Warn early with an interactive prompt and provide step-by-step instructions to create a regular user. Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/nanoclaw.sh b/nanoclaw.sh index 058dbbf..4ab7b39 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -134,6 +134,39 @@ write_header # skips re-printing the wordmark, keeping the flow visually continuous. printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" +# ─── pre-flight: root user warning (Linux) ──────────────────────────── +if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then + printf ' %s\n' \ + "$(red 'Warning: you are running as root.')" + printf ' %s\n' \ + "$(dim "Running NanoClaw as root is not recommended. It can cause permission")" + printf ' %s\n\n' \ + "$(dim "issues with containers, services, and file ownership.")" + printf ' %s\n' \ + "$(dim "We recommend creating a regular user and running setup from there.")" + printf ' %s\n\n' \ + "$(dim "If you continue as root, some things may not work as expected.")" + read -r -p " $(bold 'Continue as root anyway?') [y/N] " ROOT_ANS Date: Wed, 29 Apr 2026 12:08:22 +0000 Subject: [PATCH 43/63] Change root warning from y/N prompt to numbered menu options Clearer UX: option 1 shows user creation instructions, option 2 explicitly continues as root (not recommended). Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 4ab7b39..06086b3 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -142,14 +142,12 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then "$(dim "Running NanoClaw as root is not recommended. It can cause permission")" printf ' %s\n\n' \ "$(dim "issues with containers, services, and file ownership.")" - printf ' %s\n' \ - "$(dim "We recommend creating a regular user and running setup from there.")" - printf ' %s\n\n' \ - "$(dim "If you continue as root, some things may not work as expected.")" - read -r -p " $(bold 'Continue as root anyway?') [y/N] " ROOT_ANS Date: Wed, 29 Apr 2026 12:12:54 +0000 Subject: [PATCH 44/63] Update root warning instructions: add root login step, fix ssh user Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 06086b3..8c0bf32 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -155,11 +155,12 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then ph_event setup_root_aborted printf '\n %s\n' "$(bold 'To set up a regular user:')" printf ' %s\n' "$(dim '1. Open another terminal (keep this one for reference)')" - printf ' %s\n' "$(dim '2. Create a new user: adduser nanoclaw')" - printf ' %s\n' "$(dim '3. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '4. Log out of this SSH session: exit')" - printf ' %s\n' "$(dim '5. Log back in as the new user: ssh your-user@your-server')" - printf ' %s\n\n' "$(dim '6. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim '2. Log in as root: ssh root@your-server')" + printf ' %s\n' "$(dim '3. Create a new user: adduser nanoclaw')" + printf ' %s\n' "$(dim '4. Add to sudo group: usermod -aG sudo nanoclaw')" + printf ' %s\n' "$(dim '5. Log out of this SSH session: exit')" + printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" exit 1 ;; esac From dec1be6adc4e0f993a40cd1eaa7c6c735eb78350 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 18:57:15 +0000 Subject: [PATCH 45/63] Add clone step to root warning user-creation instructions Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 8c0bf32..1813980 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -160,7 +160,8 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf ' %s\n' "$(dim '4. Add to sudo group: usermod -aG sudo nanoclaw')" printf ' %s\n' "$(dim '5. Log out of this SSH session: exit')" printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" - printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" + printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" exit 1 ;; esac From 0a18c1d21a2516b3745a6de9261189c4a8fa1b7c Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 21:12:14 +0000 Subject: [PATCH 46/63] Ensure user is in docker group before sg docker, revert workarounds The root cause of broken keyboard navigation was sg docker prompting for the (unset) group password when the user wasn't in the docker group. Fix by running sudo usermod -aG docker before sg docker. This makes the stty sane calls and p.confirm workaround unnecessary, so revert those. Also remove the manual docker group instruction from nanoclaw.sh since container.ts handles it automatically. Co-Authored-By: Claude Opus 4.6 --- setup/container.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/setup/container.ts b/setup/container.ts index 6ecd032..18de61a 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -127,11 +127,22 @@ export async function run(args: string[]): Promise { } // Socket is unreachable due to group perms — current shell's supplementary - // groups are fixed at login, so `usermod -aG docker` (via install-docker.sh - // or a prior install) doesn't affect us until next login. Re-exec this - // step under `sg docker` so the child picks up docker as its primary - // group and can talk to /var/run/docker.sock without a logout. + // groups are fixed at login, so `usermod -aG docker` doesn't affect us + // until next login. Ensure the user is in the docker group (install-docker.sh + // does this on fresh installs, but skips when Docker is already present), + // then re-exec under `sg docker` so the child picks up docker as its + // primary group and can talk to /var/run/docker.sock without a logout. if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) { + // Ensure the current user is in the docker group — without this, + // sg will ask for the (typically unset) group password and fail. + const inGroup = spawnSync('id', ['-nG'], { encoding: 'utf-8' }); + if (!(inGroup.stdout ?? '').split(/\s+/).includes('docker')) { + log.info('Adding current user to docker group'); + spawnSync('sudo', ['usermod', '-aG', 'docker', process.env.USER ?? ''], { + stdio: 'inherit', + }); + } + log.info('Re-executing container step under `sg docker`'); const res = spawnSync( 'sg', From 3d2996541337f35e969bc6577c7793c9725e90b8 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 21:23:42 +0000 Subject: [PATCH 47/63] Update root warning instructions: add SSH key copy, remove extra step Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 1813980..5dd366f 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -154,11 +154,11 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then *) ph_event setup_root_aborted printf '\n %s\n' "$(bold 'To set up a regular user:')" - printf ' %s\n' "$(dim '1. Open another terminal (keep this one for reference)')" - printf ' %s\n' "$(dim '2. Log in as root: ssh root@your-server')" - printf ' %s\n' "$(dim '3. Create a new user: adduser nanoclaw')" - printf ' %s\n' "$(dim '4. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '5. Log out of this SSH session: exit')" + printf ' %s\n' "$(dim '1. Log in as root: ssh root@your-server')" + printf ' %s\n' "$(dim '2. Create a new user: adduser nanoclaw')" + printf ' %s\n' "$(dim '3. Add to sudo group: usermod -aG sudo nanoclaw')" + printf ' %s\n' "$(dim '4. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" + printf ' %s\n' "$(dim '5. Log out: exit')" printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" From d07cd7afa0d873cb00658db4da2ca2fb64a45d3e Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 29 Apr 2026 21:38:15 +0000 Subject: [PATCH 48/63] Remove redundant root login step from user-creation instructions Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 5dd366f..fdb24a1 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -154,14 +154,13 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then *) ph_event setup_root_aborted printf '\n %s\n' "$(bold 'To set up a regular user:')" - printf ' %s\n' "$(dim '1. Log in as root: ssh root@your-server')" - printf ' %s\n' "$(dim '2. Create a new user: adduser nanoclaw')" - printf ' %s\n' "$(dim '3. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '4. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" - printf ' %s\n' "$(dim '5. Log out: exit')" - printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" - printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" - printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" + printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" + printf ' %s\n' "$(dim '3. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" + printf ' %s\n' "$(dim '4. Log out: exit')" + printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" + printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" exit 1 ;; esac From 72837c1643dc39898b21e2fbef7fd4301490c54a Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 07:44:49 +0000 Subject: [PATCH 49/63] Fix sg docker re-exec restarting setup from scratch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When maybeReexecUnderSg() re-launches setup:auto under `sg docker`, the new process had no memory of completed steps — it re-prompted the welcome menu, re-ran environment and container checks, and then failed on onecli because the earlier run's state was lost. Pass NANOCLAW_SKIP with completedStepNames() so the re-exec'd process skips already-finished steps, suppress the welcome menu and existing-env prompts on re-exec since the user already answered them. Co-Authored-By: Claude Opus 4.6 --- setup/auto.ts | 66 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 37e3cb6..425778f 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -85,17 +85,21 @@ async function main(): Promise { // Welcome menu — default path or open advanced overrides before any setup // work begins. Default lands on standard so Enter is the happy path. - const startChoice = ensureAnswer( - await brightSelect<'default' | 'advanced'>({ - message: 'How would you like to begin?', - options: [ - { value: 'default', label: 'Standard setup' }, - { value: 'advanced', label: 'Advanced', hint: 'override defaults' }, - ], - initialValue: 'default', - }), - ) as 'default' | 'advanced'; - setupLog.userInput('start_choice', startChoice); + // On sg re-exec, the user already chose — skip straight to standard. + let startChoice: 'default' | 'advanced' = 'default'; + if (process.env.NANOCLAW_REEXEC_SG !== '1') { + startChoice = ensureAnswer( + await brightSelect<'default' | 'advanced'>({ + message: 'How would you like to begin?', + options: [ + { value: 'default', label: 'Standard setup' }, + { value: 'advanced', label: 'Advanced', hint: 'override defaults' }, + ], + initialValue: 'default', + }), + ) as 'default' | 'advanced'; + setupLog.userInput('start_choice', startChoice); + } if (startChoice === 'advanced') { configValues = await runAdvancedScreen(configValues); applyToEnv(configValues); @@ -126,22 +130,28 @@ async function main(): Promise { // paste credentials again on a re-run. const existingEnv = detectExistingEnv(); if (existingEnv) { - const lines = Object.values(existingEnv.groups).map( - (g) => ` ${k.green('✓')} ${g.label}`, - ); - note(lines.join('\n'), 'Found existing configuration'); + // On sg re-exec, auto-reuse — the user already decided in the first run. + const isReexec = process.env.NANOCLAW_REEXEC_SG === '1'; + let reuseChoice: 'reuse' | 'fresh' = 'reuse'; - const reuseChoice = ensureAnswer( - await brightSelect({ - message: 'Use this existing environment?', - options: [ - { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' }, - { value: 'fresh', label: 'No, start fresh' }, - ], - initialValue: 'reuse', - }), - ) as 'reuse' | 'fresh'; - setupLog.userInput('existing_env_choice', reuseChoice); + if (!isReexec) { + const lines = Object.values(existingEnv.groups).map( + (g) => ` ${k.green('✓')} ${g.label}`, + ); + note(lines.join('\n'), 'Found existing configuration'); + + reuseChoice = ensureAnswer( + await brightSelect({ + message: 'Use this existing environment?', + options: [ + { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' }, + { value: 'fresh', label: 'No, start fresh' }, + ], + initialValue: 'reuse', + }), + ) as 'reuse' | 'fresh'; + setupLog.userInput('existing_env_choice', reuseChoice); + } if (reuseChoice === 'reuse') { for (const [key, value] of Object.entries(existingEnv.raw)) { @@ -1178,9 +1188,11 @@ function maybeReexecUnderSg(): void { if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return; p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.'); + const existingSkip = (process.env.NANOCLAW_SKIP ?? '').split(',').map((s) => s.trim()).filter(Boolean); + const skipList = [...new Set([...existingSkip, ...setupLog.completedStepNames()])].join(','); const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], { stdio: 'inherit', - env: { ...process.env, NANOCLAW_REEXEC_SG: '1' }, + env: { ...process.env, NANOCLAW_REEXEC_SG: '1', ...(skipList ? { NANOCLAW_SKIP: skipList } : {}) }, }); process.exit(res.status ?? 1); } From 23a3fea868c21be09a2c31df2d70825db2d6eb3a Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 09:26:34 +0000 Subject: [PATCH 50/63] Add passwordless sudo step to root warning instructions Setup steps like install-node.sh and install-docker.sh run sudo non-interactively. Without NOPASSWD, password prompts can silently hang when piped through the setup runner. Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index fdb24a1..fd35c47 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -156,9 +156,18 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf '\n %s\n' "$(bold 'To set up a regular user:')" printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '3. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" - printf ' %s\n' "$(dim '4. Log out: exit')" - printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" + printf ' %s\n' "$(dim '4. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" + printf ' %s\n' "$(dim '5. Log out: exit')" + printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" + printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n\n' "$(bold 'If you are using a web terminal (hosting provider console):')" + printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" + printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" + printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" + printf ' %s\n' "$(dim '4. Log out: logout')" + printf ' %s\n' "$(dim '5. Log in as the new user at the login prompt')" printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" exit 1 From d5388a168ba5ddf5676dbc5cdcdba11a155ecc07 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 10:37:42 +0000 Subject: [PATCH 51/63] Replace web terminal instructions with SSH setup hint Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index fd35c47..57997f9 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -162,14 +162,8 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" - printf ' %s\n\n' "$(bold 'If you are using a web terminal (hosting provider console):')" - printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" - printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" - printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" - printf ' %s\n' "$(dim '4. Log out: logout')" - printf ' %s\n' "$(dim '5. Log in as the new user at the login prompt')" - printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" - printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')" + printf '\n' exit 1 ;; esac From 35f8e9d2f5ef25f77fa122475aa9187b8ba78d08 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 10:40:45 +0000 Subject: [PATCH 52/63] Move SSH hint above user-creation steps Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 57997f9..8693537 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -153,7 +153,8 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then ;; *) ph_event setup_root_aborted - printf '\n %s\n' "$(bold 'To set up a regular user:')" + printf '\n %s\n' "$(bold 'To set up a regular user (via SSH):')" + printf ' %s\n\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')" printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" @@ -162,8 +163,6 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" - printf ' %s\n' "$(dim 'Not using SSH? Refer to your hosting provider docs or ask your coding agent to help you set up SSH access.')" - printf '\n' exit 1 ;; esac From e56132d04a55d1ac105095b67b8aec67fbcbd498 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Thu, 30 Apr 2026 11:33:20 +0000 Subject: [PATCH 53/63] Remove SSH key copy step from root warning instructions Co-Authored-By: Claude Opus 4.6 --- nanoclaw.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index 8693537..d44b367 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -158,11 +158,10 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then printf ' %s\n' "$(dim '1. Create a new user: adduser nanoclaw')" printf ' %s\n' "$(dim '2. Add to sudo group: usermod -aG sudo nanoclaw')" printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')" - printf ' %s\n' "$(dim '4. Copy SSH keys to new user: cp -r ~/.ssh /home/nanoclaw/.ssh && chown -R nanoclaw:nanoclaw /home/nanoclaw/.ssh')" - printf ' %s\n' "$(dim '5. Log out: exit')" - printf ' %s\n' "$(dim '6. Log back in as the new user: ssh nanoclaw@your-server')" - printf ' %s\n' "$(dim '7. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" - printf ' %s\n\n' "$(dim '8. Re-run setup: bash nanoclaw.sh')" + printf ' %s\n' "$(dim '4. Log out: exit')" + printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')" + printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')" + printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')" exit 1 ;; esac From 5be15be139fd221f46abb22b6610e39fa4007cd4 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 30 Apr 2026 12:07:53 +0000 Subject: [PATCH 54/63] fix: prevent telegram pairing spinner from flooding the terminal The spinner label exceeded terminal width, breaking clack's cursor-up redraw and causing each animation tick to print a new line instead of updating in-place. Wrap with fitToWidth() like other setup spinners. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/channels/telegram.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 5681fd1..1aa7cb5 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { accentGreen, brandBold, note } from '../lib/theme.js'; +import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -254,11 +254,11 @@ async function runPairTelegram(): Promise< stopSpinner("Old code expired. Here's a fresh one."); } note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code'); - s.start('Waiting for you to send the code from Telegram…'); + s.start(fitToWidth('Waiting for you to send the code from Telegram…', '')); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') { stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`); - s.start('Waiting for the correct code…'); + s.start(fitToWidth('Waiting for the correct code…', '')); spinnerActive = true; } else if (block.type === 'PAIR_TELEGRAM') { if (block.fields.STATUS === 'success') { From 1db98ee614c36bbf09d279b3a7d5e70c2691a995 Mon Sep 17 00:00:00 2001 From: Gabi Date: Thu, 30 Apr 2026 12:36:25 +0000 Subject: [PATCH 55/63] refactor(setup): check env vars per-step instead of upfront all-or-nothing Remove the grouped detectExistingEnv() block that asked "reuse all or start fresh" at the top of setup. Each channel step now reads credentials directly from .env on disk via readEnvKey() and offers to reuse them individually at the point of use. - Add readEnvKey() helper in setup/environment.ts - Remove ENV_KEY_GROUPS, ExistingEnvGroup, detectExistingEnv from auto.ts - Move detectRegisteredGroups skip to right before cli-agent step - Switch all channel files (telegram, discord, slack, teams, imessage) from process.env to readEnvKey() Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/auto.ts | 88 +++----------------------------------- setup/channels/discord.ts | 3 +- setup/channels/imessage.ts | 5 ++- setup/channels/slack.ts | 5 ++- setup/channels/teams.ts | 9 ++-- setup/channels/telegram.ts | 3 +- setup/environment.ts | 24 +++++++++++ 7 files changed, 44 insertions(+), 93 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 392bc13..da5f194 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -122,39 +122,6 @@ async function main(): Promise { } } - // Detect existing .env and offer to reuse it so the user doesn't have to - // paste credentials again on a re-run. - const existingEnv = detectExistingEnv(); - if (existingEnv) { - const lines = Object.values(existingEnv.groups).map( - (g) => ` ${k.green('✓')} ${g.label}`, - ); - note(lines.join('\n'), 'Found existing configuration'); - - const reuseChoice = ensureAnswer( - await brightSelect({ - message: 'Use this existing environment?', - options: [ - { value: 'reuse', label: 'Yes, use what I already have', hint: 'recommended' }, - { value: 'fresh', label: 'No, start fresh' }, - ], - initialValue: 'reuse', - }), - ) as 'reuse' | 'fresh'; - setupLog.userInput('existing_env_choice', reuseChoice); - - if (reuseChoice === 'reuse') { - for (const [key, value] of Object.entries(existingEnv.raw)) { - if (!process.env[key]) process.env[key] = value; - } - if (existingEnv.groups.onecli) skip.add('onecli'); - if (detectRegisteredGroups(process.cwd())) { - skip.add('cli-agent'); - skip.add('first-chat'); - } - } - } - if (!skip.has('container')) { p.log.message(brandBody(dimWrap('Your assistant lives in its own sandbox. It can only see what you explicitly share.', 4))); p.log.message( @@ -344,6 +311,11 @@ async function main(): Promise { return displayName; } + if (!skip.has('cli-agent') && detectRegisteredGroups(process.cwd())) { + skip.add('cli-agent'); + skip.add('first-chat'); + } + if (!skip.has('cli-agent')) { await resolveDisplayName(); const res = await runQuietStep( @@ -1063,56 +1035,6 @@ async function askChannelChoice(): Promise { // ─── interactive / env helpers ───────────────────────────────────────── -interface ExistingEnvGroup { - label: string; - keys: string[]; -} - -const ENV_KEY_GROUPS: Record = { - onecli: { label: 'OneCLI', keys: ['ONECLI_URL'] }, - telegram: { label: 'Telegram', keys: ['TELEGRAM_BOT_TOKEN'] }, - discord: { label: 'Discord', keys: ['DISCORD_BOT_TOKEN', 'DISCORD_APPLICATION_ID', 'DISCORD_PUBLIC_KEY'] }, - slack: { label: 'Slack', keys: ['SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'] }, - signal: { label: 'Signal', keys: ['SIGNAL_ACCOUNT'] }, - teams: { label: 'Teams', keys: ['TEAMS_APP_ID', 'TEAMS_APP_PASSWORD', 'TEAMS_APP_TENANT_ID', 'TEAMS_APP_TYPE'] }, - whatsapp: { label: 'WhatsApp', keys: ['ASSISTANT_HAS_OWN_NUMBER'] }, - imessage: { label: 'iMessage', keys: ['IMESSAGE_LOCAL', 'IMESSAGE_ENABLED', 'IMESSAGE_SERVER_URL', 'IMESSAGE_API_KEY'] }, -}; - -function detectExistingEnv(): { groups: Record; raw: Record } | null { - const envPath = path.join(process.cwd(), '.env'); - if (!fs.existsSync(envPath)) return null; - - let content: string; - try { - content = fs.readFileSync(envPath, 'utf-8'); - } catch { - return null; - } - - const raw: Record = {}; - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eq = trimmed.indexOf('='); - if (eq < 1) continue; - raw[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); - } - - if (Object.keys(raw).length === 0) return null; - - const groups: Record = {}; - for (const [id, def] of Object.entries(ENV_KEY_GROUPS)) { - const found = def.keys.filter((key) => raw[key] !== undefined); - if (found.length > 0) { - groups[id] = { label: def.label, keys: found }; - } - } - - if (Object.keys(groups).length === 0) return null; - return { groups, raw }; -} - function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3638e4e..0c6ff89 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -32,6 +32,7 @@ import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, brandBody, note } from '../lib/theme.js'; +import { readEnvKey } from '../environment.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -240,7 +241,7 @@ async function walkThroughServerCreation(): Promise { } async function collectDiscordToken(): Promise { - const existing = process.env.DISCORD_BOT_TOKEN?.trim(); + const existing = readEnvKey('DISCORD_BOT_TOKEN'); if (existing && /^[A-Za-z0-9._-]{50,}$/.test(existing)) { const reuse = ensureAnswer(await p.confirm({ message: `Found an existing Discord bot token (${existing.slice(0, 10)}…). Use it?`, diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index a2654c0..8c0b78d 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -37,6 +37,7 @@ import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; +import { readEnvKey } from '../environment.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -222,8 +223,8 @@ async function walkThroughFullDiskAccess(): Promise { } async function collectRemoteCreds(): Promise { - const existingUrl = process.env.IMESSAGE_SERVER_URL?.trim(); - const existingKey = process.env.IMESSAGE_API_KEY?.trim(); + const existingUrl = readEnvKey('IMESSAGE_SERVER_URL'); + const existingKey = readEnvKey('IMESSAGE_API_KEY'); if (existingUrl && existingKey && /^https?:\/\//i.test(existingUrl)) { const reuse = ensureAnswer(await p.confirm({ message: `Found existing Photon credentials (${existingUrl}). Use them?`, diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 167fa72..fc786c5 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -29,6 +29,7 @@ import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; +import { readEnvKey } from '../environment.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -151,7 +152,7 @@ async function walkThroughAppCreation(): Promise { } async function collectBotToken(): Promise { - const existing = process.env.SLACK_BOT_TOKEN?.trim(); + const existing = readEnvKey('SLACK_BOT_TOKEN'); if (existing && existing.startsWith('xoxb-') && existing.length >= 24) { const reuse = ensureAnswer(await p.confirm({ message: `Found an existing Slack bot token (${existing.slice(0, 10)}…). Use it?`, @@ -185,7 +186,7 @@ async function collectBotToken(): Promise { } async function collectSigningSecret(): Promise { - const existing = process.env.SLACK_SIGNING_SECRET?.trim(); + const existing = readEnvKey('SLACK_SIGNING_SECRET'); if (existing && /^[a-f0-9]{16,}$/i.test(existing)) { const reuse = ensureAnswer(await p.confirm({ message: 'Found an existing Slack signing secret. Use it?', diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 01839c4..41e2070 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -42,6 +42,7 @@ import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { buildTeamsAppPackage } from '../lib/teams-manifest.js'; import { note } from '../lib/theme.js'; import * as setupLog from '../logs.js'; +import { readEnvKey } from '../environment.js'; const CHANNEL = 'teams'; const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams'); @@ -60,8 +61,8 @@ export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; - const existingAppId = process.env.TEAMS_APP_ID?.trim(); - const existingPassword = process.env.TEAMS_APP_PASSWORD?.trim(); + const existingAppId = readEnvKey('TEAMS_APP_ID'); + const existingPassword = readEnvKey('TEAMS_APP_PASSWORD'); if (existingAppId && existingPassword) { const reuse = ensureAnswer(await p.confirm({ message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, @@ -70,9 +71,9 @@ export async function runTeamsChannel(_displayName: string): Promise { if (reuse) { collected.appId = existingAppId; collected.appPassword = existingPassword; - collected.appType = (process.env.TEAMS_APP_TYPE?.trim() as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; + collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; if (collected.appType === 'SingleTenant') { - collected.tenantId = process.env.TEAMS_APP_TENANT_ID?.trim(); + collected.tenantId = readEnvKey('TEAMS_APP_TENANT_ID') ?? undefined; } setupLog.userInput('teams_credentials', 'reused-existing'); await installAdapter(collected); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 1aa7cb5..bcfe393 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -34,6 +34,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js'; +import { readEnvKey } from '../environment.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -132,7 +133,7 @@ export async function runTelegramChannel(displayName: string): Promise { } async function collectTelegramToken(): Promise { - const existing = process.env.TELEGRAM_BOT_TOKEN?.trim(); + const existing = readEnvKey('TELEGRAM_BOT_TOKEN'); if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { const reuse = ensureAnswer(await p.confirm({ message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, diff --git a/setup/environment.ts b/setup/environment.ts index c351023..5960b0e 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -11,6 +11,30 @@ import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +/** + * Read a single key from `.env` on disk (not process.env). + * Returns the trimmed value or null if the key isn't set / file doesn't exist. + */ +export function readEnvKey(key: string, projectRoot?: string): string | null { + const envPath = path.join(projectRoot ?? process.cwd(), '.env'); + let content: string; + try { + content = fs.readFileSync(envPath, 'utf-8'); + } catch { + return null; + } + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 1) continue; + if (trimmed.slice(0, eq) === key) { + return trimmed.slice(eq + 1).trim() || null; + } + } + return null; +} + export function detectExistingDisplayName(projectRoot: string): string | null { const dbPath = path.join(projectRoot, 'data', 'v2.db'); if (!fs.existsSync(dbPath)) return null; From a66cd545d531a32ffada17ca9f235201657cf808 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 29 Apr 2026 13:32:27 +0000 Subject: [PATCH 56/63] feat(setup): switch elapsed-time suffixes to "Xm Ys" past 60s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `fmtDuration(ms)` helper in `setup/lib/theme.ts` that returns `47s` under a minute and `1m 34s` from 60s onward, then routes every elapsed-time spinner suffix in the setup flow through it. Replaces the inline `Math.round((Date.now() - start) / 1000)` + `(${elapsed}s)` pattern at every site. Format is consistent past 60s — `1m 0s` over `1m` — so the live spinner doesn't change shape at every whole-minute crossing. Sites updated: setup/auto.ts, setup/lib/{runner,tz-from-claude, claude-assist}.ts, and setup/channels/{signal,whatsapp,telegram, discord,slack}.ts. Pre-allocated suffix budgets in `fitToWidth` calls bumped from `' (999s)'` to `' (99m 59s)'` so long-running steps don't blow past the reserved width. --- setup/auto.ts | 10 ++++------ setup/channels/discord.ts | 20 +++++++------------- setup/channels/signal.ts | 5 ++--- setup/channels/slack.ts | 14 +++++--------- setup/channels/telegram.ts | 8 +++----- setup/channels/whatsapp.ts | 5 ++--- setup/lib/claude-assist.ts | 8 +++----- setup/lib/runner.ts | 10 ++++------ setup/lib/theme.ts | 16 ++++++++++++++++ setup/lib/tz-from-claude.ts | 10 ++++------ 10 files changed, 50 insertions(+), 56 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 392bc13..94ffe20 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -53,7 +53,7 @@ import { claudeCliAvailable, resolveTimezoneViaClaude } from './lib/tz-from-clau import * as setupLog from './logs.js'; 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 { 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'; @@ -579,18 +579,16 @@ async function confirmAssistantResponds(): Promise { const s = p.spinner(); const start = Date.now(); const label = 'Waking your assistant…'; - s.start(fitToWidth(label, ' (999s)')); + s.start(fitToWidth(label, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); }, 1000); const result = await pingCliAgent(); clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result === 'ok') { s.stop(`${k.bold(fitToWidth('Your assistant is ready.', suffix))}${k.dim(suffix)}`); } else { diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 3638e4e..c25f2de 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -31,7 +31,7 @@ import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { accentGreen, brandBody, note } from '../lib/theme.js'; +import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const DISCORD_API = 'https://discord.com/api/v10'; @@ -289,9 +289,8 @@ async function validateDiscordToken(token: string): Promise { username?: string; message?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (res.ok && data.username) { - s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${data.username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('discord-validate', 'success', Date.now() - start, { BOT_USERNAME: data.username, BOT_ID: data.id ?? '', @@ -309,8 +308,7 @@ async function validateDiscordToken(token: string): Promise { 'Copy the token again from the Developer Portal and retry setup.', ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-validate', 'failed', Date.now() - start, { ERROR: message, @@ -338,7 +336,6 @@ async function fetchApplicationInfo(token: string): Promise { team?: unknown; message?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (!res.ok || !data.id || !data.verify_key) { const reason = data.message ?? `HTTP ${res.status}`; s.stop(`Couldn't read application info: ${reason}`, 1); @@ -351,7 +348,7 @@ async function fetchApplicationInfo(token: string): Promise { 'Re-run setup. If it keeps failing, check the bot token has the right scopes.', ); } - s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Got your application details. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); // owner is populated for solo applications; team-owned apps return a // team object instead and we'll fall back to a manual user-id prompt. const owner = @@ -369,8 +366,7 @@ async function fetchApplicationInfo(token: string): Promise { owner, }; } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-app-info', 'failed', Date.now() - start, { ERROR: message, @@ -479,7 +475,6 @@ async function openDmChannel(token: string, userId: string): Promise { body: JSON.stringify({ recipient_id: userId }), }); const data = (await res.json()) as { id?: string; message?: string }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (!res.ok || !data.id) { const reason = data.message ?? `HTTP ${res.status}`; s.stop(`Couldn't open a DM channel: ${reason}`, 1); @@ -492,14 +487,13 @@ async function openDmChannel(token: string, userId: string): Promise { 'Make sure the bot is in a server you\'re also in, then retry setup.', ); } - s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('discord-open-dm', 'success', Date.now() - start, { DM_CHANNEL_ID: data.id, }); return data.id; } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Discord. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('discord-open-dm', 'failed', Date.now() - start, { ERROR: message, diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 0c5718e..8462a56 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -44,7 +44,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { accentGreen, note } from '../lib/theme.js'; +import { accentGreen, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -324,8 +324,7 @@ async function restartService(): Promise { // Give the adapter a moment to connect to signal-cli before // init-first-agent's welcome DM hits the delivery path. await new Promise((r) => setTimeout(r, 5000)); - const elapsed = Math.round((Date.now() - start) / 1000); - s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('signal-restart', 'success', Date.now() - start, { PLATFORM: platform, }); diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 167fa72..340eabc 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -28,7 +28,7 @@ import * as setupLog from '../logs.js'; import { confirmThenOpen } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; -import { accentGreen, note, wrapForGutter } from '../lib/theme.js'; +import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js'; const SLACK_API = 'https://slack.com/api'; const SLACK_APPS_URL = 'https://api.slack.com/apps'; @@ -241,10 +241,9 @@ async function validateSlackToken(token: string): Promise { user_id?: string; error?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.team && data.user) { s.stop( - `Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`, + `Connected to ${data.team} as @${data.user}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, ); const info: WorkspaceInfo = { teamName: data.team, @@ -273,8 +272,7 @@ async function validateSlackToken(token: string): Promise { : `Slack said "${reason}". Check the token scopes and workspace install, then retry.`, ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('slack-validate', 'failed', Date.now() - start, { ERROR: message, @@ -334,9 +332,8 @@ async function openDmChannel(token: string, userId: string): Promise { channel?: { id?: string }; error?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.channel?.id) { - s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`DM channel ready. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('slack-open-dm', 'success', Date.now() - start, { DM_CHANNEL_ID: data.channel.id, }); @@ -360,8 +357,7 @@ async function openDmChannel(token: string, userId: string): Promise { `Slack said "${reason}". Check the member ID and app permissions, then retry.`, ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Slack. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('slack-open-dm', 'failed', Date.now() - start, { ERROR: message, diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 1aa7cb5..ad749eb 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -33,7 +33,7 @@ import { spawnStep, writeStepEntry, } from '../lib/runner.js'; -import { accentGreen, brandBold, fitToWidth, note } from '../lib/theme.js'; +import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; @@ -191,10 +191,9 @@ async function validateTelegramToken(token: string): Promise { result?: { username?: string; id?: number }; description?: string; }; - const elapsedS = Math.round((Date.now() - start) / 1000); if (data.ok && data.result?.username) { const username = data.result.username; - s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`); + s.stop(`Found your bot: @${username}. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('telegram-validate', 'success', Date.now() - start, { BOT_USERNAME: username, BOT_ID: data.result.id ?? '', @@ -212,8 +211,7 @@ async function validateTelegramToken(token: string): Promise { 'Copy the token again from @BotFather and try setup once more.', ); } catch (err) { - const elapsedS = Math.round((Date.now() - start) / 1000); - s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1); + s.stop(`Couldn't reach Telegram. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`, 1); const message = err instanceof Error ? err.message : String(err); setupLog.step('telegram-validate', 'failed', Date.now() - start, { ERROR: message, diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 96d23d5..922c985 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -46,7 +46,7 @@ import { writeStepEntry, } from '../lib/runner.js'; import { askOperatorRole } from '../lib/role-prompt.js'; -import { accentGreen, brandBody, brandBold, note } from '../lib/theme.js'; +import { accentGreen, brandBody, brandBold, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); @@ -379,8 +379,7 @@ async function restartService(): Promise { // Give the adapter a moment to reconnect before init-first-agent's // welcome DM hits the delivery path. await new Promise((r) => setTimeout(r, 5000)); - const elapsed = Math.round((Date.now() - start) / 1000); - s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + s.stop(`NanoClaw restarted. ${k.dim(`(${fmtDuration(Date.now() - start)})`)}`); setupLog.step('whatsapp-restart', 'success', Date.now() - start, { PLATFORM: platform, }); diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index e76a4fc..187377e 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -28,7 +28,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { ensureAnswer } from './runner.js'; -import { brandBody, fitToWidth, note } from './theme.js'; +import { brandBody, fitToWidth, fmtDuration, note } from './theme.js'; export interface AssistContext { stepName: string; @@ -295,9 +295,8 @@ async function queryClaudeUnderSpinner( // Move cursor back to the start of the block (WINDOW_SIZE + 1 = header + window). out.write(`\x1b[${WINDOW_SIZE + 1}A`); - const elapsed = Math.round((Date.now() - start) / 1000); const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]; - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; const header = fitToWidth('Asking Claude to diagnose…', suffix); out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); @@ -355,8 +354,7 @@ async function queryClaudeUnderSpinner( clearBlock(); out.write(SHOW_CURSOR); process.off('exit', restoreCursorOnExit); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (kind === 'ok') { p.log.success(`${brandBody(fitToWidth('Claude replied.', suffix))}${k.dim(suffix)}`); resolve(payload); diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index cf7a86d..6ffffed 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -20,7 +20,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { offerClaudeAssist } from './claude-assist.js'; import { emit as phEmit } from './diagnostics.js'; -import { brandBody, fitToWidth } from './theme.js'; +import { brandBody, fitToWidth, fmtDuration } from './theme.js'; export type Fields = Record; export type Block = { type: string; fields: Fields }; @@ -307,18 +307,16 @@ async function runUnderSpinner< ): Promise { const s = p.spinner(); const start = Date.now(); - s.start(fitToWidth(labels.running, ' (999s)')); + s.start(fitToWidth(labels.running, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; s.message(`${fitToWidth(labels.running, suffix)}${k.dim(suffix)}`); }, 1000); const result = await work(); clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; diff --git a/setup/lib/theme.ts b/setup/lib/theme.ts index 0dfa53f..2c80c8a 100644 --- a/setup/lib/theme.ts +++ b/setup/lib/theme.ts @@ -51,6 +51,22 @@ export function accentGreen(s: string): string { return k.green(s); } +/** + * Format an elapsed-time duration (in milliseconds) for the spinner + * suffixes setup writes everywhere. Sub-minute durations stay in plain + * seconds (`47s`); once the timer crosses 60 seconds we switch to the + * `Xm Ys` form (`2m 34s`) so a long step doesn't read as `247s` or + * similar. The format is consistent above 60s — `4m 0s` over `4m` — + * so live spinner output doesn't change shape at every whole minute. + */ +export function fmtDuration(ms: number): string { + const totalSec = Math.round(ms / 1000); + if (totalSec < 60) return `${totalSec}s`; + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}m ${s}s`; +} + /** * Brand body color for setup-flow prose. Used for card bodies (via the * `note()` formatter) and `p.log.*` body arguments — anywhere the diff --git a/setup/lib/tz-from-claude.ts b/setup/lib/tz-from-claude.ts index 5486fbb..f861f64 100644 --- a/setup/lib/tz-from-claude.ts +++ b/setup/lib/tz-from-claude.ts @@ -17,7 +17,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { isValidTimezone } from '../../src/timezone.js'; -import { fitToWidth } from './theme.js'; +import { fitToWidth, fmtDuration } from './theme.js'; export function claudeCliAvailable(): boolean { try { @@ -44,18 +44,16 @@ export async function resolveTimezoneViaClaude( const s = p.spinner(); const start = Date.now(); const label = 'Looking up that timezone…'; - s.start(fitToWidth(label, ' (999s)')); + s.start(fitToWidth(label, ' (99m 59s)')); const tick = setInterval(() => { - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`); }, 1000); const reply = await queryClaude(prompt); clearInterval(tick); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; const resolved = reply ? extractTimezone(reply) : null; if (resolved) { From 4d42bb95fb56bb264a44c345d5a2e51ec29a60cf Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 10:03:36 +0000 Subject: [PATCH 57/63] feat(setup): skip browser-open prompts on headless devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the existing `isHeadless()` from setup/platform.ts into `confirmThenOpen`. When the helper detects a headless device (Linux without `DISPLAY`/`WAYLAND_DISPLAY`), both the "Press Enter to open your browser" prompt and the actual `openUrl(...)` call are skipped — there's no browser to launch and the user can't usefully press Enter to summon one. Why this is enough — the surrounding flow already supports the headless path implicitly: - Every `confirmThenOpen` call site sits beneath a `note(...)` that prints the URL and the steps the user needs to take. The URL is already visible to copy-paste onto another device. - Every site is followed by an explicit confirmation prompt ("Got your bot token?", "Done with the X?", etc.) that naturally serves as the headless user's "I finished the thing on my other device" signal. So the headless branch becomes: read the note, do the thing, answer the next prompt — without a useless "Press Enter to open your browser" detour in between. Coverage rationale (~95% accurate for the cases that actually cause user confusion today): - Linux + no `DISPLAY`/`WAYLAND_DISPLAY` → headless. Catches: • Raspberry Pi headless installs • Bare-metal Linux servers • SSH'd into Linux without X11 forwarding • CI environments on Linux • Linux containers (which have no display) - macOS → never headless. Even SSH'd Macs can usually still open URLs through the local user's session, so treating them as GUI-capable is the right default. - Windows → never headless (effectively always GUI in practice). The remaining ~5% are edge cases (someone manually unset `DISPLAY` on a desktop Linux session, etc.) that almost never happen accidentally and recover gracefully — the URL is still visible in the surrounding note. Six call sites in channel adapters (Discord ×3, Slack ×1, Telegram ×1, Teams ×1) all change behavior atomically through the single helper. No per-site copy changes needed; consistency is enforced by the central wiring. --- setup/lib/browser.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/setup/lib/browser.ts b/setup/lib/browser.ts index 9d801fa..fc6eb17 100644 --- a/setup/lib/browser.ts +++ b/setup/lib/browser.ts @@ -9,12 +9,18 @@ * `confirmThenOpen` pauses for the operator before triggering the open — * the browser tends to steal focus when it pops, and a split-second * "wait what just happened" moment is worse than letting the user hit - * Enter when they're ready. + * Enter when they're ready. On headless devices (no graphical session + * available) it skips both the prompt and the open: there's no browser + * to launch, the surrounding `note(...)` already shows the URL for + * copy-paste on another device, and the next prompt in the channel + * flow ("Got your bot token?" etc.) provides the natural completion + * confirmation. */ import { spawn } from 'child_process'; import * as p from '@clack/prompts'; +import { isHeadless } from '../platform.js'; import { ensureAnswer } from './runner.js'; /** Best-effort open of a URL in the user's default browser. Silent on failure. */ @@ -35,12 +41,15 @@ export function openUrl(url: string): void { /** * Gate a browser-open on a confirm so the user is ready for their browser * to take focus. Proceeds on cancel as well — the user can always copy the - * URL from the note that precedes the prompt. + * URL from the note that precedes the prompt. On headless devices both + * the prompt and the open are skipped — there's no browser to time + * focus for, and the URL is already visible in the surrounding note. */ export async function confirmThenOpen( url: string, message = 'Press Enter to open your browser', ): Promise { + if (isHeadless()) return; ensureAnswer( await p.confirm({ message, From 6863e0f63bbbd6e52622f0fc99bf74c66c525bae Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 10:31:43 +0000 Subject: [PATCH 58/63] feat(setup): label headless URL fallback with "Get started:" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a card's auto-open is gated on `confirmThenOpen`, the URL also appears inside the surrounding `note(...)` as a copy-paste fallback — rendered dim because on a GUI device the auto-open is doing the heavy lifting and the printed URL is just an incidental backup. On headless devices the auto-open doesn't run (per #2145), so the URL inside the note is the user's *only* path forward. A dim URL reads as "incidental reference" exactly when it should be reading as "this is the action." Adds `formatNoteLink(url)` to setup/lib/browser.ts: - GUI device → `k.dim(url)` (unchanged from today) - Headless device → `Get started: ` at full strength Replaces five call sites (Discord ×3, Slack ×1, Telegram ×1). Single helper, atomic switch via the same `isHeadless()` plumbing introduced in #2145, so the headless behavior across all five flows stays in sync. --- setup/channels/discord.ts | 8 ++++---- setup/channels/slack.ts | 4 ++-- setup/channels/telegram.ts | 4 ++-- setup/lib/browser.ts | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index c25f2de..1dec8e0 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -28,7 +28,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { brightSelect } from '../lib/bright-select.js'; -import { confirmThenOpen } from '../lib/browser.js'; +import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, brandBody, fmtDuration, note } from '../lib/theme.js'; @@ -165,7 +165,7 @@ async function walkThroughBotCreation(): Promise { ' 3. On the same tab, enable "Message Content Intent"', ' (under Privileged Gateway Intents)', '', - k.dim(url), + formatNoteLink(url), ].join('\n'), 'Create a Discord bot', ); @@ -225,7 +225,7 @@ async function walkThroughServerCreation(): Promise { ' 2. Choose "Create My Own" → "For me and my friends"', ' 3. Give it any name (e.g. "NanoClaw")', '', - k.dim(url), + formatNoteLink(url), ].join('\n'), 'Create a Discord server', ); @@ -447,7 +447,7 @@ async function promptInviteBot( ' 1. Pick any server you\'re in (a personal one is fine)', ' 2. Click "Authorize"', '', - k.dim(url), + formatNoteLink(url), ].join('\n'), 'Add bot to a server', ); diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 340eabc..03cbf46 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -25,7 +25,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen } from '../lib/browser.js'; +import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { accentGreen, fmtDuration, note, wrapForGutter } from '../lib/theme.js'; @@ -136,7 +136,7 @@ async function walkThroughAppCreation(): Promise { ' 4. Basic Information → copy the "Signing Secret"', ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', '', - k.dim(SLACK_APPS_URL), + formatNoteLink(SLACK_APPS_URL), ].join('\n'), 'Create a Slack app', ); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index ad749eb..7130f8b 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,7 +21,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen } from '../lib/browser.js'; +import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -51,7 +51,7 @@ export async function runTelegramChannel(displayName: string): Promise { [ `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, '', - k.dim(botUrl), + formatNoteLink(botUrl), ].join('\n'), 'Open Telegram', ); diff --git a/setup/lib/browser.ts b/setup/lib/browser.ts index fc6eb17..4fbcbd7 100644 --- a/setup/lib/browser.ts +++ b/setup/lib/browser.ts @@ -19,6 +19,7 @@ import { spawn } from 'child_process'; import * as p from '@clack/prompts'; +import k from 'kleur'; import { isHeadless } from '../platform.js'; import { ensureAnswer } from './runner.js'; @@ -38,6 +39,21 @@ export function openUrl(url: string): void { } } +/** + * Format a URL for display inside a setup `note(...)` card. On + * GUI devices the URL renders dim — it's a fallback in case the + * auto-open misses, and `confirmThenOpen` is doing the heavy + * lifting of getting the user there. On headless devices the + * URL becomes the user's only path forward, so we surface it + * with a "Get started:" label and full-strength text — copy- + * pasting onto another device is the actual action, not an + * incidental reference. + */ +export function formatNoteLink(url: string): string { + if (isHeadless()) return `Get started: ${url}`; + return k.dim(url); +} + /** * Gate a browser-open on a confirm so the user is ready for their browser * to take focus. Proceeds on cancel as well — the user can always copy the From cb15e606c3115f6958c1788ce2d9caa7c413a1c9 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 11:11:43 +0000 Subject: [PATCH 59/63] feat(setup): move URL fallback into the open-browser prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On GUI devices the URL was previously rendered dim inside the instructional `note(...)` card, then `confirmThenOpen` printed its prompt below: read the card, see the URL, then a separate "Press Enter to open the X" prompt with no link near it. Two visual moments for what's really one decision. This PR pulls the URL out of the card on GUI devices and relocates it directly under the action line of the confirm prompt, separated only by a dim "If browser does not appear, please visit: " line: │ ◆ Press Enter to open the Developer Portal │ If browser does not appear, please visit: … (dim) │ ● Yes / ○ No │ Action and fallback live as one prompt block — the user sees both at the same time, no need to scroll back up to grab the URL if the auto-open misses. Headless behavior is unchanged: `formatNoteLink` still emits "Get started: " inside the card on headless devices (per #2146), and `confirmThenOpen` still no-ops on headless (per #2145). The only thing that changed for headless is the leading `\n` in the helper output, which acts as a visual separator from the steps above. Five call sites adjusted (Discord ×3, Slack ×1, Telegram ×1) to use `.filter((line) => line !== null)` so the now-nullable `formatNoteLink` cleanly drops out of GUI-rendered cards. --- setup/channels/discord.ts | 9 +++------ setup/channels/slack.ts | 3 +-- setup/channels/telegram.ts | 3 +-- setup/lib/browser.ts | 39 ++++++++++++++++++++++---------------- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 1dec8e0..435956f 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -164,9 +164,8 @@ async function walkThroughBotCreation(): Promise { ' 2. In the "Bot" tab, click "Reset Token" and copy the token', ' 3. On the same tab, enable "Message Content Intent"', ' (under Privileged Gateway Intents)', - '', formatNoteLink(url), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Create a Discord bot', ); await confirmThenOpen(url, 'Press Enter to open the Developer Portal'); @@ -224,9 +223,8 @@ async function walkThroughServerCreation(): Promise { ' 1. In Discord, click the "+" at the bottom of the server list', ' 2. Choose "Create My Own" → "For me and my friends"', ' 3. Give it any name (e.g. "NanoClaw")', - '', formatNoteLink(url), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Create a Discord server', ); await confirmThenOpen(url, 'Press Enter to open Discord'); @@ -446,9 +444,8 @@ async function promptInviteBot( '', ' 1. Pick any server you\'re in (a personal one is fine)', ' 2. Click "Authorize"', - '', formatNoteLink(url), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Add bot to a server', ); await confirmThenOpen(url, 'Press Enter to open the invite page'); diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 03cbf46..24a10ce 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -135,9 +135,8 @@ async function walkThroughAppCreation(): Promise { ' slash commands and messages from the messages tab"', ' 4. Basic Information → copy the "Signing Secret"', ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', - '', formatNoteLink(SLACK_APPS_URL), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Create a Slack app', ); await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 7130f8b..799a97f 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -50,9 +50,8 @@ export async function runTelegramChannel(displayName: string): Promise { note( [ `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, - '', formatNoteLink(botUrl), - ].join('\n'), + ].filter((line): line is string => line !== null).join('\n'), 'Open Telegram', ); await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); diff --git a/setup/lib/browser.ts b/setup/lib/browser.ts index 4fbcbd7..7c5c970 100644 --- a/setup/lib/browser.ts +++ b/setup/lib/browser.ts @@ -40,35 +40,42 @@ export function openUrl(url: string): void { } /** - * Format a URL for display inside a setup `note(...)` card. On - * GUI devices the URL renders dim — it's a fallback in case the - * auto-open misses, and `confirmThenOpen` is doing the heavy - * lifting of getting the user there. On headless devices the - * URL becomes the user's only path forward, so we surface it - * with a "Get started:" label and full-strength text — copy- - * pasting onto another device is the actual action, not an - * incidental reference. + * Format a URL for inclusion in a setup `note(...)` card. On + * headless devices we surface the URL inside the card with a + * "Get started:" label at full strength — copy-pasting onto + * another device is the actual action, not an incidental + * reference. The leading `\n` acts as a visual separator from + * the body steps above; callers `.filter(line => line !== null)` + * before joining, so on GUI we drop the line entirely (and the + * URL ends up below the next-step confirm prompt as a "if + * browser does not appear, please visit" fallback — see + * `confirmThenOpen`). */ -export function formatNoteLink(url: string): string { - if (isHeadless()) return `Get started: ${url}`; - return k.dim(url); +export function formatNoteLink(url: string): string | null { + if (isHeadless()) return `\nGet started: ${url}`; + return null; } /** * Gate a browser-open on a confirm so the user is ready for their browser - * to take focus. Proceeds on cancel as well — the user can always copy the - * URL from the note that precedes the prompt. On headless devices both - * the prompt and the open are skipped — there's no browser to time - * focus for, and the URL is already visible in the surrounding note. + * to take focus. Proceeds on cancel as well. On headless devices both the + * prompt and the open are skipped — the URL is already surfaced inside + * the surrounding note (via `formatNoteLink`). + * + * On GUI devices the confirm message includes the fallback URL on the + * lines below the action ("If browser does not appear, please visit: + * " in dim) so the user has a copy-paste path right next to the + * action button without needing to scroll back up to the card. */ export async function confirmThenOpen( url: string, message = 'Press Enter to open your browser', ): Promise { if (isHeadless()) return; + const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`; ensureAnswer( await p.confirm({ - message, + message: `${message}${fallback}`, initialValue: true, }), ); From e51f6e0c4132bb7dec8facfbd5b7d50a26c590f6 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 13:21:38 +0000 Subject: [PATCH 60/63] feat(setup): show under-the-sea lobster splash at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-line `NanoClaw` wordmark printed by nanoclaw.sh with a multi-line splash frame: the lobster mascot rendered as truecolor braille, drifting bubbles on either side, the figlet wordmark below (Nano in bold, Claw in cyan bold), three taglines — "Small.", "Runs on your machine.", "Yours to modify." — and a navy seafloor line. The frame is pre-rendered into `assets/setup-splash.txt` (built from `assets/nanoclaw-icon.png` via chafa for the lobster + figlet for the wordmark). nanoclaw.sh just streams the literal bytes — no runtime dependency on chafa, figlet, or ImageMagick. Total height: 30 lines. Visible width: ~40 columns (fits any terminal). Truecolor ANSI codes are used directly; terminals without truecolor support will see a degraded but still readable frame. Also removes the standalone "Small. Runs on your machine. Yours to modify." tagline line that nanoclaw.sh used to print above the bootstrap spinner — those taglines now appear inside the splash, so showing them again would duplicate. The wordmark-suppression flow downstream (`setup:auto` honoring `NANOCLAW_BOOTSTRAPPED=1`) is unchanged: the splash prints once in nanoclaw.sh, setup:auto's `printIntro()` sees the flag and keeps the clack `p.intro` line clean ("Let's get you set up."). --- assets/setup-splash.txt | 30 ++++++++++++++++++++++++++++++ nanoclaw.sh | 14 +++++++------- 2 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 assets/setup-splash.txt diff --git a/assets/setup-splash.txt b/assets/setup-splash.txt new file mode 100644 index 0000000..e4b77ec --- /dev/null +++ b/assets/setup-splash.txt @@ -0,0 +1,30 @@ + + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ° + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀ + ⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧ + o ⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿ + ⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇ + ⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀ o + ⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀ + ° ⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀ + ⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀ + ⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ O + ⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀ + ⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀ + ⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀ + o ⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀ + + _ _  ___ _  +| \| |__ _ _ _ ___  / __| |__ ___ __ __ +| .` / _` | ' \/ _ \| (__| / _` \ V V / +|_|\_\__,_|_||_\___/ \___|_\__,_|\_/\_/  + + Small. + Runs on your machine. + Yours to modify. + +════════════════════════════════════════ diff --git a/nanoclaw.sh b/nanoclaw.sh index 058dbbf..a3e5c1d 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -129,10 +129,13 @@ rm -f "$PROGRESS_LOG" mkdir -p "$STEPS_DIR" "$LOGS_DIR" write_header -# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing, -# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and -# skips re-printing the wordmark, keeping the flow visually continuous. -printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" +# NanoClaw splash — under-the-sea lobster mascot in truecolor braille, +# with the figlet wordmark and taglines below. Pre-rendered into +# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa + +# figlet); the bash script just streams the literal frame. clack's intro +# then carries the "let's get you set up" framing — setup:auto sees +# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark. +cat "$PROJECT_ROOT/assets/setup-splash.txt" # ─── pre-flight: Homebrew on macOS ───────────────────────────────────── # setup/install-node.sh and setup/install-docker.sh both require `brew` on @@ -188,9 +191,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" BOOTSTRAP_LABEL="Installing the basics" BOOTSTRAP_START=$(date +%s) -# One-line "why" that teaches a differentiator while the user waits. -printf '%s %s\n' "$(gray '│')" \ - "$(dim "Small. Runs on your machine. Yours to modify.")" spinner_start "$BOOTSTRAP_LABEL" # Run in the background so we can tick elapsed time. Capture exit code via From 0218159ef032934730b09e9d9e3634192a3d0d2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 19:54:21 +0000 Subject: [PATCH 61/63] chore: bump version to 2.0.20 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b36863..556269c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.19", + "version": "2.0.20", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 46cd91c306368da416308b2d449db984b817ff61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 19:54:26 +0000 Subject: [PATCH 62/63] =?UTF-8?q?docs:=20update=20token=20count=20to=20138?= =?UTF-8?q?k=20tokens=20=C2=B7=2069%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 86587f7..5832849 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 135k tokens, 68% of context window + + 138k tokens, 69% of context window @@ -15,8 +15,8 @@ tokens - - 135k + + 138k From 3d6a9b74f3cec320b93293bff85d4872bfbeb985 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 30 Apr 2026 23:16:34 +0300 Subject: [PATCH 63/63] 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 846baef..2610c23 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.";