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.
- package/package.json +1 -1
- package/scripts/require-parallel.ts +41 -15
- package/src/agent/live-subagents.ts +0 -1
- package/src/agent/session-origin.ts +10 -0
- package/src/agent/subagent-completion-reminder.ts +4 -1
- package/src/agent/subagents.ts +72 -13
- package/src/agent/system-prompt.ts +5 -5
- package/src/agent/tools/channel-reply.ts +47 -7
- package/src/agent/tools/channel-send.ts +43 -11
- package/src/agent/tools/restart.ts +13 -2
- package/src/agent/tools/runtime-notice.ts +41 -0
- package/src/agent/tools/spawn-subagent.ts +0 -1
- package/src/agent/tools/subagent-output.ts +3 -51
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
- package/src/bundled-plugins/memory/index.ts +77 -26
- package/src/bundled-plugins/memory/memory-retrieval.ts +7 -1
- package/src/bundled-plugins/memory/migration.ts +91 -16
- package/src/bundled-plugins/memory/stream-io.ts +71 -1
- package/src/channels/adapters/kakaotalk-classify.ts +4 -1
- package/src/channels/adapters/kakaotalk.ts +1 -1
- package/src/channels/manager.ts +7 -0
- package/src/channels/router.ts +260 -15
- package/src/channels/schema.ts +1 -1
- package/src/cli/compose.ts +23 -2
- package/src/cli/logs.ts +17 -2
- package/src/compose/logs.ts +8 -4
- package/src/config/config.ts +8 -0
- package/src/container/index.ts +1 -1
- package/src/container/logs.ts +38 -11
- package/src/init/dockerfile.ts +147 -4
- package/src/inspect/live.ts +32 -1
- package/src/inspect/render.ts +32 -0
- package/src/inspect/replay.ts +44 -0
- package/src/inspect/types.ts +26 -0
- package/src/run/index.ts +28 -11
- package/src/server/index.ts +59 -19
- package/src/shared/protocol.ts +30 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +131 -0
- package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
- package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
- package/src/skills/typeclaw-config/SKILL.md +32 -31
- package/src/test-helpers/wait-for.ts +15 -7
- package/typeclaw.schema.json +24 -11
package/src/init/dockerfile.ts
CHANGED
|
@@ -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(
|
|
839
|
-
|
|
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
|
}
|
package/src/inspect/live.ts
CHANGED
|
@@ -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
|
}
|
package/src/inspect/render.ts
CHANGED
|
@@ -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
|
|
package/src/inspect/replay.ts
CHANGED
|
@@ -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
|
package/src/inspect/types.ts
CHANGED
|
@@ -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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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 }
|