typeclaw 0.9.1 → 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.
Files changed (62) hide show
  1. package/package.json +2 -2
  2. package/scripts/require-parallel.ts +41 -15
  3. package/src/agent/index.ts +9 -7
  4. package/src/agent/live-subagents.ts +0 -1
  5. package/src/agent/session-origin.ts +10 -0
  6. package/src/agent/subagent-completion-reminder.ts +4 -1
  7. package/src/agent/system-prompt.ts +5 -5
  8. package/src/agent/tools/restart.ts +13 -2
  9. package/src/agent/tools/spawn-subagent.ts +0 -1
  10. package/src/agent/tools/subagent-output.ts +3 -51
  11. package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
  12. package/src/bundled-plugins/memory/index.ts +55 -25
  13. package/src/bundled-plugins/memory/memory-retrieval.ts +1 -1
  14. package/src/bundled-plugins/memory/migration.ts +21 -17
  15. package/src/bundled-plugins/memory/stream-io.ts +71 -1
  16. package/src/bundled-plugins/security/index.ts +19 -17
  17. package/src/bundled-plugins/security/permissions.ts +9 -8
  18. package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
  19. package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
  20. package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
  21. package/src/channels/manager.ts +7 -0
  22. package/src/channels/router.ts +267 -14
  23. package/src/channels/schema.ts +22 -1
  24. package/src/cli/compose.ts +23 -2
  25. package/src/cli/cron.ts +1 -1
  26. package/src/cli/inspect.ts +105 -12
  27. package/src/cli/logs.ts +17 -2
  28. package/src/cli/role.ts +2 -2
  29. package/src/compose/logs.ts +8 -4
  30. package/src/config/config.ts +8 -0
  31. package/src/config/providers.ts +18 -0
  32. package/src/container/index.ts +1 -1
  33. package/src/container/logs.ts +38 -11
  34. package/src/cron/bridge.ts +25 -4
  35. package/src/hostd/daemon.ts +44 -24
  36. package/src/hostd/portbroker-manager.ts +19 -3
  37. package/src/init/dockerfile.ts +199 -4
  38. package/src/init/gitignore.ts +8 -0
  39. package/src/inspect/index.ts +42 -5
  40. package/src/inspect/live.ts +32 -1
  41. package/src/inspect/loop.ts +20 -0
  42. package/src/inspect/render.ts +32 -0
  43. package/src/inspect/replay.ts +14 -0
  44. package/src/inspect/types.ts +26 -0
  45. package/src/permissions/builtins.ts +29 -21
  46. package/src/permissions/permissions.ts +32 -5
  47. package/src/role-claim/code.ts +9 -9
  48. package/src/role-claim/controller.ts +3 -2
  49. package/src/role-claim/match-rule.ts +14 -19
  50. package/src/role-claim/pending.ts +2 -2
  51. package/src/run/index.ts +1 -0
  52. package/src/server/index.ts +59 -19
  53. package/src/shared/protocol.ts +30 -0
  54. package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
  55. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +144 -0
  56. package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
  57. package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
  58. package/src/skills/typeclaw-config/SKILL.md +39 -32
  59. package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
  60. package/src/skills/typeclaw-permissions/SKILL.md +24 -18
  61. package/src/test-helpers/wait-for.ts +15 -7
  62. package/typeclaw.schema.json +111 -10
@@ -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
  `
@@ -755,6 +807,136 @@ RUN chmod +x ${TYPECLAW_CC_SESSION_START_HOOK_PATH} ${TYPECLAW_CC_STOP_HOOK_PATH
755
807
  && printf '%s\\n' '${CLAUDE_CODE_ONBOARDING_SEED}' > "$HOME/.claude.json"`
756
808
  }
757
809
 
810
+ // Codex CLI's official distribution channel is the npm package
811
+ // \`@openai/codex\` (https://www.npmjs.com/package/@openai/codex). Unlike
812
+ // Claude Code's \`curl | bash\` install, Codex installs cleanly via the same
813
+ // bun-global path agent-browser uses, so we layer it under the existing
814
+ // \`bun install -g\` invariant: cache-mount keyed install, no \$HOME/.local/bin
815
+ // symlink dance, no installer scratch.
816
+ //
817
+ // Hook architecture: Codex CLI ships a hook system with the SAME event names
818
+ // as Claude Code (SessionStart, Stop, PreToolUse, PostToolUse, etc.) and the
819
+ // SAME JSON shape (\`{ session_id, transcript_path, cwd, hook_event_name }\` on
820
+ // stdin). The two CLIs are NOT interoperable — Codex's config file is
821
+ // \`~/.codex/hooks.json\` (or inline \`[hooks]\` in \`config.toml\`), and the trust
822
+ // model differs (Codex prompts for hook trust on first run, gated by
823
+ // \`--dangerously-bypass-hook-trust\`). But the hook script body, the
824
+ // per-session filenames (\`.done-<sid>\`, \`sentinel-<sid>.json\`), the
825
+ // \`.session-id\` fast path, and the \`malformed\` fallback are byte-for-byte
826
+ // reusable. We deliberately use distinct paths (\`typeclaw-cx-*\` instead of
827
+ // \`typeclaw-cc-*\`) and a distinct settings file (\`~/.codex/hooks.json\` vs
828
+ // \`~/.claude/settings.json\`) so an agent with BOTH toggles on doesn't have
829
+ // the two CLIs racing on the same sentinel files. The skill side
830
+ // (\`typeclaw-codex-cli\`) documents which UUID-bearing artifacts belong to
831
+ // which CLI.
832
+ //
833
+ // Onboarding: Codex has NO theme picker, NO telemetry consent dialog, NO
834
+ // terms-of-service prompt — verified against
835
+ // codex-rs/tui/src/onboarding/onboarding_screen.rs in the upstream repo.
836
+ // The TUI's onboarding state machine has exactly three steps: Welcome,
837
+ // Auth, TrustDirectory. We CAN'T pre-seed past Welcome (it's an animation,
838
+ // not a prompt), and we DELIBERATELY don't pre-seed past Auth (the user
839
+ // must paste their OPENAI_API_KEY or run \`codex login\` themselves) or past
840
+ // TrustDirectory (trusting an arbitrary worktree silently widens the trust
841
+ // surface in ways the operator hasn't consented to — exact same reasoning
842
+ // as Claude Code's "don't preseed hasTrustDialogAccepted"). The
843
+ // \`typeclaw-codex-cli\` skill handles all three at runtime via dialog
844
+ // polling, the same shape Claude Code's skill uses for its post-seed
845
+ // modals.
846
+ //
847
+ // Smoke test: \`codex --version\` is supported per codex-rs/cli/src/main.rs
848
+ // (clap \`version\` attribute), so we use it as the build-time install check.
849
+ const TYPECLAW_CX_STOP_HOOK_PATH = '/usr/local/bin/typeclaw-cx-stop-hook'
850
+ const TYPECLAW_CX_SESSION_START_HOOK_PATH = '/usr/local/bin/typeclaw-cx-session-start-hook'
851
+
852
+ const TYPECLAW_CX_SESSION_START_HOOK_SCRIPT = `#!/bin/sh
853
+ # typeclaw SessionStart-hook for Codex CLI. Stdin carries the SessionStart
854
+ # event JSON. Writes \$PWD/.session-id with the session UUID as a fast-path
855
+ # optimization (the operator falls back to discovering session_id from the
856
+ # first Stop sentinel if .session-id never appears). Rationale lives in
857
+ # src/init/dockerfile.ts.
858
+ set -eu
859
+ tmp_out="\${PWD}/.session-id.\$\$.tmp"
860
+ trap 'rm -f "\$tmp_out"' EXIT
861
+ sid=\$(bun -e 'try { const j = await new Response(Bun.stdin.stream()).json(); process.stdout.write(String(j.session_id ?? "")) } catch { process.stdout.write("") }')
862
+ case "\$sid" in
863
+ [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
864
+ *) sid=malformed ;;
865
+ esac
866
+ printf '%s\\n' "\$sid" > "\$tmp_out"
867
+ mv "\$tmp_out" "\${PWD}/.session-id"
868
+ trap - EXIT
869
+ `
870
+
871
+ const TYPECLAW_CX_STOP_HOOK_SCRIPT = `#!/bin/sh
872
+ # typeclaw Stop-hook for Codex CLI. Stdin carries the Stop event JSON.
873
+ # Writes per-session sentinel/.done files into \$PWD. Rationale (the
874
+ # security model, \$PWD semantics, and why bun-not-sed for JSON
875
+ # extraction) lives in src/init/dockerfile.ts.
876
+ set -eu
877
+ tmp_in="\${PWD}/.cx-stop-hook-in.\$\$"
878
+ trap 'rm -f "\$tmp_in"' EXIT
879
+ cat > "\$tmp_in"
880
+ sid=\$(bun -e 'try { const j = await Bun.file(process.argv[1]).json(); process.stdout.write(String(j.session_id ?? "")) } catch { process.stdout.write("") }' "\$tmp_in")
881
+ case "\$sid" in
882
+ [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
883
+ *) sid=malformed ;;
884
+ esac
885
+ mv "\$tmp_in" "\${PWD}/sentinel-\${sid}.json"
886
+ trap - EXIT
887
+ touch "\${PWD}/.done-\${sid}"
888
+ `
889
+
890
+ // Codex CLI's hook config file lives at ~/.codex/hooks.json. The JSON shape
891
+ // mirrors Claude Code's settings.json hooks block exactly — same nesting,
892
+ // same matcher syntax, same exec-form via \`args: []\`. Built via
893
+ // JSON.stringify so a shape edit fails the JSON.parse regression test, not
894
+ // the docker build (or the first failed delegation).
895
+ //
896
+ // SessionStart matcher \`startup|resume\`: Codex documents \`startup\` and
897
+ // \`resume\` as the two SessionStart sources (vs Claude Code's
898
+ // \`startup|resume|clear|compact\` — Codex has no \`/clear\` command and no
899
+ // auto-compaction in the same shape). Claude's broader matcher would not
900
+ // be wrong but would match against events Codex never fires.
901
+ const TYPECLAW_CX_GLOBAL_HOOKS = JSON.stringify({
902
+ hooks: {
903
+ SessionStart: [
904
+ {
905
+ matcher: 'startup|resume',
906
+ hooks: [{ type: 'command', command: TYPECLAW_CX_SESSION_START_HOOK_PATH, args: [] }],
907
+ },
908
+ ],
909
+ Stop: [
910
+ {
911
+ matcher: '*',
912
+ hooks: [{ type: 'command', command: TYPECLAW_CX_STOP_HOOK_PATH, args: [] }],
913
+ },
914
+ ],
915
+ },
916
+ })
917
+
918
+ function renderCodexCliInstallLayer(enabled: boolean): string {
919
+ if (!enabled) return ''
920
+ return `# Layer 5.7 (toggle): install OpenAI's Codex CLI. Opt-in via
921
+ # typeclaw.json#docker.file.codexCli. The skill \`typeclaw-codex-cli\`
922
+ # documents the auth + usage flow. Codex ships as the npm package
923
+ # \`@openai/codex\`, so we install via \`bun install -g\` reusing the same
924
+ # cache mount agent-browser uses. Hook scripts and ~/.codex/hooks.json
925
+ # are pre-written at build time so the operator subagent never has to
926
+ # construct that JSON itself — same load-bearing reason as the Claude
927
+ # Code layer above.
928
+ RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\
929
+ bun install -g @openai/codex \\
930
+ && codex --version > /dev/null \\
931
+ && cat > ${TYPECLAW_CX_SESSION_START_HOOK_PATH} <<'TYPECLAW_CX_SESSION_START_HOOK_EOF'
932
+ ${TYPECLAW_CX_SESSION_START_HOOK_SCRIPT}TYPECLAW_CX_SESSION_START_HOOK_EOF
933
+ RUN cat > ${TYPECLAW_CX_STOP_HOOK_PATH} <<'TYPECLAW_CX_STOP_HOOK_EOF'
934
+ ${TYPECLAW_CX_STOP_HOOK_SCRIPT}TYPECLAW_CX_STOP_HOOK_EOF
935
+ RUN chmod +x ${TYPECLAW_CX_SESSION_START_HOOK_PATH} ${TYPECLAW_CX_STOP_HOOK_PATH} \\
936
+ && mkdir -p "$HOME/.codex" \\
937
+ && printf '%s\\n' '${TYPECLAW_CX_GLOBAL_HOOKS}' > "$HOME/.codex/hooks.json"`
938
+ }
939
+
758
940
  // Shared-library runtime deps Chrome for Testing needs to launch on amd64
759
941
  // Debian trixie (base of `oven/bun:1-slim`). `agent-browser install
760
942
  // --with-deps` (v0.27.0) is supposed to install these but silently no-ops:
@@ -833,10 +1015,18 @@ export function buildDockerfile(
833
1015
  const baseImageVersion = options.baseImageVersion ?? null
834
1016
 
835
1017
  const claudeCodeLayer = renderClaudeCodeInstallLayer(config.claudeCode)
1018
+ const codexCliLayer = renderCodexCliInstallLayer(config.codexCli)
836
1019
  const fromAndHeavyLayers =
837
1020
  baseImageVersion !== null
838
- ? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer)
839
- : renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer)
1021
+ ? renderVersionedHead(
1022
+ baseImageVersion,
1023
+ ghKeyringLayer,
1024
+ toggleAptArgs,
1025
+ cloudflaredLayer,
1026
+ claudeCodeLayer,
1027
+ codexCliLayer,
1028
+ )
1029
+ : renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer, codexCliLayer)
840
1030
 
841
1031
  return `${BUILDKIT_HEADER}
842
1032
  # AUTOGENERATED by typeclaw — do not edit.
@@ -884,17 +1074,19 @@ function renderVersionedHead(
884
1074
  toggleAptArgs: string[],
885
1075
  cloudflaredLayer: string,
886
1076
  claudeCodeLayer: string,
1077
+ codexCliLayer: string,
887
1078
  ): string {
888
1079
  const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
889
1080
  const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
890
1081
  const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
1082
+ const codexCliBlock = codexCliLayer === '' ? '' : `${codexCliLayer}\n\n`
891
1083
  return `FROM ${GHCR_BASE_IMAGE_REPO}:${baseImageVersion}
892
1084
 
893
1085
  WORKDIR /agent
894
1086
 
895
1087
  ARG TARGETARCH
896
1088
 
897
- ${ghKeyringLayer}${toggleAptLayer}${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
1089
+ ${ghKeyringLayer}${toggleAptLayer}${cloudflaredBlock}${claudeCodeBlock}${codexCliBlock}${renderEntrypointShimLayer()}
898
1090
 
899
1091
  `
900
1092
  }
@@ -908,10 +1100,12 @@ function renderInlineHead(
908
1100
  toggleAptArgs: string[],
909
1101
  cloudflaredLayer: string,
910
1102
  claudeCodeLayer: string,
1103
+ codexCliLayer: string,
911
1104
  ): string {
912
1105
  const baselineAndToggleArgs = [...BASELINE_APT_PACKAGES, ...toggleAptArgs]
913
1106
  const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
914
1107
  const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
1108
+ const codexCliBlock = codexCliLayer === '' ? '' : `${codexCliLayer}\n\n`
915
1109
  return `${FROM_AND_WORKDIR}
916
1110
 
917
1111
  # Layers are ordered most-stable first to maximize Docker layer cache hits on
@@ -954,7 +1148,7 @@ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
954
1148
 
955
1149
  ${LAYER_5_CHROME_FOR_TESTING}
956
1150
 
957
- ${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
1151
+ ${cloudflaredBlock}${claudeCodeBlock}${codexCliBlock}${renderEntrypointShimLayer()}
958
1152
 
959
1153
  `
960
1154
  }
@@ -1223,6 +1417,7 @@ function defaultConfig(): DockerfileConfig {
1223
1417
  cloudflared: true,
1224
1418
  xvfb: true,
1225
1419
  claudeCode: false,
1420
+ codexCli: false,
1226
1421
  append: [],
1227
1422
  }
1228
1423
  }
@@ -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 {
@@ -21,6 +21,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
21
21
  let pendingError: string | null = null
22
22
 
23
23
  const accumulators = new Map<string, string>()
24
+ const thinkingAccumulators = new Map<string, string>()
24
25
 
25
26
  const wake = (): void => {
26
27
  if (resolveNext !== null) {
@@ -55,7 +56,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
55
56
  return
56
57
  }
57
58
  if (msg.type !== 'frame') return
58
- const event = frameToEvent(msg.payload, msg.ts, accumulators)
59
+ const event = frameToEvent(msg.payload, msg.ts, accumulators, thinkingAccumulators)
59
60
  if (event !== null) {
60
61
  buffer.push(event)
61
62
  wake()
@@ -134,6 +135,7 @@ function frameToEvent(
134
135
  payload: InspectFramePayload,
135
136
  ts: number,
136
137
  accumulators: Map<string, string>,
138
+ thinkingAccumulators: Map<string, string>,
137
139
  ): InspectEvent | null {
138
140
  switch (payload.kind) {
139
141
  case 'text_delta': {
@@ -141,6 +143,18 @@ function frameToEvent(
141
143
  accumulators.set(payload.sessionId, existing + payload.delta)
142
144
  return null
143
145
  }
146
+ case 'thinking_delta': {
147
+ const existing = thinkingAccumulators.get(payload.sessionId) ?? ''
148
+ thinkingAccumulators.set(payload.sessionId, existing + payload.delta)
149
+ return null
150
+ }
151
+ case 'thinking_end': {
152
+ const accumulated = thinkingAccumulators.get(payload.sessionId) ?? ''
153
+ thinkingAccumulators.delete(payload.sessionId)
154
+ const text = accumulated !== '' ? accumulated : payload.text
155
+ if (text === '' && payload.redacted !== true) return null
156
+ return { cat: 'thinking', ts, text, ...(payload.redacted === true ? { redacted: true } : {}) }
157
+ }
144
158
  case 'tool_start':
145
159
  return {
146
160
  cat: 'tool',
@@ -172,6 +186,23 @@ function frameToEvent(
172
186
  }
173
187
  case 'cron-fire':
174
188
  return { cat: 'cron-fire', ts, jobId: payload.jobId, payload: payload.payload }
189
+ case 'channel_inbound':
190
+ return {
191
+ cat: 'inbound',
192
+ ts: payload.ts > 0 ? payload.ts : ts,
193
+ adapter: payload.adapter,
194
+ workspace: payload.workspace,
195
+ chat: payload.chat,
196
+ thread: payload.thread,
197
+ authorId: payload.authorId,
198
+ authorName: payload.authorName,
199
+ authorIsBot: payload.authorIsBot,
200
+ isDm: payload.isDm,
201
+ isBotMention: payload.isBotMention,
202
+ text: payload.text,
203
+ externalMessageId: payload.externalMessageId,
204
+ decision: payload.decision,
205
+ }
175
206
  default:
176
207
  return null
177
208
  }
@@ -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
+ }
@@ -34,6 +34,8 @@ function renderTag(event: InspectEvent, opts: RenderOptions): string {
34
34
  return tint(opts, 'cyan', padEnd('user', 9))
35
35
  case 'assistant':
36
36
  return tint(opts, 'green', padEnd('assist', 9))
37
+ case 'thinking':
38
+ return tint(opts, 'gray', padEnd('think', 9))
37
39
  case 'tool':
38
40
  return tint(opts, 'yellow', padEnd(event.phase === 'start' ? 'tool ▸' : 'tool ◂', 9))
39
41
  case 'error':
@@ -44,6 +46,8 @@ function renderTag(event: InspectEvent, opts: RenderOptions): string {
44
46
  return tint(opts, 'magenta', padEnd('bcast', 9))
45
47
  case 'cron-fire':
46
48
  return tint(opts, 'magenta', padEnd('cron', 9))
49
+ case 'inbound':
50
+ return tint(opts, 'cyan', padEnd('inbound', 9))
47
51
  }
48
52
  }
49
53
 
@@ -55,6 +59,11 @@ function renderBody(event: InspectEvent, opts: RenderOptions): string {
55
59
  return truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
56
60
  case 'assistant':
57
61
  return truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
62
+ case 'thinking': {
63
+ const prefix = event.redacted === true ? `${tint(opts, 'dim', '[redacted]')} ` : ''
64
+ const body = event.text === '' ? '' : truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
65
+ return `${prefix}${tint(opts, 'dim', body)}`
66
+ }
58
67
  case 'tool': {
59
68
  if (event.phase === 'start') {
60
69
  return `${event.name}(${renderArgs(event.args)})`
@@ -72,6 +81,29 @@ function renderBody(event: InspectEvent, opts: RenderOptions): string {
72
81
  return renderBroadcastBody(event.payload, opts.maxTextLength ?? DEFAULT_MAX_TEXT)
73
82
  case 'cron-fire':
74
83
  return `${event.jobId} fired`
84
+ case 'inbound':
85
+ return renderInboundBody(event, opts)
86
+ }
87
+ }
88
+
89
+ function renderInboundBody(event: Extract<InspectEvent, { cat: 'inbound' }>, opts: RenderOptions): string {
90
+ const coord = `${event.adapter}:${event.workspace}/${event.chat}${event.thread === null ? '' : `#${event.thread}`}`
91
+ const who = event.authorName !== '' ? event.authorName : event.authorId
92
+ const decisionTag = tint(opts, decisionColor(event.decision), `[${event.decision}]`)
93
+ const text = truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
94
+ return `${decisionTag} ${tint(opts, 'dim', coord)} ${who}: ${text}`
95
+ }
96
+
97
+ function decisionColor(decision: Extract<InspectEvent, { cat: 'inbound' }>['decision']): ColorName {
98
+ switch (decision) {
99
+ case 'engage':
100
+ return 'green'
101
+ case 'observe':
102
+ return 'dim'
103
+ case 'denied':
104
+ return 'red'
105
+ case 'claim':
106
+ return 'magenta'
75
107
  }
76
108
  }
77
109
 
@@ -113,6 +113,7 @@ function* assistantEvents(
113
113
  ts: number,
114
114
  pending: Map<string, { name: string; startTs: number }>,
115
115
  ): Iterable<InspectEvent> {
116
+ yield* readThinkingEvents(message.content, ts)
116
117
  const text = readTextContent(message.content)
117
118
  if (text !== null && text !== '') {
118
119
  const ev: InspectEvent = {
@@ -218,6 +219,19 @@ function readUsage(value: unknown): {
218
219
  }
219
220
  }
220
221
 
222
+ function* readThinkingEvents(content: unknown, ts: number): Iterable<InspectEvent> {
223
+ if (!Array.isArray(content)) return
224
+ for (const block of content) {
225
+ if (typeof block !== 'object' || block === null) continue
226
+ const b = block as Record<string, unknown>
227
+ if (b.type !== 'thinking') continue
228
+ const text = typeof b.thinking === 'string' ? b.thinking : ''
229
+ const redacted = b.redacted === true
230
+ if (text === '' && !redacted) continue
231
+ yield { cat: 'thinking', ts, text, ...(redacted ? { redacted: true } : {}) }
232
+ }
233
+ }
234
+
221
235
  function readTextContent(content: unknown): string | null {
222
236
  if (typeof content === 'string') return content
223
237
  if (!Array.isArray(content)) return null
@@ -4,18 +4,28 @@ export const INSPECT_CATEGORIES = [
4
4
  'meta',
5
5
  'user',
6
6
  'assistant',
7
+ 'thinking',
7
8
  'tool',
8
9
  'error',
9
10
  'done',
10
11
  'broadcast',
11
12
  'cron-fire',
13
+ 'inbound',
12
14
  ] as const
13
15
  export type InspectCategory = (typeof INSPECT_CATEGORIES)[number]
14
16
 
17
+ export type InboundDecision = 'engage' | 'observe' | 'denied' | 'claim'
18
+
15
19
  export type InspectEvent =
16
20
  | { cat: 'meta'; ts: number; origin: MinimalSessionOrigin }
17
21
  | { cat: 'user'; ts: number; text: string }
18
22
  | { cat: 'assistant'; ts: number; text: string; provider?: string; model?: string }
23
+ // Reasoning trace from the model (Claude extended thinking, OpenAI reasoning
24
+ // summary, Gemini thoughts, etc.). Surfaced for debugging — why the model
25
+ // picked the next tool / wrote the next thing. `redacted` is true when the
26
+ // upstream provider hid the content behind a safety filter and only the
27
+ // opaque continuation payload survives; in that case `text` is empty.
28
+ | { cat: 'thinking'; ts: number; text: string; redacted?: boolean }
19
29
  | {
20
30
  cat: 'tool'
21
31
  ts: number
@@ -41,6 +51,22 @@ export type InspectEvent =
41
51
  }
42
52
  | { cat: 'broadcast'; ts: number; payload: unknown; meta?: Record<string, string> }
43
53
  | { cat: 'cron-fire'; ts: number; jobId: string; payload: unknown }
54
+ | {
55
+ cat: 'inbound'
56
+ ts: number
57
+ adapter: string
58
+ workspace: string
59
+ chat: string
60
+ thread: string | null
61
+ authorId: string
62
+ authorName: string
63
+ authorIsBot: boolean
64
+ isDm: boolean
65
+ isBotMention: boolean
66
+ text: string
67
+ externalMessageId: string
68
+ decision: InboundDecision
69
+ }
44
70
 
45
71
  export type InspectFilter = {
46
72
  include?: ReadonlySet<InspectCategory>