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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.
Wraps the word "assistant" in `accentGreen` (#3fba50, added in #2103)
across the six channel adapters that ask "What should your assistant
be called?" — Discord, iMessage, Signal, Slack, Telegram, WhatsApp.
Mirrors the green emphasis on "you" in the display-name prompt: the
green word names the subject of the question (assistant vs operator)
so the operator parses it at a glance.
Adds a `brandBody` helper in setup/lib/theme.ts that wraps prose in
brand cyan (#2BB7CE), with the same TTY/NO_COLOR/truecolor gating used
by `brand`/`brandBold`/`brandChip`. The helper splits multi-line input
and colors each line independently so the SGR sequence doesn't bleed
across clack's gutter prefix.
Routing:
- `note()` (the un-dim card wrapper from #2095) now passes
`brandBody` as its `format` callback, so card bodies render
cyan line-by-line.
- Every prose `p.log.{message,info,success,step,warn}` call in the
setup flow wraps its body argument in `brandBody`. Calls whose
body is explicitly `k.dim(...)` (failure transcript tails, log
paths, claude-assist response previews) are left alone — those
are the "preview/debug" cases the dim-policy comment in
theme.ts already carves out.
- Spinner-finish lines in windowed-runner / claude-assist color
only the message portion; the `(5s)` elapsed suffix stays dim.
Brand cyan accents (chips, wordmark, inline emphasis) are unchanged.
This PR only adds the body color.
A follow-up will add OSC 11 dark/light detection so light-mode
terminals get a brand blue (#2b6fdc) variant — opt-in upgrade with
no regression for the dark-mode default.
Clack's `p.note` defaults to `format: e => styleText("dim", e)`, which
fades note bodies regardless of the project's stated readability stance
(see comment on `dimWrap` in setup/lib/theme.ts: "prose renders at the
terminal's regular weight"). The dim styling makes body copy hard to
read on dark terminals and visibly washes out brand-colored segments
embedded in cards (e.g. the chip + bold heading rows).
Add a `note()` helper in setup/lib/theme.ts that wraps `p.note` with a
pass-through formatter, and route every setup-flow `p.note` call site
through it: setup/auto.ts, every setup/channels/*.ts adapter, and the
two setup/lib/claude-* helpers.
Pre-styled segments (brandBold, brandChip, formatPairingCard,
formatCodeCard) now render at full strength instead of being faded
alongside surrounding prose.
- Container step: duration hint + 3-line rolling output window with
60s stall detector that offers "keep waiting" vs "ask Claude"
- First chat: reframed as a try-out with sandbox-model explainer
(wakes on message, sleeps when idle, context persists)
- Timezone: auto-detected non-UTC zones now get an explicit
confirm from the user instead of silent persist
- Outro: added always-on warning + prominent "check your DM" banner
when a channel was configured; directive last line
- Discord: always show token-location reminder even when user says
they have one; new "do you have a server?" branch walks through
server creation if not
- All select prompts: custom brightSelect renderer keeps inactive
option labels at full brightness (was dim gray); adds @clack/core
as a direct dep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two NanoClaw installs on the same host used to fight over the shared `com.nanoclaw` launchd label / `nanoclaw.service` systemd unit and the `nanoclaw-agent:latest` docker tag — the second install silently rewrote the service pointer and rebuilt the image out from under the first. Introduces a deterministic per-checkout slug (sha1(projectRoot)[:8]) and namespaces everything off it:
- Service: `com.nanoclaw-v2-<slug>` / `nanoclaw-v2-<slug>.service`
- Image: `nanoclaw-agent-v2-<slug>:latest` (base), `nanoclaw-agent-v2-<slug>:<agentGroupId>` (per-group)
New shared helpers: src/install-slug.ts (host) + setup/lib/install-slug.sh (bash). Both compute the same slug so verify/probe/add-*.sh/build.sh/container-runner all agree. Any v1 `com.nanoclaw` service left on the host stays untouched and can coexist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously init-first-agent auto-granted global owner to the first
user wired through it and left every subsequent user as nothing — no
role, no membership. That worked for the bootstrap path but broke the
second channel's welcome DM: the access gate saw no role + no
membership and dropped the message with accessReason='not_member'.
Make the role explicit:
- scripts/init-first-agent.ts accepts --role owner|admin|member
(default: owner). Role drives the grant:
owner -> global owner (agent_group_id=null)
admin -> admin scoped to this agent group
member -> no role row, just membership
Idempotent via getUserRoles pre-check — safe on re-runs. addMember
runs unconditionally (INSERT OR IGNORE) so the access gate has a
row even for users who'd otherwise pass via role alone.
- setup/lib/role-prompt.ts — shared askOperatorRole(channel) prompt
with owner as the default pick. Self-host single-operator is the
dominant case, so the user's fingers default to Enter.
- Telegram / Discord / WhatsApp drivers all call askOperatorRole
before resolving the agent name and pass --role <choice> through.
Captured in progression log via setupLog.userInput('<channel>_role').
Summary output drops the fragile "promoted on first owner" hint in
favor of a dedicated role: line ("owner (global)" / "admin (scoped to
<ag-id>)" / "member") so re-runs make the current grant legible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WhatsApp (community/Baileys) joins the setup:auto channel picker, with
the same clack-native UX discipline as Telegram and Discord:
- setup/channels/whatsapp.ts — driver. Collects auth method (QR terminal
or pairing code), runs the auth step, renders QR blocks in-place with
ANSI cursor-rewind on rotation so the terminal doesn't fill up with
stale codes, reads creds.me.id for the bot phone, restarts the service,
asks for the operator's personal phone (defaulting to the authed
number), writes ASSISTANT_HAS_OWN_NUMBER=true when they differ
(dedicated mode), and hands off to init-first-agent.
- setup/whatsapp-auth.ts — forked standalone auth step. Channels-branch
version had a browser-QR path with an HTTP server + <canvas> QR
renderer; stripped entirely (headless/SSH users hit dead ends too
often, and the extra deps complicate install). The remaining terminal
QR emits raw QR strings in WHATSAPP_AUTH_QR blocks so the parent
driver owns the rendering. Pairing-code path retained. Status blocks
now use the runner's vocabulary (success/skipped/failed) so spawnStep
sets ok correctly; WhatsApp-specific UI text ("WhatsApp linked", "You
chat") lives in the driver.
- setup/add-whatsapp.sh — non-interactive installer, mirror of
add-telegram.sh. Fetches the adapter + groups step from the channels
branch (whatsapp-auth.ts stays local, pair-telegram.ts pattern),
installs pinned baileys/qrcode/pino, registers the steps in
setup/index.ts's STEPS map. No service restart (adapter factory
returns null until creds exist).
Cross-channel fixes bundled:
- scripts/init-first-agent.ts: always addMember(user, agentGroup) for
the target user so subsequent wirings (not the first) pass the access
gate. Telegram wiring first → Discord/WhatsApp second was dropping
every inbound with accessReason='not_member' because only the first
user gets owner. namespacedPlatformId also passes through JID-format
raws (contains '@') so WhatsApp's bare <phone>@s.whatsapp.net matches
what the adapter stores.
- setup/service.ts: launchctl unload-then-load instead of bare load (bare
load errors 'already loaded' when a prior plist was cached, keeping
launchd on the OLD ProgramArguments even after the file on disk
changed). systemctl start → restart (start is a no-op on an active
unit, swallowing unit-file edits).
- setup/add-telegram.sh: removed the in-script open "tg://resolve"
block. The driver (setup/channels/telegram.ts) now owns the deep-link,
gated on a p.confirm so the browser can't steal focus unexpectedly.
- setup/channels/discord.ts + setup/channels/telegram.ts: every browser
open goes through confirmThenOpen (new shared helper in
setup/lib/browser.ts) — operator presses Enter before their browser
takes focus. Telegram switched from tg://resolve?domain= to
https://t.me/<bot> which works everywhere.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>