From 4743513018b4a568f87c5b2ace16404818164073 Mon Sep 17 00:00:00 2001 From: NanoClaw Date: Fri, 27 Mar 2026 15:01:38 +0000 Subject: [PATCH 01/49] 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/49] 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/49] 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/49] 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/49] 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 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 06/49] feat(permissions): richer channel-approval flow with agent selection and free-text naming Replace the hardcoded Approve/Ignore card with a multi-step flow: - Single agent: "Connect to [name]" / "Connect new agent" / "Reject" - Multiple agents: "Choose existing agent" (follow-up list) / "Connect new agent" / "Reject" - "Connect new agent" prompts for a free-text name via DM, creates immediately on reply - Add setMessageInterceptor router hook for capturing free-text replies - Add resolveChannelName optional method to ChannelAdapter interface Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/adapter.ts | 1 + .../permissions/channel-approval.test.ts | 14 +- src/modules/permissions/channel-approval.ts | 217 +++++++++---- .../db/pending-channel-approvals.ts | 6 + src/modules/permissions/index.ts | 300 +++++++++++++++--- src/router.ts | 18 ++ 6 files changed, 458 insertions(+), 98 deletions(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index 82247a1..a2a7069 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -135,6 +135,7 @@ export interface ChannelAdapter { // Optional setTyping?(platformId: string, threadId: string | null): Promise; syncConversations?(): Promise; + resolveChannelName?(platformId: string): Promise; /** * Subscribe the bot to a thread so follow-up messages route via the diff --git a/src/modules/permissions/channel-approval.test.ts b/src/modules/permissions/channel-approval.test.ts index da992d2..a2e6690 100644 --- a/src/modules/permissions/channel-approval.test.ts +++ b/src/modules/permissions/channel-approval.test.ts @@ -153,8 +153,10 @@ describe('unknown-channel registration flow', () => { expect(kind).toBe('chat-sdk'); const payload = JSON.parse(content as string); expect(payload.type).toBe('ask_question'); - // Card names the target agent so the owner knows what they're wiring to. - expect(payload.question).toContain('Andy'); + // Single-agent card offers a direct "Connect to " button. + const connectOption = payload.options.find((o: { value: string }) => o.value.startsWith('connect:')); + expect(connectOption).toBeDefined(); + expect(connectOption.label).toContain('Andy'); const { getDb } = await import('../../db/connection.js'); const rows = getDb().prepare('SELECT * FROM pending_channel_approvals').all() as Array<{ @@ -202,11 +204,11 @@ describe('unknown-channel registration flow', () => { }; expect(pending).toBeDefined(); - // Owner clicks approve. + // Owner clicks "Connect to Andy" (single-agent card). for (const handler of getResponseHandlers()) { const claimed = await handler({ questionId: pending.messaging_group_id, - value: 'approve', + value: 'connect:ag-1', userId: 'owner', // raw platform id — handler namespaces it channelType: 'telegram', platformId: 'dm-owner', @@ -215,7 +217,7 @@ describe('unknown-channel registration flow', () => { if (claimed) break; } - // Wiring created with MVP defaults. + // Wiring created with defaults. const mga = getDb() .prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ?') .get(pending.messaging_group_id) as { @@ -261,7 +263,7 @@ describe('unknown-channel registration flow', () => { for (const handler of getResponseHandlers()) { const claimed = await handler({ questionId: pending.messaging_group_id, - value: 'approve', + value: 'connect:ag-1', userId: 'owner', channelType: 'telegram', platformId: 'dm-owner', diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index 8ab41bc..6127cea 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -5,24 +5,32 @@ * addressed to the bot (SDK-confirmed mention or DM), it calls * `requestChannelApproval` instead of silently dropping. The flow: * - * 1. Pick the target agent group we'd wire to (MVP: first by name). - * Multi-agent picker is a follow-up — see ACTION-ITEMS. + * 1. Gather all existing agent groups. * 2. Pick an eligible approver (owner / admin) and a reachable DM for * them, reusing the same primitives the sender-approval flow uses. - * 3. Deliver an Approve / Ignore card that names the target agent - * explicitly so the owner knows what they're wiring to. + * 3. Deliver a card with three action families: + * a. Connect to [agent] — one button per existing agent group. + * Single-agent installs get a one-click connect. + * b. Connect new agent — prompts for a free-text name, creates + * the agent immediately on reply. + * c. Reject — deny the channel. * 4. Record a `pending_channel_approvals` row holding the original event - * so it can be re-routed on approve. + * so it can be re-routed on connect/create. * - * On approve (handler in index.ts): - * - Create `messaging_group_agents` with MVP defaults + * On connect (handler in index.ts): + * - Create `messaging_group_agents` with defaults * (mention-sticky for groups / pattern='.' for DMs, * sender_scope='known', ignored_message_policy='accumulate') * - Add the triggering sender to `agent_group_members` so sender_scope * doesn't bounce the replayed message into a sender-approval cascade * - Delete the pending row, replay the original event * - * On ignore: + * On connect new agent (handler in index.ts): + * - Prompt for a free-text agent name via DM + * - On reply: create the agent group + filesystem, then wire + * and replay as above + * + * On reject: * - Set `messaging_groups.denied_at = now()` so the router stops * escalating on this channel until an admin explicitly re-wires * - Delete the pending row @@ -36,19 +44,81 @@ * - Approver has no reachable DM. * - Delivery adapter missing. */ -import { normalizeOptions, type RawOption } from '../../channels/ask-question.js'; -import { getAllAgentGroups } from '../../db/agent-groups.js'; -import { getMessagingGroup } from '../../db/messaging-groups.js'; +import { normalizeOptions, type NormalizedOption, type RawOption } from '../../channels/ask-question.js'; +import { createAgentGroup, getAgentGroup, getAgentGroupByFolder, getAllAgentGroups } from '../../db/agent-groups.js'; +import { getChannelAdapter } from '../../channels/channel-registry.js'; +import { getMessagingGroup, updateMessagingGroup } from '../../db/messaging-groups.js'; import { getDeliveryAdapter } from '../../delivery.js'; +import { initGroupFilesystem } from '../../group-init.js'; import { log } from '../../log.js'; import type { InboundEvent } from '../../channels/adapter.js'; +import type { AgentGroup } from '../../types.js'; import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js'; import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js'; -const APPROVAL_OPTIONS: RawOption[] = [ - { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, - { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, -]; +// ── Value constants (response handler in index.ts parses these) ── + +export const CONNECT_PREFIX = 'connect:'; +export const NEW_AGENT_VALUE = 'new_agent'; +export const CHOOSE_EXISTING_VALUE = 'choose_existing'; +export const REJECT_VALUE = 'reject'; + +// ── Utilities ── + +function toFolder(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unnamed' + ); +} + +// ── Card builders ── + +function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] { + const options: RawOption[] = []; + if (agentGroups.length === 1) { + options.push({ + label: `Connect to ${agentGroups[0].name}`, + selectedLabel: `✅ Connected to ${agentGroups[0].name}`, + value: `${CONNECT_PREFIX}${agentGroups[0].id}`, + }); + } else { + options.push({ + label: 'Choose existing agent', + selectedLabel: '📋 Choosing…', + value: CHOOSE_EXISTING_VALUE, + }); + } + options.push({ + label: 'Connect new agent', + selectedLabel: '🆕 Connecting new agent…', + value: NEW_AGENT_VALUE, + }); + options.push({ + label: 'Reject', + selectedLabel: '🙅 Rejected', + value: REJECT_VALUE, + }); + return options; +} + +function buildQuestionText( + isGroup: boolean, + senderName: string | undefined, + channelName: string | null, + channelType: string, +): string { + const who = senderName ?? 'Someone'; + if (isGroup) { + const where = channelName ? `${channelName} on ${channelType}` : `a ${channelType} channel`; + return `${who} mentioned your bot in ${where}. How would you like to handle this channel?`; + } + return `${who} sent your bot a DM on ${channelType}. How would you like to handle it?`; +} + +// ── Main flow ── export interface RequestChannelApprovalInput { messagingGroupId: string; @@ -58,17 +128,11 @@ export interface RequestChannelApprovalInput { export async function requestChannelApproval(input: RequestChannelApprovalInput): Promise { const { messagingGroupId, event } = input; - // In-flight dedup: don't spam the owner if the same unwired channel - // gets more mentions / DMs while a card is already pending. if (hasInFlightChannelApproval(messagingGroupId)) { - log.debug('Channel registration already in flight — dropping retry', { - messagingGroupId, - }); + log.debug('Channel registration already in flight — dropping retry', { messagingGroupId }); return; } - // MVP: pick the first agent group by name. Multi-agent systems will get - // a richer card later (user picks the target from a list). const agentGroups = getAllAgentGroups(); if (agentGroups.length === 0) { log.warn('Channel registration skipped — no agent groups configured. Run /init-first-agent.', { @@ -76,55 +140,65 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) }); return; } - const target = agentGroups[0]; + // Use first agent group for approver resolution — owners and global admins + // are returned regardless of which group we pass. + const referenceGroup = agentGroups[0]; - // pickApprover takes the target agent group's id — gets scoped admins + - // global admins + owners. For fresh installs with only an owner, the - // owner is returned. - const approvers = pickApprover(target.id); + const approvers = pickApprover(referenceGroup.id); if (approvers.length === 0) { log.warn('Channel registration skipped — no owner or admin configured', { messagingGroupId, - targetAgentGroupId: target.id, + targetAgentGroupId: referenceGroup.id, }); return; } const originMg = getMessagingGroup(messagingGroupId); const originChannelType = originMg?.channel_type ?? ''; + + // Resolve channel name if not yet persisted. + if (originMg && !originMg.name) { + const channelAdapter = getChannelAdapter(originChannelType); + if (channelAdapter?.resolveChannelName) { + try { + const name = await channelAdapter.resolveChannelName(originMg.platform_id); + if (name) { + updateMessagingGroup(originMg.id, { name }); + originMg.name = name; + } + } catch { + /* non-critical */ + } + } + } + const delivery = await pickApprovalDelivery(approvers, originChannelType); if (!delivery) { log.warn('Channel registration skipped — no DM channel for any approver', { messagingGroupId, - targetAgentGroupId: target.id, + targetAgentGroupId: referenceGroup.id, }); return; } const isGroup = event.message?.isGroup ?? originMg?.is_group === 1; - // Extract sender name from the event content for a human-readable card. let senderName: string | undefined; try { const parsed = JSON.parse(event.message.content) as Record; senderName = (parsed.senderName ?? parsed.sender) as string | undefined; } catch { - // non-critical — fall through to generic wording + // non-critical } - const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; - const question = isGroup - ? senderName - ? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` - : `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` - : senderName - ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` - : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; - const options = normalizeOptions(APPROVAL_OPTIONS); + const channelName = originMg?.name ?? null; + const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message'; + const question = buildQuestionText(isGroup, senderName, channelName, originChannelType); + const options = normalizeOptions(buildApprovalOptions(agentGroups)); createPendingChannelApproval({ messaging_group_id: messagingGroupId, - agent_group_id: target.id, + agent_group_id: referenceGroup.id, original_message: JSON.stringify(event), approver_user_id: delivery.userId, created_at: new Date().toISOString(), @@ -134,9 +208,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) const adapter = getDeliveryAdapter(); if (!adapter) { - log.error('Channel registration row created but no delivery adapter is wired', { - messagingGroupId, - }); + log.error('Channel registration row created but no delivery adapter is wired', { messagingGroupId }); return; } @@ -148,9 +220,6 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) 'chat-sdk', JSON.stringify({ type: 'ask_question', - // Use messaging_group_id as the questionId — it's unique per card - // (PK on pending table dedups) and lets the response handler look - // up the pending row directly without another index. questionId: messagingGroupId, title, question, @@ -159,16 +228,56 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) ); log.info('Channel registration card delivered', { messagingGroupId, - targetAgentGroupId: target.id, + agentGroupCount: agentGroups.length, approver: delivery.userId, }); } catch (err) { - log.error('Channel registration card delivery failed', { - messagingGroupId, - err, - }); + log.error('Channel registration card delivery failed', { messagingGroupId, err }); } } -export const APPROVE_VALUE = 'approve'; -export const REJECT_VALUE = 'reject'; +// ── Helpers for the response handler (index.ts) ── + +/** + * Build normalized options for the agent-selection follow-up card. + */ +export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] { + const options: RawOption[] = agentGroups.map((ag) => ({ + label: ag.name, + selectedLabel: `✅ Connected to ${ag.name}`, + value: `${CONNECT_PREFIX}${ag.id}`, + })); + options.push({ + label: 'Cancel', + selectedLabel: '🙅 Cancelled', + value: REJECT_VALUE, + }); + return normalizeOptions(options); +} + +/** + * Create a new agent group and initialize its filesystem. Handles + * folder-name collisions with numeric suffixes. + */ +export function createNewAgentGroup(name: string): AgentGroup { + let folder = toFolder(name); + const baseFolder = folder; + let suffix = 2; + while (getAgentGroupByFolder(folder)) { + folder = `${baseFolder}-${suffix}`; + suffix++; + } + + const agId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createAgentGroup({ + id: agId, + name, + folder, + agent_provider: null, + created_at: new Date().toISOString(), + }); + + const ag = getAgentGroup(agId)!; + initGroupFilesystem(ag); + return ag; +} diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts index d402074..24f7209 100644 --- a/src/modules/permissions/db/pending-channel-approvals.ts +++ b/src/modules/permissions/db/pending-channel-approvals.ts @@ -51,6 +51,12 @@ export function hasInFlightChannelApproval(messagingGroupId: string): boolean { return row !== undefined; } +export function updatePendingChannelApprovalCard(messagingGroupId: string, title: string, optionsJson: string): void { + getDb() + .prepare('UPDATE pending_channel_approvals SET title = ?, options_json = ? WHERE messaging_group_id = ?') + .run(title, optionsJson, messagingGroupId); +} + export function deletePendingChannelApproval(messagingGroupId: string): void { getDb().prepare('DELETE FROM pending_channel_approvals WHERE messaging_group_id = ?').run(messagingGroupId); } diff --git a/src/modules/permissions/index.ts b/src/modules/permissions/index.ts index 83390d8..98a9463 100644 --- a/src/modules/permissions/index.ts +++ b/src/modules/permissions/index.ts @@ -16,27 +16,53 @@ * access gate is not registered and core defaults to allow-all. */ import { recordDroppedMessage } from '../../db/dropped-messages.js'; +import { getAgentGroup, getAllAgentGroups } from '../../db/agent-groups.js'; import { createMessagingGroupAgent, setMessagingGroupDeniedAt } from '../../db/messaging-groups.js'; import { routeInbound, setAccessGate, setChannelRequestGate, + setMessageInterceptor, setSenderResolver, setSenderScopeGate, type AccessGateResult, } from '../../router.js'; import type { InboundEvent } from '../../channels/adapter.js'; import { registerResponseHandler, type ResponsePayload } from '../../response-registry.js'; +import { getDeliveryAdapter } from '../../delivery.js'; import { log } from '../../log.js'; import type { MessagingGroup, MessagingGroupAgent } from '../../types.js'; import { canAccessAgentGroup } from './access.js'; -import { requestChannelApproval } from './channel-approval.js'; +import { + buildAgentSelectionOptions, + CHOOSE_EXISTING_VALUE, + CONNECT_PREFIX, + createNewAgentGroup, + NEW_AGENT_VALUE, + REJECT_VALUE, + requestChannelApproval, +} from './channel-approval.js'; import { addMember } from './db/agent-group-members.js'; -import { deletePendingChannelApproval, getPendingChannelApproval } from './db/pending-channel-approvals.js'; +import { + deletePendingChannelApproval, + getPendingChannelApproval, + updatePendingChannelApprovalCard, +} from './db/pending-channel-approvals.js'; import { deletePendingSenderApproval, getPendingSenderApproval } from './db/pending-sender-approvals.js'; import { hasAdminPrivilege } from './db/user-roles.js'; import { getUser, upsertUser } from './db/users.js'; import { requestSenderApproval } from './sender-approval.js'; +import { ensureUserDm } from './user-dm.js'; + +// ── Free-text name input state ── +// Tracks approvers waiting for a text reply with the agent name. Keyed by +// namespaced userId (e.g. "slack:U0ABC"). Cleared on receipt or restart. +interface PendingNameInput { + channelMgId: string; + dmChannelType: string; + dmPlatformId: string; +} +const awaitingNameInput = new Map(); function extractAndUpsertUser(event: InboundEvent): string | null { let content: Record; @@ -271,22 +297,17 @@ setChannelRequestGate(async (mg, event) => { * by messaging_group_id). If no such row, return false so downstream * handlers get a shot. * - * Approve: create the wiring with MVP defaults (mention-sticky for - * groups / pattern='.' for DMs; sender_scope='known'; - * ignored_message_policy='accumulate'), add the triggering sender as a - * member so sender_scope doesn't immediately bounce them into a - * sender-approval card, then replay the original event. - * - * Deny: set `messaging_groups.denied_at = now()` so future mentions on - * this channel drop silently until an admin explicitly wires it. + * Value dispatch: + * connect: — wire to an existing agent group, replay the message + * choose_existing — send a follow-up card listing all agents + * new_agent — prompt for a free-text agent name (interceptor + * captures the reply and creates immediately) + * reject — set denied_at, delete pending row */ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise { const row = getPendingChannelApproval(payload.questionId); if (!row) return false; - // Click-auth: same pattern as sender-approval (see commit 68058cb). - // Raw platform userId → namespace with channelType → must match the - // designated approver OR have admin privilege over the target agent. const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null; const isAuthorized = clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id)); @@ -296,25 +317,129 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< clickerId, expectedApprover: row.approver_user_id, }); - return true; // claim but take no action + return true; } const approverId = clickerId; - const approved = payload.value === 'approve'; - if (!approved) { + // ── Reject / Cancel ── + if (payload.value === REJECT_VALUE) { setMessagingGroupDeniedAt(row.messaging_group_id, new Date().toISOString()); deletePendingChannelApproval(row.messaging_group_id); log.info('Channel registration denied', { messagingGroupId: row.messaging_group_id, - agentGroupId: row.agent_group_id, approverId, }); return true; } - // Rehydrate the original event to know (a) whether it was a DM or group - // (chooses engage_mode default), and (b) who the triggering sender was - // (auto-member-add so sender_scope='known' doesn't bounce the replay). + // ── Choose existing agent — send agent-selection follow-up card ── + if (payload.value === CHOOSE_EXISTING_VALUE) { + const approverDm = await ensureUserDm(row.approver_user_id); + if (!approverDm) { + log.error('Channel registration: no DM channel for approver', { + messagingGroupId: row.messaging_group_id, + approverUserId: row.approver_user_id, + }); + return true; + } + + const adapter = getDeliveryAdapter(); + if (!adapter) return true; + + const agentGroups = getAllAgentGroups(); + const options = buildAgentSelectionOptions(agentGroups); + const title = '📋 Choose an agent'; + updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options)); + + try { + await adapter.deliver( + approverDm.channel_type, + approverDm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: row.messaging_group_id, + title, + question: 'Which agent should handle this channel?', + options, + }), + ); + } catch (err) { + log.error('Channel registration: agent-selection card delivery failed', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + return true; + } + + // ── Create new agent — prompt for free-text name ── + if (payload.value === NEW_AGENT_VALUE) { + const approverDm = await ensureUserDm(row.approver_user_id); + if (!approverDm) { + log.error('Channel registration: no DM channel for approver', { + messagingGroupId: row.messaging_group_id, + approverUserId: row.approver_user_id, + }); + return true; + } + + const adapter = getDeliveryAdapter(); + if (!adapter) { + log.error('Channel registration: no delivery adapter for name prompt', { + messagingGroupId: row.messaging_group_id, + }); + return true; + } + + awaitingNameInput.set(row.approver_user_id, { + channelMgId: row.messaging_group_id, + dmChannelType: approverDm.channel_type, + dmPlatformId: approverDm.platform_id, + }); + + try { + await adapter.deliver( + approverDm.channel_type, + approverDm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ text: 'Reply with the name for your new agent:' }), + ); + } catch (err) { + log.error('Channel registration: name prompt delivery failed', { + messagingGroupId: row.messaging_group_id, + err, + }); + awaitingNameInput.delete(row.approver_user_id); + } + return true; + } + + // ── Resolve target agent group (connect to existing or create new) ── + let targetAgentGroupId: string; + + if (payload.value.startsWith(CONNECT_PREFIX)) { + targetAgentGroupId = payload.value.slice(CONNECT_PREFIX.length); + const ag = getAgentGroup(targetAgentGroupId); + if (!ag) { + log.error('Channel registration: target agent group no longer exists', { + messagingGroupId: row.messaging_group_id, + targetAgentGroupId, + }); + deletePendingChannelApproval(row.messaging_group_id); + return true; + } + } else { + log.warn('Channel registration: unknown response value', { + messagingGroupId: row.messaging_group_id, + value: payload.value, + }); + return true; + } + + // ── Wire + replay (shared path for connect and create) ── let event: InboundEvent; try { event = JSON.parse(row.original_message) as InboundEvent; @@ -327,15 +452,6 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< return true; } - // Decide engage_mode from the original event. DMs (`isMention=true` & - // not in a group) get `pattern='.'` (always respond). Group mentions - // get `mention-sticky` (respond now + follow the thread). - // - // We can't read `mg.is_group` reliably here because we only auto-create - // the mg with `is_group=0` on first sight — the adapter hasn't told us - // yet whether it's actually a group. Fall back to the InboundEvent's - // `threadId`: a non-null threadId implies a threaded platform (Slack - // channel thread, Discord thread), which we treat as a group. const isGroup = event.threadId !== null; const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern'; const engagePattern = isGroup ? null : '.'; @@ -344,7 +460,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< createMessagingGroupAgent({ id: mgaId, messaging_group_id: row.messaging_group_id, - agent_group_id: row.agent_group_id, + agent_group_id: targetAgentGroupId, engage_mode: engageMode, engage_pattern: engagePattern, sender_scope: 'known', @@ -355,28 +471,22 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< }); log.info('Channel registration approved — wiring created', { messagingGroupId: row.messaging_group_id, - agentGroupId: row.agent_group_id, + agentGroupId: targetAgentGroupId, mgaId, engageMode, approverId, }); - // Auto-admit the triggering sender. Without this, the replay below - // would bounce through sender-approval (sender_scope='known' + - // sender-is-not-a-member). const senderUserId = extractAndUpsertUser(event); if (senderUserId) { addMember({ user_id: senderUserId, - agent_group_id: row.agent_group_id, + agent_group_id: targetAgentGroupId, added_by: approverId, added_at: new Date().toISOString(), }); } - // Clear the pending row BEFORE replay so the gate check on the second - // attempt sees a wired channel (agentCount > 0) and takes the fan-out - // path normally. deletePendingChannelApproval(row.messaging_group_id); try { @@ -391,3 +501,117 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise< } registerResponseHandler(handleChannelApprovalResponse); + +// ── Free-text name interceptor ── +// Captures the next DM from an approver who clicked "Create new agent", +// creates the agent immediately, wires the channel, and replays. + +setMessageInterceptor(async (event: InboundEvent): Promise => { + const userId = extractAndUpsertUser(event); + if (!userId) return false; + + const pending = awaitingNameInput.get(userId); + if (!pending) return false; + if (event.channelType !== pending.dmChannelType || event.platformId !== pending.dmPlatformId) return false; + + awaitingNameInput.delete(userId); + + let text: string | undefined; + try { + const parsed = JSON.parse(event.message.content) as Record; + text = (typeof parsed.text === 'string' ? parsed.text : undefined)?.trim(); + } catch { + /* fall through */ + } + + if (!text) { + log.warn('Channel registration: empty name reply, ignoring', { userId }); + return true; + } + + const row = getPendingChannelApproval(pending.channelMgId); + if (!row) return true; + + const ag = createNewAgentGroup(text); + log.info('Channel registration: new agent group created', { + messagingGroupId: row.messaging_group_id, + agentGroupId: ag.id, + agentName: ag.name, + folder: ag.folder, + }); + + let originalEvent: InboundEvent; + try { + originalEvent = JSON.parse(row.original_message) as InboundEvent; + } catch (err) { + log.error('Channel registration: failed to parse stored event', { + messagingGroupId: row.messaging_group_id, + err, + }); + deletePendingChannelApproval(row.messaging_group_id); + return true; + } + + const isGroup = originalEvent.threadId !== null; + const engageMode: MessagingGroupAgent['engage_mode'] = isGroup ? 'mention-sticky' : 'pattern'; + const engagePattern = isGroup ? null : '.'; + + const mgaId = `mga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createMessagingGroupAgent({ + id: mgaId, + messaging_group_id: row.messaging_group_id, + agent_group_id: ag.id, + engage_mode: engageMode, + engage_pattern: engagePattern, + sender_scope: 'known', + ignored_message_policy: 'accumulate', + session_mode: 'shared', + priority: 0, + created_at: new Date().toISOString(), + }); + log.info('Channel registration approved — wiring created', { + messagingGroupId: row.messaging_group_id, + agentGroupId: ag.id, + mgaId, + engageMode, + approverId: userId, + }); + + const senderUserId = extractAndUpsertUser(originalEvent); + if (senderUserId) { + addMember({ + user_id: senderUserId, + agent_group_id: ag.id, + added_by: userId, + added_at: new Date().toISOString(), + }); + } + + deletePendingChannelApproval(row.messaging_group_id); + + try { + await routeInbound(originalEvent); + } catch (err) { + log.error('Failed to replay message after channel approval', { + messagingGroupId: row.messaging_group_id, + err, + }); + } + + const adapter = getDeliveryAdapter(); + if (adapter) { + const dm = await ensureUserDm(row.approver_user_id); + if (dm) { + adapter + .deliver( + dm.channel_type, + dm.platform_id, + null, + 'chat-sdk', + JSON.stringify({ text: `✅ Agent "${ag.name}" created and connected.` }), + ) + .catch(() => {}); + } + } + return true; +}); diff --git a/src/router.ts b/src/router.ts index 995496d..844041e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -108,6 +108,20 @@ export function setSenderScopeGate(fn: SenderScopeGateFn): void { senderScopeGate = fn; } +/** + * Message-interceptor hook. Runs at the very top of routeInbound, before + * messaging-group resolution. When the interceptor returns true the message + * is consumed and routing stops. Used by the permissions module to capture + * free-text replies during multi-step approval flows (e.g. agent naming). + */ +export type MessageInterceptorFn = (event: InboundEvent) => Promise; + +let messageInterceptor: MessageInterceptorFn | null = null; + +export function setMessageInterceptor(fn: MessageInterceptorFn): void { + messageInterceptor = fn; +} + /** * Channel-registration hook. Runs when the router sees a mention/DM on a * messaging group that has no wirings AND hasn't been denied. The hook is @@ -142,6 +156,10 @@ function safeParseContent(raw: string): { text?: string; sender?: string; sender * Creates messaging group + session if they don't exist yet. */ export async function routeInbound(event: InboundEvent): Promise { + // Pre-route interceptor — lets modules consume messages before any routing + // (e.g. free-text replies during multi-step approval flows). + if (messageInterceptor && (await messageInterceptor(event))) return; + // 0. Apply the adapter's thread policy. Non-threaded adapters (Telegram, // WhatsApp, iMessage, email) collapse threads to the channel. const adapter = getChannelAdapter(event.channelType); From 5f34e262403867dcbc21e6942f09f38e34b8bd76 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 17:02:15 +0300 Subject: [PATCH 07/49] fix(credentials): translate auth errors and require OneCLI for spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the case where credentials aren't usable: 1. Replace Claude Code's "Not logged in / Invalid API key · Please run /login" output with a host-aware message. The user can't run /login from chat, so the raw text is unhelpful. Provider gains an optional isAuthRequired() classifier; the poll-loop substitutes the message on both result-text and error paths. 2. Treat OneCLI gateway failure as a transient hard error instead of spawning a credential-less container. The catch in container-runner now propagates; router and host-sweep wrap wakeContainer to log and leave the inbound row pending so the next 60s sweep tick retries. Router also stops the typing indicator on failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 44 ++++++++++++++----- .../agent-runner/src/providers/claude.ts | 12 +++++ container/agent-runner/src/providers/types.ts | 8 ++++ src/container-runner.ts | 24 +++++----- src/host-sweep.ts | 9 +++- src/router.ts | 12 ++++- 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index bd48db2..2846337 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,6 +21,20 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +const AUTH_REQUIRED_USER_TEXT = + "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; + +function writeAuthRequiredMessage(routing: RoutingContext): void { + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: AUTH_REQUIRED_USER_TEXT }), + }); +} + export interface PollLoopConfig { provider: AgentProvider; /** @@ -171,7 +185,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing, processingIds, config.providerName); + const result = await processQuery(query, routing, processingIds, config.provider, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setContinuation(config.providerName, continuation); @@ -189,15 +203,18 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearContinuation(config.providerName); } - // Write error response so the user knows something went wrong - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Error: ${errMsg}` }), - }); + if (config.provider.isAuthRequired?.(errMsg)) { + writeAuthRequiredMessage(routing); + } else { + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); + } } // Ensure completed even if processQuery ended without a result event @@ -249,6 +266,7 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], + provider: AgentProvider, providerName: string, ): Promise { let queryContinuation: string | undefined; @@ -310,7 +328,11 @@ async function processQuery( // at all — either way the turn is finished. markCompleted(initialBatchIds); if (event.text) { - dispatchResultText(event.text, routing); + if (provider.isAuthRequired?.(event.text)) { + writeAuthRequiredMessage(routing); + } else { + dispatchResultText(event.text, routing); + } } } } diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..6dcdb5a 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -236,6 +236,14 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; */ const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; +/** + * Auth-required detection. Matches Claude Code's output when no usable + * credential is available — "Not logged in · Please run /login" or + * "Invalid API key · Please run /login". The user can't run /login from + * chat, so the poll-loop substitutes a host-aware message. + */ +const AUTH_REQUIRED_RE = /(Not logged in|Invalid API key)[\s\S]*?Please run \/login/i; + export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; @@ -259,6 +267,10 @@ export class ClaudeProvider implements AgentProvider { return STALE_SESSION_RE.test(msg); } + isAuthRequired(text: string): boolean { + return AUTH_REQUIRED_RE.test(text); + } + query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 55ab919..99833a7 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -14,6 +14,14 @@ export interface AgentProvider { * (missing transcript, unknown session, etc.) and should be cleared. */ isSessionInvalid(err: unknown): boolean; + + /** + * True if the given text/error indicates the underlying SDK or CLI has no + * usable Anthropic auth (e.g. Claude Code's "Not logged in · Please run + * /login"). The poll-loop swaps the raw output for a host-aware message + * since the user can't run /login from chat. + */ + isAuthRequired?(text: string): boolean; } /** diff --git a/src/container-runner.ts b/src/container-runner.ts index 029b5fe..dc71248 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -435,20 +435,18 @@ async function buildContainerArgs( } // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls - // are routed through the agent vault for credential injection. - try { - if (agentIdentifier) { - await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); - } - const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); - if (onecliApplied) { - log.info('OneCLI gateway applied', { containerName }); - } else { - log.warn('OneCLI gateway not applied — container will have no credentials', { containerName }); - } - } catch (err) { - log.warn('OneCLI gateway error — container will have no credentials', { containerName, err }); + // are routed through the agent vault for credential injection. Treated as + // a transient hard failure: if we can't wire the gateway, we don't spawn. + // The caller (router or host-sweep) catches the throw, leaves the inbound + // message pending, and the next sweep tick retries. + if (agentIdentifier) { + await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); } + const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier }); + if (!onecliApplied) { + throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials'); + } + log.info('OneCLI gateway applied', { containerName }); // Host gateway args.push(...hostGatewayArgs()); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 4dc2fb7..ff88fb0 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -168,7 +168,14 @@ async function sweepSession(session: Session): Promise { const dueCount = countDueMessages(inDb); if (dueCount > 0 && !isContainerRunning(session.id)) { log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - await wakeContainer(session); + try { + await wakeContainer(session); + } catch (err) { + // Transient spawn failure (e.g. OneCLI gateway down). Leave messages + // pending so the next sweep tick retries; don't abort the rest of + // the sweep cycle for other sessions. + log.warn('wakeContainer failed — will retry on next sweep', { sessionId: session.id, err }); + } } const alive = isContainerRunning(session.id); diff --git a/src/router.ts b/src/router.ts index 3cf0192..e429977 100644 --- a/src/router.ts +++ b/src/router.ts @@ -27,7 +27,7 @@ import { getMessagingGroupWithAgentCount, } from './db/messaging-groups.js'; import { findSessionForAgent } from './db/sessions.js'; -import { startTypingRefresh } from './modules/typing/index.js'; +import { startTypingRefresh, stopTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; @@ -450,7 +450,15 @@ async function deliverToAgent( startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); const freshSession = getSession(session.id); if (freshSession) { - await wakeContainer(freshSession); + try { + await wakeContainer(freshSession); + } catch (err) { + // Transient spawn failure (e.g. OneCLI gateway down). The inbound + // row is already persisted — host-sweep will retry the wake on its + // next tick. Don't bubble out of the channel adapter. + log.warn('wakeContainer failed — host-sweep will retry', { sessionId: freshSession.id, err }); + stopTypingRefresh(freshSession.id); + } } } } From d86051805b48dd29a64cabd4187f1bb663d4a796 Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Wed, 29 Apr 2026 14:09:47 +0000 Subject: [PATCH 08/49] 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/49] 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/49] fix(setup): isolate scratch agent with hardcoded _ping-test folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scratch agent uses fixed folder `_ping-test` so it can never collide with a real agent on re-runs - Added --folder flag to init-cli-agent.ts and cli-agent step wrapper - Delete always targets `_ping-test` exactly — no re-derivation needed - Removed normalizeName coupling and FOLDER status field (no longer needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/delete-cli-agent.ts | 7 ++++--- scripts/init-cli-agent.ts | 8 +++++++- setup/auto.ts | 5 ++--- setup/cli-agent.ts | 17 +++++++++++------ 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/scripts/delete-cli-agent.ts b/scripts/delete-cli-agent.ts index be3d959..01a9e33 100644 --- a/scripts/delete-cli-agent.ts +++ b/scripts/delete-cli-agent.ts @@ -1,9 +1,10 @@ /** * Delete the scratch CLI agent created during setup's ping-pong test. * - * Removes the agent group, its messaging_group_agents wiring, any - * agent_destinations rows, and the groups// directory. Leaves the - * CLI messaging group intact so it can be reused for a new agent. + * Dynamically finds and removes all rows referencing the agent group + * (any table with an agent_group_id column), deletes the agent group + * itself, and removes the groups// directory. Leaves the CLI + * messaging group intact so it can be reused for a new agent. * * Usage: * pnpm exec tsx scripts/delete-cli-agent.ts --folder diff --git a/scripts/init-cli-agent.ts b/scripts/init-cli-agent.ts index 4a56827..73fb9d1 100644 --- a/scripts/init-cli-agent.ts +++ b/scripts/init-cli-agent.ts @@ -41,11 +41,13 @@ const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`; interface Args { displayName: string; agentName: string; + folder?: string; } function parseArgs(argv: string[]): Args { let displayName: string | undefined; let agentName: string | undefined; + let folder: string | undefined; for (let i = 0; i < argv.length; i++) { const key = argv[i]; const val = argv[i + 1]; @@ -55,6 +57,9 @@ function parseArgs(argv: string[]): Args { } else if (key === '--agent-name') { agentName = val; i++; + } else if (key === '--folder') { + folder = val; + i++; } } @@ -67,6 +72,7 @@ function parseArgs(argv: string[]): Args { return { displayName, agentName: agentName?.trim() || displayName, + folder, }; } @@ -95,7 +101,7 @@ async function main(): Promise { const promotedToOwner = false; // 2. Agent group + filesystem. - const folder = `cli-with-${normalizeName(args.displayName)}`; + const folder = args.folder || `cli-with-${normalizeName(args.displayName)}`; let ag: AgentGroup | undefined = getAgentGroupByFolder(folder); if (!ag) { const agId = generateId('ag'); diff --git a/setup/auto.ts b/setup/auto.ts index 6ebf486..0b2cfa1 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -351,7 +351,7 @@ async function main(): Promise { running: 'Preparing connection test…', done: 'Ready to test.', }, - ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME], + ['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME, '--folder', '_ping-test'], ); if (!res.ok) { await fail( @@ -372,8 +372,7 @@ async function main(): Promise { const ping = await confirmAssistantResponds(); if (ping === 'ok') { phEmit('first_chat_ready'); - const scratchFolder = res.terminal?.fields.FOLDER ?? ''; - spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', scratchFolder], { + spawnSync('pnpm', ['exec', 'tsx', 'scripts/delete-cli-agent.ts', '--folder', '_ping-test'], { stdio: 'ignore', }); const next = ensureAnswer( diff --git a/setup/cli-agent.ts b/setup/cli-agent.ts index 18b8e97..73b8557 100644 --- a/setup/cli-agent.ts +++ b/setup/cli-agent.ts @@ -8,6 +8,7 @@ * Args: * --display-name (required) operator's display name * --agent-name (optional) agent persona name, defaults to display-name + * --folder (optional) explicit folder name, defaults to cli-with- */ import { execFileSync } from 'child_process'; import path from 'path'; @@ -18,9 +19,11 @@ import { emitStatus } from './status.js'; function parseArgs(args: string[]): { displayName: string; agentName?: string; + folder?: string; } { let displayName: string | undefined; let agentName: string | undefined; + let folder: string | undefined; for (let i = 0; i < args.length; i++) { const key = args[i]; @@ -34,6 +37,10 @@ function parseArgs(args: string[]): { agentName = val; i++; break; + case '--folder': + folder = val; + i++; + break; } } @@ -46,23 +53,23 @@ function parseArgs(args: string[]): { process.exit(2); } - return { displayName, agentName }; + return { displayName, agentName, folder }; } export async function run(args: string[]): Promise { - const { displayName, agentName } = parseArgs(args); + const { displayName, agentName, folder } = parseArgs(args); const projectRoot = process.cwd(); const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts'); const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName]; if (agentName) scriptArgs.push('--agent-name', agentName); + if (folder) scriptArgs.push('--folder', folder); log.info('Invoking init-cli-agent', { displayName, agentName }); - let stdout = ''; try { - stdout = execFileSync('pnpm', scriptArgs, { + execFileSync('pnpm', scriptArgs, { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', @@ -83,11 +90,9 @@ export async function run(args: string[]): Promise { process.exit(1); } - const folderMatch = stdout.match(/@ groups\/(\S+)/); emitStatus('CLI_AGENT', { DISPLAY_NAME: displayName, AGENT_NAME: agentName || displayName, - FOLDER: folderMatch?.[1] ?? '', CHANNEL: 'cli/local', STATUS: 'success', LOG: 'logs/setup.log', From d5b48e474278a8dc6067944c63049a64fcd950f1 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 17:51:32 +0300 Subject: [PATCH 11/49] fix(credentials): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wakeContainer now never throws — returns Promise, catches internally. Closes the regression risk for the 5 awaited callers in agent-to-agent, interactive, and approvals/response-handler that the previous version left unwrapped. Router uses the boolean to stop the typing indicator on transient failure; host-sweep just awaits. - Tighten AUTH_REQUIRED_RE: anchor to start-of-string with the specific `·` (U+00B7) separator the CLI uses, so an agent that quotes the banner mid-sentence in a normal reply doesn't trip the classifier. - Log a one-line note from writeAuthRequiredMessage so substitutions are visible when debugging "user got the credentials message but I don't see why." - Add unit tests for ClaudeProvider.isAuthRequired covering both banner variants, trailing content, mid-sentence quoting, leading-prose quoting, alternate separators, and unrelated text. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 1 + .../agent-runner/src/providers/claude.test.ts | 37 +++++++++++++++++++ .../agent-runner/src/providers/claude.ts | 8 +++- src/container-runner.ts | 24 +++++++++--- src/host-sweep.ts | 11 ++---- src/router.ts | 14 +++---- 6 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 container/agent-runner/src/providers/claude.test.ts diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 2846337..43c9cf1 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -25,6 +25,7 @@ const AUTH_REQUIRED_USER_TEXT = "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; function writeAuthRequiredMessage(routing: RoutingContext): void { + log('Auth-required detected — substituting host-aware message for the user'); writeMessageOut({ id: generateId(), kind: 'chat', diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts new file mode 100644 index 0000000..d906280 --- /dev/null +++ b/container/agent-runner/src/providers/claude.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'bun:test'; + +import { ClaudeProvider } from './claude.js'; + +describe('ClaudeProvider.isAuthRequired', () => { + const provider = new ClaudeProvider(); + + it('matches the "Not logged in" banner', () => { + expect(provider.isAuthRequired('Not logged in · Please run /login')).toBe(true); + }); + + it('matches the "Invalid API key" banner', () => { + expect(provider.isAuthRequired('Invalid API key · Please run /login')).toBe(true); + }); + + it('matches with trailing content after the banner', () => { + expect(provider.isAuthRequired('Not logged in · Please run /login\n\nstack trace …')).toBe(true); + }); + + it('does not match when the agent quotes the phrase mid-sentence', () => { + const quoted = "The error 'Invalid API key · Please run /login' means your auth has expired."; + expect(provider.isAuthRequired(quoted)).toBe(false); + }); + + it('does not match when the agent leads its reply with the phrase in prose', () => { + const prose = '"Not logged in · Please run /login" is a Claude Code error.'; + expect(provider.isAuthRequired(prose)).toBe(false); + }); + + it('does not match a different separator (defensive against typos in agent output)', () => { + expect(provider.isAuthRequired('Not logged in - Please run /login')).toBe(false); + }); + + it('does not match unrelated text', () => { + expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 6dcdb5a..11ea4b0 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -237,12 +237,16 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; /** - * Auth-required detection. Matches Claude Code's output when no usable + * Auth-required detection. Matches Claude Code's banner when no usable * credential is available — "Not logged in · Please run /login" or * "Invalid API key · Please run /login". The user can't run /login from * chat, so the poll-loop substitutes a host-aware message. + * + * Anchored to start-of-string with the specific `·` separator (U+00B7) + * the CLI uses, so an agent that quotes the phrase verbatim mid-sentence + * in a normal reply doesn't trip the classifier. */ -const AUTH_REQUIRED_RE = /(Not logged in|Invalid API key)[\s\S]*?Please run \/login/i; +const AUTH_REQUIRED_RE = /^(Not logged in|Invalid API key)\s*·\s*Please run \/login/; export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; diff --git a/src/container-runner.ts b/src/container-runner.ts index dc71248..27b0f5c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -58,7 +58,7 @@ const activeContainers = new Map>(); +const wakePromises = new Map>(); export function getActiveContainerCount(): number { return activeContainers.size; @@ -73,20 +73,32 @@ export function isContainerRunning(sessionId: string): boolean { * (the in-flight wake promise is reused). * * The container runs the v2 agent-runner which polls the session DB. + * + * Contract: never throws. Returns `true` on successful spawn, `false` on + * transient spawn failure (e.g. OneCLI gateway unreachable). Callers don't + * need to wrap — the inbound row stays pending and host-sweep retries on + * its next tick. Callers that care (e.g. the router's typing indicator) + * can branch on the boolean. */ -export function wakeContainer(session: Session): Promise { +export function wakeContainer(session: Session): Promise { if (activeContainers.has(session.id)) { log.debug('Container already running', { sessionId: session.id }); - return Promise.resolve(); + return Promise.resolve(true); } const existing = wakePromises.get(session.id); if (existing) { log.debug('Container wake already in-flight — joining existing promise', { sessionId: session.id }); return existing; } - const promise = spawnContainer(session).finally(() => { - wakePromises.delete(session.id); - }); + const promise = spawnContainer(session) + .then(() => true) + .catch((err) => { + log.warn('wakeContainer failed — host-sweep will retry', { sessionId: session.id, err }); + return false; + }) + .finally(() => { + wakePromises.delete(session.id); + }); wakePromises.set(session.id, promise); return promise; } diff --git a/src/host-sweep.ts b/src/host-sweep.ts index ff88fb0..69a4d61 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -168,14 +168,9 @@ async function sweepSession(session: Session): Promise { const dueCount = countDueMessages(inDb); if (dueCount > 0 && !isContainerRunning(session.id)) { log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - try { - await wakeContainer(session); - } catch (err) { - // Transient spawn failure (e.g. OneCLI gateway down). Leave messages - // pending so the next sweep tick retries; don't abort the rest of - // the sweep cycle for other sessions. - log.warn('wakeContainer failed — will retry on next sweep', { sessionId: session.id, err }); - } + // wakeContainer never throws — transient spawn failures (OneCLI down, + // etc.) return false and leave messages pending for the next tick. + await wakeContainer(session); } const alive = isContainerRunning(session.id); diff --git a/src/router.ts b/src/router.ts index e429977..69d7313 100644 --- a/src/router.ts +++ b/src/router.ts @@ -450,15 +450,11 @@ async function deliverToAgent( startTypingRefresh(session.id, session.agent_group_id, event.channelType, event.platformId, event.threadId); const freshSession = getSession(session.id); if (freshSession) { - try { - await wakeContainer(freshSession); - } catch (err) { - // Transient spawn failure (e.g. OneCLI gateway down). The inbound - // row is already persisted — host-sweep will retry the wake on its - // next tick. Don't bubble out of the channel adapter. - log.warn('wakeContainer failed — host-sweep will retry', { sessionId: freshSession.id, err }); - stopTypingRefresh(freshSession.id); - } + const woke = await wakeContainer(freshSession); + // wakeContainer never throws — it returns false on transient spawn + // failure (host-sweep retries). Stop the typing indicator we just + // started so it doesn't leak; the inbound row stays pending. + if (!woke) stopTypingRefresh(freshSession.id); } } } From b9d302524e8b40be7e194e866cf2dd33d853fdc7 Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 29 Apr 2026 15:01:09 +0000 Subject: [PATCH 12/49] fix(session-manager): derive attachment extension from mimeType and att.type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a channel bridge passes an attachment without an explicit `name`, extractAttachmentFiles fell back to `attachment-` with no extension. Agents could not tell whether the file was a JPEG, PDF, or audio clip, and tools keyed on extension (image viewers, exiftool, etc.) misbehaved. Two cases are now covered: 1. Channels that set `mimeType` but no `name` (Discord/Slack documents, Telegram document uploads). A small MIME-to-extension table covers the common content types — image/*, audio/*, video/*, pdf, zip, txt, json. Unknown MIMEs fall back to the unsuffixed name. 2. Channels that set `att.type` but no `mimeType` (Telegram photos, stickers, voice, animations). The chat-sdk bridge sets a coarse media-class (`photo` / `sticker` / `voice` / `video` / `animation`) which is reliable enough to derive a canonical extension. Telegram GIFs are MP4 under the hood. The existing isSafeAttachmentName security guard is preserved — the derived name still passes through it before disk I/O. The new lookup tables emit static values from internal maps and cannot construct a path-traversal payload; attacker-controlled att.name continues to flow through the same validator. --- src/session-manager.ts | 56 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/session-manager.ts b/src/session-manager.ts index 996a750..342c155 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -230,6 +230,60 @@ export function writeSessionMessage( updateSession(sessionId, { last_active: new Date().toISOString() }); } +// Map common MIME types to canonical file extensions. Used to derive a +// usable suffix when the channel bridge passes an attachment without an +// explicit `name`. Without an extension, agents (and humans) can't tell +// what kind of file landed in the inbox. +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/heic': 'heic', + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/mp4': 'm4a', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'application/json': 'json', + 'application/zip': 'zip', +}; + +// Fallback when `mimeType` is missing — Telegram photos and stickers arrive +// without an explicit MIME on the attachment object. The channel bridge sets +// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) +// which is reliable enough to derive a canonical extension. Telegram's GIFs +// are actually MP4, hence `animation: 'mp4'`. +const TYPE_TO_EXT: Record = { + image: 'jpg', + photo: 'jpg', + sticker: 'webp', + voice: 'ogg', + audio: 'mp3', + video: 'mp4', + animation: 'mp4', +}; + +function extForMime(mime: string | undefined): string { + if (!mime) return ''; + const clean = mime.split(';')[0].trim().toLowerCase(); + return MIME_TO_EXT[clean] ?? ''; +} + +function deriveAttachmentName(att: Record): string { + const explicit = att.name as string | undefined; + if (explicit) return explicit; + let ext = extForMime(att.mimeType as string | undefined); + if (!ext && typeof att.type === 'string') { + ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; + } + return ext ? `attachment-${Date.now()}.${ext}` : `attachment-${Date.now()}`; +} + /** * If message content has attachments with base64 `data`, save them to * the session's inbox directory and replace with `localPath`. @@ -259,7 +313,7 @@ function extractAttachmentFiles( // this guard, `path.join(inboxDir, '../../...')` writes anywhere the // host process has fs permission — see Signal Desktop's Nov 2025 // attachment-fileName advisory for the same archetype. - const rawName = (att.name as string | undefined) ?? `attachment-${Date.now()}`; + const rawName = deriveAttachmentName(att); const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`; if (filename !== rawName) { log.warn('Refused unsafe attachment filename — would escape inbox', { From beb5e049eda84b178bc02f10c4346e6ef99d1279 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:03:25 +0300 Subject: [PATCH 13/49] fix(credentials): move auth-required remediation message into provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a paired `authRequiredMessage()` method to AgentProvider so per-provider auth-failure remediation can differ. Claude returns the Anthropic/`claude` instruction; future providers (Codex, OpenCode, …) can return their own remediation text. The poll-loop calls `provider.authRequiredMessage?.()` and falls back to a generic message if a provider implements `isAuthRequired` without supplying its own remediation. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 17 +++++++++++------ .../agent-runner/src/providers/claude.test.ts | 10 ++++++++++ container/agent-runner/src/providers/claude.ts | 4 ++++ container/agent-runner/src/providers/types.ts | 17 +++++++++++++++-- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 43c9cf1..fb54378 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,10 +21,15 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -const AUTH_REQUIRED_USER_TEXT = - "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; +// Generic fallback for providers that classify auth failures via +// `isAuthRequired` but don't supply their own remediation text. Concrete +// providers (Claude, Codex, …) override this with a provider-specific +// message via `authRequiredMessage()`. +const GENERIC_AUTH_REQUIRED_MESSAGE = + "I can't reach my credentials right now. The operator running NanoClaw needs to re-authenticate on the host machine."; -function writeAuthRequiredMessage(routing: RoutingContext): void { +function writeAuthRequiredMessage(provider: AgentProvider, routing: RoutingContext): void { + const text = provider.authRequiredMessage?.() ?? GENERIC_AUTH_REQUIRED_MESSAGE; log('Auth-required detected — substituting host-aware message for the user'); writeMessageOut({ id: generateId(), @@ -32,7 +37,7 @@ function writeAuthRequiredMessage(routing: RoutingContext): void { platform_id: routing.platformId, channel_type: routing.channelType, thread_id: routing.threadId, - content: JSON.stringify({ text: AUTH_REQUIRED_USER_TEXT }), + content: JSON.stringify({ text }), }); } @@ -205,7 +210,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { } if (config.provider.isAuthRequired?.(errMsg)) { - writeAuthRequiredMessage(routing); + writeAuthRequiredMessage(config.provider, routing); } else { writeMessageOut({ id: generateId(), @@ -330,7 +335,7 @@ async function processQuery( markCompleted(initialBatchIds); if (event.text) { if (provider.isAuthRequired?.(event.text)) { - writeAuthRequiredMessage(routing); + writeAuthRequiredMessage(provider, routing); } else { dispatchResultText(event.text, routing); } diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts index d906280..91836e4 100644 --- a/container/agent-runner/src/providers/claude.test.ts +++ b/container/agent-runner/src/providers/claude.test.ts @@ -35,3 +35,13 @@ describe('ClaudeProvider.isAuthRequired', () => { expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); }); }); + +describe('ClaudeProvider.authRequiredMessage', () => { + const provider = new ClaudeProvider(); + + it('returns the Anthropic-specific remediation', () => { + const msg = provider.authRequiredMessage(); + expect(msg).toContain('Anthropic credentials'); + expect(msg).toContain('claude'); + }); +}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 11ea4b0..89dd5cd 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -275,6 +275,10 @@ export class ClaudeProvider implements AgentProvider { return AUTH_REQUIRED_RE.test(text); } + authRequiredMessage(): string { + return "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; + } + query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 99833a7..3124c07 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -17,11 +17,24 @@ export interface AgentProvider { /** * True if the given text/error indicates the underlying SDK or CLI has no - * usable Anthropic auth (e.g. Claude Code's "Not logged in · Please run + * usable credentials (e.g. Claude Code's "Not logged in · Please run * /login"). The poll-loop swaps the raw output for a host-aware message - * since the user can't run /login from chat. + * since the user can't authenticate from chat. + * + * Paired with `authRequiredMessage()` — providers that implement one + * should implement both. The matcher is provider-specific because each + * SDK/CLI has its own auth-failure banner format. */ isAuthRequired?(text: string): boolean; + + /** + * User-facing remediation message returned when `isAuthRequired` matches. + * Provider-specific because the actionable instruction differs across + * providers (e.g. Claude vs Codex vs OpenCode each direct the operator + * to a different auth flow). Falls back to a generic message in the + * poll-loop if a provider implements `isAuthRequired` but not this. + */ + authRequiredMessage?(): string; } /** From 9889848932d5b27b6f53c3e6d3b717c41d8aabea Mon Sep 17 00:00:00 2001 From: robbyczgw-cla Date: Wed, 29 Apr 2026 15:07:26 +0000 Subject: [PATCH 14/49] fix(claude-provider): respect operator-set CLAUDE_CODE_AUTO_COMPACT_WINDOW Closes #1820. The container agent-runner sets CLAUDE_CODE_AUTO_COMPACT_WINDOW unconditionally on the container process env, with no way to override it per-deployment without editing source. Read process.env first and fall back to the existing 165000 literal when unset. Default behavior is unchanged for installs that do not set the env var. Operators running 1M-context models or emergency-tuning a live deployment can now raise or lower the threshold from the host env. --- container/agent-runner/src/providers/claude.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c..c9478b8 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -226,8 +226,12 @@ function createPreCompactHook(assistantName?: string): HookCallback { /** * Claude Code auto-compacts context at this window (tokens). Kept here so * the generic bootstrap doesn't need to know about Claude-specific env vars. + * + * Operator override: set CLAUDE_CODE_AUTO_COMPACT_WINDOW in the host env to + * raise or lower the threshold without editing source — useful when running + * with a 1M-context model variant or when emergency-tuning a deployment. */ -const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; +const CLAUDE_CODE_AUTO_COMPACT_WINDOW = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '165000'; /** * Stale-session detection. Matches Claude Code's error text when a From 70cb35f58b221a8236302cb409df6cc37b1c6cb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:13:37 +0000 Subject: [PATCH 15/49] chore: bump version to 2.0.16 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9b3b6fb..419e4b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.15", + "version": "2.0.16", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From ee165d09c2959032c789309f5c6994767fce1fd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:13:40 +0000 Subject: [PATCH 16/49] =?UTF-8?q?docs:=20update=20token=20count=20to=20134?= =?UTF-8?q?k=20tokens=20=C2=B7=2067%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 5a0fe82..cccf8c7 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 133k tokens, 67% of context window + + 134k tokens, 67% of context window @@ -15,8 +15,8 @@ tokens - - 133k + + 134k From e31a6c7e3493e00371a616eacb94d570e0174ca5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 29 Apr 2026 18:26:04 +0300 Subject: [PATCH 17/49] revert(credentials): drop auth-required login-message handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the "Not logged in · Please run /login" detection and substitution from this PR — narrowing scope to just the OneCLI gateway transient-retry change. The login-message handling will be addressed separately. Reverts: - AgentProvider.isAuthRequired / authRequiredMessage - ClaudeProvider auth-required regex, classifier, and remediation text - poll-loop writeAuthRequiredMessage helper + call sites - claude.test.ts (auth-only test file) OneCLI/wakeContainer changes (the remaining content of the PR) are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/poll-loop.ts | 50 ++++--------------- .../agent-runner/src/providers/claude.test.ts | 47 ----------------- .../agent-runner/src/providers/claude.ts | 20 -------- container/agent-runner/src/providers/types.ts | 21 -------- 4 files changed, 11 insertions(+), 127 deletions(-) delete mode 100644 container/agent-runner/src/providers/claude.test.ts diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index fb54378..bd48db2 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -21,26 +21,6 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -// Generic fallback for providers that classify auth failures via -// `isAuthRequired` but don't supply their own remediation text. Concrete -// providers (Claude, Codex, …) override this with a provider-specific -// message via `authRequiredMessage()`. -const GENERIC_AUTH_REQUIRED_MESSAGE = - "I can't reach my credentials right now. The operator running NanoClaw needs to re-authenticate on the host machine."; - -function writeAuthRequiredMessage(provider: AgentProvider, routing: RoutingContext): void { - const text = provider.authRequiredMessage?.() ?? GENERIC_AUTH_REQUIRED_MESSAGE; - log('Auth-required detected — substituting host-aware message for the user'); - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text }), - }); -} - export interface PollLoopConfig { provider: AgentProvider; /** @@ -191,7 +171,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing, processingIds, config.provider, config.providerName); + const result = await processQuery(query, routing, processingIds, config.providerName); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setContinuation(config.providerName, continuation); @@ -209,18 +189,15 @@ export async function runPollLoop(config: PollLoopConfig): Promise { clearContinuation(config.providerName); } - if (config.provider.isAuthRequired?.(errMsg)) { - writeAuthRequiredMessage(config.provider, routing); - } else { - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Error: ${errMsg}` }), - }); - } + // Write error response so the user knows something went wrong + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: `Error: ${errMsg}` }), + }); } // Ensure completed even if processQuery ended without a result event @@ -272,7 +249,6 @@ async function processQuery( query: AgentQuery, routing: RoutingContext, initialBatchIds: string[], - provider: AgentProvider, providerName: string, ): Promise { let queryContinuation: string | undefined; @@ -334,11 +310,7 @@ async function processQuery( // at all — either way the turn is finished. markCompleted(initialBatchIds); if (event.text) { - if (provider.isAuthRequired?.(event.text)) { - writeAuthRequiredMessage(provider, routing); - } else { - dispatchResultText(event.text, routing); - } + dispatchResultText(event.text, routing); } } } diff --git a/container/agent-runner/src/providers/claude.test.ts b/container/agent-runner/src/providers/claude.test.ts deleted file mode 100644 index 91836e4..0000000 --- a/container/agent-runner/src/providers/claude.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect } from 'bun:test'; - -import { ClaudeProvider } from './claude.js'; - -describe('ClaudeProvider.isAuthRequired', () => { - const provider = new ClaudeProvider(); - - it('matches the "Not logged in" banner', () => { - expect(provider.isAuthRequired('Not logged in · Please run /login')).toBe(true); - }); - - it('matches the "Invalid API key" banner', () => { - expect(provider.isAuthRequired('Invalid API key · Please run /login')).toBe(true); - }); - - it('matches with trailing content after the banner', () => { - expect(provider.isAuthRequired('Not logged in · Please run /login\n\nstack trace …')).toBe(true); - }); - - it('does not match when the agent quotes the phrase mid-sentence', () => { - const quoted = "The error 'Invalid API key · Please run /login' means your auth has expired."; - expect(provider.isAuthRequired(quoted)).toBe(false); - }); - - it('does not match when the agent leads its reply with the phrase in prose', () => { - const prose = '"Not logged in · Please run /login" is a Claude Code error.'; - expect(provider.isAuthRequired(prose)).toBe(false); - }); - - it('does not match a different separator (defensive against typos in agent output)', () => { - expect(provider.isAuthRequired('Not logged in - Please run /login')).toBe(false); - }); - - it('does not match unrelated text', () => { - expect(provider.isAuthRequired('Tool execution failed: timeout')).toBe(false); - }); -}); - -describe('ClaudeProvider.authRequiredMessage', () => { - const provider = new ClaudeProvider(); - - it('returns the Anthropic-specific remediation', () => { - const msg = provider.authRequiredMessage(); - expect(msg).toContain('Anthropic credentials'); - expect(msg).toContain('claude'); - }); -}); diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 89dd5cd..fbb077c 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -236,18 +236,6 @@ const CLAUDE_CODE_AUTO_COMPACT_WINDOW = '165000'; */ const STALE_SESSION_RE = /no conversation found|ENOENT.*\.jsonl|session.*not found/i; -/** - * Auth-required detection. Matches Claude Code's banner when no usable - * credential is available — "Not logged in · Please run /login" or - * "Invalid API key · Please run /login". The user can't run /login from - * chat, so the poll-loop substitutes a host-aware message. - * - * Anchored to start-of-string with the specific `·` separator (U+00B7) - * the CLI uses, so an agent that quotes the phrase verbatim mid-sentence - * in a normal reply doesn't trip the classifier. - */ -const AUTH_REQUIRED_RE = /^(Not logged in|Invalid API key)\s*·\s*Please run \/login/; - export class ClaudeProvider implements AgentProvider { readonly supportsNativeSlashCommands = true; @@ -271,14 +259,6 @@ export class ClaudeProvider implements AgentProvider { return STALE_SESSION_RE.test(msg); } - isAuthRequired(text: string): boolean { - return AUTH_REQUIRED_RE.test(text); - } - - authRequiredMessage(): string { - return "I can't reach my Anthropic credentials right now. The operator running NanoClaw needs to re-run setup, or run `claude` in the project directory on the machine I'm running on."; - } - query(input: QueryInput): AgentQuery { const stream = new MessageStream(); stream.push(input.prompt); diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 3124c07..55ab919 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -14,27 +14,6 @@ export interface AgentProvider { * (missing transcript, unknown session, etc.) and should be cleared. */ isSessionInvalid(err: unknown): boolean; - - /** - * True if the given text/error indicates the underlying SDK or CLI has no - * usable credentials (e.g. Claude Code's "Not logged in · Please run - * /login"). The poll-loop swaps the raw output for a host-aware message - * since the user can't authenticate from chat. - * - * Paired with `authRequiredMessage()` — providers that implement one - * should implement both. The matcher is provider-specific because each - * SDK/CLI has its own auth-failure banner format. - */ - isAuthRequired?(text: string): boolean; - - /** - * User-facing remediation message returned when `isAuthRequired` matches. - * Provider-specific because the actionable instruction differs across - * providers (e.g. Claude vs Codex vs OpenCode each direct the operator - * to a different auth flow). Falls back to a generic message in the - * poll-loop if a provider implements `isAuthRequired` but not this. - */ - authRequiredMessage?(): string; } /** From 1452ed262b018c7f07430018cd156c6b1bc3e754 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:30:20 +0000 Subject: [PATCH 18/49] chore: bump version to 2.0.17 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 419e4b3..6287027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.16", + "version": "2.0.17", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 34f361287746a507ffa952cc1c542fe35e4b5b5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Apr 2026 15:30:22 +0000 Subject: [PATCH 19/49] =?UTF-8?q?docs:=20update=20token=20count=20to=20135?= =?UTF-8?q?k=20tokens=20=C2=B7=2067%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index cccf8c7..20b07a3 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 134k tokens, 67% of context window + + 135k tokens, 67% of context window @@ -15,8 +15,8 @@ tokens - - 134k + + 135k From 8dd004ca75c881d19139efde85dab8c6526d06f5 Mon Sep 17 00:00:00 2001 From: Mike Nolet Date: Thu, 30 Apr 2026 08:13:59 +0200 Subject: [PATCH 20/49] 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 2a3be9ec7fc00b687489674258d6c8ffb35ce742 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 30 Apr 2026 09:40:44 +0300 Subject: [PATCH 21/49] extract attachment-naming, harden mimeType guard, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the MIME/type-to-extension maps and derivation helpers out of session-manager.ts into a dedicated attachment-naming module — keeps session-manager focused on session lifecycle and gives the helpers a natural home for unit tests alongside the existing attachment-safety module. Two small fixes alongside the extraction: - extForMime now guards `typeof mime !== 'string'` before .split, so a buggy bridge passing `mimeType: { ... }` (object) no longer crashes the inbound write loop. - deriveAttachmentName computes Date.now() once per call instead of twice, and tightens the explicit-name check to a string-and-truthy guard so non-string values fall through to derivation. Adds attachment-naming.test.ts with 11 cases covering MIME normalization (case + parameters), Telegram type fallback, the non-string defensive guard, and the bare-timestamp fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/attachment-naming.test.ts | 71 +++++++++++++++++++++++++++++++++++ src/attachment-naming.ts | 69 ++++++++++++++++++++++++++++++++++ src/session-manager.ts | 55 +-------------------------- 3 files changed, 141 insertions(+), 54 deletions(-) create mode 100644 src/attachment-naming.test.ts create mode 100644 src/attachment-naming.ts diff --git a/src/attachment-naming.test.ts b/src/attachment-naming.test.ts new file mode 100644 index 0000000..5ca13f1 --- /dev/null +++ b/src/attachment-naming.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; + +import { deriveAttachmentName, extForMime } from './attachment-naming.js'; + +describe('extForMime', () => { + it('returns empty for undefined / non-string / empty', () => { + expect(extForMime(undefined)).toBe(''); + expect(extForMime('')).toBe(''); + expect(extForMime({})).toBe(''); + expect(extForMime(null)).toBe(''); + expect(extForMime(42)).toBe(''); + }); + + it('maps common MIME types to canonical extensions', () => { + expect(extForMime('image/jpeg')).toBe('jpg'); + expect(extForMime('application/pdf')).toBe('pdf'); + expect(extForMime('audio/ogg')).toBe('ogg'); + }); + + it('strips parameters and is case-insensitive', () => { + expect(extForMime('image/JPEG; foo=bar')).toBe('jpg'); + expect(extForMime(' Application/PDF ')).toBe('pdf'); + expect(extForMime('text/plain; charset=utf-8')).toBe('txt'); + }); + + it('returns empty for unknown MIMEs', () => { + expect(extForMime('application/octet-stream')).toBe(''); + expect(extForMime('application/x-totally-made-up')).toBe(''); + }); +}); + +describe('deriveAttachmentName', () => { + it('returns explicit name when set, no derivation', () => { + expect(deriveAttachmentName({ name: 'photo.jpg', mimeType: 'application/pdf' })).toBe('photo.jpg'); + }); + + it('ignores empty / non-string explicit name and falls through to derivation', () => { + const out = deriveAttachmentName({ name: '', mimeType: 'application/pdf' }); + expect(out).toMatch(/^attachment-\d+\.pdf$/); + + const out2 = deriveAttachmentName({ name: 42, mimeType: 'application/pdf' }); + expect(out2).toMatch(/^attachment-\d+\.pdf$/); + }); + + it('derives extension from mimeType when no name', () => { + expect(deriveAttachmentName({ mimeType: 'application/pdf' })).toMatch(/^attachment-\d+\.pdf$/); + expect(deriveAttachmentName({ mimeType: 'image/jpeg' })).toMatch(/^attachment-\d+\.jpg$/); + }); + + it('falls back to att.type when mimeType is missing (Telegram photos/stickers)', () => { + expect(deriveAttachmentName({ type: 'photo' })).toMatch(/^attachment-\d+\.jpg$/); + expect(deriveAttachmentName({ type: 'sticker' })).toMatch(/^attachment-\d+\.webp$/); + expect(deriveAttachmentName({ type: 'voice' })).toMatch(/^attachment-\d+\.ogg$/); + expect(deriveAttachmentName({ type: 'animation' })).toMatch(/^attachment-\d+\.mp4$/); + }); + + it('case-insensitive att.type lookup', () => { + expect(deriveAttachmentName({ type: 'PHOTO' })).toMatch(/^attachment-\d+\.jpg$/); + }); + + it('returns bare timestamp when nothing matches', () => { + expect(deriveAttachmentName({})).toMatch(/^attachment-\d+$/); + expect(deriveAttachmentName({ mimeType: 'application/octet-stream' })).toMatch(/^attachment-\d+$/); + expect(deriveAttachmentName({ type: 'mystery-class' })).toMatch(/^attachment-\d+$/); + }); + + it('does not crash on non-string mimeType (defensive against buggy bridges)', () => { + expect(() => deriveAttachmentName({ mimeType: { foo: 'bar' } })).not.toThrow(); + expect(deriveAttachmentName({ mimeType: { foo: 'bar' } })).toMatch(/^attachment-\d+$/); + }); +}); diff --git a/src/attachment-naming.ts b/src/attachment-naming.ts new file mode 100644 index 0000000..2dfe8c1 --- /dev/null +++ b/src/attachment-naming.ts @@ -0,0 +1,69 @@ +/** + * Derive a safe, extensioned filename for inbound attachments when the + * channel bridge passes data without an explicit `name`. + * + * Two-step lookup: + * 1. `mimeType` → extension (Discord/Slack documents, Telegram document + * uploads — channels that set the MIME but not a filename). + * 2. `att.type` → extension (Telegram photos/stickers/voice/animations — + * coarse media-class set by the chat-sdk bridge with no MIME). + * + * Output is still passed through `isSafeAttachmentName` at the call site. + * The maps emit static values, so no derivation path can construct a + * traversal payload — only an attacker-controlled `att.name` can, and that + * goes through the safety guard unchanged. + */ + +// Map common MIME types to canonical file extensions. Without an extension, +// agents (and humans) can't tell what kind of file landed in the inbox, and +// tools keyed on extension (image viewers, exiftool, etc.) misbehave. +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', + 'image/heic': 'heic', + 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/mp4': 'm4a', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'application/json': 'json', + 'application/zip': 'zip', +}; + +// Fallback when `mimeType` is missing — Telegram photos and stickers arrive +// without an explicit MIME on the attachment object. The channel bridge sets +// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) +// which is reliable enough to derive a canonical extension. Telegram's GIFs +// are actually MP4, hence `animation: 'mp4'`. +const TYPE_TO_EXT: Record = { + image: 'jpg', + photo: 'jpg', + sticker: 'webp', + voice: 'ogg', + audio: 'mp3', + video: 'mp4', + animation: 'mp4', +}; + +export function extForMime(mime: unknown): string { + if (typeof mime !== 'string' || !mime) return ''; + const clean = mime.split(';')[0].trim().toLowerCase(); + return MIME_TO_EXT[clean] ?? ''; +} + +export function deriveAttachmentName(att: Record): string { + const explicit = att.name; + if (typeof explicit === 'string' && explicit) return explicit; + let ext = extForMime(att.mimeType); + if (!ext && typeof att.type === 'string') { + ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; + } + const ts = Date.now(); + return ext ? `attachment-${ts}.${ext}` : `attachment-${ts}`; +} diff --git a/src/session-manager.ts b/src/session-manager.ts index 342c155..7751fba 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -14,6 +14,7 @@ import type Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; +import { deriveAttachmentName } from './attachment-naming.js'; import { isSafeAttachmentName } from './attachment-safety.js'; import type { OutboundFile } from './channels/adapter.js'; import { DATA_DIR } from './config.js'; @@ -230,60 +231,6 @@ export function writeSessionMessage( updateSession(sessionId, { last_active: new Date().toISOString() }); } -// Map common MIME types to canonical file extensions. Used to derive a -// usable suffix when the channel bridge passes an attachment without an -// explicit `name`. Without an extension, agents (and humans) can't tell -// what kind of file landed in the inbox. -const MIME_TO_EXT: Record = { - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/webp': 'webp', - 'image/gif': 'gif', - 'image/heic': 'heic', - 'audio/ogg': 'ogg', - 'audio/mpeg': 'mp3', - 'audio/wav': 'wav', - 'audio/mp4': 'm4a', - 'video/mp4': 'mp4', - 'video/webm': 'webm', - 'video/quicktime': 'mov', - 'application/pdf': 'pdf', - 'text/plain': 'txt', - 'application/json': 'json', - 'application/zip': 'zip', -}; - -// Fallback when `mimeType` is missing — Telegram photos and stickers arrive -// without an explicit MIME on the attachment object. The channel bridge sets -// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.) -// which is reliable enough to derive a canonical extension. Telegram's GIFs -// are actually MP4, hence `animation: 'mp4'`. -const TYPE_TO_EXT: Record = { - image: 'jpg', - photo: 'jpg', - sticker: 'webp', - voice: 'ogg', - audio: 'mp3', - video: 'mp4', - animation: 'mp4', -}; - -function extForMime(mime: string | undefined): string { - if (!mime) return ''; - const clean = mime.split(';')[0].trim().toLowerCase(); - return MIME_TO_EXT[clean] ?? ''; -} - -function deriveAttachmentName(att: Record): string { - const explicit = att.name as string | undefined; - if (explicit) return explicit; - let ext = extForMime(att.mimeType as string | undefined); - if (!ext && typeof att.type === 'string') { - ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? ''; - } - return ext ? `attachment-${Date.now()}.${ext}` : `attachment-${Date.now()}`; -} - /** * If message content has attachments with base64 `data`, save them to * the session's inbox directory and replace with `localPath`. From 6e5e568da12a48e05dbfb0dcc8f10e67887936ff Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 30 Apr 2026 10:33:46 +0300 Subject: [PATCH 22/49] sanitize agent sent file names to prevent path traversal --- src/session-manager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/session-manager.ts b/src/session-manager.ts index 996a750..edd4b08 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -372,6 +372,11 @@ export function readOutboxFiles( if (!fs.existsSync(outboxDir)) return undefined; const files: OutboundFile[] = []; for (const filename of filenames) { + // Reject any name that isn't a bare basename before touching the filesystem. + if (!isSafeAttachmentName(filename)) { + log.warn('Refused unsafe outbox filename — would escape outbox', { messageId, filename }); + continue; + } const filePath = path.join(outboxDir, filename); if (fs.existsSync(filePath)) { files.push({ filename, data: fs.readFileSync(filePath) }); From 15f286b73dd9c6efe5c81d2a09b0ed942247de30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:34:23 +0000 Subject: [PATCH 23/49] chore: bump version to 2.0.18 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6287027..72020d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.17", + "version": "2.0.18", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 43f49b988ebdf202f43c25e739d0c53d127b99bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:40:16 +0000 Subject: [PATCH 24/49] =?UTF-8?q?docs:=20update=20token=20count=20to=20135?= =?UTF-8?q?k=20tokens=20=C2=B7=2068%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 20b07a3..86587f7 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 135k tokens, 67% of context window + + 135k tokens, 68% of context window From f828e2971cd22470a4f90f84ea6d0e6911dc50d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 07:40:21 +0000 Subject: [PATCH 25/49] chore: bump version to 2.0.19 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 72020d7..2b36863 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.18", + "version": "2.0.19", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 8a205808e0eeb5910afa6a8605defc7b05efff3a Mon Sep 17 00:00:00 2001 From: gabi-simons Date: Thu, 30 Apr 2026 07:56:34 +0000 Subject: [PATCH 26/49] 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 27/49] 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 28/49] 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 29/49] 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 30/49] 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 31/49] 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 32/49] 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 33/49] 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 34/49] 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 35/49] 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 36/49] 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 37/49] 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 38/49] 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 39/49] 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 40/49] 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 41/49] 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 42/49] 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 43/49] 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 44/49] 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 45/49] feat(setup): show under-the-sea lobster splash at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-line `NanoClaw` wordmark printed by nanoclaw.sh with a multi-line splash frame: the lobster mascot rendered as truecolor braille, drifting bubbles on either side, the figlet wordmark below (Nano in bold, Claw in cyan bold), three taglines — "Small.", "Runs on your machine.", "Yours to modify." — and a navy seafloor line. The frame is pre-rendered into `assets/setup-splash.txt` (built from `assets/nanoclaw-icon.png` via chafa for the lobster + figlet for the wordmark). nanoclaw.sh just streams the literal bytes — no runtime dependency on chafa, figlet, or ImageMagick. Total height: 30 lines. Visible width: ~40 columns (fits any terminal). Truecolor ANSI codes are used directly; terminals without truecolor support will see a degraded but still readable frame. Also removes the standalone "Small. Runs on your machine. Yours to modify." tagline line that nanoclaw.sh used to print above the bootstrap spinner — those taglines now appear inside the splash, so showing them again would duplicate. The wordmark-suppression flow downstream (`setup:auto` honoring `NANOCLAW_BOOTSTRAPPED=1`) is unchanged: the splash prints once in nanoclaw.sh, setup:auto's `printIntro()` sees the flag and keeps the clack `p.intro` line clean ("Let's get you set up."). --- assets/setup-splash.txt | 30 ++++++++++++++++++++++++++++++ nanoclaw.sh | 14 +++++++------- 2 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 assets/setup-splash.txt diff --git a/assets/setup-splash.txt b/assets/setup-splash.txt new file mode 100644 index 0000000..e4b77ec --- /dev/null +++ b/assets/setup-splash.txt @@ -0,0 +1,30 @@ + + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⣄⠘⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⡆⢸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ° + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠀⢀⣠⣴⠾⠟⠛⠛⠿⢶⣦⣾⠇⣾⠁⠀⠀⠀⢀⣤⣤⠀⢀⣄⠀ + ⠀⠀⠀⠀⣴⡿⡋⠀⠀⠀⠀⠀⢤⣾⣿⢛⢿⣏⠀⠀⠀⢰⣟⣽⡏⠀⣸⡿⣧ + o ⠀⠀⢀⣾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠘⠈⣧⣀⣿⣧⠀⠀⣿⣼⣿⣇⣾⠋⢠⣿ + ⠀⠀⣾⢃⠀⢲⣷⡋⣰⡀⢀⣀⣀⡀⠠⣿⣿⣠⣿⣇⠀⣿⢻⣉⠉⠙⠠⣼⠇ + ⠀⣼⡏⠃⠀⢸⣿⣿⡿⠃⣾⣷⣻⣿⡏⢹⠿⠿⣿⣿⢀⣿⣐⠙⣷⣦⡾⠋⠀ o + ⢠⣿⡃⠀⠀⠀⠀⠀⠈⠀⠀⠉⠙⠁⠀⠀⠀⠐⣿⣿⣟⠁⣿⣿⠟⠋⠀⠀⠀ + ° ⢸⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣨⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀ + ⢸⣿⣿⣷⣤⣤⠀⣀⢀⠀⢀⣀⣠⣴⣶⣿⣿⣿⣿⡿⠛⠁⠀⠀⠀⠀⠀⠀⠀ + ⣿⢋⠿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⠿⠿⠿⣿⣅⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀ O + ⣿⣿⠙⢾⣽⣟⣿⣿⣼⣿⣿⣿⣩⣶⣶⣦⠀⠀⠩⢻⣆⠀⠀⠀⠀⠀⠀⠀⠀ + ⠘⣿⣶⣤⣿⣿⣿⣿⣵⢖⡀⠉⠹⡛⢷⣝⡿⠁⠀⠀⣿⡆⠀⠀⠀⠀⠀⠀⠀ + ⠀⢹⣯⣽⣟⣛⣻⣿⣿⣾⣽⢶⣽⣿⣿⣿⣏⠀⠠⣤⣿⡇⠀⠀⠀⠀⠀⠀⠀ + ⠀⠀⠻⣿⣶⣾⣿⢿⣻⣿⣿⣿⣿⣿⣿⣏⣛⣧⣦⣿⣿⣧⣄⠀⠀⠀⠀⠀⠀ + o ⠀⠀⠀⠈⠻⣿⣶⣥⣼⣿⣿⣽⣿⣿⣿⣷⣶⣾⣿⣿⣯⣘⣿⣧⠀⠀⠀⠀⠀ + ⠀⠀⠀⠀⠤⣤⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠋⠀⠀⠀⠀⠀ + + _ _  ___ _  +| \| |__ _ _ _ ___  / __| |__ ___ __ __ +| .` / _` | ' \/ _ \| (__| / _` \ V V / +|_|\_\__,_|_||_\___/ \___|_\__,_|\_/\_/  + + Small. + Runs on your machine. + Yours to modify. + +════════════════════════════════════════ diff --git a/nanoclaw.sh b/nanoclaw.sh index 058dbbf..a3e5c1d 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -129,10 +129,13 @@ rm -f "$PROGRESS_LOG" mkdir -p "$STEPS_DIR" "$LOGS_DIR" write_header -# NanoClaw wordmark — clack's intro carries the "let's get you set up" framing, -# so we don't print a subtitle here. setup:auto sees NANOCLAW_BOOTSTRAPPED=1 and -# skips re-printing the wordmark, keeping the flow visually continuous. -printf '\n %s%s\n\n' "$(bold 'Nano')" "$(brand_bold 'Claw')" +# NanoClaw splash — under-the-sea lobster mascot in truecolor braille, +# with the figlet wordmark and taglines below. Pre-rendered into +# assets/setup-splash.txt (built from assets/nanoclaw-icon.png via chafa + +# figlet); the bash script just streams the literal frame. clack's intro +# then carries the "let's get you set up" framing — setup:auto sees +# NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark. +cat "$PROJECT_ROOT/assets/setup-splash.txt" # ─── pre-flight: Homebrew on macOS ───────────────────────────────────── # setup/install-node.sh and setup/install-docker.sh both require `brew` on @@ -188,9 +191,6 @@ BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log" BOOTSTRAP_LABEL="Installing the basics" BOOTSTRAP_START=$(date +%s) -# One-line "why" that teaches a differentiator while the user waits. -printf '%s %s\n' "$(gray '│')" \ - "$(dim "Small. Runs on your machine. Yours to modify.")" spinner_start "$BOOTSTRAP_LABEL" # Run in the background so we can tick elapsed time. Capture exit code via From 0218159ef032934730b09e9d9e3634192a3d0d2f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Apr 2026 19:54:21 +0000 Subject: [PATCH 46/49] 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 47/49] =?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 48/49] 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 49/49] 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",