typeclaw 0.9.2 → 0.10.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.
@@ -2,15 +2,17 @@ import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
- import { runInspect, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
5
+ import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
6
6
  import { originLabel, shortSessionId } from '@/inspect/label'
7
7
 
8
8
  import { cancel, c, errorLine, isCancel } from './ui'
9
9
 
10
+ const ESC_LISTEN_DELAY_MS = 50
11
+
10
12
  export const inspectCommand = defineCommand({
11
13
  meta: {
12
14
  name: 'inspect',
13
- description: 'replay a session transcript and tail live activity (host stage)',
15
+ description: 'observe a session: replay the transcript, then tail live activity (host stage)',
14
16
  },
15
17
  args: {
16
18
  session: {
@@ -32,12 +34,6 @@ export const inspectCommand = defineCommand({
32
34
  description: 'emit one JSON event per line; requires an explicit session id',
33
35
  default: false,
34
36
  },
35
- follow: {
36
- type: 'boolean',
37
- description:
38
- 'tail live activity after replay (default: true when the container is running); pass --no-follow to replay-then-exit',
39
- default: true,
40
- },
41
37
  },
42
38
  async run({ args }) {
43
39
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
@@ -45,26 +41,39 @@ export const inspectCommand = defineCommand({
45
41
  const sessionArg = typeof args.session === 'string' ? args.session : undefined
46
42
  const filterArg = typeof args.filter === 'string' ? args.filter : undefined
47
43
  const sinceArg = typeof args.since === 'string' ? args.since : undefined
48
- const follow = args.follow !== false
49
44
 
50
45
  const isJson = args.json === true
51
- const liveSource = !follow || isJson ? undefined : await buildLiveSource(cwd)
46
+ const liveSource = isJson ? undefined : await buildLiveSource(cwd)
52
47
  const signal = installSigintAbort()
48
+ const escListener = isJson ? null : createEscListener()
49
+ const liveHint = escListener === null ? undefined : escHintLine(color)
53
50
 
54
- const result = await runInspect({
51
+ const result = await runInspectLoop({
55
52
  agentDir: cwd,
56
53
  ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
57
54
  ...(filterArg !== undefined ? { filter: filterArg } : {}),
58
55
  ...(sinceArg !== undefined ? { since: sinceArg } : {}),
59
56
  json: isJson,
60
57
  color,
61
- selectSession: clackSelect,
58
+ selectSession: (sessions) => {
59
+ escListener?.pause()
60
+ return clackSelect(sessions).finally(() => {
61
+ escListener?.resume()
62
+ })
63
+ },
62
64
  ...(liveSource !== undefined ? { liveSource } : {}),
63
65
  signal,
66
+ newEscSignal: () => {
67
+ if (escListener === null) return new AbortController().signal
68
+ return escListener.armForStream()
69
+ },
70
+ ...(liveHint !== undefined ? { liveHint } : {}),
64
71
  stdout: (line) => process.stdout.write(`${line}\n`),
65
72
  stderr: (line) => process.stderr.write(`${line}\n`),
66
73
  })
67
74
 
75
+ escListener?.stop()
76
+
68
77
  if (!result.ok) {
69
78
  process.stderr.write(`${errorLine(result.reason)}\n`)
70
79
  process.exit(result.exitCode)
@@ -104,6 +113,90 @@ function installSigintAbort(): AbortSignal {
104
113
  return ctrl.signal
105
114
  }
106
115
 
116
+ type EscListener = {
117
+ armForStream: () => AbortSignal
118
+ pause: () => void
119
+ resume: () => void
120
+ stop: () => void
121
+ }
122
+
123
+ function createEscListener(): EscListener | null {
124
+ const stdin = process.stdin
125
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
126
+
127
+ let currentCtrl: AbortController | null = null
128
+ let pendingEsc: ReturnType<typeof setTimeout> | null = null
129
+ let active = false
130
+
131
+ const onData = (chunk: Buffer): void => {
132
+ if (chunk.length === 0) return
133
+ const first = chunk[0]
134
+ if (first === 0x03) {
135
+ process.kill(process.pid, 'SIGINT')
136
+ return
137
+ }
138
+ if (chunk.length === 1 && first === 0x1b) {
139
+ if (pendingEsc !== null) clearTimeout(pendingEsc)
140
+ pendingEsc = setTimeout(() => {
141
+ pendingEsc = null
142
+ currentCtrl?.abort()
143
+ }, ESC_LISTEN_DELAY_MS)
144
+ return
145
+ }
146
+ if (pendingEsc !== null) {
147
+ clearTimeout(pendingEsc)
148
+ pendingEsc = null
149
+ }
150
+ }
151
+
152
+ const start = (): void => {
153
+ if (active) return
154
+ active = true
155
+ stdin.setRawMode(true)
156
+ stdin.resume()
157
+ stdin.on('data', onData)
158
+ }
159
+ const stop = (): void => {
160
+ if (!active) return
161
+ active = false
162
+ stdin.off('data', onData)
163
+ try {
164
+ stdin.setRawMode(false)
165
+ } catch {
166
+ /* terminal already torn down */
167
+ }
168
+ stdin.pause()
169
+ if (pendingEsc !== null) {
170
+ clearTimeout(pendingEsc)
171
+ pendingEsc = null
172
+ }
173
+ }
174
+
175
+ return {
176
+ armForStream: () => {
177
+ currentCtrl = new AbortController()
178
+ start()
179
+ return currentCtrl.signal
180
+ },
181
+ pause: () => {
182
+ stop()
183
+ },
184
+ resume: () => {
185
+ currentCtrl = new AbortController()
186
+ start()
187
+ },
188
+ stop: () => {
189
+ currentCtrl = null
190
+ stop()
191
+ },
192
+ }
193
+ }
194
+
195
+ function escHintLine(color: boolean): string {
196
+ const text = '(press esc to return to session list)'
197
+ return color ? `\u001b[2m${text}\u001b[0m` : text
198
+ }
199
+
107
200
  function useColor(): boolean {
108
201
  if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
109
202
  if (process.env.FORCE_COLOR === '0') return false
package/src/cli/role.ts CHANGED
@@ -11,7 +11,7 @@ const claimSub = defineCommand({
11
11
  meta: {
12
12
  name: 'claim',
13
13
  description:
14
- 'claim a channel identity (Slack/Discord/etc.) for a role on this agent. Sends a code via the host CLI; you DM that code back to the bot to prove control of the channel account.',
14
+ 'claim a channel identity (Slack/Discord/etc.) for a role on this agent. Sends a code via the host CLI; you send that code back to the bot from any chat (DM, group, channel) to prove control of the channel account. The resulting match rule grants the role to your author identity across every chat on that platform.',
15
15
  },
16
16
  args: {
17
17
  as: {
@@ -57,7 +57,7 @@ const claimSub = defineCommand({
57
57
  s.stop('Ready.')
58
58
  const expiresInSec = Math.max(0, Math.round((payload.expiresAt - Date.now()) / 1000))
59
59
  const lines = [
60
- `Send this message to your bot as a DM:`,
60
+ `Send this message to your bot from any chat (DM, group, or channel):`,
61
61
  '',
62
62
  ` ${c.bold(payload.code)}`,
63
63
  '',
@@ -465,6 +465,24 @@ export function providerForModelRef(ref: KnownModelRef): KnownProviderId {
465
465
  throw new Error(`Unknown provider in model ref: ${ref}`)
466
466
  }
467
467
 
468
+ // Per-provider default for pi-coding-agent's `thinkingLevel` knob. Returning
469
+ // `undefined` defers to the SDK default (`medium`); returning a level pins it
470
+ // to that value at session-creation time.
471
+ //
472
+ // OpenAI-family providers (`openai`, `openai-codex`) pin to `low`: GPT-5.x at
473
+ // `medium` pads reasoning tokens on routine tool-driven turns (code edits,
474
+ // channel replies, cron prompts) with no observable quality delta on this
475
+ // codebase's workloads. Applies to every session that resolves to a GPT model
476
+ // regardless of profile, so the saving is uniform.
477
+ //
478
+ // Anthropic, GLM, and Kimi don't share the padding behavior, so they keep the
479
+ // SDK default.
480
+ export function defaultThinkingLevelForRef(ref: KnownModelRef): 'low' | undefined {
481
+ const providerId = providerForModelRef(ref)
482
+ if (providerId === 'openai' || providerId === 'openai-codex') return 'low'
483
+ return undefined
484
+ }
485
+
468
486
  // `as const satisfies` narrows each entry's `auth` to a tuple of its specific
469
487
  // literal values, which makes `provider.auth.includes('oauth')` fail to
470
488
  // compile on api-key-only entries (because TS thinks the array can never
@@ -1,10 +1,14 @@
1
- import { resolveHostPort, resolveTuiToken } from '@/container'
1
+ import { CONTAINER_PORT, resolveHostPort, resolveTuiToken } from '@/container'
2
2
  import type { ClientMessage, CronListEntryPayload, ServerMessage } from '@/shared'
3
3
 
4
4
  export type CronListBridgeOptions = {
5
5
  cwd: string
6
6
  url?: string
7
7
  timeoutMs?: number
8
+ // Injected for tests so the in-container short-circuit can be exercised
9
+ // without polluting process.env. Production callers omit this and the
10
+ // bridge reads from process.env directly.
11
+ env?: NodeJS.ProcessEnv
8
12
  }
9
13
 
10
14
  export type CronListBridgeResult =
@@ -34,9 +38,7 @@ async function dial(opts: CronListBridgeOptions): Promise<DialResult> {
34
38
  let url = opts.url
35
39
  if (url === undefined) {
36
40
  try {
37
- const port = await resolveHostPort({ cwd: opts.cwd })
38
- const token = await resolveTuiToken({ cwd: opts.cwd })
39
- url = buildBridgeUrl(port, token)
41
+ url = resolveInContainerUrl(opts.env ?? process.env) ?? (await resolveHostUrl(opts.cwd))
40
42
  } catch (err) {
41
43
  return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
42
44
  }
@@ -115,6 +117,25 @@ async function awaitReply(ws: WebSocket, timeoutMs: number, requestId: string):
115
117
  })
116
118
  }
117
119
 
120
+ // In-container short-circuit: when typeclaw runs `docker run`, it sets
121
+ // TYPECLAW_CONTAINER_NAME (always) and TYPECLAW_TUI_TOKEN (when configured).
122
+ // Inside the container, docker is not on $PATH, so the host-side discovery
123
+ // path (resolveHostPort/resolveTuiToken — both shell out to `docker`) fails
124
+ // with "docker: command not found". We don't need docker here: the agent's
125
+ // WS server is listening on CONTAINER_PORT on the container's loopback, and
126
+ // the token is already in our env. Skip docker entirely and dial directly.
127
+ export function resolveInContainerUrl(env: NodeJS.ProcessEnv): string | null {
128
+ if (env.TYPECLAW_CONTAINER_NAME === undefined) return null
129
+ const token = env.TYPECLAW_TUI_TOKEN ?? ''
130
+ return buildBridgeUrl(CONTAINER_PORT, token !== '' ? token : null)
131
+ }
132
+
133
+ async function resolveHostUrl(cwd: string): Promise<string> {
134
+ const port = await resolveHostPort({ cwd })
135
+ const token = await resolveTuiToken({ cwd })
136
+ return buildBridgeUrl(port, token)
137
+ }
138
+
118
139
  function buildBridgeUrl(port: number, token: string | null): string {
119
140
  const url = new URL(`ws://127.0.0.1:${port}`)
120
141
  if (token !== null) url.searchParams.set('token', token)
@@ -69,7 +69,7 @@ export type RestartPreflight = (input: {
69
69
  }) => Promise<RpcResponse | null>
70
70
 
71
71
  export type PortbrokerCallbacks = {
72
- start: (input: PortbrokerStartInput) => void
72
+ start: (input: PortbrokerStartInput) => Promise<void>
73
73
  stop: (containerName: string, reason: 'deregistered' | 'broker-stopped') => 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
@@ -157,8 +157,10 @@ function isValidRestoredPayload(value: unknown, expectedName: string): value is
157
157
  }
158
158
 
159
159
  async function restorePersistedRegistrations(
160
- apply: (payload: RestoredPayload) => void,
160
+ apply: (payload: RestoredPayload) => Promise<void>,
161
161
  log: (event: DaemonLogEvent | SupervisorLogEvent) => void,
162
+ probe: (name: string) => Promise<'alive' | 'gone' | 'unknown'>,
163
+ removeFile: (name: string) => Promise<void>,
162
164
  ): Promise<void> {
163
165
  let entries: string[]
164
166
  try {
@@ -181,7 +183,23 @@ async function restorePersistedRegistrations(
181
183
  log({ kind: 'registration-skipped', containerName: expectedName, reason: 'schema mismatch' })
182
184
  continue
183
185
  }
184
- apply(parsed)
186
+ // Probe before reviving. A registration file for a container that no
187
+ // longer exists is a leftover from a daemon that died ungracefully
188
+ // (crash, `kill -9`, OS reboot) before deregister could clean up.
189
+ // Reviving its broker would create a stale T_old broker that races a
190
+ // subsequent `register` call's T_new broker — see portbroker-manager.ts
191
+ // start() for the swap-race description. `unknown` (docker probe call
192
+ // failed) errs toward restore: the existing GC tick will tear down the
193
+ // registration if the container is genuinely gone, and we'd rather pay
194
+ // one swap-race attempt than tear down a live registration on a flaky
195
+ // `docker ps`.
196
+ const status = await probe(expectedName)
197
+ if (status === 'gone') {
198
+ await removeFile(expectedName)
199
+ log({ kind: 'registration-skipped', containerName: expectedName, reason: 'container not running' })
200
+ continue
201
+ }
202
+ await apply(parsed)
185
203
  }
186
204
  }
187
205
 
@@ -267,7 +285,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
267
285
  } catch {}
268
286
  }
269
287
 
270
- const applyRegistration = (payload: RegisterPayload): void => {
288
+ const applyRegistration = async (payload: RegisterPayload): Promise<void> => {
271
289
  const alreadyRegistered = cwds.has(payload.containerName)
272
290
  cwds.set(payload.containerName, payload.cwd)
273
291
  if (payload.restartToken) restartTokens.set(payload.containerName, payload.restartToken)
@@ -281,7 +299,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
281
299
  payload.portForward !== undefined &&
282
300
  payload.brokerToken !== undefined
283
301
  ) {
284
- opts.portbroker.start({
302
+ await opts.portbroker.start({
285
303
  containerName: payload.containerName,
286
304
  cwd: payload.cwd,
287
305
  policy: payload.portForward,
@@ -308,7 +326,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
308
326
  reason: `failed to persist registration: ${error instanceof Error ? error.message : String(error)}`,
309
327
  }
310
328
  }
311
- applyRegistration(req)
329
+ await applyRegistration(req)
312
330
  return { ok: true }
313
331
  })
314
332
  }
@@ -528,12 +546,31 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
528
546
  httpPort = httpServer.port ?? 0
529
547
  log({ kind: 'daemon-http-listening', host: httpHostname, port: httpPort })
530
548
 
549
+ // GC tick distinguishes "container confirmed gone" from "docker call failed":
550
+ // a `docker ps` blip should not deregister a live container registration, so
551
+ // we require gcMissesToDeregister consecutive confirmed absences. Boot-time
552
+ // restore reuses the same probe but with a stricter policy — see
553
+ // restorePersistedRegistrations.
554
+ const probeContainerAlive = async (name: string): Promise<'alive' | 'gone' | 'unknown'> => {
555
+ try {
556
+ const result = await exec(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'])
557
+ if (result.exitCode !== 0) return 'unknown'
558
+ const names = result.stdout
559
+ .trim()
560
+ .split('\n')
561
+ .filter((s) => s.length > 0)
562
+ return names.includes(name) ? 'alive' : 'gone'
563
+ } catch {
564
+ return 'unknown'
565
+ }
566
+ }
567
+
531
568
  // Boot-time restore: replay every persisted registration into the in-memory
532
569
  // maps and revive portbroker for it. Runs before Bun.listen so the socket
533
570
  // is never accepting RPCs against a half-restored registry. A bad file
534
571
  // (parse error, schema mismatch) is logged-and-skipped — one corrupt
535
572
  // registration must not gate every other container's recovery.
536
- await restorePersistedRegistrations(applyRegistration, log)
573
+ await restorePersistedRegistrations(applyRegistration, log, probeContainerAlive, removeRegistrationFile)
537
574
 
538
575
  const listener: UnixSocketListener<ServerState> = Bun.listen<ServerState>({
539
576
  unix: path,
@@ -550,23 +587,6 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
550
587
  await chmod(path, 0o600).catch(() => {})
551
588
  log({ kind: 'daemon-listening', socket: path })
552
589
 
553
- // GC tick distinguishes "container confirmed gone" from "docker call failed":
554
- // a `docker ps` blip should not deregister a live container registration, so
555
- // we require gcMissesToDeregister consecutive confirmed absences.
556
- const probeContainerAlive = async (name: string): Promise<'alive' | 'gone' | 'unknown'> => {
557
- try {
558
- const result = await exec(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'])
559
- if (result.exitCode !== 0) return 'unknown'
560
- const names = result.stdout
561
- .trim()
562
- .split('\n')
563
- .filter((s) => s.length > 0)
564
- return names.includes(name) ? 'alive' : 'gone'
565
- } catch {
566
- return 'unknown'
567
- }
568
- }
569
-
570
590
  const runGc = async (): Promise<void> => {
571
591
  for (const name of Array.from(cwds.keys())) {
572
592
  const status = await probeContainerAlive(name)
@@ -26,15 +26,31 @@ export function createPortbrokerManager(opts: PortbrokerManagerOptions = {}): Po
26
26
  const brokerFactory = opts.createBrokerFor ?? createBroker
27
27
 
28
28
  return {
29
- start(input: PortbrokerStartInput) {
29
+ // start() awaits the previous broker's stop before constructing the new
30
+ // one. The fire-and-forget shape this replaced let a stale T_old broker
31
+ // win the race to send broker-hello against a brand-new container that
32
+ // expects T_new, producing a one-shot `auth-failed: token mismatch`
33
+ // broadcast at every re-register that arrived while the old broker was
34
+ // still mid-stop. The race window was narrow but reproducible across
35
+ // hostd-respawn-after-ungraceful-death + typeclaw restart, because the
36
+ // restored T_old broker is alive for the duration of the register RPC.
37
+ // Awaiting collapses the window to zero — by the time the T_new broker's
38
+ // first connect() fires, the T_old broker has set stopped=true, cleared
39
+ // its reconnect timer, and closed its WS.
40
+ async start(input: PortbrokerStartInput) {
30
41
  const existing = brokers.get(input.containerName)
31
42
  if (existing) {
32
- void existing.stop().catch(() => {})
43
+ brokers.delete(input.containerName)
44
+ try {
45
+ await existing.stop()
46
+ } catch {}
33
47
  }
34
48
  const existingTailscale = tailscaleManagers.get(input.containerName)
35
49
  if (existingTailscale) {
36
50
  tailscaleManagers.delete(input.containerName)
37
- void existingTailscale.stopAll().catch(() => {})
51
+ try {
52
+ await existingTailscale.stopAll()
53
+ } catch {}
38
54
  }
39
55
  const tailscale = createTailscaleServeManager({
40
56
  containerName: input.containerName,
@@ -281,6 +281,56 @@ set -eu
281
281
  # -nolisten tcp refuse TCP connections (Unix socket only).
282
282
  # Defense-in-depth — we are in a netns with
283
283
  # no inbound exposure anyway.
284
+ # link_persistent_home_files symlinks credential files that tools write
285
+ # to $HOME into a bind-mounted location so they survive container
286
+ # restarts. The canonical case is Codex CLI's ~/.codex/auth.json: codex
287
+ # rewrites the file in place to rotate OAuth tokens, and the official
288
+ # CI/CD guidance is to persist auth.json so refresh-token state
289
+ # compounds across runs. The container's $HOME (/root by default) lives
290
+ # on Docker's writable overlay and is wiped on every \`stop\`+\`start\`
291
+ # cycle, so without this symlink the operator would have to re-paste
292
+ # auth.json after every restart.
293
+ #
294
+ # The persist root lives under /agent/.typeclaw/home/ (bind-mounted
295
+ # from the agent folder via the -v <cwd>:/agent flag in start.ts).
296
+ # Namespacing under .typeclaw/ keeps the agent's top-level layout clean and reserves
297
+ # a system-owned subtree we can extend later (e.g. ~/.gemini/,
298
+ # ~/.config/<tool>/) without colliding with user files. The directory
299
+ # is gitignored by buildGitignore() so credentials never enter history.
300
+ #
301
+ # Three invariants this function enforces:
302
+ #
303
+ # 1. Symlink is unconditional and idempotent. We never check whether
304
+ # auth.json exists before linking — \`ln -sfn\` creates a dangling
305
+ # symlink on first boot, and the first \`codex login\` write goes
306
+ # through it to land at the persistent location. -f replaces an
307
+ # existing symlink; -n stops ln from dereferencing into a directory
308
+ # if a previous container life happened to write a real ~/.codex/
309
+ # dir before this code shipped.
310
+ #
311
+ # 2. We symlink the FILE, not the directory. Codex writes other state
312
+ # to ~/.codex/ over time (history.jsonl, log/, config.toml). Linking
313
+ # only auth.json keeps the persistence scope tight to credentials;
314
+ # history/logs stay ephemeral by design. Future credentials get
315
+ # added file-by-file here, not by widening to a directory link.
316
+ #
317
+ # 3. We mkdir -p the target's parent on every boot. /agent is bind-
318
+ # mounted, so the host-side path may exist or not depending on
319
+ # whether the operator ever started the container before this code
320
+ # shipped. mkdir -p is idempotent and cheap.
321
+ #
322
+ # 4. The root is overridable via TYPECLAW_PERSIST_HOME_ROOT, which only
323
+ # the shim's executable tests set. Production never sets it, so the
324
+ # in-container path is always /agent/.typeclaw/home/. The override
325
+ # lets the shim's behavioral tests verify symlink semantics against
326
+ # a real tmpdir on the host without touching /agent (which doesn't
327
+ # exist on developer machines and CI runners).
328
+ link_persistent_home_files() {
329
+ persist_root="\${TYPECLAW_PERSIST_HOME_ROOT:-/agent/.typeclaw/home}"
330
+ mkdir -p "$persist_root/.codex" "$HOME/.codex"
331
+ ln -sfn "$persist_root/.codex/auth.json" "$HOME/.codex/auth.json"
332
+ }
333
+
284
334
  start_xvfb() {
285
335
  if ! command -v Xvfb >/dev/null 2>&1; then
286
336
  return 0
@@ -314,6 +364,7 @@ start_xvfb() {
314
364
  }
315
365
 
316
366
  if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
367
+ link_persistent_home_files
317
368
  start_xvfb
318
369
  exec bun run typeclaw "$@"
319
370
  fi
@@ -359,6 +410,7 @@ ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
359
410
  ip6tables -A OUTPUT -o lo -j ACCEPT
360
411
  ${ipv6Rules.join('\n')}
361
412
 
413
+ link_persistent_home_files
362
414
  start_xvfb
363
415
  exec setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin -- bun run typeclaw "$@"
364
416
  `
@@ -17,10 +17,18 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
17
17
  # as a safety net so an agent folder cloned from a pre-rename machine never
18
18
  # stages credentials by accident, even if its agent boot hasn't yet run the
19
19
  # auth.json -> secrets.json migration.
20
+ #
21
+ # .typeclaw/home/ is the persistent-$HOME overlay populated by the
22
+ # entrypoint shim's \`link_persistent_home_files\` (see
23
+ # src/init/dockerfile.ts). It mirrors selected files from the container's
24
+ # $HOME (e.g. ~/.codex/auth.json) into the bind-mounted agent folder so
25
+ # tool credentials survive container restarts. Always credentials; never
26
+ # commit.
20
27
  .env
21
28
  .env.local
22
29
  secrets.json
23
30
  auth.json
31
+ .typeclaw/home/
24
32
  node_modules/
25
33
  packages/*/node_modules/
26
34
  workspace/
@@ -16,6 +16,8 @@ export { replayJsonl } from './replay'
16
16
  export { streamLive } from './live'
17
17
  export { parseDuration, parseFilter } from './types'
18
18
  export type { InspectCategory, InspectEvent, InspectFilter } from './types'
19
+ export { runInspectLoop } from './loop'
20
+ export type { RunInspectLoopOptions } from './loop'
19
21
 
20
22
  export type RunInspectOptions = {
21
23
  agentDir: string
@@ -29,6 +31,10 @@ export type RunInspectOptions = {
29
31
  stderr: (line: string) => void
30
32
  liveSource?: LiveSourceFactory
31
33
  signal?: AbortSignal
34
+ // Aborting escSignal (and only escSignal) returns escToPicker=true so a
35
+ // caller-side loop can re-open the picker; signal still means process exit.
36
+ escSignal?: AbortSignal
37
+ liveHint?: string
32
38
  }
33
39
 
34
40
  export type SelectSession = (sessions: SessionSummary[]) => Promise<SessionSummary | null>
@@ -40,7 +46,9 @@ export type LiveSourceFactory = (opts: {
40
46
  onSubscribed?: (sessionLive: boolean) => void
41
47
  }) => AsyncIterable<InspectEvent>
42
48
 
43
- export type RunInspectResult = { ok: true; exitCode: 0 } | { ok: false; exitCode: number; reason: string }
49
+ export type RunInspectResult =
50
+ | { ok: true; exitCode: 0; escToPicker?: boolean }
51
+ | { ok: false; exitCode: number; reason: string }
44
52
 
45
53
  export async function runInspect(opts: RunInspectOptions): Promise<RunInspectResult> {
46
54
  const filterResult = parseFilter(opts.filter)
@@ -59,7 +67,7 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
59
67
  const summary = await chooseSession(opts, sessionsDir, sinceMs)
60
68
  if (!summary.ok) return summary
61
69
 
62
- await streamSession({
70
+ const streamResult = await streamSession({
63
71
  summary: summary.summary,
64
72
  filter,
65
73
  sinceMs,
@@ -69,7 +77,10 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
69
77
  stderr: opts.stderr,
70
78
  ...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
71
79
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
80
+ ...(opts.escSignal !== undefined ? { escSignal: opts.escSignal } : {}),
81
+ ...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
72
82
  })
83
+ if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
73
84
  return { ok: true, exitCode: 0 }
74
85
  }
75
86
 
@@ -132,7 +143,9 @@ async function streamSession(opts: {
132
143
  stderr: (line: string) => void
133
144
  liveSource?: LiveSourceFactory
134
145
  signal?: AbortSignal
135
- }): Promise<void> {
146
+ escSignal?: AbortSignal
147
+ liveHint?: string
148
+ }): Promise<{ escToPicker: boolean }> {
136
149
  if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
137
150
  const emit = (event: InspectEvent): void => {
138
151
  if (opts.sinceMs !== undefined && event.ts > 0 && event.ts < opts.sinceMs) return
@@ -144,20 +157,26 @@ async function streamSession(opts: {
144
157
  }
145
158
  }
146
159
 
160
+ const escAborted = (): boolean => opts.escSignal?.aborted === true
161
+
147
162
  for await (const event of replayJsonl(opts.summary.sessionFile, { onWarn: opts.stderr })) {
163
+ if (escAborted()) return { escToPicker: true }
148
164
  emit(event)
149
165
  }
150
166
 
151
167
  if (opts.liveSource === undefined) {
152
168
  if (!opts.json) opts.stdout('─── end of transcript ───')
153
- return
169
+ return { escToPicker: escAborted() }
154
170
  }
155
171
 
172
+ if (escAborted()) return { escToPicker: true }
173
+
174
+ const combinedSignal = combineSignals(opts.signal, opts.escSignal)
156
175
  let sessionLive = false
157
176
  const liveIter = opts.liveSource({
158
177
  sessionId: opts.summary.sessionId,
159
178
  ...(opts.sinceMs !== undefined ? { sinceMs: opts.sinceMs } : {}),
160
- ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
179
+ ...(combinedSignal !== undefined ? { signal: combinedSignal } : {}),
161
180
  onSubscribed: (live) => {
162
181
  sessionLive = live
163
182
  },
@@ -170,6 +189,9 @@ async function streamSession(opts: {
170
189
  opts.stdout(
171
190
  divider(opts.color, sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
172
191
  )
192
+ if (opts.liveHint !== undefined && opts.liveHint !== '') {
193
+ opts.stdout(divider(opts.color, opts.liveHint))
194
+ }
173
195
  liveAnnounced = true
174
196
  }
175
197
  emit(event)
@@ -178,6 +200,21 @@ async function streamSession(opts: {
178
200
  opts.stderr(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
179
201
  }
180
202
  if (!opts.json) opts.stdout('─── end of transcript ───')
203
+ return { escToPicker: escAborted() && opts.signal?.aborted !== true }
204
+ }
205
+
206
+ function combineSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
207
+ if (a === undefined) return b
208
+ if (b === undefined) return a
209
+ if (a.aborted) return a
210
+ if (b.aborted) return b
211
+ const ctrl = new AbortController()
212
+ const onAbort = (): void => {
213
+ ctrl.abort()
214
+ }
215
+ a.addEventListener('abort', onAbort, { once: true })
216
+ b.addEventListener('abort', onAbort, { once: true })
217
+ return ctrl.signal
181
218
  }
182
219
 
183
220
  function divider(color: boolean, text: string): string {
@@ -0,0 +1,20 @@
1
+ import { runInspect, type RunInspectOptions, type RunInspectResult } from './index'
2
+
3
+ export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
4
+ newEscSignal: () => AbortSignal
5
+ }
6
+
7
+ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
8
+ let sessionArg = opts.sessionIdOrPrefix
9
+ while (true) {
10
+ const escSignal = opts.newEscSignal()
11
+ const callOpts: RunInspectOptions = { ...opts, escSignal }
12
+ if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
13
+ else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
14
+
15
+ const result = await runInspect(callOpts)
16
+ if (!result.ok) return result
17
+ if (result.escToPicker !== true) return result
18
+ sessionArg = undefined
19
+ }
20
+ }