From 4743513018b4a568f87c5b2ace16404818164073 Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 15:01:38 +0000 Subject: [PATCH 01/46] 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/46] 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/46] 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/46] 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/46] 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 a80f095174ecf2fbc0b2678d91c03dd767394287 Mon Sep 17 00:00:00 2001 From: dooha333 Date: Mon, 27 Apr 2026 00:56:29 +0000 Subject: [PATCH 06/46] fix(setup): inject ~/.local/bin into PATH so post-install onecli is reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup/auto.ts spawned register-claude-token.sh via runInheritScript, which inherits the parent Node process's PATH. When OneCLI was installed earlier in the same setup run, its installer wrote the binary to ~/.local/bin and appended a PATH line to the user's shell rc — but rc updates do not reach an already-running process. The script's first guard, `command -v onecli`, failed instantly (~3ms), and the auth step reported "Couldn't complete the Claude sign-in" even though the real blocker was OneCLI not on PATH. Patch process.env.PATH at the top of main() so every subsequent shell-out sees ~/.local/bin. Idempotent — no-op if already present. Also drops a duplicate `pollHealth` import that was lurking in the import block. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/setup/auto.ts b/setup/auto.ts index 5ce2712..5429a0d 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -23,6 +23,7 @@ */ import { spawn, spawnSync } from 'child_process'; import fs from 'fs'; +import * as os from 'os'; import path from 'path'; import * as p from '@clack/prompts'; @@ -61,6 +62,13 @@ const RUN_START = Date.now(); type ChannelChoice = 'telegram' | 'discord' | 'whatsapp' | 'signal' | 'teams' | 'slack' | 'imessage' | 'skip'; async function main(): Promise { + // Make sure ~/.local/bin is on PATH for every child process we spawn. + // Installers we run mid-setup (OneCLI, claude) drop binaries there and + // append a PATH line to the user's shell rc, but rc updates don't reach + // an already-running Node process — so without this patch a freshly + // installed `onecli` is invisible to a subsequent `runInheritScript`. + ensureLocalBinOnPath(); + // Parse CLI flags first — `--help` short-circuits before we render anything, // and flag values get folded into process.env so existing step code reading // NANOCLAW_* sees them unchanged. @@ -1013,6 +1021,14 @@ async function askChannelChoice(): Promise { // ─── interactive / env helpers ───────────────────────────────────────── +function ensureLocalBinOnPath(): void { + const localBin = path.join(os.homedir(), '.local', 'bin'); + const current = process.env.PATH ?? ''; + const segments = current.split(path.delimiter).filter(Boolean); + if (segments.includes(localBin)) return; + process.env.PATH = current ? `${localBin}${path.delimiter}${current}` : localBin; +} + function anthropicSecretExists(): boolean { try { const res = spawnSync('onecli', ['secrets', 'list'], { 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 07/46] 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 d86051805b48dd29a64cabd4187f1bb663d4a796 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Wed, 29 Apr 2026 14:09:47 +0000 Subject: [PATCH 08/46] 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 09/46] 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 10/46] 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 ef8e3aa1b8ab1091abc1e3890fa79ccc1eb19170 Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 29 Apr 2026 14:55:47 +0000 Subject: [PATCH 11/46] fix(poll-loop): apply pre-task scripts to follow-up injections too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks arriving during an active query were pushed into the stream as follow-ups without running their `script` gate — so a wakeAgent=false pre-script that was supposed to suppress the tick silently leaked through and woke the agent every time. Evidence: monitoring cron firing every 10 min with [task-script] log lines never showing. Run applyPreTaskScripts on the follow-up batch too: wakeAgent=false tasks get marked completed and dropped; wakeAgent=true tasks have scriptOutput enriched exactly like the initial-batch path. Added a pollInFlight guard to serialize async runs and avoid overlapping script executions when the interval fires while one is still going. Wrapped in a MODULE-HOOK:scheduling-pre-task-followup marker block to match the existing initial-batch hook convention. --- container/agent-runner/src/poll-loop.ts | 69 +++++++++++++++++-------- 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index bd48db2..589b80b 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -260,31 +260,58 @@ async function processQuery( // Stream liveness is decided host-side via the heartbeat file + processing // claim age (see src/host-sweep.ts); if something is truly stuck, the host // will kill the container and messages get reset to pending. + let pollInFlight = false; const pollHandle = setInterval(() => { - if (done) return; + if (done || pollInFlight) return; + pollInFlight = true; - // Skip system messages (MCP tool responses) and /clear (needs fresh query). - // Thread routing is the router's concern — if a message landed in this - // session, the agent should see it. Per-thread sessions already isolate - // threads into separate containers; shared sessions intentionally merge - // everything. Filtering on thread_id here caused deadlocks when the - // initial batch and follow-ups had mismatched thread_ids (e.g. a - // host-generated welcome trigger with null thread vs a Discord DM reply). - const newMessages = getPendingMessages().filter((m) => { - if (m.kind === 'system') return false; - if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false; - return true; - }); - if (newMessages.length > 0) { - const newIds = newMessages.map((m) => m.id); - markProcessing(newIds); + void (async () => { + try { + // Skip system messages (MCP tool responses) and /clear (needs fresh query). + // Thread routing is the router's concern — if a message landed in this + // session, the agent should see it. Per-thread sessions already isolate + // threads into separate containers; shared sessions intentionally merge + // everything. Filtering on thread_id here caused deadlocks when the + // initial batch and follow-ups had mismatched thread_ids (e.g. a + // host-generated welcome trigger with null thread vs a Discord DM reply). + const newMessages = getPendingMessages().filter((m) => { + if (m.kind === 'system') return false; + if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false; + return true; + }); + if (newMessages.length === 0) return; - const prompt = formatMessages(newMessages); - log(`Pushing ${newMessages.length} follow-up message(s) into active query`); - query.push(prompt); + const newIds = newMessages.map((m) => m.id); + markProcessing(newIds); - markCompleted(newIds); - } + // Run pre-task scripts on follow-ups too — without this, a task that + // arrives during an active query (e.g. a */10 monitoring cron) bypasses + // its script gate and always wakes the agent, defeating the gate. + // Mirrors the initial-batch hook above. + let keep = newMessages; + let skipped: string[] = []; + // MODULE-HOOK:scheduling-pre-task-followup:start + const { applyPreTaskScripts } = await import('./scheduling/task-script.js'); + const preTask = await applyPreTaskScripts(newMessages); + keep = preTask.keep; + skipped = preTask.skipped; + if (skipped.length > 0) { + markCompleted(skipped); + log(`Pre-task script skipped ${skipped.length} follow-up task(s): ${skipped.join(', ')}`); + } + // MODULE-HOOK:scheduling-pre-task-followup:end + + if (keep.length === 0) return; + + const keptIds = keep.map((m) => m.id); + const prompt = formatMessages(keep); + log(`Pushing ${keep.length} follow-up message(s) into active query`); + query.push(prompt); + markCompleted(keptIds); + } finally { + pollInFlight = false; + } + })(); }, ACTIVE_POLL_INTERVAL_MS); try { From 8dd004ca75c881d19139efde85dab8c6526d06f5 Mon Sep 17 00:00:00 2001 From: Mike Nolet Date: Thu, 30 Apr 2026 08:13:59 +0200 Subject: [PATCH 12/46] fix(scheduling): include routing in schedule_task content JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schedule_task MCP tool wrote routing fields (platform_id, channel_type, thread_id) onto the outbound system message's row columns, but handleSystemAction (src/delivery.ts) parses content JSON and forwards only that to handlers. handleScheduleTask (src/modules/scheduling/actions.ts) reads content.platformId/channelType/threadId — which the writer never populated — so every kind='task' row landed in messages_in with all-null routing. When host-sweep wakes a scheduled task, dispatchResultText's fast path requires routing on the message and bails when it's null, falling through to the "Routing recovery" retry prompt. End-user delivery still works because the agent can pick a destination from its destinations table on retry — so the bug went undetected, silently costing one extra LLM turn per scheduled-task wake. Sessions whose destinations table has no channel row (e.g. agent-only destinations) fail outright with a recovery loop. Fix: add the routing fields to the content JSON so the writer matches the contract handleScheduleTask already expects. cancel/pause/resume/update_task operate by id alone and don't need routing. --- container/agent-runner/src/mcp-tools/scheduling.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/container/agent-runner/src/mcp-tools/scheduling.ts b/container/agent-runner/src/mcp-tools/scheduling.ts index 00e41bb..9b8451d 100644 --- a/container/agent-runner/src/mcp-tools/scheduling.ts +++ b/container/agent-runner/src/mcp-tools/scheduling.ts @@ -89,6 +89,9 @@ export const scheduleTask: McpToolDefinition = { script, processAfter, recurrence, + platformId: r.platform_id, + channelType: r.channel_type, + threadId: r.thread_id, }), }); From 8a205808e0eeb5910afa6a8605defc7b05efff3a Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 30 Apr 2026 07:56:34 +0000 Subject: [PATCH 13/46] 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 14/46] 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 15/46] 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 16/46] 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 17/46] 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 18/46] 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 19/46] 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 20/46] 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 21/46] 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 22/46] 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 23/46] 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 24/46] 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 25/46] 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 26/46] 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 27/46] 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 28/46] 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 29/46] 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 30/46] 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 31/46] 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 32/46] 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 ccfdf2dd7576603ba9832a5eeed430ee43e2e35a Mon Sep 17 00:00:00 2001 From: Claw <728255-_ky@users.noreply.gitlab.com> Date: Thu, 30 Apr 2026 15:06:01 -0400 Subject: [PATCH 33/46] fix(agent-runner): open inbound.db fresh per messages_in read Cached singleton can return stale rows on virtiofs/NFS mounts, causing follow-up messages to silently never be polled. Add openInboundDb() with mmap_size=0 and switch the three messages_in readers to it. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/db/connection.ts | 28 +++++++- container/agent-runner/src/db/messages-in.ts | 75 ++++++++++++-------- 2 files changed, 71 insertions(+), 32 deletions(-) diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 3f0e73b..3ca44a8 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -28,11 +28,37 @@ let _inbound: Database | null = null; let _outbound: Database | null = null; let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; -/** Inbound DB — container opens read-only (host is the sole writer). */ +/** + * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. + * + * Use this (not getInboundDb) for readers that need to see host-written rows + * promptly — e.g. messages_in polling. Caller must .close() the returned + * connection (try/finally). + * + * Needed for mounts where host writes don't reliably invalidate + * SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple + * Container), NFS. + * + * Cost is microseconds per query, so safe for universal use. + */ +export function openInboundDb(): Database { + const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA mmap_size = 0'); + return db; +} + +/** + * Inbound DB — long-lived singleton, OK for tables the host writes once + * at spawn and never again (destinations, session_routing). For + * messages_in polling — where the host writes continuously and a stale + * view causes the pollHandle hang — use `openInboundDb()` instead. + */ export function getInboundDb(): Database { if (!_inbound) { _inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); _inbound.exec('PRAGMA busy_timeout = 5000'); + _inbound.exec('PRAGMA mmap_size = 0'); } return _inbound; } diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 4ecf818..88906ed 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -8,7 +8,7 @@ * processing_ack. The host reads processing_ack to sync message lifecycle. */ import { getConfig } from '../config.js'; -import { getInboundDb, getOutboundDb } from './connection.js'; +import { openInboundDb, getOutboundDb } from './connection.js'; export interface MessageInRow { id: string; @@ -50,31 +50,35 @@ function getMaxMessagesPerPrompt(): number { * trigger=1 separately (see src/db/session-db.ts). */ export function getPendingMessages(): MessageInRow[] { - const inbound = getInboundDb(); + const inbound = openInboundDb(); const outbound = getOutboundDb(); - const pending = inbound - .prepare( - `SELECT * FROM messages_in - WHERE status = 'pending' - AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) - ORDER BY seq DESC - LIMIT ?`, - ) - .all(getMaxMessagesPerPrompt()) as MessageInRow[]; + try { + const pending = inbound + .prepare( + `SELECT * FROM messages_in + WHERE status = 'pending' + AND (process_after IS NULL OR datetime(process_after) <= datetime('now')) + ORDER BY seq DESC + LIMIT ?`, + ) + .all(getMaxMessagesPerPrompt()) as MessageInRow[]; - if (pending.length === 0) return []; + if (pending.length === 0) return []; - // Filter out messages already acknowledged in outbound.db - const ackedIds = new Set( - (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map( - (r) => r.message_id, - ), - ); + // Filter out messages already acknowledged in outbound.db + const ackedIds = new Set( + (outbound.prepare('SELECT message_id FROM processing_ack').all() as Array<{ message_id: string }>).map( + (r) => r.message_id, + ), + ); - // Reverse: we fetched DESC to take the most recent N, but the agent - // should see them in chronological order (oldest first). - return pending.filter((m) => !ackedIds.has(m.id)).reverse(); + // Reverse: we fetched DESC to take the most recent N, but the agent + // should see them in chronological order (oldest first). + return pending.filter((m) => !ackedIds.has(m.id)).reverse(); + } finally { + inbound.close(); + } } /** Mark messages as processing — writes to processing_ack in outbound.db. */ @@ -112,7 +116,12 @@ export function markFailed(id: string): void { /** Get a message by ID (read from inbound.db). */ export function getMessageIn(id: string): MessageInRow | undefined { - return getInboundDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; + const inbound = openInboundDb(); + try { + return inbound.prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined; + } finally { + inbound.close(); + } } /** @@ -120,19 +129,23 @@ export function getMessageIn(id: string): MessageInRow | undefined { * Reads from inbound.db, checks processing_ack to skip already-handled responses. */ export function findQuestionResponse(questionId: string): MessageInRow | undefined { - const inbound = getInboundDb(); + const inbound = openInboundDb(); const outbound = getOutboundDb(); - const response = inbound - .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") - .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined; + try { + const response = inbound + .prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?") + .get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined; - if (!response) return undefined; + if (!response) return undefined; - // Check it hasn't been acked already - const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); - if (acked) return undefined; + // Check it hasn't been acked already + const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id); + if (acked) return undefined; - return response; + return response; + } finally { + inbound.close(); + } } From 0218159ef032934730b09e9d9e3634192a3d0d2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 19:54:21 +0000 Subject: [PATCH 34/46] 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 35/46] =?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 36/46] 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."; From 58d875b3c35dec20695b270480a62c7143f62b62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 21:31:18 +0000 Subject: [PATCH 37/46] chore: bump version to 2.0.21 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 556269c..ecdc1aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.20", + "version": "2.0.21", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 5ab1a2733c0fde0eb6879180b026e06e9f8e9725 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 1 May 2026 00:55:46 +0300 Subject: [PATCH 38/46] review: catch follow-up poll errors + re-check done before push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes on top of the follow-up pre-task-script work: 1. The void async IIFE inside the interval handler had no catch, so a throw from the dynamic import or applyPreTaskScripts escaped as an unhandled rejection — terminating the container. The initial-batch path is wrapped by processQuery's outer try/catch; the follow-up path needs its own. Now logs the error and lets the next tick retry. 2. Re-check `done` immediately before query.push. The flag can flip true while applyPreTaskScripts is awaited (outer stream finishes during the script execution); without the re-check we'd push into a closed query. Claimed messages get released by the host's processing-claim sweep — same recovery posture as the rest of the poller. Co-Authored-By: Michael Zazon Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 589b80b..986489f 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -302,12 +302,23 @@ async function processQuery( // MODULE-HOOK:scheduling-pre-task-followup:end if (keep.length === 0) return; + // Re-check done — the outer query may have finished while the script + // was awaited. Pushing into a closed stream is wasted work; the + // claimed messages get released by the host's processing-claim sweep. + if (done) return; const keptIds = keep.map((m) => m.id); const prompt = formatMessages(keep); log(`Pushing ${keep.length} follow-up message(s) into active query`); query.push(prompt); markCompleted(keptIds); + } catch (err) { + // Without this catch the rejection escapes the void IIFE and Node + // terminates the container on unhandled-rejection. The initial-batch + // path is wrapped by processQuery's outer try/catch; the follow-up + // path is not, so it needs its own. + const errMsg = err instanceof Error ? err.message : String(err); + log(`Follow-up poll error: ${errMsg}`); } finally { pollInFlight = false; } From 8977f0d0be1a4bf9dd6c1bc1ad5c2ec4c96614de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 21:57:45 +0000 Subject: [PATCH 39/46] chore: bump version to 2.0.22 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ecdc1aa..df9cb99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.21", + "version": "2.0.22", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 852009dcb15979fb77a012fd3d80b4565528a77a Mon Sep 17 00:00:00 2001 From: hinotoi-agent Date: Sat, 25 Apr 2026 11:38:12 +0800 Subject: [PATCH 40/46] fix(container): confine outbound attachment paths --- src/host-core.test.ts | 54 ++++++++++++++++++++++++++++++++++++ src/session-manager.ts | 63 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 2bb72d4..cbbaf27 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -23,6 +23,8 @@ import { sessionDir, inboundDbPath, outboundDbPath, + readOutboxFiles, + clearOutbox, } from './session-manager.js'; import { getSession, findSession } from './db/sessions.js'; import type { InboundEvent } from './channels/adapter.js'; @@ -108,6 +110,58 @@ describe('session manager', () => { outDb.close(); }); + it('should reject outbound attachment filenames that escape the message outbox', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + const msgOutbox = path.join(dir, 'outbox', 'msg-1'); + fs.mkdirSync(msgOutbox, { recursive: true }); + + const outside = path.join(TEST_DIR, 'outside.txt'); + fs.writeFileSync(outside, 'outside secret'); + + expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['../../../../../outside.txt'])).toBeUndefined(); + }); + + it('should reject outbound attachment symlinks that escape the message outbox', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + const msgOutbox = path.join(dir, 'outbox', 'msg-1'); + fs.mkdirSync(msgOutbox, { recursive: true }); + + const outside = path.join(TEST_DIR, 'outside.txt'); + fs.writeFileSync(outside, 'outside secret'); + fs.symlinkSync('../../../../../outside.txt', path.join(msgOutbox, 'safe-name.txt')); + + expect(readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['safe-name.txt'])).toBeUndefined(); + }); + + it('should not recursively delete outside the outbox for unsafe message ids', () => { + initSessionFolder('ag-1', 'sess-test'); + const victimDir = path.join(TEST_DIR, 'victim-dir'); + fs.mkdirSync(victimDir, { recursive: true }); + fs.writeFileSync(path.join(victimDir, 'keep.txt'), 'do not delete'); + + clearOutbox('ag-1', 'sess-test', '../../../../victim-dir'); + + expect(fs.existsSync(path.join(victimDir, 'keep.txt'))).toBe(true); + }); + + it('should still read and clear normal basename outbox files', () => { + initSessionFolder('ag-1', 'sess-test'); + const dir = sessionDir('ag-1', 'sess-test'); + const msgOutbox = path.join(dir, 'outbox', 'msg-1'); + fs.mkdirSync(msgOutbox, { recursive: true }); + fs.writeFileSync(path.join(msgOutbox, 'result.txt'), 'ok'); + + const files = readOutboxFiles('ag-1', 'sess-test', 'msg-1', ['result.txt']); + expect(files).toHaveLength(1); + expect(files?.[0]?.filename).toBe('result.txt'); + expect(files?.[0]?.data.toString()).toBe('ok'); + + clearOutbox('ag-1', 'sess-test', 'msg-1'); + expect(fs.existsSync(msgOutbox)).toBe(false); + }); + it('should resolve to existing session (shared mode)', () => { const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared'); expect(c1).toBe(true); diff --git a/src/session-manager.ts b/src/session-manager.ts index 96bca96..2772443 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -21,7 +21,6 @@ import { DATA_DIR } from './config.js'; import { getMessagingGroup } from './db/messaging-groups.js'; import { createSession, - findSession, findSessionByAgentGroup, findSessionForAgent, getSession, @@ -38,6 +37,11 @@ import { import { log } from './log.js'; import type { Session } from './types.js'; +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + /** Root directory for all session data. */ export function sessionsBaseDir(): string { return path.join(DATA_DIR, 'v2-sessions'); @@ -369,19 +373,48 @@ export function readOutboxFiles( messageId: string, filenames: string[], ): OutboundFile[] | undefined { + if (!isSafeAttachmentName(messageId)) { + log.warn('Rejecting unsafe outbox message id', { messageId }); + return undefined; + } + const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId); if (!fs.existsSync(outboxDir)) return undefined; + + let realOutboxDir: string; + try { + const stat = fs.lstatSync(outboxDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + log.warn('Rejecting unsafe outbox directory', { messageId, outboxDir }); + return undefined; + } + realOutboxDir = fs.realpathSync(outboxDir); + } catch (err) { + log.warn('Failed to inspect outbox directory', { messageId, err }); + 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 }); + 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) }); - } else { + try { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) { + log.warn('Rejecting unsafe outbox file', { messageId, filename }); + continue; + } + const realFilePath = fs.realpathSync(filePath); + if (!isPathInside(realOutboxDir, realFilePath)) { + log.warn('Rejecting outbox file outside message directory', { messageId, filename }); + continue; + } + files.push({ filename, data: fs.readFileSync(realFilePath) }); + } catch { log.warn('Outbox file not found', { messageId, filename }); } } @@ -395,10 +428,26 @@ export function readOutboxFiles( * thrown error would trigger the delivery retry path and deliver twice. */ export function clearOutbox(agentGroupId: string, sessionId: string, messageId: string): void { + if (!isSafeAttachmentName(messageId)) { + log.warn('Rejecting unsafe outbox cleanup message id', { messageId }); + return; + } + const outboxDir = path.join(sessionDir(agentGroupId, sessionId), 'outbox', messageId); if (!fs.existsSync(outboxDir)) return; try { - fs.rmSync(outboxDir, { recursive: true, force: true }); + const stat = fs.lstatSync(outboxDir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + log.warn('Rejecting unsafe outbox cleanup directory', { messageId, outboxDir }); + return; + } + const realOutboxBase = fs.realpathSync(path.join(sessionDir(agentGroupId, sessionId), 'outbox')); + const realOutboxDir = fs.realpathSync(outboxDir); + if (!isPathInside(realOutboxBase, realOutboxDir)) { + log.warn('Rejecting outbox cleanup outside session outbox', { messageId, outboxDir }); + return; + } + fs.rmSync(realOutboxDir, { recursive: true, force: true }); } catch (err) { log.warn('Outbox cleanup failed (message already delivered)', { messageId, err }); } From fc3c11b6b901f128cadc3b3fa9583e64e38edb9b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 1 May 2026 01:26:18 +0300 Subject: [PATCH 41/46] fix(session-manager): apply outbox path-confinement to inbound attachments Mirrors the four defenses on the outbound side onto extractAttachmentFiles: 1. Reject unsafe messageId via isSafeAttachmentName before any inbox path is built. WhatsApp passes msg.key.id through raw and that field is client generated, so a peer can craft it; future end to end encrypted adapters will have the same property. 2. lstatSync on the inbox dir refuses a pre placed symlink before mkdirSync would silently follow it. 3. realpathSync + isPathInside contains the resolved dir under the session inbox root. 4. writeFileSync uses the wx flag so a pre placed symlink at the file path is refused atomically by the kernel; EEXIST surfaces as a logged skip. Threat: the session dir is mounted writable into the container at /workspace, so a compromised agent can pre place inbox// as a symlink and wait for a chat message with a matching id to redirect the host write. The four guards together close that window. Consolidates with the existing isSafeAttachmentName helper from attachment-safety.ts rather than introducing a duplicate basename validator inside session-manager. Co-Authored-By: Daisuke Tsuji Co-Authored-By: Claude Opus 4.7 (1M context) --- src/host-core.test.ts | 89 ++++++++++++++++++++++++++++++++++ src/session-manager.ts | 105 +++++++++++++++++++++++++++++++---------- 2 files changed, 169 insertions(+), 25 deletions(-) diff --git a/src/host-core.test.ts b/src/host-core.test.ts index cbbaf27..043b6b1 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -162,6 +162,95 @@ describe('session manager', () => { expect(fs.existsSync(msgOutbox)).toBe(false); }); + it('should reject inbound attachment writes through a pre-placed symlinked inbox dir', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + // The container has /workspace write access, so it can pre create + // inbox/ as a symlink to escape. + const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox'); + fs.mkdirSync(inboxRoot, { recursive: true }); + const evilTarget = path.join(TEST_DIR, 'evil-target'); + fs.mkdirSync(evilTarget, { recursive: true }); + fs.symlinkSync(evilTarget, path.join(inboxRoot, 'msg-evil')); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-evil', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'evil', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + expect(fs.existsSync(path.join(evilTarget, 'photo.png'))).toBe(false); + }); + + it('should refuse to follow a pre-existing symlink at the inbound attachment path', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + // The container pre creates inbox//photo.png as a symlink to a + // host file. Without the wx flag, writeFileSync would follow it. + const inboxDir = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-sym'); + fs.mkdirSync(inboxDir, { recursive: true }); + const outside = path.join(TEST_DIR, 'outside.txt'); + fs.writeFileSync(outside, 'ORIGINAL'); + fs.symlinkSync(outside, path.join(inboxDir, 'photo.png')); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-sym', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'sym', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + expect(fs.readFileSync(outside, 'utf-8')).toBe('ORIGINAL'); + }); + + it('should reject inbound attachments when messageId is unsafe', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + writeSessionMessage('ag-1', session.id, { + id: '../../escape', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'msgid', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox'); + if (fs.existsSync(inboxRoot)) { + expect(fs.readdirSync(inboxRoot)).toEqual([]); + } + }); + + it('should still save inbound attachments with safe basenames', () => { + initSessionFolder('ag-1', 'sess-test'); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + writeSessionMessage('ag-1', session.id, { + id: 'msg-ok', + kind: 'chat', + timestamp: now(), + content: JSON.stringify({ + text: 'ok', + attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }], + }), + }); + + const expected = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-ok', 'photo.png'); + expect(fs.existsSync(expected)).toBe(true); + expect(fs.readFileSync(expected, 'utf-8')).toBe('PNGBYTES'); + }); + it('should resolve to existing session (shared mode)', () => { const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared'); expect(c1).toBe(true); diff --git a/src/session-manager.ts b/src/session-manager.ts index 2772443..6b00655 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -238,6 +238,20 @@ export function writeSessionMessage( /** * If message content has attachments with base64 `data`, save them to * the session's inbox directory and replace with `localPath`. + * + * Both `messageId` and `att.name` originate in untrusted input. WhatsApp + * passes `msg.key.id` through raw (and that field is client generated, so a + * peer can craft it), and other adapters may follow. The session dir is + * mounted writable into the container, so a compromised agent can also + * pre-place a symlink at `inbox//` and wait for a chat message + * with a matching id to redirect the host's write. + * + * Defenses, mirrored from the outbound side: + * 1. basename check on `messageId` and `filename`. + * 2. lstat of the inbox dir to refuse pre-placed symlinks. + * 3. realpath-based containment under the session inbox root. + * 4. `wx` flag on writeFileSync to refuse following a pre-existing symlink + * at the target file path or overwriting any existing file. */ function extractAttachmentFiles( agentGroupId: string, @@ -255,34 +269,75 @@ function extractAttachmentFiles( const attachments = parsed.attachments as Array> | undefined; if (!Array.isArray(attachments)) return contentStr; + if (!isSafeAttachmentName(messageId)) { + log.warn('Rejecting unsafe inbound message id', { messageId }); + return contentStr; + } + 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 = deriveAttachmentName(att); - 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 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; - log.debug('Saved attachment to inbox', { messageId, filename, size: att.size }); + if (typeof att.data !== 'string') continue; + + const rawName = deriveAttachmentName(att); + 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); + + // Refuse to mkdir through a symlink that the container may have pre placed + // at inboxDir. With recursive:true, mkdirSync would silently no op on a + // pre existing symlink and the subsequent writeFileSync would follow it. + if (fs.existsSync(inboxDir)) { + const stat = fs.lstatSync(inboxDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + log.warn('Rejecting unsafe inbox directory', { messageId, inboxDir }); + continue; + } + } + fs.mkdirSync(inboxDir, { recursive: true }); + + let realInboxDir: string; + try { + realInboxDir = fs.realpathSync(inboxDir); + } catch (err) { + log.warn('Failed to resolve inbox directory', { messageId, err }); + continue; + } + const inboxRoot = path.join(sessionDir(agentGroupId, sessionId), 'inbox'); + if (!isPathInside(fs.realpathSync(inboxRoot), realInboxDir)) { + log.warn('Inbox directory escaped session inbox root', { messageId, inboxDir }); + continue; + } + + const filePath = path.join(inboxDir, filename); + try { + // wx = exclusive create. Refuses to follow a pre existing symlink or + // overwrite any existing file. The host expects to be the sole writer + // of these attachments. + fs.writeFileSync(filePath, Buffer.from(att.data as string, 'base64'), { flag: 'wx' }); + } catch (err: unknown) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EEXIST') { + log.warn('Inbox attachment target already exists, refusing to overwrite', { + messageId, + filename, + }); + continue; + } + throw err; + } + + att.name = filename; + att.localPath = `inbox/${messageId}/${filename}`; + delete att.data; + changed = true; + log.debug('Saved attachment to inbox', { messageId, filename, size: att.size }); } return changed ? JSON.stringify(parsed) : contentStr; From 7ac8dd0f6de8d5d8c57f0183384183c3a9a5c0ea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 22:28:25 +0000 Subject: [PATCH 42/46] =?UTF-8?q?docs:=20update=20token=20count=20to=20139?= =?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 5832849..d6afa67 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 138k tokens, 69% of context window + + 139k tokens, 69% of context window @@ -15,8 +15,8 @@ tokens - - 138k + + 139k From 28c38ae28b9b7cc10d120b3af57d2185b379d575 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 30 Apr 2026 23:00:02 +0000 Subject: [PATCH 43/46] fix(container): pin vercel to 52.2.1 to dodge broken 53.0.1 publish vercel@53.0.1 declares a dep on @vercel/static-build@2.9.22 which is not published on npm (only 2.9.21 exists), breaking every fresh container build that resolves vercel@latest. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/container/Dockerfile b/container/Dockerfile index 4b4cf22..efa58b6 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -21,7 +21,7 @@ ARG INSTALL_CJK_FONTS=false # across all users. ARG CLAUDE_CODE_VERSION=2.1.116 ARG AGENT_BROWSER_VERSION=latest -ARG VERCEL_VERSION=latest +ARG VERCEL_VERSION=52.2.1 ARG BUN_VERSION=1.3.12 # ---- System dependencies ----------------------------------------------------- From 8c962d3f73edcb627c3043b5fe7595d7d8755422 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 23:00:24 +0000 Subject: [PATCH 44/46] chore: bump version to 2.0.23 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index df9cb99..146af58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.22", + "version": "2.0.23", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From a590fbd830e79af7822a99976e8c29ac9ebaa5cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 13:30:19 +0000 Subject: [PATCH 45/46] chore: bump version to 2.0.24 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 146af58..e8c1dc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.23", + "version": "2.0.24", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 663d9a409190bd1e79fa505fae04644dcdab2429 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 May 2026 13:30:23 +0000 Subject: [PATCH 46/46] =?UTF-8?q?docs:=20update=20token=20count=20to=20139?= =?UTF-8?q?k=20tokens=20=C2=B7=2070%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index d6afa67..8f04fa8 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 139k tokens, 69% of context window + + 139k tokens, 70% of context window @@ -10,7 +10,7 @@ - +