typeclaw 0.23.0 → 0.25.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.
Files changed (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +133 -27
  4. package/src/agent/llm-replay-sanitizer.ts +120 -0
  5. package/src/agent/loop-guard.ts +34 -0
  6. package/src/agent/multimodal/look-at.ts +1 -1
  7. package/src/agent/plugin-tools.ts +122 -8
  8. package/src/agent/restart/index.ts +15 -3
  9. package/src/agent/restart-handoff/index.ts +110 -12
  10. package/src/agent/session-origin.ts +30 -0
  11. package/src/agent/subagent-completion-reminder.ts +26 -1
  12. package/src/agent/subagents.ts +75 -3
  13. package/src/agent/system-prompt.ts +5 -1
  14. package/src/agent/todo/continuation-policy.ts +242 -0
  15. package/src/agent/todo/continuation-state.ts +87 -0
  16. package/src/agent/todo/continuation-wiring.ts +113 -0
  17. package/src/agent/todo/continuation.ts +71 -0
  18. package/src/agent/todo/scope.ts +77 -0
  19. package/src/agent/todo/store.ts +98 -0
  20. package/src/agent/tool-not-found-nudge.ts +126 -0
  21. package/src/agent/tools/channel-reply.ts +51 -0
  22. package/src/agent/tools/curl-impersonate.ts +2 -2
  23. package/src/agent/tools/restart.ts +11 -4
  24. package/src/agent/tools/spawn-subagent.ts +19 -2
  25. package/src/agent/tools/subagent-access.ts +40 -5
  26. package/src/agent/tools/subagent-cancel.ts +3 -1
  27. package/src/agent/tools/subagent-output.ts +6 -2
  28. package/src/agent/tools/todo/index.ts +119 -0
  29. package/src/agent/tools/webfetch/fetch.ts +18 -18
  30. package/src/agent/tools/webfetch/index.ts +1 -1
  31. package/src/agent/tools/webfetch/tool.ts +13 -13
  32. package/src/agent/tools/webfetch/types.ts +1 -1
  33. package/src/agent/tools/websearch.ts +6 -6
  34. package/src/bundled-plugins/backup/index.ts +40 -37
  35. package/src/bundled-plugins/backup/runner.ts +23 -2
  36. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  37. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  38. package/src/bundled-plugins/memory/README.md +11 -11
  39. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  40. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  41. package/src/bundled-plugins/operator/operator.ts +5 -1
  42. package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
  43. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  44. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  45. package/src/bundled-plugins/scout/scout.ts +7 -7
  46. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  47. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  48. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  49. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  50. package/src/channels/adapters/discord-bot.ts +25 -3
  51. package/src/channels/adapters/github/inbound.ts +172 -10
  52. package/src/channels/adapters/github/index.ts +10 -0
  53. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  54. package/src/channels/adapters/github/webhook-register.ts +32 -27
  55. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  56. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  57. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  58. package/src/channels/adapters/slack-bot.ts +67 -8
  59. package/src/channels/manager.ts +8 -2
  60. package/src/channels/router.ts +506 -45
  61. package/src/channels/schema.ts +21 -4
  62. package/src/channels/subagent-completion-bridge.ts +18 -18
  63. package/src/channels/types.ts +69 -1
  64. package/src/cli/inspect-controller.ts +132 -33
  65. package/src/cli/inspect.ts +2 -1
  66. package/src/commands/index.ts +9 -0
  67. package/src/container/start.ts +7 -1
  68. package/src/git/mutex.ts +22 -0
  69. package/src/git/reconcile-ignored.ts +214 -0
  70. package/src/hostd/daemon.ts +26 -1
  71. package/src/hostd/portbroker-manager.ts +7 -0
  72. package/src/init/dockerfile.ts +1 -1
  73. package/src/init/gitignore.ts +28 -16
  74. package/src/inspect/index.ts +53 -4
  75. package/src/inspect/loop.ts +16 -12
  76. package/src/plugin/define.ts +2 -2
  77. package/src/plugin/index.ts +2 -2
  78. package/src/portbroker/hostd-client.ts +36 -13
  79. package/src/run/index.ts +74 -5
  80. package/src/sandbox/build.ts +20 -0
  81. package/src/sandbox/index.ts +10 -0
  82. package/src/sandbox/policy.ts +22 -0
  83. package/src/sandbox/session-tmp.ts +43 -0
  84. package/src/sandbox/writable-zones.ts +178 -0
  85. package/src/server/command-runner.ts +1 -1
  86. package/src/server/index.ts +126 -4
  87. package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
  88. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  89. package/src/tui/format.ts +11 -11
  90. package/typeclaw.schema.json +10 -0
@@ -0,0 +1,214 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { SYSTEM_MANAGED_ROOTS, TRULY_IGNORED_PATTERNS } from '@/init/gitignore'
7
+
8
+ export type UntrackResult = { untracked: string[] }
9
+
10
+ // Removes from the index any tracked file that now matches a truly-ignored
11
+ // rule. .gitignore only affects UNtracked files, so a file committed before its
12
+ // ignore rule existed (e.g. public/review.json after `public/` was added to the
13
+ // template) keeps getting tracked forever; this reconciles that on every start.
14
+ // Files are removed with --cached, so they stay on disk.
15
+ //
16
+ // Best-effort, like commitSystemFile: no-ops when the folder is not a git repo,
17
+ // Bun is missing, the repo has no matching tracked files, or any git step fails.
18
+ // Never throws — start() hygiene must not block boot. The removals are staged
19
+ // but NOT committed here; the caller folds them into the .gitignore commit so
20
+ // the rule change and its index cleanup land atomically.
21
+ export async function untrackTrulyIgnoredFiles(
22
+ cwd: string,
23
+ customAppend: readonly string[] = [],
24
+ ): Promise<UntrackResult> {
25
+ const empty: UntrackResult = { untracked: [] }
26
+
27
+ const bun = getBun()
28
+ if (!bun) return empty
29
+ if (!existsSync(join(cwd, '.git'))) return empty
30
+
31
+ const patterns = [...TRULY_IGNORED_PATTERNS, ...customAppend]
32
+ let excludeDir: string | null = null
33
+ try {
34
+ excludeDir = await mkdtemp(join(tmpdir(), 'typeclaw-untrack-'))
35
+ const excludeFile = join(excludeDir, 'exclude')
36
+ await writeFile(excludeFile, `${patterns.join('\n')}\n`)
37
+
38
+ const candidates = await listTrackedIgnored(bun, cwd, excludeFile)
39
+ const removable = candidates.filter((path) => !isSystemManaged(path))
40
+ if (removable.length === 0) return empty
41
+
42
+ const removed = await gitRmCached(bun, cwd, removable)
43
+ return { untracked: removed }
44
+ } catch {
45
+ return empty
46
+ } finally {
47
+ if (excludeDir) await rm(excludeDir, { recursive: true, force: true }).catch(() => {})
48
+ }
49
+ }
50
+
51
+ // Commits exactly { .gitignore + the untrack removals } in one commit, leaving
52
+ // any UNRELATED user-staged work out of the commit but still staged.
53
+ //
54
+ // Why plumbing instead of `git commit -- <paths>`: a pathspec/--only commit
55
+ // re-snapshots the listed paths from the WORKING TREE, so a `git rm --cached`
56
+ // removal of a file that still exists on disk gets silently re-added. A plain
57
+ // `git commit` gets the removal right but sweeps in unrelated staged work. So
58
+ // we build the exact tree in a throwaway index seeded from HEAD, commit-tree
59
+ // it, and compare-and-swap HEAD via update-ref (race-safe against concurrent
60
+ // HEAD moves). This bypasses commit hooks, which is fine for a system commit.
61
+ //
62
+ // Caller contract: the REAL index already has .gitignore staged and the
63
+ // untracked paths removed (via git rm --cached). update-ref then makes the
64
+ // real index match the new HEAD, so those paths read clean afterward.
65
+ //
66
+ // If the plumbing path can't run (no HEAD, update-ref CAS race, transient git
67
+ // failure) we fall back to a plain `git commit` of the real index — but ONLY
68
+ // when the staged set is exactly { .gitignore + the removals }. This guarantees
69
+ // start()'s hygiene rewrite never gets stranded staged-but-uncommitted (leaving
70
+ // the repo dirty), while the staged-set check still protects any pre-existing
71
+ // user-staged work from being swept into the fallback commit.
72
+ export type CommitGitignoreDeps = {
73
+ // Seam so tests can force the plumbing path to fail and exercise the fallback;
74
+ // a real update-ref CAS race or transient git failure is otherwise hard to
75
+ // reproduce deterministically. Defaults to the real temp-index commit.
76
+ commitAtomic?: (
77
+ bun: BunLike,
78
+ cwd: string,
79
+ gitignoreFile: string,
80
+ untracked: readonly string[],
81
+ message: string,
82
+ ) => Promise<boolean>
83
+ }
84
+
85
+ export async function commitGitignoreWithUntracks(
86
+ cwd: string,
87
+ gitignoreFile: string,
88
+ untracked: readonly string[],
89
+ message: string,
90
+ deps: CommitGitignoreDeps = {},
91
+ ): Promise<boolean> {
92
+ const bun = getBun()
93
+ if (!bun) return false
94
+ if (!existsSync(join(cwd, '.git'))) return false
95
+ if (untracked.length === 0) return false
96
+
97
+ const commitAtomic = deps.commitAtomic ?? commitViaTempIndex
98
+ if (!(await run(bun, cwd, ['add', '--', gitignoreFile]))) return false
99
+ if (await commitAtomic(bun, cwd, gitignoreFile, untracked, message)) return true
100
+ return await commitRealIndexIfExactlyOurs(bun, cwd, gitignoreFile, untracked, message)
101
+ }
102
+
103
+ async function commitViaTempIndex(
104
+ bun: BunLike,
105
+ cwd: string,
106
+ gitignoreFile: string,
107
+ untracked: readonly string[],
108
+ message: string,
109
+ ): Promise<boolean> {
110
+ let indexDir: string | null = null
111
+ try {
112
+ const parent = (await capture(bun, cwd, ['rev-parse', '--verify', 'HEAD'])).trim()
113
+ if (parent.length === 0) return false
114
+
115
+ indexDir = await mkdtemp(join(tmpdir(), 'typeclaw-untrack-idx-'))
116
+ const env = { ...process.env, GIT_INDEX_FILE: join(indexDir, 'index') }
117
+ if (!(await run(bun, cwd, ['read-tree', parent], env))) return false
118
+ if (!(await run(bun, cwd, ['add', '--', gitignoreFile], env))) return false
119
+ if (!(await forceRemove(bun, cwd, untracked, env))) return false
120
+
121
+ const tree = (await capture(bun, cwd, ['write-tree'], env)).trim()
122
+ if (tree.length === 0) return false
123
+ const commit = (await capture(bun, cwd, ['commit-tree', tree, '-p', parent, '-m', message])).trim()
124
+ if (commit.length === 0) return false
125
+
126
+ return await run(bun, cwd, ['update-ref', '-m', message, 'HEAD', commit, parent])
127
+ } catch {
128
+ return false
129
+ } finally {
130
+ if (indexDir) await rm(indexDir, { recursive: true, force: true }).catch(() => {})
131
+ }
132
+ }
133
+
134
+ async function commitRealIndexIfExactlyOurs(
135
+ bun: BunLike,
136
+ cwd: string,
137
+ gitignoreFile: string,
138
+ untracked: readonly string[],
139
+ message: string,
140
+ ): Promise<boolean> {
141
+ const staged = (await capture(bun, cwd, ['diff', '--cached', '--name-only', '-z']))
142
+ .split('\0')
143
+ .filter((entry) => entry.length > 0)
144
+ if (staged.length === 0) return false
145
+
146
+ const expected = new Set([gitignoreFile, ...untracked])
147
+ if (staged.length !== expected.size || !staged.every((path) => expected.has(path))) return false
148
+
149
+ return await run(bun, cwd, ['commit', '-m', message])
150
+ }
151
+
152
+ // Hardcoded fail-closed guard: even if a custom git.ignore.append pattern
153
+ // matches a system-managed root, never untrack it. The git ls-files match is
154
+ // against the truly-ignored set only, but custom patterns are user-supplied and
155
+ // could be arbitrarily broad (`**`), so re-check here as the last line.
156
+ function isSystemManaged(path: string): boolean {
157
+ return SYSTEM_MANAGED_ROOTS.some((root) => path === root.replace(/\/$/, '') || path.startsWith(root))
158
+ }
159
+
160
+ async function listTrackedIgnored(bun: BunLike, cwd: string, excludeFile: string): Promise<string[]> {
161
+ const proc = bun.spawn({
162
+ cmd: ['git', 'ls-files', '-z', '-c', '-i', '--exclude-from', excludeFile],
163
+ cwd,
164
+ stdout: 'pipe',
165
+ stderr: 'pipe',
166
+ })
167
+ if ((await proc.exited) !== 0) return []
168
+ const raw = await new Response(proc.stdout).text()
169
+ return raw.split('\0').filter((entry) => entry.length > 0)
170
+ }
171
+
172
+ async function gitRmCached(bun: BunLike, cwd: string, files: string[]): Promise<string[]> {
173
+ const proc = bun.spawn({
174
+ cmd: ['git', 'rm', '--cached', '-q', '--', ...files],
175
+ cwd,
176
+ stdout: 'pipe',
177
+ stderr: 'pipe',
178
+ })
179
+ if ((await proc.exited) !== 0) return []
180
+ return files
181
+ }
182
+
183
+ type GitEnv = Record<string, string | undefined>
184
+
185
+ async function run(bun: BunLike, cwd: string, args: string[], env?: GitEnv): Promise<boolean> {
186
+ const proc = bun.spawn({ cmd: ['git', ...args], cwd, env, stdout: 'pipe', stderr: 'pipe' })
187
+ return (await proc.exited) === 0
188
+ }
189
+
190
+ async function capture(bun: BunLike, cwd: string, args: string[], env?: GitEnv): Promise<string> {
191
+ const proc = bun.spawn({ cmd: ['git', ...args], cwd, env, stdout: 'pipe', stderr: 'pipe' })
192
+ if ((await proc.exited) !== 0) return ''
193
+ return await new Response(proc.stdout).text()
194
+ }
195
+
196
+ // --force-remove drops the index entry regardless of the file existing on disk;
197
+ // the NUL-delimited stdin form is safe for paths with spaces/newlines.
198
+ async function forceRemove(bun: BunLike, cwd: string, files: readonly string[], env: GitEnv): Promise<boolean> {
199
+ const proc = bun.spawn({
200
+ cmd: ['git', 'update-index', '-z', '--force-remove', '--stdin'],
201
+ cwd,
202
+ env,
203
+ stdin: new TextEncoder().encode(files.join('\0')),
204
+ stdout: 'pipe',
205
+ stderr: 'pipe',
206
+ })
207
+ return (await proc.exited) === 0
208
+ }
209
+
210
+ type BunLike = { spawn: typeof Bun.spawn }
211
+
212
+ function getBun(): BunLike | undefined {
213
+ return (globalThis as { Bun?: BunLike }).Bun
214
+ }
@@ -70,7 +70,7 @@ export type RestartPreflight = (input: {
70
70
 
71
71
  export type PortbrokerCallbacks = {
72
72
  start: (input: PortbrokerStartInput) => Promise<void>
73
- stop: (containerName: string, reason: 'deregistered' | 'broker-stopped') => Promise<void>
73
+ stop: (containerName: string, reason: 'deregistered' | 'broker-stopped' | 'fatal-auth') => Promise<void>
74
74
  // Returns ports the broker is currently exposing on the host for this
75
75
  // container. Empty array when the container is unregistered, when the broker
76
76
  // is disabled (`portForward.allow: []`), or when nothing inside the
@@ -87,6 +87,7 @@ export type PortbrokerStartInput = {
87
87
  brokerToken: string
88
88
  onEvent: (event: PortForwardEvent) => void
89
89
  onTailscaleServeEvent: (event: TailscaleServeEvent) => void
90
+ onFatalAuthFailure?: (info: { brokerToken: string; reason: string }) => void
90
91
  }
91
92
 
92
93
  export type DaemonLogEvent =
@@ -227,6 +228,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
227
228
  const version = opts.version ?? UNVERSIONED_SENTINEL
228
229
  const cwds = new Map<string, string>()
229
230
  const restartTokens = new Map<string, string>()
231
+ const brokerTokens = new Map<string, string>()
230
232
  const perContainerSerial = new Map<string, Promise<unknown>>()
231
233
  const gcMisses = new Map<string, number>()
232
234
  let stopped = false
@@ -285,6 +287,24 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
285
287
  } catch {}
286
288
  }
287
289
 
290
+ // A broker reports fatal auth when its token no longer matches the running
291
+ // container (typically a stale T_old broker revived on hostd boot racing a
292
+ // container that now expects T_new). The token guard is load-bearing: a fresh
293
+ // register may have overwritten brokerTokens with T_new before this late
294
+ // callback fires, in which case we must NOT delete the live registration.
295
+ const handleFatalAuthFailure = (containerName: string, failedToken: string, reason: string): Promise<void> =>
296
+ runSerially(containerName, async () => {
297
+ if (brokerTokens.get(containerName) !== failedToken) return
298
+ brokerTokens.delete(containerName)
299
+ cwds.delete(containerName)
300
+ restartTokens.delete(containerName)
301
+ gcMisses.delete(containerName)
302
+ if (opts.portbroker) await opts.portbroker.stop(containerName, 'fatal-auth').catch(() => {})
303
+ if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(containerName).catch(() => {})
304
+ await removeRegistrationFile(containerName)
305
+ log({ kind: 'registration-skipped', containerName, reason: `fatal broker auth: ${reason}` })
306
+ })
307
+
288
308
  const applyRegistration = async (payload: RegisterPayload): Promise<void> => {
289
309
  const alreadyRegistered = cwds.has(payload.containerName)
290
310
  cwds.set(payload.containerName, payload.cwd)
@@ -299,6 +319,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
299
319
  payload.portForward !== undefined &&
300
320
  payload.brokerToken !== undefined
301
321
  ) {
322
+ brokerTokens.set(payload.containerName, payload.brokerToken)
302
323
  await opts.portbroker.start({
303
324
  containerName: payload.containerName,
304
325
  cwd: payload.cwd,
@@ -307,6 +328,9 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
307
328
  brokerToken: payload.brokerToken,
308
329
  onEvent: (event) => log({ kind: 'port-forward-event', event }),
309
330
  onTailscaleServeEvent: (event) => log({ kind: 'tailscale-serve-event', event }),
331
+ onFatalAuthFailure: ({ brokerToken, reason }) => {
332
+ void handleFatalAuthFailure(payload.containerName, brokerToken, reason)
333
+ },
310
334
  })
311
335
  }
312
336
  if (opts.kakaoRenewal) {
@@ -335,6 +359,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
335
359
  runSerially(req.containerName, async () => {
336
360
  const hadCwd = cwds.delete(req.containerName)
337
361
  restartTokens.delete(req.containerName)
362
+ brokerTokens.delete(req.containerName)
338
363
  gcMisses.delete(req.containerName)
339
364
  if (opts.portbroker) await opts.portbroker.stop(req.containerName, 'deregistered').catch(() => {})
340
365
  if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(req.containerName).catch(() => {})
@@ -70,6 +70,13 @@ export function createPortbrokerManager(opts: PortbrokerManagerOptions = {}): Po
70
70
  if (event.kind === 'port-forward-opened') tailscale.servePort(event.port)
71
71
  else if (event.kind === 'port-forward-closed') tailscale.stopPort(event.port)
72
72
  },
73
+ onFatalAuthFailure: (reason) => {
74
+ // The broker has already stopped itself. Drop it from the map so a
75
+ // later stop()/start() doesn't double-stop or race the dead instance,
76
+ // then let hostd GC the stale registration (token-guarded).
77
+ if (brokers.get(input.containerName) === broker) brokers.delete(input.containerName)
78
+ input.onFatalAuthFailure?.({ brokerToken: input.brokerToken, reason })
79
+ },
73
80
  onLog: (msg) => log(`[portbroker:${input.containerName}] ${msg}`),
74
81
  })
75
82
  brokers.set(input.containerName, broker)
@@ -1336,7 +1336,7 @@ RUN rm -f /etc/apt/apt.conf.d/docker-clean \\
1336
1336
  && mkdir -p -m 755 /etc/apt/keyrings`
1337
1337
 
1338
1338
  // Layer 2.5: install pinned curl-impersonate (lexiforest fork) for the
1339
- // websearch tool's DDG scraper. Required to evade DDG's TLS/HTTP2
1339
+ // web_search tool's DDG scraper. Required to evade DDG's TLS/HTTP2
1340
1340
  // fingerprinting on residential IPs — see src/agent/tools/ddg.ts for the
1341
1341
  // full rationale. Placed after Layer 2 so curl + ca-certificates + tar
1342
1342
  // (already in baseline) are present, and before agent-browser so a version
@@ -2,6 +2,29 @@ import type { GitignoreConfig } from '@/config/config'
2
2
 
3
3
  export const GITIGNORE_FILE = '.gitignore'
4
4
 
5
+ // Match scope for the untrack reconciler (src/git/reconcile-ignored.ts): when a
6
+ // tracked file later matches one of these, start() removes it from the index.
7
+ export const TRULY_IGNORED_PATTERNS = [
8
+ '.env',
9
+ '.env.local',
10
+ 'secrets.json',
11
+ 'auth.json',
12
+ '.typeclaw/home/',
13
+ 'node_modules/',
14
+ 'packages/*/node_modules/',
15
+ 'workspace/',
16
+ 'public/',
17
+ 'mounts/',
18
+ 'Dockerfile',
19
+ '.DS_Store',
20
+ ] as const
21
+
22
+ // Gitignored but force-committed by TypeClaw (auto-backup, dreaming, channels).
23
+ // The reconciler MUST fail-closed and never untrack these, even if a custom
24
+ // git.ignore.append pattern (e.g. `**`) matches them — doing so would drop
25
+ // runtime-owned state out of git.
26
+ export const SYSTEM_MANAGED_ROOTS = ['sessions/', 'memory/', 'channels/', 'todo/'] as const
27
+
5
28
  export function buildGitignore(config: GitignoreConfig = { append: [] }): string {
6
29
  const customEntries = renderCustomGitignoreEntries(config.append)
7
30
 
@@ -24,24 +47,13 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
24
47
  # $HOME (e.g. ~/.codex/auth.json) into the bind-mounted agent folder so
25
48
  # tool credentials survive container restarts. Always credentials; never
26
49
  # commit.
27
- .env
28
- .env.local
29
- secrets.json
30
- auth.json
31
- .typeclaw/home/
32
- node_modules/
33
- packages/*/node_modules/
34
- workspace/
35
- mounts/
36
- Dockerfile
37
- .DS_Store
50
+ ${TRULY_IGNORED_PATTERNS.join('\n')}
38
51
 
39
52
  # System-managed: gitignored by default so the agent never stages them by hand,
40
- # but TypeClaw force-commits them on its own schedule (sessions/ via auto-backup,
41
- # memory/ via the dreaming subagent). Treat them as runtime-owned, not agent-owned.
42
- sessions/
43
- memory/
44
- channels/
53
+ # but TypeClaw force-commits them on its own schedule (sessions/ + todo/ via
54
+ # auto-backup, memory/ via the dreaming subagent). Treat them as runtime-owned,
55
+ # not agent-owned.
56
+ ${SYSTEM_MANAGED_ROOTS.join('\n')}
45
57
  `
46
58
  }
47
59
 
@@ -34,6 +34,7 @@ export type RunInspectOptions = {
34
34
  // caller's loop inspects its own scope intent to tell back from exit.
35
35
  signal?: AbortSignal
36
36
  liveHint?: string
37
+ interactive?: boolean
37
38
  }
38
39
 
39
40
  export type SelectSessionOptions = {
@@ -53,7 +54,20 @@ export type RunInspectResult =
53
54
  | { ok: true; exitCode: 0; escToPicker?: boolean }
54
55
  | { ok: false; exitCode: number; reason: string }
55
56
 
56
- export async function runInspect(opts: RunInspectOptions): Promise<RunInspectResult> {
57
+ export type InspectTarget = {
58
+ summary: SessionSummary
59
+ filter: InspectFilter
60
+ sinceMs: number | undefined
61
+ }
62
+
63
+ export type ResolveInspectResult = { ok: true; target: InspectTarget } | { ok: false; exitCode: number; reason: string }
64
+
65
+ // Picker phase, split out from the streaming phase so the tail scope is created
66
+ // AFTER the picker, never before. A raw-mode 'data' listener active during the
67
+ // Clack picker (which forces cooked mode via prepareStdinForClack) fights Clack
68
+ // for stdin and leaves the later stream in cooked mode — that was the recurring
69
+ // "esc does nothing" regression.
70
+ export async function resolveInspectTarget(opts: Omit<RunInspectOptions, 'signal'>): Promise<ResolveInspectResult> {
57
71
  const filterResult = parseFilter(opts.filter)
58
72
  if (!filterResult.ok) return { ok: false, exitCode: 2, reason: filterResult.reason }
59
73
  const filter = filterResult.filter
@@ -70,10 +84,24 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
70
84
  const summary = await chooseSession(opts, sessionsDir, sinceMs)
71
85
  if (!summary.ok) return summary
72
86
 
87
+ return { ok: true, target: { summary: summary.summary, filter, sinceMs } }
88
+ }
89
+
90
+ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectResult> {
91
+ const resolved = await resolveInspectTarget(opts)
92
+ if (!resolved.ok) return resolved
93
+ return streamInspectTarget({ ...opts, target: resolved.target })
94
+ }
95
+
96
+ export async function streamInspectTarget(
97
+ opts: Omit<RunInspectOptions, 'sessionIdOrPrefix' | 'filter' | 'since' | 'selectSession'> & {
98
+ target: InspectTarget
99
+ },
100
+ ): Promise<RunInspectResult> {
73
101
  const streamResult = await streamSession({
74
- summary: summary.summary,
75
- filter,
76
- sinceMs,
102
+ summary: opts.target.summary,
103
+ filter: opts.target.filter,
104
+ sinceMs: opts.target.sinceMs,
77
105
  json: opts.json === true,
78
106
  color: opts.color,
79
107
  stdout: opts.stdout,
@@ -81,6 +109,7 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
81
109
  ...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
82
110
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
83
111
  ...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
112
+ ...(opts.interactive === true ? { interactive: true } : {}),
84
113
  })
85
114
  if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
86
115
  return { ok: true, exitCode: 0 }
@@ -146,6 +175,7 @@ async function streamSession(opts: {
146
175
  liveSource?: LiveSourceFactory
147
176
  signal?: AbortSignal
148
177
  liveHint?: string
178
+ interactive?: boolean
149
179
  }): Promise<{ escToPicker: boolean }> {
150
180
  if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
151
181
  const emit = (event: InspectEvent): void => {
@@ -167,6 +197,18 @@ async function streamSession(opts: {
167
197
 
168
198
  if (opts.liveSource === undefined) {
169
199
  if (!opts.json) opts.stdout('─── end of transcript ───')
200
+ // Already aborted during replay (user pressed esc/q): honor it, don't lose the keystroke.
201
+ if (aborted()) return { escToPicker: true }
202
+ // Interactive replay-only: hold a stable viewer like `dreams` instead of
203
+ // bouncing straight back to the picker. Block until the tail scope aborts
204
+ // (esc → back, q/ctrl-c → exit). Never block without a signal (non-TTY has
205
+ // no listener and would hang) or in json/non-interactive mode (scriptability).
206
+ if (opts.interactive === true && !opts.json && opts.signal !== undefined) {
207
+ if (opts.liveHint !== undefined && opts.liveHint !== '') {
208
+ opts.stdout(divider(opts.color, opts.liveHint))
209
+ }
210
+ await waitForAbort(opts.signal)
211
+ }
170
212
  return { escToPicker: aborted() }
171
213
  }
172
214
 
@@ -208,6 +250,13 @@ function divider(color: boolean, text: string): string {
208
250
  return text
209
251
  }
210
252
 
253
+ export async function waitForAbort(signal: AbortSignal): Promise<void> {
254
+ if (signal.aborted) return
255
+ await new Promise<void>((resolve) => {
256
+ signal.addEventListener('abort', () => resolve(), { once: true })
257
+ })
258
+ }
259
+
211
260
  function writeHeader(summary: SessionSummary, color: boolean, stdout: (line: string) => void): void {
212
261
  const id = shortSessionId(summary.sessionId)
213
262
  const label = summary.origin === null ? '(unknown origin)' : originLabel(summary.origin)
@@ -1,4 +1,4 @@
1
- import { runInspect, type RunInspectOptions, type RunInspectResult } from './index'
1
+ import { resolveInspectTarget, type RunInspectOptions, type RunInspectResult, streamInspectTarget } from './index'
2
2
 
3
3
  export type TailController = {
4
4
  signal: AbortSignal
@@ -8,9 +8,10 @@ export type TailController = {
8
8
 
9
9
  export type RunInspectLoopOptions = Omit<RunInspectOptions, 'signal'> & {
10
10
  // Builds a fresh interaction scope for ONE live-tail attempt: a new
11
- // AbortController plus a temporary raw-mode listener. The loop disposes it
12
- // before the picker re-opens so clack always owns a clean, non-raw stdin —
13
- // this is what replaces the old pause/resume-same-controller model.
11
+ // AbortController plus a temporary raw-mode listener. The loop creates it
12
+ // only AFTER the picker has resolved a session and disposes it before the
13
+ // picker re-opens, so clack always owns a clean, non-raw stdin — this is what
14
+ // replaces the old pause/resume-same-controller model.
14
15
  createTailScope: () => TailController
15
16
  }
16
17
 
@@ -25,17 +26,20 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
25
26
  }
26
27
 
27
28
  while (true) {
29
+ const resolveOpts: Omit<RunInspectOptions, 'signal'> = { ...opts, selectSession: wrappedSelectSession }
30
+ if (sessionArg !== undefined) resolveOpts.sessionIdOrPrefix = sessionArg
31
+ else delete (resolveOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
32
+
33
+ // Picker phase: cooked-mode stdin, no tail scope alive.
34
+ const resolved = await resolveInspectTarget(resolveOpts)
35
+ if (!resolved.ok) return resolved
36
+
37
+ // Streaming phase: scope owns raw-mode stdin start-to-dispose, never
38
+ // spanning the picker above or the next iteration's picker below.
28
39
  const scope = opts.createTailScope()
29
40
  let result: RunInspectResult
30
41
  try {
31
- const callOpts: RunInspectOptions = {
32
- ...opts,
33
- selectSession: wrappedSelectSession,
34
- signal: scope.signal,
35
- }
36
- if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
37
- else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
38
- result = await runInspect(callOpts)
42
+ result = await streamInspectTarget({ ...opts, target: resolved.target, signal: scope.signal })
39
43
  } finally {
40
44
  scope.dispose()
41
45
  }
@@ -78,5 +78,5 @@ export const writeTool: BuiltinToolRef = { __builtinTool: 'write' }
78
78
  export const grepTool: BuiltinToolRef = { __builtinTool: 'grep' }
79
79
  export const findTool: BuiltinToolRef = { __builtinTool: 'find' }
80
80
  export const lsTool: BuiltinToolRef = { __builtinTool: 'ls' }
81
- export const websearchTool: BuiltinToolRef = { __builtinTool: 'websearch' }
82
- export const webfetchTool: BuiltinToolRef = { __builtinTool: 'webfetch' }
81
+ export const webSearchTool: BuiltinToolRef = { __builtinTool: 'web_search' }
82
+ export const webFetchTool: BuiltinToolRef = { __builtinTool: 'web_fetch' }
@@ -9,8 +9,8 @@ export {
9
9
  grepTool,
10
10
  lsTool,
11
11
  readTool,
12
- webfetchTool,
13
- websearchTool,
12
+ webFetchTool,
13
+ webSearchTool,
14
14
  writeTool,
15
15
  } from './define'
16
16
 
@@ -25,6 +25,10 @@ export type BrokerOptions = {
25
25
  resolveHostPort: () => Promise<number | null>
26
26
  brokerToken: string
27
27
  onEvent: (event: PortForwardEvent) => void
28
+ // A broker's token is immutable for its lifetime, so an auth rejection can
29
+ // never be repaired by reconnecting. On auth nack the broker stops for good
30
+ // and reports the reason so hostd can GC the stale registration.
31
+ onFatalAuthFailure?: (reason: string) => void
28
32
  onLog?: (msg: string) => void
29
33
  connectWs?: (url: string) => Promise<WsClient>
30
34
  listenHost?: ListenHostFn
@@ -68,6 +72,11 @@ export type Broker = {
68
72
  const DEFAULT_RECONNECT_DELAYS = [1_000, 2_000, 4_000, 10_000]
69
73
  const DEFAULT_HOST_BIND = '127.0.0.1'
70
74
 
75
+ // broker-hello-nack reasons emitted by container-server.ts when authentication
76
+ // fails. These are immutable for a broker instance's lifetime, so reconnecting
77
+ // the same broker can only reproduce them — the broker must stop instead.
78
+ const AUTH_NACK_REASONS: ReadonlySet<string> = new Set(['invalid token', 'expected broker-hello first'])
79
+
71
80
  export function createBroker(opts: BrokerOptions): Broker {
72
81
  const log = opts.onLog ?? (() => {})
73
82
  const reconnectDelays = opts.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS
@@ -211,7 +220,11 @@ export function createBroker(opts: BrokerOptions): Broker {
211
220
  return
212
221
  case 'broker-hello-nack':
213
222
  log(`broker-hello rejected: ${msg.reason}`)
214
- if (ws) ws.close()
223
+ if (AUTH_NACK_REASONS.has(msg.reason)) {
224
+ fatalStop(msg.reason)
225
+ } else if (ws) {
226
+ ws.close()
227
+ }
215
228
  return
216
229
  case 'port-listen-snapshot':
217
230
  for (const { port, bindAddr } of msg.ports) {
@@ -307,6 +320,27 @@ export function createBroker(opts: BrokerOptions): Broker {
307
320
  }, delay)
308
321
  }
309
322
 
323
+ const teardown = (): void => {
324
+ stopped = true
325
+ if (reconnectTimer !== null) {
326
+ clearTimeout(reconnectTimer)
327
+ reconnectTimer = null
328
+ }
329
+ teardownAllForwarders('broker-stopped')
330
+ if (ws) {
331
+ try {
332
+ ws.close()
333
+ } catch {}
334
+ ws = null
335
+ }
336
+ }
337
+
338
+ const fatalStop = (reason: string): void => {
339
+ if (stopped) return
340
+ teardown()
341
+ opts.onFatalAuthFailure?.(reason)
342
+ }
343
+
310
344
  return {
311
345
  start() {
312
346
  if (!brokerEnabled(opts.policy)) {
@@ -316,18 +350,7 @@ export function createBroker(opts: BrokerOptions): Broker {
316
350
  void connect()
317
351
  },
318
352
  async stop() {
319
- stopped = true
320
- if (reconnectTimer !== null) {
321
- clearTimeout(reconnectTimer)
322
- reconnectTimer = null
323
- }
324
- teardownAllForwarders('broker-stopped')
325
- if (ws) {
326
- try {
327
- ws.close()
328
- } catch {}
329
- ws = null
330
- }
353
+ teardown()
331
354
  },
332
355
  forwardedPorts() {
333
356
  return Array.from(forwarders.keys())