typeclaw 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -297,9 +297,30 @@ type LiveSession = {
297
297
  unsubProviderErrors: (() => void) | null
298
298
  }
299
299
 
300
+ // `event` is null for command invocations that originated outside the inbound
301
+ // pipeline (e.g. Discord native slash commands fired from listener.on
302
+ // ('interaction_create')). Handlers that need a real inbound — for some
303
+ // future hypothetical command like `/quote` — must guard on event !== null
304
+ // instead of assuming it.
300
305
  type ChannelCommandContext = {
301
306
  live: LiveSession
302
- event: InboundMessage
307
+ event: InboundMessage | null
308
+ }
309
+
310
+ export type ExecuteCommandResult =
311
+ | { kind: 'handled'; name: string }
312
+ | { kind: 'unknown-command'; name: string }
313
+ | { kind: 'no-live-session' }
314
+ | { kind: 'permission-denied' }
315
+ | { kind: 'ambiguous'; matchCount: number }
316
+
317
+ // Identifies who invoked an adapter-driven command. Required so the router
318
+ // can run the same channel.respond permission gate the text-prefix command
319
+ // path runs (isChannelRespondDenied in route()). Without it, a guest user
320
+ // in a public Slack channel could /stop an owner-created session that
321
+ // happened to be live, bypassing role gating entirely.
322
+ export type ExecuteCommandOptions = {
323
+ invokerId: string
303
324
  }
304
325
 
305
326
  export type SendSource = 'tool' | 'system'
@@ -345,6 +366,22 @@ export type ChannelRouter = {
345
366
  registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
346
367
  unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
347
368
  fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
369
+ // Execute a command by name against an existing live session, bypassing
370
+ // the inbound classifier, engagement gate, debounce, and prompt queue.
371
+ // Used by adapters that receive commands through a native surface
372
+ // (Discord application-command interactions) rather than text. Gates
373
+ // the invoker on channel.respond — same permission gate the text-prefix
374
+ // command path runs — so a guest user cannot abort an owner's session
375
+ // by clicking the slash-command picker. Adapters MUST forward the
376
+ // invoker's platform-specific user id; without it the gate cannot
377
+ // identify the actor and resolves to 'guest' which denies. Returns:
378
+ // - handled: command ran
379
+ // - permission-denied: invoker lacks channel.respond
380
+ // - no-live-session: channel has no active session
381
+ // - ambiguous: multiple thread-keyed sessions in same chat (Slack);
382
+ // caller should refuse to act rather than abort an arbitrary one
383
+ // - unknown-command: name is not registered
384
+ executeCommand: (key: ChannelKey, name: string, options: ExecuteCommandOptions) => Promise<ExecuteCommandResult>
348
385
  // Lowered self-aliases (configured + implicit dir-name). Adapters use
349
386
  // this to anchor outbound threading on alias-only inbounds — see
350
387
  // slack-bot-classify.ts. Read live so a reload of `alias` propagates
@@ -1733,6 +1770,48 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1733
1770
  }
1734
1771
  }
1735
1772
 
1773
+ const executeCommand = async (
1774
+ key: ChannelKey,
1775
+ name: string,
1776
+ options: ExecuteCommandOptions,
1777
+ ): Promise<ExecuteCommandResult> => {
1778
+ const lowered = name.toLowerCase()
1779
+ if (!commands.has(lowered)) {
1780
+ return { kind: 'unknown-command', name: lowered }
1781
+ }
1782
+ // Permission gate runs BEFORE the live-session lookup so a guest user
1783
+ // invoking /stop on a non-existent session gets 'permission-denied'
1784
+ // (consistent answer regardless of session state) rather than leaking
1785
+ // session presence via the 'no-live-session' vs 'permission-denied'
1786
+ // distinction.
1787
+ const partial: SessionOrigin = {
1788
+ kind: 'channel',
1789
+ adapter: key.adapter,
1790
+ workspace: key.workspace,
1791
+ chat: key.chat,
1792
+ thread: key.thread,
1793
+ lastInboundAuthorId: options.invokerId,
1794
+ }
1795
+ if (!permissions.has(partial, CORE_PERMISSIONS.channelRespond)) {
1796
+ return { kind: 'permission-denied' }
1797
+ }
1798
+ const resolved = resolveLiveSessionForCommand(liveSessions, key)
1799
+ if (resolved.kind === 'none') {
1800
+ return { kind: 'no-live-session' }
1801
+ }
1802
+ if (resolved.kind === 'ambiguous') {
1803
+ return { kind: 'ambiguous', matchCount: resolved.count }
1804
+ }
1805
+ const result = await commands.execute(`/${lowered}`, { live: resolved.session, event: null })
1806
+ if (result.kind === 'handled') {
1807
+ return { kind: 'handled', name: result.name }
1808
+ }
1809
+ // commands.execute can only return not-command (impossible — we pass a
1810
+ // leading slash), unknown-command (impossible — we just checked has()),
1811
+ // or handled. Any other outcome is a bug.
1812
+ return { kind: 'unknown-command', name: lowered }
1813
+ }
1814
+
1736
1815
  return {
1737
1816
  route,
1738
1817
  send,
@@ -1752,6 +1831,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1752
1831
  registerFetchAttachment,
1753
1832
  unregisterFetchAttachment,
1754
1833
  fetchAttachment,
1834
+ executeCommand,
1755
1835
  getSelfAliases: computeSelfAliases,
1756
1836
  stop,
1757
1837
  liveCount: () => liveSessions.size,
@@ -1912,6 +1992,52 @@ function consecutiveSendKey(chat: string, thread: string | null | undefined): st
1912
1992
  return `${chat}:${thread ?? ''}`
1913
1993
  }
1914
1994
 
1995
+ export type ResolveLiveSessionResult =
1996
+ | { kind: 'found'; session: LiveSession }
1997
+ | { kind: 'none' }
1998
+ | { kind: 'ambiguous'; count: number }
1999
+
2000
+ // Lookup policy for adapter-driven commands. Exact-key match always wins.
2001
+ // On miss, fall back to (adapter, workspace, chat) without thread — but
2002
+ // only when EXACTLY ONE non-destroyed candidate exists. Ambiguous matches
2003
+ // return 'ambiguous' so the caller can refuse to act rather than abort an
2004
+ // arbitrary session.
2005
+ //
2006
+ // Why the fallback: Slack slash commands carry channel_id but no thread_ts,
2007
+ // so a slash invocation from a thread-keyed live session would otherwise
2008
+ // report no-live-session. Discord doesn't hit this — Discord treats threads
2009
+ // as channels, so the exact-key path already resolves.
2010
+ //
2011
+ // Why ambiguity-rejection: "first match wins" map-iteration semantics would
2012
+ // abort an arbitrary thread when multiple thread-keyed sessions coexist in
2013
+ // one channel (plausible on Slack: bot mentioned in multiple threads). The
2014
+ // user's slash command picker doesn't know about threads; we don't know
2015
+ // which they meant; refusing is safer than guessing.
2016
+ export function resolveLiveSessionForCommand(
2017
+ liveSessions: ReadonlyMap<string, LiveSession>,
2018
+ key: ChannelKey,
2019
+ ): ResolveLiveSessionResult {
2020
+ const exact = liveSessions.get(channelKeyId(key))
2021
+ if (exact && !exact.destroyed) return { kind: 'found', session: exact }
2022
+
2023
+ const matches: LiveSession[] = []
2024
+ for (const candidate of liveSessions.values()) {
2025
+ if (candidate.destroyed) continue
2026
+ if (
2027
+ candidate.key.adapter === key.adapter &&
2028
+ candidate.key.workspace === key.workspace &&
2029
+ candidate.key.chat === key.chat
2030
+ ) {
2031
+ matches.push(candidate)
2032
+ if (matches.length > 1) {
2033
+ return { kind: 'ambiguous', count: matches.length }
2034
+ }
2035
+ }
2036
+ }
2037
+ if (matches.length === 1) return { kind: 'found', session: matches[0]! }
2038
+ return { kind: 'none' }
2039
+ }
2040
+
1915
2041
  function normalizeSendText(text: string | undefined): string | undefined {
1916
2042
  if (text === undefined) return undefined
1917
2043
  if (text === '') return undefined
package/src/cli/role.ts CHANGED
@@ -95,8 +95,13 @@ const listSub = defineCommand({
95
95
  },
96
96
  async run() {
97
97
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
98
- const { loadConfigSync } = await import('@/config')
99
- const config = loadConfigSync(cwd)
98
+ // Diagnostic command: route through `loadConfigSyncOrDefaults` (same
99
+ // soft-fail pattern as PR #288's `status`/`doctor` and the follow-up for
100
+ // `model list`) so a broken `typeclaw.json` doesn't crash the very
101
+ // command users reach for to see which roles the agent thinks it has.
102
+ // Defaults have no `roles` block, so the empty-state hint fires next.
103
+ const { loadConfigSyncOrDefaults } = await import('@/config')
104
+ const config = loadConfigSyncOrDefaults(cwd)
100
105
  if (!config.roles || Object.keys(config.roles).length === 0) {
101
106
  console.log(c.dim('No roles declared. Run `typeclaw role claim` to add one, or edit typeclaw.json by hand.'))
102
107
  return
package/src/cli/tunnel.ts CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path'
4
4
  import { select, text, isCancel, cancel, log } from '@clack/prompts'
5
5
  import { defineCommand } from 'citty'
6
6
 
7
- import { loadConfigSync } from '@/config'
7
+ import { loadConfigSync, validateConfig } from '@/config'
8
8
  import { resolveHostPort, resolveTuiToken } from '@/container'
9
9
  import { findAgentDir, isInitialized } from '@/init'
10
10
  import type { ClientMessage, ServerMessage, TunnelLogsServerMessage, TunnelSnapshot } from '@/shared'
@@ -168,6 +168,15 @@ export async function runTunnelAddFlow(
168
168
  args: AddArgs,
169
169
  prompts: TunnelPrompts = defaultPrompts,
170
170
  ): Promise<LiveResult<TunnelConfig>> {
171
+ // Strict gate before any read: a malformed or schema-invalid `typeclaw.json`
172
+ // would otherwise throw out of the subsequent `loadConfigSync` and surface
173
+ // as an uncaught exception instead of the clean exit-1-with-reason that
174
+ // every other LiveResult consumer expects. Same fence PR #288 documented
175
+ // for the `start`/`restart`/`reload` path: destructive paths route through
176
+ // `validateConfig` so the file's invariants are checked once, up front,
177
+ // and the rest of the flow can lean on them.
178
+ const validation = validateConfig(cwd)
179
+ if (!validation.ok) return { ok: false, reason: validation.reason }
171
180
  const config = loadConfigSync(cwd)
172
181
  if (config.tunnels.some((entry) => entry.name === args.name))
173
182
  return { ok: false, reason: `tunnel "${args.name}" already exists` }
@@ -206,6 +215,9 @@ export async function runTunnelAddFlow(
206
215
  }
207
216
 
208
217
  export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{ removed: TunnelConfig }> {
218
+ // Same strict gate as `runTunnelAddFlow`. See the comment there for why.
219
+ const validation = validateConfig(cwd)
220
+ if (!validation.ok) return { ok: false, reason: validation.reason }
209
221
  const config = loadConfigSync(cwd)
210
222
  const tunnel = config.tunnels.find((entry) => entry.name === args.name)
211
223
  if (tunnel === undefined) return { ok: false, reason: `unknown tunnel: ${args.name}` }
package/src/cli/ui.ts CHANGED
@@ -142,6 +142,27 @@ export const SLACK_APP_MANIFEST = {
142
142
  messages_tab_enabled: true,
143
143
  messages_tab_read_only_enabled: false,
144
144
  },
145
+ // Slash commands listed here appear in Slack's compose-box picker with
146
+ // their description as a tooltip. `url` is required by Slack's manifest
147
+ // schema even for Socket Mode bots, but is ignored at runtime when the
148
+ // app is in Socket Mode — Slack delivers `slash_commands` envelopes
149
+ // over the same WebSocket as message events. We point it at a
150
+ // deliberately-invalid placeholder (RFC 6761 reserved .invalid TLD)
151
+ // so a misconfigured (non-Socket-Mode) deployment fails fast rather
152
+ // than silently routing real slash invocations to a third-party URL.
153
+ slash_commands: [
154
+ {
155
+ command: '/stop',
156
+ description: 'Abort the current turn in this channel',
157
+ // usage_hint is intentionally omitted. Slack's manifest validator
158
+ // rejects an empty string ("Must be more than 0 characters") but
159
+ // the field is optional, so the cleanest answer is to leave it out
160
+ // rather than invent placeholder text for a command that takes no
161
+ // arguments.
162
+ url: 'https://example.invalid/typeclaw-uses-socket-mode',
163
+ should_escape: false,
164
+ },
165
+ ],
145
166
  },
146
167
  oauth_config: {
147
168
  scopes: {
@@ -150,13 +171,16 @@ export const SLACK_APP_MANIFEST = {
150
171
  // write scopes (chat, files, im/mpim/groups, pins, reactions) let the
151
172
  // agent post replies, upload attachments, open DMs, pin messages, and
152
173
  // react to messages. `channels:join` lets the bot self-join public
153
- // channels it's invited to discuss in.
174
+ // channels it's invited to discuss in. `commands` is required for
175
+ // Slack to deliver `slash_commands` envelopes — without it, slash
176
+ // commands registered in `features` would silently fail to route.
154
177
  bot: [
155
178
  'app_mentions:read',
156
179
  'channels:history',
157
180
  'channels:join',
158
181
  'channels:read',
159
182
  'chat:write',
183
+ 'commands',
160
184
  'emoji:read',
161
185
  'files:read',
162
186
  'files:write',
@@ -10,6 +10,7 @@ export {
10
10
  gitSchema,
11
11
  gitignoreSchema,
12
12
  loadConfigSync,
13
+ loadConfigSyncOrDefaults,
13
14
  loadPluginConfigsSync,
14
15
  migrateLegacyConfigShape,
15
16
  modelsSchema,
@@ -3,7 +3,7 @@ import { join } from 'node:path'
3
3
 
4
4
  import { commitSystemFileSync } from '@/git/system-commit'
5
5
 
6
- import { configSchema, loadConfigSync, validateConfig } from './config'
6
+ import { configSchema, loadConfigSyncOrDefaults, validateConfig } from './config'
7
7
  import {
8
8
  KNOWN_PROVIDERS,
9
9
  listKnownModelRefs,
@@ -33,8 +33,16 @@ export type ModelProfileEntry = {
33
33
 
34
34
  export type ModelMutationResult = { ok: true } | { ok: false; reason: string }
35
35
 
36
+ // `listModelProfiles` is the read-only path behind `typeclaw model list`, a
37
+ // diagnostic command. It routes through `loadConfigSyncOrDefaults` (same
38
+ // soft-fail pattern as `typeclaw status` / `doctor`, PR #288) so a broken
39
+ // `typeclaw.json` doesn't crash the command users reach for to see what
40
+ // model config the agent thinks it has. Mutation paths (`setProfile`,
41
+ // `addProfile`, `removeProfile`) stay on the strict gate via `validateConfig`
42
+ // in `writeModels`, because writing through a broken-on-disk file would
43
+ // silently land schema-invalid bytes.
36
44
  export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.env): ModelProfileEntry[] {
37
- const models = loadConfigSync(cwd).models
45
+ const models = loadConfigSyncOrDefaults(cwd).models
38
46
  const out: ModelProfileEntry[] = []
39
47
  for (const [profile, refs] of Object.entries(models)) {
40
48
  const headRef = refs[0]!
@@ -394,14 +394,101 @@ RUN echo "${encoded}" | base64 -d > ${TYPECLAW_ENTRYPOINT_PATH} \\
394
394
  // `~/.local/bin/claude` shim, which itself dereferences to the versioned
395
395
  // binary under `~/.local/share/claude/versions/<ver>/`, so upgrades via
396
396
  // `claude update` keep working without re-running this layer.
397
+ // `~/.claude.json` is Claude Code's internal state file (NOT
398
+ // `~/.claude/settings.json`, which is user-facing). On first run with an
399
+ // empty or missing file, `claude` enters a TTY-only theme picker:
400
+ // "Welcome to Claude Code … Choose the text style that looks best with
401
+ // your terminal" with 7 options. The picker is unskippable via CLI
402
+ // flags or env vars (no `--skip-onboarding`, no `--theme=dark`;
403
+ // `IS_DEMO=1` exists but has documented side effects). The single
404
+ // official escape hatch is writing `{"hasCompletedOnboarding": true,
405
+ // "theme": "dark"}` to `~/.claude.json` before the first launch —
406
+ // confirmed by Anthropic in multiple GitHub issues
407
+ // (anthropics/claude-code#4714, #8938, #13827) and the empirical
408
+ // answer used by metabase/metabase's `bin/claude-dangerous`, the
409
+ // `claudeCodeAlDevContainer` feature, and dozens of other Docker
410
+ // integrations.
411
+ //
412
+ // Without the pre-seed, the very first agent-driven `tmux new-session …
413
+ // claude` invocation hangs on the theme picker: the agent's
414
+ // `send-keys "<prompt>" Enter` arrives at the picker, gets interpreted
415
+ // as picker input, and never reaches claude's actual prompt. The
416
+ // `typeclaw-claude-code` skill is structured around a `Stop`-hook
417
+ // sentinel, which never fires while the picker is up, so the polling
418
+ // loop only learns of the hang at the 10-minute wall-clock budget.
419
+ // Pre-seeding here costs ~85 bytes on disk and zero runtime overhead.
420
+ //
421
+ // SCOPE: this seed is NECESSARY but not SUFFICIENT for a fully
422
+ // no-questions-asked first launch. Claude Code also shows two
423
+ // post-seed modal dialogs that this file deliberately does NOT
424
+ // pre-clear:
425
+ // 1. "Detected a custom API key from environment. Do you want to use
426
+ // this API key?" — fires when ANTHROPIC_API_KEY is set. Options
427
+ // `[No (recommended), Yes]`, focus on No, picker does NOT wrap.
428
+ // 2. Workspace trust ("Do you trust the files in this folder?") —
429
+ // fires on every new cwd. Options `[Yes, proceed, No, exit]`,
430
+ // focus on Yes.
431
+ // Both are kept as runtime decisions handled by the
432
+ // `typeclaw-claude-code` skill (see its "Driving the session" section,
433
+ // "Clear startup dialogs" step, which uses dialog-specific keystrokes
434
+ // because the picker doesn't wrap). Pre-seeding
435
+ // `hasTrustDialogAccepted` or `customApiKeyResponses.approved` here
436
+ // would silently widen the trust surface in ways the operator hasn't
437
+ // consented to — the seed's job is strictly cosmetic-wizard removal,
438
+ // not trust/permission preemption.
439
+ //
440
+ // `theme: "dark"` matches typeclaw's default TUI theme so the visual
441
+ // transition between the typeclaw TUI and a tmux-attached claude pane
442
+ // is consistent. Users on light terminals can override by editing
443
+ // `~/.claude.json` (which persists across container restarts only if
444
+ // they mount it; in the default container-ephemeral state it resets
445
+ // to this default on every rebuild, which is fine — `claude` reads
446
+ // the file at startup and the theme has no behavioral impact).
447
+ //
448
+ // `lastOnboardingVersion` is INTENTIONALLY OMITTED. ii-agent and a
449
+ // few other templates ship `lastOnboardingVersion: "1.0.30"`, but
450
+ // that value is version-coupled and goes stale on every Claude Code
451
+ // release. Empirically against Claude Code 2.1.146, the current
452
+ // `hasCompletedOnboarding: true` alone is honored without a version
453
+ // pin. If a future Claude version starts re-triggering the picker
454
+ // when the field is missing, capture `claude --version` output at
455
+ // build time and inject it then — don't hardcode a stale value.
456
+ //
457
+ // `installMethod: "native"` and `numStartups: 1` match the shape
458
+ // Claude Code itself writes after a clean first launch; keeping them
459
+ // makes our seed indistinguishable from a real post-onboarding state,
460
+ // which minimizes the chance of a future "if the file looks like
461
+ // agent-pre-seed, redo onboarding" detection heuristic landing on us.
462
+ //
463
+ // Built via `JSON.stringify` rather than a hand-written string
464
+ // literal so quote/escape bugs surface as TS errors at compile time,
465
+ // not as a corrupt `~/.claude.json` discovered only when the build
466
+ // runs. The `printf '%s\\n' '<JSON>'` shell pattern relies on the
467
+ // JSON containing no single quotes (true by construction — JSON.
468
+ // stringify only emits double quotes); a regression test parses the
469
+ // emitted JSON back to confirm.
470
+ const CLAUDE_CODE_ONBOARDING_SEED = JSON.stringify({
471
+ hasCompletedOnboarding: true,
472
+ theme: 'dark',
473
+ installMethod: 'native',
474
+ numStartups: 1,
475
+ })
476
+
397
477
  function renderClaudeCodeInstallLayer(enabled: boolean): string {
398
478
  if (!enabled) return ''
399
479
  return `# Layer 5.6 (toggle): install Anthropic's Claude Code CLI. Opt-in via
400
480
  # typeclaw.json#docker.file.claudeCode. The skill \`typeclaw-claude-code\`
401
- # documents the auth + usage flow.
481
+ # documents the auth + usage flow. Pre-seed ~/.claude.json so the first
482
+ # launch skips the TTY-only theme picker; see CLAUDE_CODE_ONBOARDING_SEED
483
+ # above for the rationale and what the seed deliberately does NOT cover.
484
+ # The seed write runs LAST in the chain so the final layer state is
485
+ # exactly the seeded config — independent of whether any earlier command
486
+ # (or a future Claude version's \`--version\` smoke test) writes a
487
+ # default \`~/.claude.json\` partway through the layer.
402
488
  RUN curl -fsSL https://claude.ai/install.sh | bash \\
403
489
  && ln -sf "$HOME/.local/bin/claude" /usr/local/bin/claude \\
404
- && claude --version > /dev/null`
490
+ && claude --version > /dev/null \\
491
+ && printf '%s\\n' '${CLAUDE_CODE_ONBOARDING_SEED}' > "$HOME/.claude.json"`
405
492
  }
406
493
 
407
494
  // Shared-library runtime deps Chrome for Testing needs to launch on amd64
@@ -21,4 +21,4 @@ export {
21
21
  type TunnelSnapshot,
22
22
  } from './protocol'
23
23
 
24
- export { formatLocalDate, formatLocalDateTime } from './local-time'
24
+ export { formatLocalDate, formatLocalDateTime, resolveLocalTimezoneName } from './local-time'
@@ -19,3 +19,20 @@ function formatTimezoneOffset(date: Date): string {
19
19
  const abs = Math.abs(offsetMinutes)
20
20
  return `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`
21
21
  }
22
+
23
+ // IANA timezone name of the process (e.g. `Asia/Seoul`). Reads the resolved
24
+ // zone from Intl, falling back to `UTC` if the runtime cannot resolve one —
25
+ // this should never happen on Bun + tzdata-equipped containers, but the
26
+ // fallback keeps the prompt renderable rather than throwing during session
27
+ // creation. The returned name is what the agent shows the user when asked
28
+ // "what time is it" — pairing the wall clock with a recognizable zone name
29
+ // is what disambiguates "15:31 +09:00" from "15:31 KST" for a non-technical
30
+ // reader.
31
+ export function resolveLocalTimezoneName(): string {
32
+ try {
33
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
34
+ return zone && zone.length > 0 ? zone : 'UTC'
35
+ } catch {
36
+ return 'UTC'
37
+ }
38
+ }
@@ -9,6 +9,12 @@ You can delegate work to Claude Code, Anthropic's official coding agent. The age
9
9
 
10
10
  This skill is for the case where Claude Code is the right tool: hard architecture work, multi-file refactors, deep code analysis, a second-opinion read on something you wrote. It is **not** for trivial edits — the round-trip cost (worktree setup + process spawn + auth check + TUI init + at least one full Claude turn) is 15–45 seconds and several thousand tokens of someone else's context window. Do trivial edits yourself.
11
11
 
12
+ ## Run the delegation inside `operator`, not inline
13
+
14
+ Once you've decided Claude Code is the right tool, spawn the bundled `operator` subagent to do the actual driving — don't run the worktree setup, the tmux session, the polling loop, the multi-turn decision loop, and the cleanup inline in your own context. The whole loop typically takes several minutes and produces large amounts of intermediate output (TUI buffer captures, Stop sentinels per turn, JSONL transcript references); running it inline blocks the user from talking to you and burns through your context window before you ever get to the synthesis step. `operator` is write-capable and runs the same loop, then returns a clean final report (what claude produced, what `git diff main..cc-<id>` shows, what you should review). You ship the worktree, the prompt, and the safety constraints to operator; operator ships you back the diff and the summary.
15
+
16
+ Exception: a quick sanity ping (`claude --version` to check the binary exists, `env | grep ANTHROPIC` to check auth). Those are single fast bash calls — do them inline. The "spawn through operator" rule applies to anything that runs `claude` itself as an interactive TUI.
17
+
12
18
  ## When to delegate to Claude Code
13
19
 
14
20
  Use Claude Code for:
@@ -79,6 +85,7 @@ Before you spawn `claude` for any real work:
79
85
  - **`docker.file.claudeCode: true`** in `typeclaw.json`. Verify with `which claude`; if missing, the toggle isn't on. Tell the user to enable it and `typeclaw start --build`.
80
86
  - **`docker.file.tmux: true`** (default `true`, but check). Verify with `which tmux`.
81
87
  - **Auth set up** — see above. Verify with `env | grep -E '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)='`.
88
+ - **Onboarding pre-seeded.** The Dockerfile layer writes `~/.claude.json` with `hasCompletedOnboarding: true` and `theme: "dark"` so the first `claude` invocation skips the TTY-only theme picker / welcome wizard. **This is necessary but not sufficient** — even with the seed, Claude Code can still land on two other pre-prompt modals: the "Detected a custom API key from environment. Do you want to use this API key?" confirmation (when `ANTHROPIC_API_KEY` is set in env — default focus is **No**, so `Down Enter` is needed to accept) and the workspace trust dialog ("Do you trust the files in this folder?", default focus already on **Yes**, so a bare `Enter` accepts). The "Driving the session" section below clears them as a loop. If `~/.claude.json` is empty or missing entirely (custom mount, manual `rm`, a `CLAUDE_CONFIG_DIR` pointing at a fresh directory), the theme picker also reappears. Self-heal: `printf '%s\n' '{"hasCompletedOnboarding":true,"theme":"dark","installMethod":"native","numStartups":1}' > "$HOME/.claude.json"` before spawning, then retry.
82
89
  - **Agent folder is a git repo.** Verify with `git -C /agent rev-parse --is-inside-work-tree`. The worktree model below requires it. If the user's agent folder somehow isn't a repo (rare — `typeclaw init` scaffolds one), tell them to `git init && git add -A && git commit -m "initial"` first.
83
90
  - **No uncommitted changes that you care about.** `git -C /agent status --porcelain` should be clean, or you should be willing to set the working tree aside before delegating. The worktree is a separate checkout, so claude can't see your uncommitted changes — meaning claude operates on the last committed state. If the user wants claude to work with in-progress edits, commit them first (even on a WIP branch).
84
91
 
@@ -165,11 +172,29 @@ The minimum protocol — translate to your actual tool calls:
165
172
  1. Create the worktree, write the hook config (above).
166
173
  2. `tmux new-session -d -s cc-<id> -c /tmp/cc-<id> claude`.
167
174
  3. Wait ~3 seconds for the TUI to initialize.
168
- 4. `tmux send-keys -t cc-<id> "<your prompt>" Enter`.
169
- 5. **Poll** for `/tmp/cc-<id>/.done` in a 500ms-cadence loop with a wall-clock budget (default 10 minutes). On every iteration, also check `tmux has-session -t cc-<id>` — if the session died, claude crashed or auth failed.
170
- 6. When `.done` exists: `rm .done`, read `sentinel.json`, examine `last_assistant_message`.
171
- 7. Decide using the multi-turn loop below.
172
- 8. When done: `tmux send-keys -t cc-<id> "/exit" Enter && sleep 1 && tmux kill-session -t cc-<id>`.
175
+ 4. **Clear startup dialogs (BEFORE sending the task prompt).** Even with `~/.claude.json` pre-seeded, claude can land on one or both pre-prompt modals. Run this as a **loop**, not a one-shot: clearing one dialog can immediately reveal the next, and you must keep polling until claude's actual input prompt is visible (it renders a bottom-of-pane input box with a `╭` / `╰` border).
176
+
177
+ The two known modals, with the exact keystrokes for each (Claude Code's select widget does NOT wrap — pressing `Up` from the first option is a no-op, so the direction must match the dialog's option order):
178
+ - **Custom API key confirmation** — "Detected a custom API key from environment. Do you want to use this API key?" Fires when `ANTHROPIC_API_KEY` is set (exactly typeclaw's auth path). Options are `[No (recommended), Yes]` with focus initialized on **No**. Resolution: `tmux send-keys -t cc-<id> Down Enter` to advance to **Yes** and submit. Sending `Up Enter` would submit the **No** answer, which can persist as a rejection in `customApiKeyResponses.rejected` and break subsequent launches — never do that here.
179
+
180
+ - **Workspace trust** — "Do you trust the files in this folder?" Fires on first launch in any new cwd, so every fresh `/tmp/cc-<id>/` worktree triggers it. Options are `[Yes, proceed, No, exit]` with focus on the first option (**Yes**) by default. Resolution: bare `tmux send-keys -t cc-<id> Enter` — no arrow key needed. Always verify the pane text matches the trust dialog before pressing Enter; a misidentified modal would submit a different default.
181
+
182
+ Loop shape (translate to your tool calls):
183
+ 1. Capture the last ~15 lines: `tmux capture-pane -t cc-<id> -p -S -15`.
184
+ 2. If the capture contains the API key dialog text → `send-keys Down Enter`, sleep 500ms, goto 1.
185
+ 3. If the capture contains the trust dialog text → `send-keys Enter`, sleep 500ms, goto 1.
186
+ 4. If the capture shows the input box (`╭` border on a bottom line, no dialog text above it) → ready; exit the loop.
187
+ 5. Otherwise sleep 500ms, goto 1. Apply a wall-clock budget of ~10 seconds; if the loop hasn't reached step 4 by then, abort with `/exit` and surface to the user — claude is in a state this skill doesn't model.
188
+
189
+ Do not use a fixed 2-second wait then send the prompt — cold-start and slow-disk cases can deliver a dialog at 2.5s+, and sending the task prompt into a modal corrupts the session.
190
+
191
+ **Safety note**: accepting workspace trust on a fresh `/tmp/cc-<id>/` worktree is the right call **only when its `HEAD` is the intended clean state** — typically the agent folder's last good commit on a branch the user controls. If the user just merged a third-party PR, pulled a remote branch, or checked out an untrusted ref, the worktree carries that content too and "trusting" it gives claude tool access on potentially hostile code. Before auto-accepting trust, sanity-check: if the user hasn't said something equivalent to "delegate this to Claude Code", or if you're not confident the current `HEAD` is one the user authored or reviewed, surface the trust dialog to them instead. Do NOT extend even a legitimate trust acceptance to in-session permission prompts (Bash, Edit, etc.) — those still need per-turn judgment per the multi-turn decision loop below.
192
+
193
+ 5. `tmux send-keys -t cc-<id> "<your prompt>" Enter`.
194
+ 6. **Poll** for `/tmp/cc-<id>/.done` in a 500ms-cadence loop with a wall-clock budget (default 10 minutes). On every iteration, also check `tmux has-session -t cc-<id>` — if the session died, claude crashed or auth failed.
195
+ 7. When `.done` exists: `rm .done`, read `sentinel.json`, examine `last_assistant_message`.
196
+ 8. Decide using the multi-turn loop below.
197
+ 9. When done: `tmux send-keys -t cc-<id> "/exit" Enter && sleep 1 && tmux kill-session -t cc-<id>`.
173
198
 
174
199
  The full polling implementation, the ANSI-handling rules for `capture-pane` fallbacks, and the "tmux session died unexpectedly" recovery path are in `references/tmux-driving.md`.
175
200