typeclaw 0.9.0 → 0.9.2

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 (45) hide show
  1. package/package.json +1 -1
  2. package/scripts/require-parallel.ts +41 -15
  3. package/src/agent/live-subagents.ts +0 -1
  4. package/src/agent/session-origin.ts +10 -0
  5. package/src/agent/subagent-completion-reminder.ts +4 -1
  6. package/src/agent/subagents.ts +72 -13
  7. package/src/agent/system-prompt.ts +5 -5
  8. package/src/agent/tools/channel-reply.ts +47 -7
  9. package/src/agent/tools/channel-send.ts +43 -11
  10. package/src/agent/tools/restart.ts +13 -2
  11. package/src/agent/tools/runtime-notice.ts +41 -0
  12. package/src/agent/tools/spawn-subagent.ts +0 -1
  13. package/src/agent/tools/subagent-output.ts +3 -51
  14. package/src/bundled-plugins/memory/README.md +11 -11
  15. package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
  16. package/src/bundled-plugins/memory/index.ts +77 -26
  17. package/src/bundled-plugins/memory/memory-retrieval.ts +7 -1
  18. package/src/bundled-plugins/memory/migration.ts +91 -16
  19. package/src/bundled-plugins/memory/stream-io.ts +71 -1
  20. package/src/channels/adapters/kakaotalk-classify.ts +4 -1
  21. package/src/channels/adapters/kakaotalk.ts +1 -1
  22. package/src/channels/manager.ts +7 -0
  23. package/src/channels/router.ts +260 -15
  24. package/src/channels/schema.ts +1 -1
  25. package/src/cli/compose.ts +23 -2
  26. package/src/cli/logs.ts +17 -2
  27. package/src/compose/logs.ts +8 -4
  28. package/src/config/config.ts +8 -0
  29. package/src/container/index.ts +1 -1
  30. package/src/container/logs.ts +38 -11
  31. package/src/init/dockerfile.ts +147 -4
  32. package/src/inspect/live.ts +32 -1
  33. package/src/inspect/render.ts +32 -0
  34. package/src/inspect/replay.ts +44 -0
  35. package/src/inspect/types.ts +26 -0
  36. package/src/run/index.ts +28 -11
  37. package/src/server/index.ts +59 -19
  38. package/src/shared/protocol.ts +30 -0
  39. package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
  40. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +131 -0
  41. package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
  42. package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
  43. package/src/skills/typeclaw-config/SKILL.md +32 -31
  44. package/src/test-helpers/wait-for.ts +15 -7
  45. package/typeclaw.schema.json +24 -11
@@ -755,6 +755,136 @@ RUN chmod +x ${TYPECLAW_CC_SESSION_START_HOOK_PATH} ${TYPECLAW_CC_STOP_HOOK_PATH
755
755
  && printf '%s\\n' '${CLAUDE_CODE_ONBOARDING_SEED}' > "$HOME/.claude.json"`
756
756
  }
757
757
 
758
+ // Codex CLI's official distribution channel is the npm package
759
+ // \`@openai/codex\` (https://www.npmjs.com/package/@openai/codex). Unlike
760
+ // Claude Code's \`curl | bash\` install, Codex installs cleanly via the same
761
+ // bun-global path agent-browser uses, so we layer it under the existing
762
+ // \`bun install -g\` invariant: cache-mount keyed install, no \$HOME/.local/bin
763
+ // symlink dance, no installer scratch.
764
+ //
765
+ // Hook architecture: Codex CLI ships a hook system with the SAME event names
766
+ // as Claude Code (SessionStart, Stop, PreToolUse, PostToolUse, etc.) and the
767
+ // SAME JSON shape (\`{ session_id, transcript_path, cwd, hook_event_name }\` on
768
+ // stdin). The two CLIs are NOT interoperable — Codex's config file is
769
+ // \`~/.codex/hooks.json\` (or inline \`[hooks]\` in \`config.toml\`), and the trust
770
+ // model differs (Codex prompts for hook trust on first run, gated by
771
+ // \`--dangerously-bypass-hook-trust\`). But the hook script body, the
772
+ // per-session filenames (\`.done-<sid>\`, \`sentinel-<sid>.json\`), the
773
+ // \`.session-id\` fast path, and the \`malformed\` fallback are byte-for-byte
774
+ // reusable. We deliberately use distinct paths (\`typeclaw-cx-*\` instead of
775
+ // \`typeclaw-cc-*\`) and a distinct settings file (\`~/.codex/hooks.json\` vs
776
+ // \`~/.claude/settings.json\`) so an agent with BOTH toggles on doesn't have
777
+ // the two CLIs racing on the same sentinel files. The skill side
778
+ // (\`typeclaw-codex-cli\`) documents which UUID-bearing artifacts belong to
779
+ // which CLI.
780
+ //
781
+ // Onboarding: Codex has NO theme picker, NO telemetry consent dialog, NO
782
+ // terms-of-service prompt — verified against
783
+ // codex-rs/tui/src/onboarding/onboarding_screen.rs in the upstream repo.
784
+ // The TUI's onboarding state machine has exactly three steps: Welcome,
785
+ // Auth, TrustDirectory. We CAN'T pre-seed past Welcome (it's an animation,
786
+ // not a prompt), and we DELIBERATELY don't pre-seed past Auth (the user
787
+ // must paste their OPENAI_API_KEY or run \`codex login\` themselves) or past
788
+ // TrustDirectory (trusting an arbitrary worktree silently widens the trust
789
+ // surface in ways the operator hasn't consented to — exact same reasoning
790
+ // as Claude Code's "don't preseed hasTrustDialogAccepted"). The
791
+ // \`typeclaw-codex-cli\` skill handles all three at runtime via dialog
792
+ // polling, the same shape Claude Code's skill uses for its post-seed
793
+ // modals.
794
+ //
795
+ // Smoke test: \`codex --version\` is supported per codex-rs/cli/src/main.rs
796
+ // (clap \`version\` attribute), so we use it as the build-time install check.
797
+ const TYPECLAW_CX_STOP_HOOK_PATH = '/usr/local/bin/typeclaw-cx-stop-hook'
798
+ const TYPECLAW_CX_SESSION_START_HOOK_PATH = '/usr/local/bin/typeclaw-cx-session-start-hook'
799
+
800
+ const TYPECLAW_CX_SESSION_START_HOOK_SCRIPT = `#!/bin/sh
801
+ # typeclaw SessionStart-hook for Codex CLI. Stdin carries the SessionStart
802
+ # event JSON. Writes \$PWD/.session-id with the session UUID as a fast-path
803
+ # optimization (the operator falls back to discovering session_id from the
804
+ # first Stop sentinel if .session-id never appears). Rationale lives in
805
+ # src/init/dockerfile.ts.
806
+ set -eu
807
+ tmp_out="\${PWD}/.session-id.\$\$.tmp"
808
+ trap 'rm -f "\$tmp_out"' EXIT
809
+ sid=\$(bun -e 'try { const j = await new Response(Bun.stdin.stream()).json(); process.stdout.write(String(j.session_id ?? "")) } catch { process.stdout.write("") }')
810
+ case "\$sid" in
811
+ [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]) ;;
812
+ *) sid=malformed ;;
813
+ esac
814
+ printf '%s\\n' "\$sid" > "\$tmp_out"
815
+ mv "\$tmp_out" "\${PWD}/.session-id"
816
+ trap - EXIT
817
+ `
818
+
819
+ const TYPECLAW_CX_STOP_HOOK_SCRIPT = `#!/bin/sh
820
+ # typeclaw Stop-hook for Codex CLI. Stdin carries the Stop event JSON.
821
+ # Writes per-session sentinel/.done files into \$PWD. Rationale (the
822
+ # security model, \$PWD semantics, and why bun-not-sed for JSON
823
+ # extraction) lives in src/init/dockerfile.ts.
824
+ set -eu
825
+ tmp_in="\${PWD}/.cx-stop-hook-in.\$\$"
826
+ trap 'rm -f "\$tmp_in"' EXIT
827
+ cat > "\$tmp_in"
828
+ 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")
829
+ case "\$sid" in
830
+ [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]) ;;
831
+ *) sid=malformed ;;
832
+ esac
833
+ mv "\$tmp_in" "\${PWD}/sentinel-\${sid}.json"
834
+ trap - EXIT
835
+ touch "\${PWD}/.done-\${sid}"
836
+ `
837
+
838
+ // Codex CLI's hook config file lives at ~/.codex/hooks.json. The JSON shape
839
+ // mirrors Claude Code's settings.json hooks block exactly — same nesting,
840
+ // same matcher syntax, same exec-form via \`args: []\`. Built via
841
+ // JSON.stringify so a shape edit fails the JSON.parse regression test, not
842
+ // the docker build (or the first failed delegation).
843
+ //
844
+ // SessionStart matcher \`startup|resume\`: Codex documents \`startup\` and
845
+ // \`resume\` as the two SessionStart sources (vs Claude Code's
846
+ // \`startup|resume|clear|compact\` — Codex has no \`/clear\` command and no
847
+ // auto-compaction in the same shape). Claude's broader matcher would not
848
+ // be wrong but would match against events Codex never fires.
849
+ const TYPECLAW_CX_GLOBAL_HOOKS = JSON.stringify({
850
+ hooks: {
851
+ SessionStart: [
852
+ {
853
+ matcher: 'startup|resume',
854
+ hooks: [{ type: 'command', command: TYPECLAW_CX_SESSION_START_HOOK_PATH, args: [] }],
855
+ },
856
+ ],
857
+ Stop: [
858
+ {
859
+ matcher: '*',
860
+ hooks: [{ type: 'command', command: TYPECLAW_CX_STOP_HOOK_PATH, args: [] }],
861
+ },
862
+ ],
863
+ },
864
+ })
865
+
866
+ function renderCodexCliInstallLayer(enabled: boolean): string {
867
+ if (!enabled) return ''
868
+ return `# Layer 5.7 (toggle): install OpenAI's Codex CLI. Opt-in via
869
+ # typeclaw.json#docker.file.codexCli. The skill \`typeclaw-codex-cli\`
870
+ # documents the auth + usage flow. Codex ships as the npm package
871
+ # \`@openai/codex\`, so we install via \`bun install -g\` reusing the same
872
+ # cache mount agent-browser uses. Hook scripts and ~/.codex/hooks.json
873
+ # are pre-written at build time so the operator subagent never has to
874
+ # construct that JSON itself — same load-bearing reason as the Claude
875
+ # Code layer above.
876
+ RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\
877
+ bun install -g @openai/codex \\
878
+ && codex --version > /dev/null \\
879
+ && cat > ${TYPECLAW_CX_SESSION_START_HOOK_PATH} <<'TYPECLAW_CX_SESSION_START_HOOK_EOF'
880
+ ${TYPECLAW_CX_SESSION_START_HOOK_SCRIPT}TYPECLAW_CX_SESSION_START_HOOK_EOF
881
+ RUN cat > ${TYPECLAW_CX_STOP_HOOK_PATH} <<'TYPECLAW_CX_STOP_HOOK_EOF'
882
+ ${TYPECLAW_CX_STOP_HOOK_SCRIPT}TYPECLAW_CX_STOP_HOOK_EOF
883
+ RUN chmod +x ${TYPECLAW_CX_SESSION_START_HOOK_PATH} ${TYPECLAW_CX_STOP_HOOK_PATH} \\
884
+ && mkdir -p "$HOME/.codex" \\
885
+ && printf '%s\\n' '${TYPECLAW_CX_GLOBAL_HOOKS}' > "$HOME/.codex/hooks.json"`
886
+ }
887
+
758
888
  // Shared-library runtime deps Chrome for Testing needs to launch on amd64
759
889
  // Debian trixie (base of `oven/bun:1-slim`). `agent-browser install
760
890
  // --with-deps` (v0.27.0) is supposed to install these but silently no-ops:
@@ -833,10 +963,18 @@ export function buildDockerfile(
833
963
  const baseImageVersion = options.baseImageVersion ?? null
834
964
 
835
965
  const claudeCodeLayer = renderClaudeCodeInstallLayer(config.claudeCode)
966
+ const codexCliLayer = renderCodexCliInstallLayer(config.codexCli)
836
967
  const fromAndHeavyLayers =
837
968
  baseImageVersion !== null
838
- ? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer)
839
- : renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer)
969
+ ? renderVersionedHead(
970
+ baseImageVersion,
971
+ ghKeyringLayer,
972
+ toggleAptArgs,
973
+ cloudflaredLayer,
974
+ claudeCodeLayer,
975
+ codexCliLayer,
976
+ )
977
+ : renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer, codexCliLayer)
840
978
 
841
979
  return `${BUILDKIT_HEADER}
842
980
  # AUTOGENERATED by typeclaw — do not edit.
@@ -884,17 +1022,19 @@ function renderVersionedHead(
884
1022
  toggleAptArgs: string[],
885
1023
  cloudflaredLayer: string,
886
1024
  claudeCodeLayer: string,
1025
+ codexCliLayer: string,
887
1026
  ): string {
888
1027
  const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
889
1028
  const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
890
1029
  const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
1030
+ const codexCliBlock = codexCliLayer === '' ? '' : `${codexCliLayer}\n\n`
891
1031
  return `FROM ${GHCR_BASE_IMAGE_REPO}:${baseImageVersion}
892
1032
 
893
1033
  WORKDIR /agent
894
1034
 
895
1035
  ARG TARGETARCH
896
1036
 
897
- ${ghKeyringLayer}${toggleAptLayer}${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
1037
+ ${ghKeyringLayer}${toggleAptLayer}${cloudflaredBlock}${claudeCodeBlock}${codexCliBlock}${renderEntrypointShimLayer()}
898
1038
 
899
1039
  `
900
1040
  }
@@ -908,10 +1048,12 @@ function renderInlineHead(
908
1048
  toggleAptArgs: string[],
909
1049
  cloudflaredLayer: string,
910
1050
  claudeCodeLayer: string,
1051
+ codexCliLayer: string,
911
1052
  ): string {
912
1053
  const baselineAndToggleArgs = [...BASELINE_APT_PACKAGES, ...toggleAptArgs]
913
1054
  const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
914
1055
  const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
1056
+ const codexCliBlock = codexCliLayer === '' ? '' : `${codexCliLayer}\n\n`
915
1057
  return `${FROM_AND_WORKDIR}
916
1058
 
917
1059
  # Layers are ordered most-stable first to maximize Docker layer cache hits on
@@ -954,7 +1096,7 @@ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
954
1096
 
955
1097
  ${LAYER_5_CHROME_FOR_TESTING}
956
1098
 
957
- ${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
1099
+ ${cloudflaredBlock}${claudeCodeBlock}${codexCliBlock}${renderEntrypointShimLayer()}
958
1100
 
959
1101
  `
960
1102
  }
@@ -1223,6 +1365,7 @@ function defaultConfig(): DockerfileConfig {
1223
1365
  cloudflared: true,
1224
1366
  xvfb: true,
1225
1367
  claudeCode: false,
1368
+ codexCli: false,
1226
1369
  append: [],
1227
1370
  }
1228
1371
  }
@@ -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
  }
@@ -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
 
@@ -76,6 +76,36 @@ function* eventsFromEntry(
76
76
  yield* assistantEvents(message as AssistantMessage, ts, pending)
77
77
  return
78
78
  }
79
+ if (role === 'toolResult') {
80
+ const ev = toolResultMessageEvent(message, ts, pending)
81
+ if (ev !== null) yield ev
82
+ return
83
+ }
84
+ }
85
+
86
+ function toolResultMessageEvent(
87
+ message: { role: string; [k: string]: unknown },
88
+ ts: number,
89
+ pending: Map<string, { name: string; startTs: number }>,
90
+ ): InspectEvent | null {
91
+ const toolCallId = typeof message.toolCallId === 'string' ? message.toolCallId : null
92
+ if (toolCallId === null) return null
93
+ const entry = pending.get(toolCallId)
94
+ pending.delete(toolCallId)
95
+ const name = entry?.name ?? (typeof message.toolName === 'string' ? message.toolName : 'unknown')
96
+ const durationMs = entry !== undefined ? Math.max(0, ts - entry.startTs) : 0
97
+ const isError = message.isError === true
98
+ const text = readTextContent(message.content)
99
+ return {
100
+ cat: 'tool',
101
+ ts,
102
+ phase: 'end',
103
+ toolCallId,
104
+ name,
105
+ ...(text !== null && text !== '' ? { result: text } : {}),
106
+ isError,
107
+ durationMs,
108
+ }
79
109
  }
80
110
 
81
111
  function* assistantEvents(
@@ -83,6 +113,7 @@ function* assistantEvents(
83
113
  ts: number,
84
114
  pending: Map<string, { name: string; startTs: number }>,
85
115
  ): Iterable<InspectEvent> {
116
+ yield* readThinkingEvents(message.content, ts)
86
117
  const text = readTextContent(message.content)
87
118
  if (text !== null && text !== '') {
88
119
  const ev: InspectEvent = {
@@ -188,6 +219,19 @@ function readUsage(value: unknown): {
188
219
  }
189
220
  }
190
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
+
191
235
  function readTextContent(content: unknown): string | null {
192
236
  if (typeof content === 'string') return content
193
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>
package/src/run/index.ts CHANGED
@@ -5,9 +5,11 @@ import { LiveSessionRegistry } from '@/agent/live-sessions'
5
5
  import { LiveSubagentRegistry } from '@/agent/live-subagents'
6
6
  import type { SessionOrigin } from '@/agent/session-origin'
7
7
  import {
8
+ awaitWithSubagentTimeout,
8
9
  createSubagentConsumer,
9
10
  defaultCreateSessionForSubagent,
10
11
  invokeSubagent,
12
+ isSubagentTimeoutError,
11
13
  type Subagent as InternalSubagent,
12
14
  type SubagentConsumer,
13
15
  type SubagentRegistry,
@@ -212,6 +214,7 @@ export async function startAgent({
212
214
  }),
213
215
  permissions: pluginsLoaded.permissions,
214
216
  claimHandler: claimController.claimHandler,
217
+ stream,
215
218
  })
216
219
 
217
220
  const createSessionForSubagent: import('@/agent/subagents').CreateSessionForSubagent = async (
@@ -469,17 +472,31 @@ export async function startAgent({
469
472
  options?.spawnedByOrigin !== undefined
470
473
  ? pluginsLoaded.permissions.resolveRole(options.spawnedByOrigin)
471
474
  : undefined
472
- await invokeSubagent(name, {
473
- registry: pluginRuntime.get().subagents,
474
- createSessionForSubagent,
475
- agentDir: cwd,
476
- userPrompt: '',
477
- payload,
478
- onProviderError: (message) => console.error(`[subagent] ${name}: LLM call failed: ${message}`),
479
- ...(options?.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
480
- ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
481
- ...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
482
- })
475
+ const registry = pluginRuntime.get().subagents
476
+ try {
477
+ await awaitWithSubagentTimeout(
478
+ invokeSubagent(name, {
479
+ registry,
480
+ createSessionForSubagent,
481
+ agentDir: cwd,
482
+ userPrompt: '',
483
+ payload,
484
+ onProviderError: (message) => console.error(`[subagent] ${name}: LLM call failed: ${message}`),
485
+ ...(options?.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
486
+ ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
487
+ ...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
488
+ }),
489
+ name,
490
+ coalesceKey,
491
+ registry[name]?.timeoutMs,
492
+ )
493
+ } catch (err) {
494
+ if (isSubagentTimeoutError(err)) {
495
+ console.warn(`[subagent] ${coalesceKey} timed out after ${err.timeoutMs}ms; releasing coalesce key`)
496
+ return
497
+ }
498
+ throw err
499
+ }
483
500
  } finally {
484
501
  directSpawnInFlight.delete(coalesceKey)
485
502
  }
@@ -1062,15 +1062,7 @@ function handleInspectMessage(
1062
1062
 
1063
1063
  if (stream !== undefined && typeof msg.sinceMs === 'number') {
1064
1064
  for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'broadcast' } })) {
1065
- sendInspect(ws, {
1066
- type: 'frame',
1067
- ts: event.ts,
1068
- payload: {
1069
- kind: 'broadcast',
1070
- payload: event.payload,
1071
- ...(event.meta !== undefined ? { meta: event.meta } : {}),
1072
- },
1073
- })
1065
+ sendInspect(ws, { type: 'frame', ts: event.ts, payload: broadcastEventToFrame(event) })
1074
1066
  }
1075
1067
  for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'cron' } })) {
1076
1068
  sendInspect(ws, {
@@ -1092,15 +1084,7 @@ function handleInspectMessage(
1092
1084
 
1093
1085
  if (stream !== undefined) {
1094
1086
  ws.data.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (event) => {
1095
- sendInspect(ws, {
1096
- type: 'frame',
1097
- ts: event.ts,
1098
- payload: {
1099
- kind: 'broadcast',
1100
- payload: event.payload,
1101
- ...(event.meta !== undefined ? { meta: event.meta } : {}),
1102
- },
1103
- })
1087
+ sendInspect(ws, { type: 'frame', ts: event.ts, payload: broadcastEventToFrame(event) })
1104
1088
  })
1105
1089
  ws.data.unsubCron = stream.subscribe({ target: { kind: 'cron' } }, (event) => {
1106
1090
  sendInspect(ws, {
@@ -1118,6 +1102,52 @@ function extractJobId(target: StreamMessage['target']): string {
1118
1102
  return target.kind === 'cron' ? target.jobId : ''
1119
1103
  }
1120
1104
 
1105
+ function broadcastEventToFrame(event: StreamMessage): InspectFramePayload {
1106
+ const inbound = readChannelInboundBroadcast(event.payload)
1107
+ if (inbound !== null) return inbound
1108
+ return {
1109
+ kind: 'broadcast',
1110
+ payload: event.payload,
1111
+ ...(event.meta !== undefined ? { meta: event.meta } : {}),
1112
+ }
1113
+ }
1114
+
1115
+ function readChannelInboundBroadcast(payload: unknown): InspectFramePayload | null {
1116
+ if (typeof payload !== 'object' || payload === null) return null
1117
+ const p = payload as Record<string, unknown>
1118
+ if (p.kind !== 'channel-inbound') return null
1119
+ if (typeof p.adapter !== 'string') return null
1120
+ if (typeof p.workspace !== 'string') return null
1121
+ if (typeof p.chat !== 'string') return null
1122
+ if (!(p.thread === null || typeof p.thread === 'string')) return null
1123
+ if (typeof p.authorId !== 'string') return null
1124
+ if (typeof p.authorName !== 'string') return null
1125
+ if (typeof p.authorIsBot !== 'boolean') return null
1126
+ if (typeof p.isDm !== 'boolean') return null
1127
+ if (typeof p.isBotMention !== 'boolean') return null
1128
+ if (typeof p.text !== 'string') return null
1129
+ if (typeof p.externalMessageId !== 'string') return null
1130
+ if (typeof p.ts !== 'number') return null
1131
+ const decision = p.decision
1132
+ if (decision !== 'engage' && decision !== 'observe' && decision !== 'denied' && decision !== 'claim') return null
1133
+ return {
1134
+ kind: 'channel_inbound',
1135
+ adapter: p.adapter,
1136
+ workspace: p.workspace,
1137
+ chat: p.chat,
1138
+ thread: p.thread,
1139
+ authorId: p.authorId,
1140
+ authorName: p.authorName,
1141
+ authorIsBot: p.authorIsBot,
1142
+ isDm: p.isDm,
1143
+ isBotMention: p.isBotMention,
1144
+ text: p.text,
1145
+ externalMessageId: p.externalMessageId,
1146
+ ts: p.ts,
1147
+ decision,
1148
+ }
1149
+ }
1150
+
1121
1151
  function forwardAgentEventToInspect(
1122
1152
  ws: InspectWs,
1123
1153
  event: unknown,
@@ -1128,10 +1158,20 @@ function forwardAgentEventToInspect(
1128
1158
  const e = event as { type?: unknown }
1129
1159
  const now = Date.now()
1130
1160
  if (e.type === 'message_update') {
1131
- const ev = event as { assistantMessageEvent?: { type?: unknown; delta?: unknown } }
1161
+ const ev = event as { assistantMessageEvent?: { type?: unknown; delta?: unknown; content?: unknown } }
1132
1162
  const ame = ev.assistantMessageEvent
1133
1163
  if (ame?.type === 'text_delta' && typeof ame.delta === 'string') {
1134
1164
  sendInspect(ws, { type: 'frame', ts: now, payload: { kind: 'text_delta', sessionId, delta: ame.delta } })
1165
+ return
1166
+ }
1167
+ if (ame?.type === 'thinking_delta' && typeof ame.delta === 'string') {
1168
+ sendInspect(ws, { type: 'frame', ts: now, payload: { kind: 'thinking_delta', sessionId, delta: ame.delta } })
1169
+ return
1170
+ }
1171
+ if (ame?.type === 'thinking_end') {
1172
+ const text = typeof ame.content === 'string' ? ame.content : ''
1173
+ sendInspect(ws, { type: 'frame', ts: now, payload: { kind: 'thinking_end', sessionId, text } })
1174
+ return
1135
1175
  }
1136
1176
  return
1137
1177
  }
@@ -57,6 +57,12 @@ export type InspectClientMessage = {
57
57
 
58
58
  export type InspectFramePayload =
59
59
  | { kind: 'text_delta'; sessionId: string; delta: string }
60
+ // Reasoning trace from the model, streamed as deltas like text. `thinking_end`
61
+ // closes a thinking block; `text` is the joined deltas (empty when redacted).
62
+ // `redacted: true` means the upstream provider hid the content behind a
63
+ // safety filter and only the opaque continuation payload survives.
64
+ | { kind: 'thinking_delta'; sessionId: string; delta: string }
65
+ | { kind: 'thinking_end'; sessionId: string; text: string; redacted?: boolean }
60
66
  | { kind: 'tool_start'; sessionId: string; toolCallId: string; name: string; args: unknown }
61
67
  | {
62
68
  kind: 'tool_end'
@@ -87,6 +93,30 @@ export type InspectFramePayload =
87
93
  }
88
94
  | { kind: 'broadcast'; payload: unknown; meta?: Record<string, string> }
89
95
  | { kind: 'cron-fire'; jobId: string; payload: unknown }
96
+ // Channel inbound message observed by the router. Surfaced regardless
97
+ // of the engagement decision so inspect can show what the agent saw,
98
+ // not just what it chose to act on. `decision` mirrors the router's
99
+ // EngagementDecision plus 'denied' (channel.respond gate) and 'claim'
100
+ // (role-claim intercept) for completeness. `text` is the raw inbound
101
+ // text — no batching, no compose-prompt wrapping.
102
+ | {
103
+ kind: 'channel_inbound'
104
+ adapter: string
105
+ workspace: string
106
+ chat: string
107
+ thread: string | null
108
+ authorId: string
109
+ authorName: string
110
+ authorIsBot: boolean
111
+ isDm: boolean
112
+ isBotMention: boolean
113
+ text: string
114
+ externalMessageId: string
115
+ // 0 = platform timestamp unknown; the renderer uses the frame's
116
+ // wall-clock ts instead.
117
+ ts: number
118
+ decision: 'engage' | 'observe' | 'denied' | 'claim'
119
+ }
90
120
 
91
121
  export type InspectServerMessage =
92
122
  | { type: 'subscribed'; sessionId: string; sessionLive: boolean }