From 306fa6f014319c81b24c11196d5a14a651ee85a3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 4 May 2026 12:21:43 +0000 Subject: [PATCH 01/15] feat(setup): clearer "Open Telegram" copy + mobile fallback hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two friction points in the Telegram channel's "Open Telegram" card, both surfaced when running setup on a VM-via-SSH where the user's local laptop has no Telegram client installed: 1. The opening sentence read "Opening @yourbot in Telegram so it's ready when the pairing code shows up." On a headless device that's misleading — nothing is auto-opened, the user has to click the link or use their phone. Rewrite as a direct, action-led instruction on the headless flow only: Open @yourbot in Telegram now — the pairing code is coming next, and that's where you'll send it. Plus a "Get started: " line and a full-strength mobile fallback hint inside the card so headless users have all self-serve options visible. On non-headless the original status-style line stays accurate (`xdg-open` / `open` does fire for users with Telegram desktop installed), so the card stays a single line. 2. Clicking `https://t.me/yourbot` silently fails when the user's local device has no Telegram client. Non-headless gains: - a "(must be installed here)" qualifier on the confirm prompt so users without Telegram desktop know up-front; - a single combined dim fallback line below the prompt: "If browser does not appear, please visit: — or search for @yourbot on your mobile." Direct `p.confirm` + `openUrl` instead of `confirmThenOpen` for the non-headless branch so we control the dim line fully (single combined line vs the helper's default URL-only line). Headless layout drives the same self-serve content via the card body itself; no confirm prompt fires there. --- setup/channels/telegram.ts | 40 ++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index bf474f2..7117f5d 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,7 +21,8 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { openUrl } from '../lib/browser.js'; +import { isHeadless } from '../platform.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -48,14 +49,37 @@ export async function runTelegramChannel(displayName: string): Promise { // installed, or the bot's web profile if not. tg://resolve?domain= is // more direct but silently fails when the scheme isn't registered. const botUrl = `https://t.me/${botUsername}`; - note( - [ + // Two card variants — auto-open fires only on GUI, so headless users + // need full self-serve instructions inside the card itself, while GUI + // users get a leaner status line plus the auto-open + a single + // combined dim fallback line (URL + mobile alternative) on the + // confirm prompt below. + if (isHeadless()) { + note( + [ + `Open @${botUsername} in Telegram now — the pairing code is coming next, and that's where you'll send it.`, + '', + `Get started: ${botUrl}`, + '', + `Don't have Telegram installed here? Open it on any device and search for @${botUsername}`, + ].join('\n'), + 'Open Telegram', + ); + } else { + note( `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, - formatNoteLink(botUrl), - ].filter((line): line is string => line !== null).join('\n'), - 'Open Telegram', - ); - await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); + 'Open Telegram', + ); + ensureAnswer( + await p.confirm({ + message: `Press Enter to open Telegram (must be installed here)\n${k.dim( + `If browser does not appear, please visit: ${botUrl} — or search for @${botUsername} in Telegram`, + )}`, + initialValue: true, + }), + ); + openUrl(botUrl); + } const install = await runQuietChild( 'telegram-install', From c795ecff6ec794e78718007b1ea2d9c8a5518cd3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:20:17 +0000 Subject: [PATCH 02/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Discord,=20WhatsApp,=20iMessage=20channel=20flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking the wrong messaging channel during setup left users with no way to bail out — they had to either complete the chosen flow or kill setup and start over. This adds a Back option to the first prompt of three channel sub-flows that share the same simple shape (one leading brightSelect that's easy to extend). Mechanics: - New `setup/lib/back-nav.ts` exports a BACK_TO_CHANNEL_SELECTION sentinel and ChannelFlowResult type. - `setup/auto.ts` wraps the channel dispatch in a while-loop; channels return BACK_TO_CHANNEL_SELECTION to bounce back to the chooser without restarting setup. Channels not yet wired return void and the loop exits after one pass, so the change is backwards compatible. - Discord, WhatsApp, iMessage each add a `← Back to channel selection` option to their first prompt. Telegram, Slack, Teams, and Signal will follow as separate PRs — they each need a slightly different shape (extra prompt insertions, gating inside multi-step flows, etc.) and are easier to review independently. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 67 ++++++++++++++++++++++---------------- setup/channels/discord.ts | 12 ++++--- setup/channels/imessage.ts | 54 ++++++++++++++++-------------- setup/channels/whatsapp.ts | 14 +++++--- setup/lib/back-nav.ts | 17 ++++++++++ 5 files changed, 104 insertions(+), 60 deletions(-) create mode 100644 setup/lib/back-nav.ts diff --git a/setup/auto.ts b/setup/auto.ts index b57672f..4b7ca46 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -29,6 +29,7 @@ import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js'; import { runDiscordChannel } from './channels/discord.js'; import { runIMessageChannel } from './channels/imessage.js'; import { runSignalChannel } from './channels/signal.js'; @@ -440,35 +441,45 @@ async function main(): Promise { let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { - channelChoice = await askChannelChoice(); - if (channelChoice !== 'skip' && channelChoice !== 'other') { - await resolveDisplayName(); - } - if (channelChoice === 'telegram') { - await runTelegramChannel(displayName!); - } else if (channelChoice === 'discord') { - await runDiscordChannel(displayName!); - } else if (channelChoice === 'whatsapp') { - await runWhatsAppChannel(displayName!); - } else if (channelChoice === 'signal') { - await runSignalChannel(displayName!); - } else if (channelChoice === 'teams') { - await runTeamsChannel(displayName!); - } else if (channelChoice === 'slack') { - await runSlackChannel(displayName!); - } else if (channelChoice === 'imessage') { - await runIMessageChannel(displayName!); - } else if (channelChoice === 'other') { - await askOtherChannelName(); - } else { - p.log.info( - brandBody( - wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', - 4, + // Loop so a channel sub-flow can return BACK_TO_CHANNEL_SELECTION on + // its first prompt and bounce the user back to the chooser without + // restarting setup. Channels not yet wired with the back option just + // return void and the loop exits after one pass. + let backed = true; + while (backed) { + backed = false; + channelChoice = await askChannelChoice(); + if (channelChoice !== 'skip' && channelChoice !== 'other') { + await resolveDisplayName(); + } + let result: void | typeof BACK_TO_CHANNEL_SELECTION; + if (channelChoice === 'telegram') { + await runTelegramChannel(displayName!); + } else if (channelChoice === 'discord') { + result = await runDiscordChannel(displayName!); + } else if (channelChoice === 'whatsapp') { + result = await runWhatsAppChannel(displayName!); + } else if (channelChoice === 'signal') { + await runSignalChannel(displayName!); + } else if (channelChoice === 'teams') { + await runTeamsChannel(displayName!); + } else if (channelChoice === 'slack') { + await runSlackChannel(displayName!); + } else if (channelChoice === 'imessage') { + result = await runIMessageChannel(displayName!); + } else if (channelChoice === 'other') { + await askOtherChannelName(); + } else { + p.log.info( + brandBody( + wrapForGutter( + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', + 4, + ), ), - ), - ); + ); + } + if (result === BACK_TO_CHANNEL_SELECTION) backed = true; } } diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 28c0254..ad9da17 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -27,6 +27,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; @@ -48,8 +49,10 @@ interface AppInfo { owner: { id: string; username: string } | null; } -export async function runDiscordChannel(displayName: string): Promise { - const hasBot = await askHasBotToken(); +export async function runDiscordChannel(displayName: string): Promise { + const choice = await askHasBotToken(); + if (choice === 'back') return BACK_TO_CHANNEL_SELECTION; + const hasBot = choice === 'yes'; if (!hasBot) { await walkThroughBotCreation(); } @@ -142,17 +145,18 @@ export async function runDiscordChannel(displayName: string): Promise { } } -async function askHasBotToken(): Promise { +async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> { const answer = ensureAnswer( await brightSelect({ message: 'Do you already have a Discord bot?', options: [ { value: 'yes', label: 'Yes, I have a bot token ready' }, { value: 'no', label: "No, walk me through creating one" }, + { value: 'back', label: '← Back to channel selection' }, ], }), ); - return answer === 'yes'; + return answer as 'yes' | 'no' | 'back'; } async function walkThroughBotCreation(): Promise { diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index 8c0b78d..5730fca 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; @@ -48,10 +49,11 @@ interface RemoteCreds { apiKey: string; } -export async function runIMessageChannel(displayName: string): Promise { +export async function runIMessageChannel(displayName: string): Promise { const isMac = os.platform() === 'darwin'; const mode = await askMode(isMac); + if (mode === 'back') return BACK_TO_CHANNEL_SELECTION; let remoteCreds: RemoteCreds | null = null; if (mode === 'local') { @@ -139,34 +141,38 @@ export async function runIMessageChannel(displayName: string): Promise { } } -async function askMode(isMac: boolean): Promise { +async function askMode(isMac: boolean): Promise { + const baseOptions = isMac + ? [ + { + value: 'local' as const, + label: 'Local (this Mac)', + hint: "uses this machine's iMessage account", + }, + { + value: 'remote' as const, + label: 'Remote (Photon API)', + hint: 'the bot lives on another server', + }, + ] + : [ + { + value: 'remote' as const, + label: 'Remote (Photon API)', + hint: 'only option off macOS', + }, + ]; const choice = ensureAnswer( - await brightSelect({ + await brightSelect({ message: 'How should iMessage run?', initialValue: isMac ? 'local' : 'remote', - options: isMac - ? [ - { - value: 'local', - label: 'Local (this Mac)', - hint: "uses this machine's iMessage account", - }, - { - value: 'remote', - label: 'Remote (Photon API)', - hint: 'the bot lives on another server', - }, - ] - : [ - { - value: 'remote', - label: 'Remote (Photon API)', - hint: 'only option off macOS', - }, - ], + options: [ + ...baseOptions, + { value: 'back', label: '← Back to channel selection' }, + ], }), ); - setupLog.userInput('imessage_mode', String(choice)); + if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice)); return choice; } diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 922c985..2a0de1a 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; import { @@ -53,8 +54,9 @@ const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); type AuthMethod = 'qr' | 'pairing-code'; -export async function runWhatsAppChannel(displayName: string): Promise { +export async function runWhatsAppChannel(displayName: string): Promise { const method = await askAuthMethod(); + if (method === 'back') return BACK_TO_CHANNEL_SELECTION; const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined; const install = await runQuietChild( @@ -148,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { } } -async function askAuthMethod(): Promise { +async function askAuthMethod(): Promise { const choice = ensureAnswer( await brightSelect({ message: 'How would you like to authenticate with WhatsApp?', @@ -163,10 +165,14 @@ async function askAuthMethod(): Promise { label: 'Enter a pairing code on your phone', hint: 'no camera needed', }, + { + value: 'back', + label: '← Back to channel selection', + }, ], }), - ) as AuthMethod; - setupLog.userInput('whatsapp_auth_method', choice); + ) as AuthMethod | 'back'; + if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice); return choice; } diff --git a/setup/lib/back-nav.ts b/setup/lib/back-nav.ts new file mode 100644 index 0000000..586d161 --- /dev/null +++ b/setup/lib/back-nav.ts @@ -0,0 +1,17 @@ +/** + * Channel-flow back-navigation sentinel. + * + * Each `runXxxChannel(displayName)` in `setup/channels/` may return either + * `void` (sub-flow completed normally) or `BACK_TO_CHANNEL_SELECTION` to + * signal "the user picked '← Back to channel selection' on my first + * prompt; please re-run the channel chooser." `setup/auto.ts` catches + * that signal and loops back to `askChannelChoice()`. + * + * Back is only offered on the *first* interactive prompt of each channel + * sub-flow — once the user has answered something, they're committed + * (subsequent steps may have side effects like opening browsers, hitting + * APIs, or installing adapter packages, none of which are easily undone). + */ +export const BACK_TO_CHANNEL_SELECTION = Symbol('BACK_TO_CHANNEL_SELECTION'); + +export type ChannelFlowResult = void | typeof BACK_TO_CHANNEL_SELECTION; From e1ecfb9c4866c301eb2f8d601ecb3465e3928d6c Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:29:23 +0000 Subject: [PATCH 03/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Telegram=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the back-nav scaffolding from the Discord/WhatsApp/iMessage PR — depends on setup/lib/back-nav.ts and the auto.ts loop. Telegram's "no existing token" path adds one extra prompt — a brightSelect "Ready to paste your bot token?" between the BotFather instructions and the token paste. Clack's p.password prompt doesn't support menu options so we can't fold Back into the paste itself; the cleanest fix is a separate gate immediately before. The "existing token" path doesn't add noise — the Yes/No confirm becomes Yes/No/Back. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/telegram.ts | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4b7ca46..bf3ce92 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -454,7 +454,7 @@ async function main(): Promise { } let result: void | typeof BACK_TO_CHANNEL_SELECTION; if (channelChoice === 'telegram') { - await runTelegramChannel(displayName!); + result = await runTelegramChannel(displayName!); } else if (channelChoice === 'discord') { result = await runDiscordChannel(displayName!); } else if (channelChoice === 'whatsapp') { diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index bf474f2..01a6675 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,7 +21,9 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -38,8 +40,10 @@ import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/th const DEFAULT_AGENT_NAME = 'Nano'; -export async function runTelegramChannel(displayName: string): Promise { - const token = await collectTelegramToken(); +export async function runTelegramChannel(displayName: string): Promise { + const tokenOrBack = await collectTelegramToken(); + if (tokenOrBack === 'back') return BACK_TO_CHANNEL_SELECTION; + const token = tokenOrBack; const botUsername = await validateTelegramToken(token); // Deep-link the user into the bot's chat so they're on the right screen @@ -131,17 +135,24 @@ export async function runTelegramChannel(displayName: string): Promise { } } -async function collectTelegramToken(): Promise { +async function collectTelegramToken(): Promise { const existing = readEnvKey('TELEGRAM_BOT_TOKEN'); if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { - const reuse = ensureAnswer(await p.confirm({ + const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({ message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, - initialValue: true, + options: [ + { value: 'yes', label: 'Yes, use the existing token' }, + { value: 'no', label: 'No, paste a new one' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'yes', })); - if (reuse) { + if (choice === 'back') return 'back'; + if (choice === 'yes') { setupLog.userInput('telegram_token', 'reused-existing'); return existing; } + // 'no' falls through to the paste flow below } note( @@ -159,6 +170,19 @@ async function collectTelegramToken(): Promise { 'Set up your Telegram bot', ); + // Back-aware gate before the password prompt — `p.password` doesn't + // accept extra options, so we offer Back as a separate brightSelect + // immediately after the BotFather instructions and before the paste. + const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({ + message: 'Ready to paste your bot token?', + options: [ + { value: 'continue', label: 'Yes, paste it on the next prompt' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'continue', + })); + if (proceed === 'back') return 'back'; + const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', From 6a54b699120ac7da9202d0e9c8555d5e5f84c5f6 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:32:34 +0000 Subject: [PATCH 04/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Slack=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the back-nav scaffolding from #2269 and the Telegram PR. Slack's first prompt was already a single-purpose "Press Enter to open Slack app settings" confirm. Replacing it with a 2-option brightSelect (Open / ← Back) folds the Back gate into the existing screen — net same number of prompts as before, just with a way out. The redundant confirmThenOpen Press-Enter step is dropped; openUrl is called inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/slack.ts | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index bf3ce92..b2d6dfc 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -464,7 +464,7 @@ async function main(): Promise { } else if (channelChoice === 'teams') { await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { - await runSlackChannel(displayName!); + result = await runSlackChannel(displayName!); } else if (channelChoice === 'imessage') { result = await runIMessageChannel(displayName!); } else if (channelChoice === 'other') { diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 0e3f052..0918075 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -25,7 +25,10 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { brightSelect } from '../lib/bright-select.js'; +import { formatNoteLink, openUrl } from '../lib/browser.js'; +import { isHeadless } from '../platform.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { readEnvKey } from '../environment.js'; @@ -42,8 +45,9 @@ interface WorkspaceInfo { botUserId: string; } -export async function runSlackChannel(displayName: string): Promise { - await walkThroughAppCreation(); +export async function runSlackChannel(displayName: string): Promise { + const intro = await walkThroughAppCreation(); + if (intro === 'back') return BACK_TO_CHANNEL_SELECTION; const token = await collectBotToken(); const signingSecret = await collectSigningSecret(); @@ -121,7 +125,7 @@ export async function runSlackChannel(displayName: string): Promise { showPostInstallChecklist(info); } -async function walkThroughAppCreation(): Promise { +async function walkThroughAppCreation(): Promise<'continue' | 'back'> { note( [ "You'll create a Slack app that the assistant talks through.", @@ -140,7 +144,20 @@ async function walkThroughAppCreation(): Promise { ].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'); + + // Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open + // Slack app settings" so users can bail out of Slack before we open the + // browser or ask for tokens. + const choice = ensureAnswer(await brightSelect<'open' | 'back'>({ + message: 'Open Slack app settings in your browser?', + options: [ + { value: 'open', label: 'Open Slack app settings' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'open', + })); + if (choice === 'back') return 'back'; + if (!isHeadless()) openUrl(SLACK_APPS_URL); ensureAnswer( await p.confirm({ @@ -148,6 +165,7 @@ async function walkThroughAppCreation(): Promise { initialValue: true, }), ); + return 'continue'; } async function collectBotToken(): Promise { From c44c7a6669d30f1fd75f674bb7981723b108639b Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:47:17 +0000 Subject: [PATCH 05/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Teams=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #2269 (back-nav scaffolding) plus the Telegram and Slack PRs. They share the same scaffolding file from #2269 — they don't compile without it, so they have to stack. Both Teams paths already had a brightSelect at the right place, so we just extend each with a Back option — no new prompts: - Existing-credentials path: Yes/No confirm becomes Yes/No/Back - Fresh-setup path: the very first stepGate ("How did that go?") gets a 4th option. Subsequent stepGates keep the original 3 options so we never lose mid-flow state. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/teams.ts | 55 ++++++++++++++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index b2d6dfc..8185b22 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -462,7 +462,7 @@ async function main(): Promise { } else if (channelChoice === 'signal') { await runSignalChannel(displayName!); } else if (channelChoice === 'teams') { - await runTeamsChannel(displayName!); + result = await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { result = await runSlackChannel(displayName!); } else if (channelChoice === 'imessage') { diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 41e2070..3691beb 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -30,6 +30,7 @@ import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { @@ -57,18 +58,24 @@ interface Collected { agentName?: string; } -export async function runTeamsChannel(_displayName: string): Promise { +export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; const existingAppId = readEnvKey('TEAMS_APP_ID'); const existingPassword = readEnvKey('TEAMS_APP_PASSWORD'); if (existingAppId && existingPassword) { - const reuse = ensureAnswer(await p.confirm({ + const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({ message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, - initialValue: true, + options: [ + { value: 'yes', label: 'Yes, use the existing credentials' }, + { value: 'no', label: "No, set up new ones" }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'yes', })); - if (reuse) { + if (choice === 'back') return BACK_TO_CHANNEL_SELECTION; + if (choice === 'yes') { collected.appId = existingAppId; collected.appPassword = existingPassword; collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; @@ -85,7 +92,8 @@ export async function runTeamsChannel(_displayName: string): Promise { printIntro(); - await confirmPrereqs({ collected, completed }); + const prereqsResult = await confirmPrereqs({ collected, completed }); + if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION; await stepPublicUrl({ collected, completed }); await stepAppRegistration({ collected, completed }); await stepClientSecret({ collected, completed }); @@ -116,7 +124,7 @@ function printIntro(): void { ); } -async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { +async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> { note( [ 'Before we start, confirm you have:', @@ -131,13 +139,36 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[] 'Prereqs', ); - await stepGate({ - stepName: 'teams-prereqs', - stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', - reshow: () => confirmPrereqs(args), - args, - }); + // Back-aware variant of stepGate — Back is only offered on the very first + // step of the Teams flow so users can bail out before any state is taken. + while (true) { + const choice = ensureAnswer( + await brightSelect<'done' | 'help' | 'reshow' | 'back'>({ + message: 'How did that go?', + options: [ + { value: 'done', label: "Done — let's continue" }, + { value: 'help', label: 'Stuck — hand me off to Claude' }, + { value: 'reshow', label: 'Show me the steps again' }, + { value: 'back', label: '← Back to channel selection' }, + ], + }), + ); + if (choice === 'back') return 'back'; + if (choice === 'done') break; + if (choice === 'help') { + await offerHandoff({ + step: 'teams-prereqs', + stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', + args, + }); + continue; + } + if (choice === 'reshow') { + return confirmPrereqs(args); + } + } args.completed.push('Prereqs confirmed.'); + return 'continue'; } // ─── step: public URL ────────────────────────────────────────────────── From decf18049ff06f582cbdab49dc4bc0f234df97d3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:51:21 +0000 Subject: [PATCH 06/15] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Signal=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #2269 (back-nav scaffolding) plus the Telegram, Slack, and Teams PRs. They share the same scaffolding file from #2269 — they don't compile without it, so they have to stack. Signal had no user-facing prompt before the install kicked off, so there was nothing to attach a Back option to. This adds a brief "Set up Signal" info card (what's about to happen, no new phone number needed) followed by a Continue/Back brightSelect. The card serves double duty — context for the install plus the Back gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/signal.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 8185b22..91ad83a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -460,7 +460,7 @@ async function main(): Promise { } else if (channelChoice === 'whatsapp') { result = await runWhatsAppChannel(displayName!); } else if (channelChoice === 'signal') { - await runSignalChannel(displayName!); + result = await runSignalChannel(displayName!); } else if (channelChoice === 'teams') { result = await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 8462a56..498690f 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -33,6 +33,8 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { brightSelect } from '../lib/bright-select.js'; import { type Block, type StepResult, @@ -48,7 +50,33 @@ import { accentGreen, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; -export async function runSignalChannel(displayName: string): Promise { +export async function runSignalChannel(displayName: string): Promise { + note( + [ + "NanoClaw links to Signal as a *secondary* device on your existing", + "phone — no new number needed. Your assistant will send and receive", + "messages as the number on that phone.", + '', + "Here's what's about to happen:", + '', + ' 1. Check that signal-cli is installed (we\'ll guide you if not)', + ' 2. Install the Signal adapter', + ' 3. Show a QR code — scan it from Signal → Settings → Linked Devices', + ' 4. Wire your assistant and send a welcome message', + ].join('\n'), + 'Set up Signal', + ); + + const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({ + message: 'Ready to set up Signal?', + options: [ + { value: 'continue', label: 'Continue' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'continue', + })); + if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION; + await ensureSignalCli(); const install = await runQuietChild( From 7fdd7eaa1c6c13057b1c23dd3c09f6f3661b465a Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 10:14:12 +0000 Subject: [PATCH 07/15] setup: update WhatsApp link instructions to "You / Settings" WhatsApp's mobile UI calls the menu "You" on iOS and "Settings" on Android (depending on platform/version). Both QR-scan and pairing-code captions only mentioned "Settings", so iOS users had to figure out the iOS-specific path on their own. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/whatsapp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 922c985..e95d0dc 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -312,7 +312,7 @@ async function renderQr(qr: string): Promise { const QRCode = await import('qrcode'); const qrText = await QRCode.toString(qr, { type: 'terminal', small: true }); const caption = k.dim( - ' Open WhatsApp → Settings → Linked Devices → Link a Device → scan.', + ' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.', ); return [...qrText.trimEnd().split('\n'), '', caption]; } catch { @@ -328,7 +328,7 @@ function formatPairingCard(code: string): string { '', ` ${brandBold(spaced)}`, '', - k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'), + k.dim(' Open WhatsApp → You / Settings → Linked Devices → Link a Device'), k.dim(' → "Link with phone number instead" → enter this code.'), k.dim(' It expires in ~60 seconds.'), ].join('\n'); From 92a2347dc540437f06acafd64cd40a770f596715 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 17:04:53 +0000 Subject: [PATCH 08/15] setup: auto-install signal-cli when missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user picks Signal in setup and signal-cli isn't on PATH, today NanoClaw bails with a GitHub releases link and tells them to re-run. That's a hard wall for non-technical users — GitHub releases pages are intimidating, and the Linux native build / Java decision isn't obvious. Replace the bail-out with a real install: a new install-signal-cli.sh script that does `brew install signal-cli` on macOS or downloads the native Linux release into ~/.local/bin (no Java, no sudo). Wired into ensureSignalCli with a spinner; probe again after, fall back to the original manual-install copy if anything fails. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/signal.ts | 62 ++++++++++++++++++++++------- setup/install-signal-cli.sh | 78 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 15 deletions(-) create mode 100755 setup/install-signal-cli.sh diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 8462a56..4e98ee1 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -134,42 +134,74 @@ export async function runSignalChannel(displayName: string): Promise { async function ensureSignalCli(): Promise { const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli'; - const probe = spawnSync(cli, ['--version'], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - if (!probe.error && probe.status === 0) return; + const probeFor = (): boolean => { + const r = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + return !r.error && r.status === 0; + }; + if (probeFor()) return; + note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We'll install it for you now — about 30 seconds, one-time only.", + '', + process.platform === 'darwin' + ? "On this Mac we'll use Homebrew (no admin password needed)." + : "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.", + ].join('\n'), + 'Setting up signal-cli', + ); + + const install = await runQuietChild( + 'install-signal-cli', + 'bash', + ['setup/install-signal-cli.sh'], + { + running: 'Installing signal-cli…', + done: 'signal-cli installed.', + }, + ); + + if (install.ok && probeFor()) return; + + const reason = install.terminal?.fields.ERROR; if (process.platform === 'darwin') { note( [ - "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We couldn't install signal-cli automatically.", + reason === 'homebrew_not_installed' + ? ' Reason: Homebrew is not installed.' + : ` Reason: ${reason ?? 'unknown'}.`, '', - 'The quickest way on macOS is Homebrew:', + 'You can install it manually:', '', k.cyan(' brew install signal-cli'), '', - "Install it in another terminal, then re-run setup.", + 'Then re-run setup.', ].join('\n'), - 'signal-cli not found', + "Couldn't install signal-cli", ); } else { note( [ - "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We couldn't install signal-cli automatically.", + ` Reason: ${reason ?? 'unknown'}.`, '', - 'Grab the latest release from GitHub:', + 'You can install it manually from GitHub:', '', k.cyan(' https://github.com/AsamK/signal-cli/releases'), '', - "Install it, make sure `signal-cli --version` works, then re-run setup.", + 'Then re-run setup.', ].join('\n'), - 'signal-cli not found', + "Couldn't install signal-cli", ); } await fail( - 'signal-install', - 'signal-cli is required but not installed.', - 'Install it and re-run setup.', + 'install-signal-cli', + 'signal-cli is required but the auto-install failed.', + 'Install it manually and re-run setup.', ); } diff --git a/setup/install-signal-cli.sh b/setup/install-signal-cli.sh new file mode 100755 index 0000000..870220e --- /dev/null +++ b/setup/install-signal-cli.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# install-signal-cli.sh — auto-install signal-cli on the host. +# +# NanoClaw needs `signal-cli` on PATH to talk to Signal. Picks the right +# install method per platform: +# macOS → `brew install signal-cli` (bottled, no Java needed) +# Linux → download latest native binary from GitHub releases to +# ~/.local/bin/signal-cli (no Java, no sudo) +# +# Emits the standard NanoClaw STATUS block on success or failure so the +# `runQuietChild` driver can parse the outcome. + +set -euo pipefail + +VERSION="0.14.3" +INSTALL_DIR="${HOME}/.local/bin" + +emit_status() { + local status=$1 error=${2:-} + echo "=== NANOCLAW SETUP: INSTALL_SIGNAL_CLI ===" + echo "STATUS: ${status}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[install-signal-cli] $*" >&2; } + +uname_s=$(uname) + +if [[ "${uname_s}" == "Darwin" ]]; then + if ! command -v brew >/dev/null 2>&1; then + emit_status failed "homebrew_not_installed" + exit 1 + fi + log "Installing signal-cli via Homebrew…" + brew install signal-cli >&2 || { + emit_status failed "brew_install_failed" + exit 1 + } + emit_status success + exit 0 +fi + +if [[ "${uname_s}" != "Linux" ]]; then + emit_status failed "unsupported_platform_${uname_s}" + exit 1 +fi + +# Linux native build (no Java required) → ~/.local/bin/signal-cli. +URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" +TARBALL=$(mktemp -t signal-cli.XXXXXX.tar.gz) + +log "Downloading signal-cli v${VERSION} (~96MB)…" +if ! curl -fLsS -o "${TARBALL}" "${URL}"; then + rm -f "${TARBALL}" + emit_status failed "download_failed" + exit 1 +fi + +log "Extracting…" +EXTRACT_DIR=$(mktemp -d) +if ! tar -xzf "${TARBALL}" -C "${EXTRACT_DIR}"; then + rm -rf "${TARBALL}" "${EXTRACT_DIR}" + emit_status failed "extract_failed" + exit 1 +fi + +mkdir -p "${INSTALL_DIR}" +log "Installing to ${INSTALL_DIR}/signal-cli…" +if ! mv "${EXTRACT_DIR}/signal-cli" "${INSTALL_DIR}/signal-cli"; then + rm -rf "${TARBALL}" "${EXTRACT_DIR}" + emit_status failed "install_failed" + exit 1 +fi +chmod +x "${INSTALL_DIR}/signal-cli" +rm -rf "${TARBALL}" "${EXTRACT_DIR}" + +emit_status success From 291a1fc8a47f5c0949c37fb96361b37739e3a2ea Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 17:09:39 +0000 Subject: [PATCH 09/15] update Signal intro copy to reflect auto-install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today's copy says "Check that signal-cli is installed (we'll guide you if not)" but the auto-install PR (#2281) makes that misleading — we don't guide, we just install. Update the intro list to match what will actually happen, and add a "no input needed for any of it" lead so users know to expect a hands-off run. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/signal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 498690f..5f5518d 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -57,9 +57,9 @@ export async function runSignalChannel(displayName: string): Promise Date: Tue, 5 May 2026 20:47:36 +0200 Subject: [PATCH 10/15] fix(setup): pin Baileys to 7.0.0-rc.9 in install-whatsapp scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2259 (Baileys v6→v7) was merged into the channels branch instead of main. PR #2260 was merged into main 28s later assuming v7 was already in place. The v6 pin survived in three sites while the WhatsApp adapter copied from origin/channels at install time was already on the v7 LID API, breaking every fresh migrate-v2.sh run at 2c-install-whatsapp with TS errors on remoteJidAlt/participantAlt/lid-mapping.update. Bumps the pin to 7.0.0-rc.9 (the version v1 has been running on for months) in: - setup/install-whatsapp.sh - setup/add-whatsapp.sh - .claude/skills/add-whatsapp/SKILL.md (install instruction) package.json + pnpm-lock.yaml are not touched here — install-whatsapp.sh mutates them at runtime via pnpm install with the corrected pin. Closes #2283 --- .claude/skills/add-whatsapp/SKILL.md | 2 +- setup/add-whatsapp.sh | 2 +- setup/install-whatsapp.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) mode change 100755 => 100644 setup/add-whatsapp.sh mode change 100755 => 100644 setup/install-whatsapp.sh diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 3f10ce1..232725f 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -57,7 +57,7 @@ groups: () => import('./groups.js'), ### 5. Install the adapter packages (pinned) ```bash -pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 ``` ### 6. Build diff --git a/setup/add-whatsapp.sh b/setup/add-whatsapp.sh old mode 100755 new mode 100644 index c7356af..be2dacc --- a/setup/add-whatsapp.sh +++ b/setup/add-whatsapp.sh @@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PROJECT_ROOT" # Keep in sync with .claude/skills/add-whatsapp/SKILL.md. -BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16" +BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9" QRCODE_VERSION="qrcode@1.5.4" QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" PINO_VERSION="pino@9.6.0" diff --git a/setup/install-whatsapp.sh b/setup/install-whatsapp.sh old mode 100755 new mode 100644 index 1c62d65..f18b87a --- a/setup/install-whatsapp.sh +++ b/setup/install-whatsapp.sh @@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then fi echo "STEP: pnpm-install" -pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 echo "STEP: pnpm-build" pnpm run build From 4d5af78d3589e868cbbb7a05ca407fabf56f79ad Mon Sep 17 00:00:00 2001 From: Ethan Munoz Date: Tue, 5 May 2026 23:34:14 +0200 Subject: [PATCH 11/15] fix(migrate-v2): probe correct OneCLI health endpoint (/api/health) migrate-v2.sh probes ${ONECLI_URL_CHECK}/health (with ONECLI_URL_CHECK defaulting to http://127.0.0.1:10254, the OneCLI web port). That path returns 404, so the detection branch never matches an already-running OneCLI instance and the script falls through to the install path. The web app's health endpoint is /api/health (apps/web/src/app/api/health/route.ts) and has been since the OneCLI repo was made public. /health was never exposed by the web on :10254 nor by the gateway on :10255 (the gateway uses /healthz). Verified against a running OneCLI v1.21.0: GET :10254/api/health -> 200 {"status":"ok","version":"1.21.0",...} GET :10254/health -> 404 (Next.js fallback HTML) GET :10255/healthz -> 200 GET :10255/health -> 400 (gateway parses non-/healthz as CONNECT) Closes #2285 --- migrate-v2.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrate-v2.sh b/migrate-v2.sh index 2325edd..ef3bda8 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -450,7 +450,7 @@ ONECLI_OK=false ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//') ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}" -if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then +if curl -sf "${ONECLI_URL_CHECK}/api/health" >/dev/null 2>&1; then step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")" ONECLI_OK=true log "OneCLI: running at $ONECLI_URL_CHECK" From ec23bd7a7e0c853fa838715fb0d5dfabd6eedd6e Mon Sep 17 00:00:00 2001 From: Ethan Munoz Date: Tue, 5 May 2026 23:49:18 +0200 Subject: [PATCH 12/15] fix(host-sweep): parse SQLite timestamps as UTC, not local time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite TIMESTAMP columns store UTC without a zone marker. `Date.parse` treats timezoneless ISO strings as local time, so on any non-UTC host every claim and processAfter looks (TZ offset) hours stale. That makes fresh claims trip the kill-claim path on the first sweep tick — every container gets killed within seconds of spawn. Two affected sites in host-sweep.ts: - decideStuckAction reads claim.status_changed and computes claimAge. On a TZ=Europe/Madrid host (UTC+2), a claim made 5s ago looks 7205s old and exceeds CLAIM_STUCK_MS (60s). - The orphan retry loop reads msg.processAfter and skips messages rescheduled into the future. On the same host, future timestamps look (TZ offset) hours in the past, so the skip is missed and tries gets bumped on every tick. Fix: introduce parseSqliteUtc(s) which appends "Z" only when no zone marker is present, then call it from both sites. Behavior under TZ=UTC is unchanged. Verified on a production v2 install on TZ=Europe/Madrid: with the patch applied, an idle container survived 30+ minutes without being killed (previously: killed within 60s of spawn). Tests: 5 new cases covering the bare/Z/+offset/invalid input matrix and a TZ-independence check. All 19 host-sweep tests pass and tsc clears against main. --- src/host-sweep.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/host-sweep.ts | 15 +++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts index bd2e233..0249f4d 100644 --- a/src/host-sweep.test.ts +++ b/src/host-sweep.test.ts @@ -12,6 +12,7 @@ import { CLAIM_STUCK_MS, _resetStuckProcessingRowsForTesting, decideStuckAction, + parseSqliteUtc, } from './host-sweep.js'; import type { Session } from './types.js'; @@ -292,3 +293,44 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => { expect(row.tries).toBe(1); // not bumped, the skip path held }); }); + +describe('parseSqliteUtc', () => { + // Regression: SQLite TIMESTAMP strings have no zone marker, but Date.parse + // treats those as local time. On non-UTC hosts this made every claim look + // (TZ offset) hours stale and tripped kill-claim on freshly-claimed messages. + // The helper appends "Z" only when no marker is present, so parsing is + // always anchored to UTC regardless of host timezone. + + const utcMs = Date.parse('2026-04-20T12:00:00.000Z'); + + it('treats a SQLite-style timestamp (no zone) as UTC', () => { + expect(parseSqliteUtc('2026-04-20 12:00:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00.000')).toBe(utcMs); + }); + + it('preserves an explicit Z marker', () => { + expect(parseSqliteUtc('2026-04-20T12:00:00.000Z')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T12:00:00z')).toBe(utcMs); + }); + + it('preserves an explicit numeric offset', () => { + // 14:00+02:00 == 12:00 UTC + expect(parseSqliteUtc('2026-04-20T14:00:00+02:00')).toBe(utcMs); + expect(parseSqliteUtc('2026-04-20T14:00:00+0200')).toBe(utcMs); + // 07:00-05:00 == 12:00 UTC + expect(parseSqliteUtc('2026-04-20T07:00:00-05:00')).toBe(utcMs); + }); + + it('returns NaN for unparseable input', () => { + expect(Number.isNaN(parseSqliteUtc('not a date'))).toBe(true); + }); + + it('does not drift across host timezones for SQLite-style input', () => { + // The helper itself is timezone-independent because it forces UTC parsing. + // (Verifying the regex branch — without the helper, `Date.parse` of the + // bare string returns different values depending on the host TZ.) + const bare = '2026-04-20T12:00:00'; + expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z')); + }); +}); diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 93a7e87..fbdd7e6 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -47,6 +47,17 @@ import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbe import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js'; import type { Session } from './types.js'; +/** + * SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse + * treats timezoneless ISO strings as local time, so on non-UTC hosts every + * timestamp looks (TZ offset) hours stale — leading to spurious kill-claim + * decisions on freshly-claimed messages. Append "Z" when no zone marker is + * present so Date.parse interprets the string as UTC. + */ +export function parseSqliteUtc(s: string): number { + return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z'); +} + const SWEEP_INTERVAL_MS = 60_000; // Absolute idle ceiling for a running container. If the heartbeat file hasn't // been touched in this long, the container is either stuck or doing genuinely @@ -95,7 +106,7 @@ export function decideStuckAction(args: { const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0); for (const claim of claims) { - const claimedAt = Date.parse(claim.status_changed); + const claimedAt = parseSqliteUtc(claim.status_changed); if (Number.isNaN(claimedAt)) continue; const claimAge = now - claimedAt; if (claimAge <= tolerance) continue; @@ -275,7 +286,7 @@ function resetStuckProcessingRows( // Already rescheduled for a future retry — don't bump tries again. The // wake path (sweep step 2) will fire when process_after elapses and a // fresh container will clean the orphan claim on startup. - if (msg.processAfter && Date.parse(msg.processAfter) > now) continue; + if (msg.processAfter && parseSqliteUtc(msg.processAfter) > now) continue; if (msg.tries >= MAX_TRIES) { markMessageFailed(inDb, msg.id); From 2db5173f07de03a3fc849d72ceea29f890a4b3b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 21:56:17 +0000 Subject: [PATCH 13/15] chore: bump version to 2.0.33 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 96f4ae9..3f4794c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.32", + "version": "2.0.33", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From eacb93c4e5071189bf43d0438c41f0d2296e7804 Mon Sep 17 00:00:00 2001 From: Ethan Munoz Date: Wed, 6 May 2026 00:29:54 +0200 Subject: [PATCH 14/15] fix(manage-channels): include canonical SQL queries in SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill's "Assess Current State" step said only "query agent_groups, messaging_groups, ..." without specifying columns. The `register` CLI takes `--assistant-name ""` (mentioned three times in the same SKILL.md), but the schema column is `name`, not `assistant_name` — and the SKILL.md never linked the two. When the agent had to compose a SELECT against `agent_groups` from the SKILL.md vocabulary alone, it extrapolated `--assistant-name` into a column name and produced: SELECT id, folder, assistant_name FROM agent_groups; -> Error: in prepare, no such column: assistant_name Replace the prose pointer with canonical SQL queries that match the real schema. The `name AS assistant_name` alias preserves the familiar term in the agent's output. Verified locally as a drop-in: `/manage-channels` runs clean from end to end with this version, no further inference needed. Closes #2289 --- .claude/skills/manage-channels/SKILL.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md index 9d84d3d..0b348d1 100644 --- a/.claude/skills/manage-channels/SKILL.md +++ b/.claude/skills/manage-channels/SKILL.md @@ -11,7 +11,16 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user ## Assess Current State -Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. +Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`): + +```sql +SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups; +SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups; +SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents; +SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC; +``` + +Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports. Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**. From 22715c163a1c8ef206194555762a4627a55124ca Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 6 May 2026 01:36:13 +0300 Subject: [PATCH 15/15] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 69f9ea2..f364d27 100644 --- a/README.md +++ b/README.md @@ -215,3 +215,5 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist ## License MIT + +