agentbox-app 0.1.6
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/LICENSE +674 -0
- package/README.md +176 -0
- package/bin/agentbox.mjs +103 -0
- package/briefs/adhd-mode.md +5 -0
- package/briefs/message-rules.md +95 -0
- package/briefs/worker.md +230 -0
- package/briefs/writing-rules.md +336 -0
- package/main/account-discovery.mjs +145 -0
- package/main/account-tooling.mjs +261 -0
- package/main/agent-activity.mjs +56 -0
- package/main/agent-files.mjs +422 -0
- package/main/agent-schedule.mjs +254 -0
- package/main/agent-sessions.mjs +435 -0
- package/main/agents.mjs +1095 -0
- package/main/analytics.mjs +206 -0
- package/main/answer-modes.mjs +206 -0
- package/main/approval-prompt-server.mjs +160 -0
- package/main/approvals.mjs +487 -0
- package/main/artifact-path.mjs +144 -0
- package/main/claude-bin.mjs +311 -0
- package/main/claude-input.mjs +117 -0
- package/main/claude-models.mjs +244 -0
- package/main/claude-plan.mjs +148 -0
- package/main/claude-usage.mjs +149 -0
- package/main/code-change.mjs +383 -0
- package/main/code-file.mjs +116 -0
- package/main/codex-account.mjs +146 -0
- package/main/codex-app-server.mjs +796 -0
- package/main/codex-approvals.mjs +712 -0
- package/main/codex-bin.mjs +261 -0
- package/main/codex-compaction.mjs +49 -0
- package/main/codex-models.mjs +194 -0
- package/main/codex-session.mjs +797 -0
- package/main/codex-threads.mjs +268 -0
- package/main/codex-usage-file.mjs +192 -0
- package/main/codex-usage.mjs +149 -0
- package/main/codex-watch.mjs +35 -0
- package/main/codex.mjs +757 -0
- package/main/config.mjs +345 -0
- package/main/copy-selection.mjs +55 -0
- package/main/crash-report.mjs +158 -0
- package/main/demo.mjs +254 -0
- package/main/dev-window.mjs +103 -0
- package/main/doc-file.mjs +99 -0
- package/main/doc-scheme.mjs +126 -0
- package/main/first-run.mjs +190 -0
- package/main/folders.mjs +91 -0
- package/main/fresh-user.mjs +120 -0
- package/main/git-change.mjs +323 -0
- package/main/img-scheme.mjs +90 -0
- package/main/instruction-settings.mjs +205 -0
- package/main/ipc.mjs +1558 -0
- package/main/live-replies.mjs +77 -0
- package/main/machine.mjs +113 -0
- package/main/main.mjs +972 -0
- package/main/menu.mjs +177 -0
- package/main/notify.mjs +155 -0
- package/main/open-source-file.mjs +8 -0
- package/main/project-identity.mjs +149 -0
- package/main/provider-commands.mjs +32 -0
- package/main/rail-note.mjs +74 -0
- package/main/repeats.mjs +261 -0
- package/main/row-label.mjs +185 -0
- package/main/screen-detail.mjs +44 -0
- package/main/serve.mjs +262 -0
- package/main/settings.mjs +912 -0
- package/main/staleness.mjs +76 -0
- package/main/store/chats.mjs +188 -0
- package/main/store/home.mjs +383 -0
- package/main/store/migrate.mjs +40 -0
- package/main/store/paths.mjs +82 -0
- package/main/store/pending-writes.mjs +122 -0
- package/main/store/project-lock.mjs +200 -0
- package/main/store/project.mjs +523 -0
- package/main/store/queues.mjs +31 -0
- package/main/store/work-items.mjs +299 -0
- package/main/store/write-queue.mjs +14 -0
- package/main/store-modules.mjs +23 -0
- package/main/store.mjs +1269 -0
- package/main/supervisor.mjs +6875 -0
- package/main/task-commands.mjs +161 -0
- package/main/task-folders-offthread.mjs +84 -0
- package/main/task-folders-thread.mjs +13 -0
- package/main/task-folders.mjs +409 -0
- package/main/task-remote-control.mjs +65 -0
- package/main/task-terminals.mjs +169 -0
- package/main/terminal-access.mjs +6 -0
- package/main/updater.mjs +240 -0
- package/main/zoom-keys.mjs +54 -0
- package/main/zoom-readout.mjs +20 -0
- package/mcp/agentbox-mcp.sh +34 -0
- package/mcp/core/account.mjs +77 -0
- package/mcp/core/chat.mjs +61 -0
- package/mcp/core/documents.mjs +238 -0
- package/mcp/core/page.mjs +152 -0
- package/mcp/core/products.mjs +110 -0
- package/mcp/core/skills.mjs +126 -0
- package/mcp/core/work.mjs +377 -0
- package/mcp/stdio.mjs +70 -0
- package/mcp/tools.mjs +257 -0
- package/package.json +186 -0
- package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
- package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
- package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
- package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
- package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
- package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
- package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
- package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
- package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
- package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
- package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
- package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
- package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
- package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
- package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
- package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
- package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
- package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
- package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
- package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
- package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
- package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
- package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
- package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
- package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
- package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
- package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
- package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
- package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
- package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
- package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
- package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
- package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
- package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
- package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
- package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
- package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
- package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
- package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
- package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
- package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
- package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
- package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
- package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
- package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
- package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
- package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
- package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
- package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
- package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
- package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
- package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
- package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
- package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +299 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
- package/renderer/dist/index.html +13 -0
- package/scripts/after-install.mjs +59 -0
- package/scripts/approval-server.sh +43 -0
- package/scripts/before-publish.mjs +133 -0
- package/shared/agent-import.d.mts +13 -0
- package/shared/agent-import.mjs +252 -0
- package/shared/agents.d.mts +190 -0
- package/shared/agents.mjs +937 -0
- package/shared/analytics-events.mjs +63 -0
- package/shared/answers.d.mts +12 -0
- package/shared/answers.mjs +80 -0
- package/shared/artifact-keys.d.mts +28 -0
- package/shared/artifact-keys.mjs +140 -0
- package/shared/bridge-map.mjs +128 -0
- package/shared/claude-commands.d.mts +20 -0
- package/shared/claude-commands.generated.mjs +149 -0
- package/shared/claude-commands.mjs +131 -0
- package/shared/claude-models.generated.d.mts +15 -0
- package/shared/claude-models.generated.mjs +26 -0
- package/shared/claude-usage.d.mts +16 -0
- package/shared/claude-usage.mjs +172 -0
- package/shared/codex-commands.d.mts +4 -0
- package/shared/codex-commands.mjs +17 -0
- package/shared/codex-import.d.mts +38 -0
- package/shared/codex-import.mjs +232 -0
- package/shared/codex-modes.mjs +159 -0
- package/shared/codex-usage.mjs +180 -0
- package/shared/contracts.mjs +412 -0
- package/shared/crash-scrub.mjs +182 -0
- package/shared/dashboard.mjs +481 -0
- package/shared/dead-run-trace.mjs +127 -0
- package/shared/demo-world.mjs +415 -0
- package/shared/effort-levels.d.mts +15 -0
- package/shared/effort-levels.mjs +76 -0
- package/shared/engines.d.mts +62 -0
- package/shared/engines.mjs +389 -0
- package/shared/first-run-examples.mjs +60 -0
- package/shared/first-run-line.mjs +149 -0
- package/shared/first-run-practice.d.mts +32 -0
- package/shared/first-run-practice.mjs +254 -0
- package/shared/first-run-shape.mjs +100 -0
- package/shared/fresh-user-home.mjs +189 -0
- package/shared/hook-failure.mjs +22 -0
- package/shared/install-id.mjs +39 -0
- package/shared/instruction-defaults.json +5 -0
- package/shared/library-folder.mjs +28 -0
- package/shared/notify-rules.mjs +120 -0
- package/shared/product-name.d.mts +6 -0
- package/shared/product-name.mjs +144 -0
- package/shared/project-folder-check.d.mts +22 -0
- package/shared/project-folder-check.mjs +152 -0
- package/shared/provider-commands.d.mts +8 -0
- package/shared/provider-commands.mjs +58 -0
- package/shared/rank.d.mts +19 -0
- package/shared/rank.mjs +146 -0
- package/shared/recovery.mjs +26 -0
- package/shared/referenced-files.d.mts +11 -0
- package/shared/referenced-files.mjs +122 -0
- package/shared/repeats.d.mts +45 -0
- package/shared/repeats.mjs +193 -0
- package/shared/schemes.mjs +33 -0
- package/shared/settings-terminal.d.mts +1 -0
- package/shared/settings-terminal.mjs +14 -0
- package/shared/side-build.mjs +57 -0
- package/shared/spawn-trouble.d.mts +8 -0
- package/shared/spawn-trouble.mjs +599 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +58 -0
- package/shared/usage.d.mts +53 -0
- package/shared/usage.mjs +319 -0
- package/shared/work-items.mjs +561 -0
- package/shared/work-lines.mjs +360 -0
- package/worker-permissions.json +6 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
// The transport to `codex app-server`: one child process, many work items, and
|
|
2
|
+
// an approval that always gets an answer or takes the connection down trying.
|
|
3
|
+
//
|
|
4
|
+
// THIS IS THE PART THAT WAS NEVER SOLVED.The removed build drove `codex exec`,
|
|
5
|
+
// a one-shot run over a single JSON stream, and `codex exec` has no channel
|
|
6
|
+
// back to the caller. A Claude Code worker that wants something outside its
|
|
7
|
+
// grants raises a card through main/approval-prompt-server.mjs and waits in
|
|
8
|
+
// place; a Codex worker could not raise anything, so it either ran ungated or
|
|
9
|
+
// it stopped. That was the blocker, and `git show 258d71d^:main/codex.mjs` is
|
|
10
|
+
// worth reading for what else was tried, but ITS EVENT PARSING IS OBSOLETE and
|
|
11
|
+
// none of it is carried over here.
|
|
12
|
+
//
|
|
13
|
+
// `codex app-server` is the fix, verified by running it rather than by reading
|
|
14
|
+
// about it. It speaks newline-delimited JSON-RPC 2.0 over the child's stdio,
|
|
15
|
+
// and when a turn wants to run a gated command the SERVER sends US a request,
|
|
16
|
+
// with an id, that we must answer. That is the missing channel, and it is the
|
|
17
|
+
// same shape the approvals spool already has: a question, a wait, an answer.
|
|
18
|
+
//
|
|
19
|
+
// AND IT MOVES THE FAILURE SOMEWHERE NEW, WHICH IS WHAT THIS FILE IS ABOUT. A
|
|
20
|
+
// request with an id that nobody ever answers is not an error. It is a worker
|
|
21
|
+
// parked forever, with no exit code, no message and nothing on the row. CLAUDE.md
|
|
22
|
+
// already has that incident under another name -- "A WORKER THAT DID NOT FINISH
|
|
23
|
+
// DELIVERED NOTHING, HOWEVER LONG IT TOOK" -- three items stranded for two, two
|
|
24
|
+
// and six hours. So the posture here is copied from
|
|
25
|
+
// main/approval-prompt-server.mjs and not re-argued:
|
|
26
|
+
//
|
|
27
|
+
// DENY IS THE DEFAULT IN EVERY FAILURE MODE, AND SILENCE IS NOT A REFUSAL.
|
|
28
|
+
//
|
|
29
|
+
// That sentence has three consequences, and each one cost a draft of this file.
|
|
30
|
+
//
|
|
31
|
+
// A CARD SHE NEVER ANSWERS DENIES ITSELF, AFTER FIFTEEN MINUTES. The first
|
|
32
|
+
// draft waited forever, on the theory that the caller owns the timeout policy.
|
|
33
|
+
// It does not: main/approval-prompt-server.mjs is the same channel for the same
|
|
34
|
+
// founder and it has `TIMEOUT_MS = 15 * 60_000` and denies on expiry. Waiting
|
|
35
|
+
// forever is not neutrality, it is a worker parked for the rest of the session
|
|
36
|
+
// because she went to lunch. The deadline is injectable so that tests do not
|
|
37
|
+
// sleep, and a late answer arriving after it has already denied is DROPPED --
|
|
38
|
+
// the turn was told no and has moved on, and a second answer to one request id
|
|
39
|
+
// is a protocol violation on top of an approval nobody is waiting for.
|
|
40
|
+
//
|
|
41
|
+
// A REFUSAL THAT CANNOT BE WRITTEN IS SILENCE. So a write that throws, a write
|
|
42
|
+
// that fails afterwards in its callback, and an error raised on the pipe itself
|
|
43
|
+
// are all treated as a broken connection: settle everything, tell every thread,
|
|
44
|
+
// take the child down. There is no way to answer a request over a dead pipe,
|
|
45
|
+
// and a supervisor can see a dead worker. It cannot see a hung one.
|
|
46
|
+
//
|
|
47
|
+
// A FRAME WE CANNOT READ MIGHT HAVE BEEN A CARD. stdout is the protocol here --
|
|
48
|
+
// the child's tracing goes to stderr, measured, and the live run read zero
|
|
49
|
+
// unreadable lines. A line on stdout that will not parse is therefore not noise
|
|
50
|
+
// to step over: it is a frame whose id has just been lost, and if it was an
|
|
51
|
+
// approval request then the turn that raised it waits forever. Nothing can tell
|
|
52
|
+
// whether it was, which is exactly why tolerating it is unsafe. Unreadable or
|
|
53
|
+
// unbounded input is fatal, loudly. Blank lines are framing rather than content
|
|
54
|
+
// and are the one thing here that may be skipped.
|
|
55
|
+
//
|
|
56
|
+
// ------------------------------------------------------------------------
|
|
57
|
+
// WHAT WAS MEASURED, ON THIS MAC, 2026-09-04, codex-cli 0.148.0. Some of this
|
|
58
|
+
// contradicts what was written down before; where it does, the machine wins.
|
|
59
|
+
//
|
|
60
|
+
// THE THREAD ID IS AT `result.thread.id`. Off a live `thread/start` response:
|
|
61
|
+
// `result.thread.id` was "01a068f1-be4d-7701-98e3-16137d16dcdf" and
|
|
62
|
+
// `result.threadId` was `undefined`. The obvious guess fails silently -- every
|
|
63
|
+
// thread ends up filed under the id `undefined`, which is one shared bucket --
|
|
64
|
+
// so it is asserted rather than trusted.
|
|
65
|
+
//
|
|
66
|
+
// SERVER REQUEST IDS START AT 0 AND ARE A SEPARATE NUMBER SPACE FROM OURS. In
|
|
67
|
+
// the captured session the first approval carried `id: 0` while our own
|
|
68
|
+
// `initialize` carried `id: 1`. A classifier written `if (msg.id)` drops the
|
|
69
|
+
// first approval of every session, because 0 is falsy. Everything below tests
|
|
70
|
+
// `!== undefined`, and the two spaces never share a map.
|
|
71
|
+
//
|
|
72
|
+
// A REFUSAL IS ACCEPTED EVEN WHEN THE SERVER DID NOT OFFER IT. The live
|
|
73
|
+
// approval arrived with `availableDecisions: ["accept",
|
|
74
|
+
// {acceptWithExecpolicyAmendment: ...}, "cancel"]` -- no "decline" in the list.
|
|
75
|
+
// Answering `{decision: 'decline'}` anyway worked: the item completed with
|
|
76
|
+
// `status: "declined"`, `serverRequest/resolved` fired, and the turn COMPLETED
|
|
77
|
+
// 4.0 seconds later. `availableDecisions` is a hint about which buttons a UI
|
|
78
|
+
// should draw, not a whitelist the answer is validated against. That is the
|
|
79
|
+
// measurement that makes an unconditional refusal safe, and without it the
|
|
80
|
+
// deny-by-default rule above would have been a guess.
|
|
81
|
+
//
|
|
82
|
+
// A JSON-RPC ERROR FRAME REFUSES TOO, MEASURED ON ONE METHOD. The same
|
|
83
|
+
// `item/commandExecution/requestApproval` answered with `{error: {code: -32001,
|
|
84
|
+
// message}}` left the item at `status: "failed"` and the turn still COMPLETED,
|
|
85
|
+
// 5.5 seconds later. BE PRECISE ABOUT THE SCOPE OF THAT: it was measured for
|
|
86
|
+
// command approval and for nothing else, so using it as the refusal for other
|
|
87
|
+
// server requests is a REASONED EXTENSION rather than a measured fact. It is
|
|
88
|
+
// the best one available -- a well-formed error is the only answer that is
|
|
89
|
+
// valid for a result shape nobody has measured -- but the next slice to lean on
|
|
90
|
+
// it for a specific method should measure that method.
|
|
91
|
+
//
|
|
92
|
+
// NO HEAD-OF-LINE BLOCKING. Proven separately: an approval on thread A that is
|
|
93
|
+
// deliberately never answered does not stop thread B from raising its own
|
|
94
|
+
// approval, having it answered, and completing its turn. That is what makes one
|
|
95
|
+
// process for the whole fleet safe, and it is a promise this file has to keep --
|
|
96
|
+
// hence the read loop below never awaits anything.
|
|
97
|
+
//
|
|
98
|
+
// `ephemeral: false` IS THE DEFAULT AND HAS TO BE. `thread/resume` fails with
|
|
99
|
+
// "no rollout found for thread id" on an ephemeral thread, so a thread started
|
|
100
|
+
// ephemerally cannot carry a continuation. the app's whole reply path is
|
|
101
|
+
// continuations, so the default is the one that can come back.
|
|
102
|
+
//
|
|
103
|
+
// TWO THINGS THE WRITTEN BRIEF HAD WRONG, both from the CLI's own
|
|
104
|
+
// `codex app-server generate-json-schema` dump:
|
|
105
|
+
//
|
|
106
|
+
// `abort` IS NOT A v2 APPROVAL DECISION. CommandExecutionApprovalDecision and
|
|
107
|
+
// FileChangeApprovalDecision are accept / acceptForSession / decline / cancel,
|
|
108
|
+
// plus two amendment OBJECTS (`acceptWithExecpolicyAmendment` and
|
|
109
|
+
// `applyNetworkPolicyAmendment`, command approvals only). `abort` belongs to
|
|
110
|
+
// the LEGACY `ReviewDecision` used by `execCommandApproval` and
|
|
111
|
+
// `applyPatchApproval`. Sending it to a v2 approval is a malformed answer, so
|
|
112
|
+
// it is treated as junk here.
|
|
113
|
+
//
|
|
114
|
+
// `item/permissions/requestApproval` TAKES NO `decision` AT ALL. Its response
|
|
115
|
+
// is PermissionsRequestApprovalResponse: `{ permissions, scope?,
|
|
116
|
+
// strictAutoReview? }`, a granted profile rather than a word.
|
|
117
|
+
//
|
|
118
|
+
// AND THERE ARE TEN SERVER-TO-CLIENT REQUEST METHODS IN THE STABLE SCHEMA, NOT
|
|
119
|
+
// THREE. Beyond the three approvals: item/tool/requestUserInput, item/tool/call,
|
|
120
|
+
// mcpServer/elicitation/request, account/chatgptAuthTokens/refresh,
|
|
121
|
+
// attestation/generate, and the legacy applyPatchApproval and
|
|
122
|
+
// execCommandApproval, both of which are still in the stable dump. The
|
|
123
|
+
// experimental schema adds more (currentTime/read), so "ten" is a fact about
|
|
124
|
+
// the stable dump on 0.148.0 and not a ceiling. Every one of them hangs
|
|
125
|
+
// whatever raised it if it is left open, so ANY server request this file does
|
|
126
|
+
// not know how to answer gets the error frame. That is the honest answer and
|
|
127
|
+
// not a placeholder: refusing `account/chatgptAuthTokens/refresh` may well cost
|
|
128
|
+
// a long-lived process its token refresh, and the slice that wants that
|
|
129
|
+
// capability is the slice that gets to measure the shape and answer it
|
|
130
|
+
// properly. A refusal is a bug somebody can see; silence is not.
|
|
131
|
+
//
|
|
132
|
+
// ------------------------------------------------------------------------
|
|
133
|
+
// SCOPE. Transport and routing, nothing else. Nothing here turns an event into
|
|
134
|
+
// Agentbox's work lines or its trace, nothing here touches the approvals spool,
|
|
135
|
+
// and nothing here decides which engine a row runs on.
|
|
136
|
+
//
|
|
137
|
+
// IT IS NO LONGER INERT, AND THIS HEADER SAID IT WAS FOR A WHOLE SLICE AFTER IT
|
|
138
|
+
// HAD STOPPED BEING ONE. Three files depend on it now: main/supervisor.mjs spawns the
|
|
139
|
+
// process and holds the client, main/codex-session.mjs is the per-work-item
|
|
140
|
+
// facade over one thread of it, and main/codex-approvals.mjs is the handler on
|
|
141
|
+
// the other end of `onApproval` that this file was written around. The three
|
|
142
|
+
// sentences above still hold; the fourth was a fact about a Tuesday.
|
|
143
|
+
//
|
|
144
|
+
// The transport is injected so this whole file can be tested without `codex`
|
|
145
|
+
// being installed. That is not tidiness. Four tests in this suite already fail
|
|
146
|
+
// on any machine whose clock or installs differ from hers, and a transport test
|
|
147
|
+
// that shelled out to the real binary would be the fifth.
|
|
148
|
+
|
|
149
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
150
|
+
import { NAME, Name, nameSlug } from '../shared/product-name.mjs';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* How this client names itself to the server. It comes back on `initialize` as
|
|
154
|
+
* part of `userAgent` -- measured live as "astral/0.148.0 (Mac OS 15.6.1;
|
|
155
|
+
* arm64) (astral; 1.0.0)", which is the app's name of the day rather than a
|
|
156
|
+
* constant -- and it is what appears in Codex's own session records, so it says
|
|
157
|
+
* this app rather than the name of whatever spike wrote it. READ, NEVER TYPED:
|
|
158
|
+
* that measured line is why, it is a record written months ago under a name the
|
|
159
|
+
* app no longer has.
|
|
160
|
+
*/
|
|
161
|
+
export const CLIENT_INFO = Object.freeze({ name: nameSlug, title: NAME, version: '1.0.0' });
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The four SCALAR answers this callback supports, read off the CLI's schema
|
|
165
|
+
* dump rather than remembered.
|
|
166
|
+
*
|
|
167
|
+
* Not the whole v2 vocabulary: command approvals also accept two structured
|
|
168
|
+
* amendment objects, which widen the execpolicy or pin a network rule for
|
|
169
|
+
* future commands. Those are standing policy changes rather than an answer to
|
|
170
|
+
* the question that was asked, they cannot be expressed as a word, and nothing
|
|
171
|
+
* in Agentbox can produce one yet -- so a handler returning one is refused. That
|
|
172
|
+
* is conservative in the safe direction, and it is a deliberate omission rather
|
|
173
|
+
* than an oversight.
|
|
174
|
+
*
|
|
175
|
+
* `abort` is NOT here and reads like it should be. It is a legacy word.
|
|
176
|
+
*/
|
|
177
|
+
export const APPROVAL_DECISIONS = Object.freeze(['accept', 'acceptForSession', 'decline', 'cancel']);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The three words an MCP elicitation takes, which is a SHORTER list than the
|
|
181
|
+
* one above and not a subset by accident: `McpServerElicitationRequestResponse`
|
|
182
|
+
* has no `acceptForSession`, so an approval that lasts a session cannot be
|
|
183
|
+
* expressed here at all. Read off the CLI's schema dump 2026-09-22.
|
|
184
|
+
*/
|
|
185
|
+
export const ELICITATION_ACTIONS = Object.freeze(['accept', 'decline', 'cancel']);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The refusal. `decline` and not `cancel`, deliberately: measured, `decline`
|
|
189
|
+
* leaves the item `declined` and lets the agent carry on and try something
|
|
190
|
+
* else, while `cancel` also interrupts the whole turn. A failure to reach the
|
|
191
|
+
* founder should cost the command, not the work item.
|
|
192
|
+
*/
|
|
193
|
+
export const REFUSAL_DECISION = 'decline';
|
|
194
|
+
|
|
195
|
+
/** Her deadline, matching main/approval-prompt-server.mjs exactly. */
|
|
196
|
+
export const APPROVAL_TIMEOUT_MS = 15 * 60_000;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* How long ONE FRAME may be before the connection is called corrupt. Generous,
|
|
200
|
+
* because `turn/diff/updated` carries a git-format diff of the whole turn and a
|
|
201
|
+
* big refactor is a big diff; bounded, because an unterminated line is
|
|
202
|
+
* otherwise a memory leak that ends in a dead app.
|
|
203
|
+
*
|
|
204
|
+
* IT APPLIES TO A TERMINATED LINE AS WELL AS TO A RUNAWAY ONE, and it did not.
|
|
205
|
+
* The check ran on the residual buffer AFTER every newline-delimited frame had
|
|
206
|
+
* been taken out of it, so it could only ever see a line with no end. A single
|
|
207
|
+
* complete ten-megabyte line -- which is precisely what an enormous
|
|
208
|
+
* `turn/diff/updated` is -- went straight past the bound to `JSON.parse` and on
|
|
209
|
+
* to the handlers. Both halves are checked now: `read` refuses an oversized
|
|
210
|
+
* line before it parses it, and the residual check still catches the one that
|
|
211
|
+
* never terminates.
|
|
212
|
+
*/
|
|
213
|
+
export const MAX_FRAME_LENGTH = 16 * 1024 * 1024;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* HOW LONG AN ORDINARY REQUEST MAY GO UNANSWERED.
|
|
217
|
+
*
|
|
218
|
+
* The approval had a deadline from the first draft of this file, for the reason
|
|
219
|
+
* written at the top: a request with an id that nobody answers is a worker
|
|
220
|
+
* parked forever with no exit code and nothing on the row. EVERY REQUEST HAS
|
|
221
|
+
* THAT SHAPE, and the ones that get a worker as far as raising an approval --
|
|
222
|
+
* `initialize`, `config/read`, `thread/start`, `thread/resume`, `turn/start` --
|
|
223
|
+
* had nothing to settle them at all. A live app-server that accepts the bytes
|
|
224
|
+
* and never answers holds a work item and a fleet slot for the rest of the
|
|
225
|
+
* session; the pipe is not broken, so nothing else here fires either.
|
|
226
|
+
*
|
|
227
|
+
* Two minutes, which is far above anything measured (a handshake and a
|
|
228
|
+
* `config/read` answer in milliseconds, and `turn/start` resolves when the turn
|
|
229
|
+
* is ACCEPTED rather than when it finishes) and far below a founder noticing a
|
|
230
|
+
* row is stuck. The turn itself is not on this clock; only the request that
|
|
231
|
+
* starts it is.
|
|
232
|
+
*
|
|
233
|
+
* ONE REQUEST GOES, NOT THE CONNECTION. A rejection frees the row and the slot,
|
|
234
|
+
* which is the whole failure being closed, and the worker's own chain turns it
|
|
235
|
+
* into a run that says why on stderr. Taking the shared process down over one
|
|
236
|
+
* slow answer would charge every other thread for it.
|
|
237
|
+
*/
|
|
238
|
+
export const REQUEST_TIMEOUT_MS = 2 * 60_000;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The server requests we know how to answer, and in which of the three shapes.
|
|
242
|
+
*
|
|
243
|
+
* `mcpServer/elicitation/request` IS AN APPROVAL AND NOT ONLY A FORM, which is
|
|
244
|
+
* the correction this table carries. It is how Codex asks whether an MCP TOOL
|
|
245
|
+
* CALL may run. Measured on this Mac 2026-09-22 against codex-cli 0.153.4, a
|
|
246
|
+
* thread started exactly as `workerThreadParams` starts one, with one stdio MCP
|
|
247
|
+
* server on it and the model asked to call its single tool:
|
|
248
|
+
*
|
|
249
|
+
* item/started mcpToolCall server=probe tool=ping status=inProgress
|
|
250
|
+
* REQUEST mcpServer/elicitation/request
|
|
251
|
+
* { serverName: "probe", mode: "form",
|
|
252
|
+
* message: 'Allow the probe MCP server to run tool "ping"?',
|
|
253
|
+
* _meta: { codex_approval_kind: "mcp_tool_call",
|
|
254
|
+
* persist: ["session","always"],
|
|
255
|
+
* tool_description: "...", tool_params: {} } }
|
|
256
|
+
*
|
|
257
|
+
* The method was not on this table, so it took the error frame below, and the
|
|
258
|
+
* measured consequence was the whole integration rather than one lost call:
|
|
259
|
+
*
|
|
260
|
+
* answered with the error frame -> item/completed mcpToolCall status=failed,
|
|
261
|
+
* error "user rejected MCP tool call"
|
|
262
|
+
* answered `{action:"accept"}` -> the tool ran and returned its result
|
|
263
|
+
*
|
|
264
|
+
* EVERY STORE TOOL A CODEX WORKER HAS IS AN MCP TOOL CALL, so the first row is
|
|
265
|
+
* a worker that cannot claim, checkpoint or finish anything, and the only words
|
|
266
|
+
* anybody sees are that sentence: a rejection nobody made, over a card that was
|
|
267
|
+
* never drawn. `McpServerElicitationRequestResponse` is `{ action: "accept" |
|
|
268
|
+
* "decline" | "cancel", content?, _meta? }`, which is a third answer shape, and
|
|
269
|
+
* its vocabulary happens to be three of the words the decision shape speaks.
|
|
270
|
+
*/
|
|
271
|
+
const APPROVAL_SHAPE = new Map([
|
|
272
|
+
['item/commandExecution/requestApproval', 'decision'],
|
|
273
|
+
['item/fileChange/requestApproval', 'decision'],
|
|
274
|
+
['item/permissions/requestApproval', 'grant'],
|
|
275
|
+
['mcpServer/elicitation/request', 'elicitation'],
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
const REFUSAL_ERROR = Object.freeze({
|
|
279
|
+
code: -32001,
|
|
280
|
+
message: `${Name} did not approve this request. Continue with what you are already allowed to do, and leave the rest for the founder to look at.`,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const plain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
284
|
+
const only = (keys, allowed) => keys.every((k) => allowed.includes(k));
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* One long-lived `codex app-server`.
|
|
288
|
+
*
|
|
289
|
+
* Give it either a `transport` (anything child-process-shaped: `stdin.write`,
|
|
290
|
+
* `stdin.end`, `stdout.on('data')`, an optional `stderr`, `on('exit')`,
|
|
291
|
+
* `on('error')`, `kill`) or a `spawn` function that returns one. Production
|
|
292
|
+
* passes a real child; the tests pass a notepad.
|
|
293
|
+
*
|
|
294
|
+
* `onNotification` catches every line that belongs to no watched thread: a
|
|
295
|
+
* thread we have not been told about yet, and the notifications that carry no
|
|
296
|
+
* thread at all. There are eighteen of those in the 0.148.0 stable schema --
|
|
297
|
+
* account, config, process, filesystem and remote-control news, among them
|
|
298
|
+
* `account/rateLimits/updated` and `remoteControl/status/changed` -- so this is
|
|
299
|
+
* an ordinary channel rather than an error path.
|
|
300
|
+
*/
|
|
301
|
+
export function createCodexAppServer({
|
|
302
|
+
transport = null,
|
|
303
|
+
spawn = null,
|
|
304
|
+
clientInfo = CLIENT_INFO,
|
|
305
|
+
onNotification = null,
|
|
306
|
+
approvalTimeoutMs = APPROVAL_TIMEOUT_MS,
|
|
307
|
+
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
308
|
+
maxFrameLength = MAX_FRAME_LENGTH,
|
|
309
|
+
stderrLimit = 64 * 1024,
|
|
310
|
+
} = {}) {
|
|
311
|
+
const child = transport ?? spawn?.();
|
|
312
|
+
if (!child) throw new Error('createCodexAppServer needs a transport or a spawn that makes one');
|
|
313
|
+
|
|
314
|
+
const pending = new Map(); // OUR request id -> { resolve, reject, method, onResult }
|
|
315
|
+
const threads = new Map(); // thread id -> handlers
|
|
316
|
+
const openCards = new Map(); // THEIR request id -> the deadline timer
|
|
317
|
+
const decoder = new StringDecoder('utf8');
|
|
318
|
+
let nextId = 1;
|
|
319
|
+
let buffer = '';
|
|
320
|
+
let stderrText = '';
|
|
321
|
+
let gone = null; // null while live; the closing reason once not
|
|
322
|
+
let drained = false; // stdout has ended, so the diagnostics are complete
|
|
323
|
+
let finished = false;
|
|
324
|
+
|
|
325
|
+
/* ------------------------------- writing -------------------------------- */
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Put one message on the wire, or take the connection down.
|
|
329
|
+
*
|
|
330
|
+
* Three ways a real pipe fails and all three end here: it throws where it
|
|
331
|
+
* stands, it accepts the bytes and reports the failure in the write callback,
|
|
332
|
+
* or it raises `error` later. The callback is the one that is easy to miss
|
|
333
|
+
* and the one that matters most, because a refusal that was "written" and
|
|
334
|
+
* never arrived is indistinguishable from an approval.
|
|
335
|
+
*
|
|
336
|
+
* A `false` return is NOT a failure: that is ordinary backpressure and the
|
|
337
|
+
* bytes are still queued. Only the callback and the throw are failures.
|
|
338
|
+
*/
|
|
339
|
+
function write(msg) {
|
|
340
|
+
if (gone) return false;
|
|
341
|
+
let text;
|
|
342
|
+
try {
|
|
343
|
+
text = JSON.stringify(msg) + '\n';
|
|
344
|
+
} catch (err) {
|
|
345
|
+
fail('write', { error: err });
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
child.stdin.write(text, (err) => { if (err) fail('write', { error: err }); });
|
|
350
|
+
} catch (err) {
|
|
351
|
+
fail('write', { error: err });
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
return !gone;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function send(method, params, onResult) {
|
|
358
|
+
if (gone) return Promise.reject(new Error(`${method}: codex app-server is no longer running (${gone.reason})`));
|
|
359
|
+
const id = nextId++;
|
|
360
|
+
return new Promise((resolve, reject) => {
|
|
361
|
+
// The deadline is armed BEFORE the write, so a request that is accepted
|
|
362
|
+
// and then never answered cannot outlive it. See REQUEST_TIMEOUT_MS.
|
|
363
|
+
const timer = setTimeout(() => {
|
|
364
|
+
if (!pending.delete(id)) return;
|
|
365
|
+
reject(new Error(`${method}: codex app-server accepted this request and never answered it (${Math.round(requestTimeoutMs / 1000)}s)`));
|
|
366
|
+
}, requestTimeoutMs);
|
|
367
|
+
timer?.unref?.();
|
|
368
|
+
pending.set(id, { resolve, reject, method, onResult, timer });
|
|
369
|
+
if (!write({ jsonrpc: '2.0', id, method, params })) {
|
|
370
|
+
clearTimeout(timer);
|
|
371
|
+
pending.delete(id);
|
|
372
|
+
reject(new Error(`${method}: ${cause()}`));
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const notify = (method) => write({ jsonrpc: '2.0', method });
|
|
378
|
+
|
|
379
|
+
/* ------------------------------- reading -------------------------------- */
|
|
380
|
+
|
|
381
|
+
// The decoder rather than `chunk.toString`: a chunk boundary can fall in the
|
|
382
|
+
// middle of a UTF-8 sequence, and agent messages stream through here one
|
|
383
|
+
// delta at a time in whatever language the repo is written in.
|
|
384
|
+
child.stdout.on('data', (chunk) => {
|
|
385
|
+
if (gone) return;
|
|
386
|
+
buffer += typeof chunk === 'string' ? chunk : decoder.write(chunk);
|
|
387
|
+
let at;
|
|
388
|
+
while (!gone && (at = buffer.indexOf('\n')) >= 0) {
|
|
389
|
+
const line = buffer.slice(0, at);
|
|
390
|
+
buffer = buffer.slice(at + 1);
|
|
391
|
+
read(line);
|
|
392
|
+
}
|
|
393
|
+
if (!gone && buffer.length > maxFrameLength) {
|
|
394
|
+
fail('protocol', { note: `a frame passed ${maxFrameLength} characters with no end to it` });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// The child writes tracing here, and the last line of it is usually the
|
|
399
|
+
// reason something failed -- that is where "Rejected" showed up in the
|
|
400
|
+
// measurements above. Worth keeping for a cause report, and NEVER worth
|
|
401
|
+
// parsing: nothing on this pipe is protocol, however badly formed it looks,
|
|
402
|
+
// so garbage here must not cost the founder a fleet. Only the tail is kept,
|
|
403
|
+
// because a long run's tracing is unbounded and the useful part is always at
|
|
404
|
+
// the end.
|
|
405
|
+
child.stderr?.on('data', (chunk) => {
|
|
406
|
+
stderrText += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
407
|
+
if (stderrText.length > stderrLimit) stderrText = stderrText.slice(-stderrLimit);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
child.on('error', (err) => fail('error', { error: err }));
|
|
411
|
+
child.on('exit', (code, signal) => fail('exit', { code: code ?? null, signal: signal ?? null }));
|
|
412
|
+
child.stdin?.on?.('error', (err) => fail('error', { error: err }));
|
|
413
|
+
child.stdout.on?.('error', (err) => fail('error', { error: err }));
|
|
414
|
+
child.stderr?.on?.('error', () => { /* tracing, not protocol */ });
|
|
415
|
+
for (const event of ['end', 'close']) child.stdout.on?.(event, () => { drained = true; if (gone) finish(); });
|
|
416
|
+
|
|
417
|
+
function read(line) {
|
|
418
|
+
// THE BOUND APPLIES TO A LINE THAT ENDED, TOO. The residual check below the
|
|
419
|
+
// split can only ever see a frame with no newline in it, so a single
|
|
420
|
+
// complete frame of any size walked past it into `JSON.parse`. See
|
|
421
|
+
// MAX_FRAME_LENGTH.
|
|
422
|
+
if (line.length > maxFrameLength) {
|
|
423
|
+
return fail('protocol', { note: `a frame of ${line.length} characters passed the ${maxFrameLength} this connection allows` });
|
|
424
|
+
}
|
|
425
|
+
const text = line.trim();
|
|
426
|
+
if (!text) return; // framing, not content
|
|
427
|
+
|
|
428
|
+
let msg;
|
|
429
|
+
try {
|
|
430
|
+
msg = JSON.parse(text);
|
|
431
|
+
} catch {
|
|
432
|
+
return fail('protocol', { note: 'a line on stdout could not be read as JSON, and it may have been an approval request' });
|
|
433
|
+
}
|
|
434
|
+
if (!plain(msg)) {
|
|
435
|
+
return fail('protocol', { note: 'a line on stdout could not be read as a message, and it may have been an approval request' });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Three kinds, and the test is `!== undefined` in both places because the
|
|
439
|
+
// server's ids start at zero.
|
|
440
|
+
if (msg.id !== undefined && msg.method !== undefined) return serverRequest(msg);
|
|
441
|
+
if (msg.id !== undefined) return response(msg);
|
|
442
|
+
if (typeof msg.method === 'string') return notification(msg);
|
|
443
|
+
fail('protocol', { note: 'a message on stdout was neither a request, a response nor a notification' });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function response(msg) {
|
|
447
|
+
const waiting = pending.get(msg.id);
|
|
448
|
+
if (!waiting) return;
|
|
449
|
+
pending.delete(msg.id);
|
|
450
|
+
clearTimeout(waiting.timer);
|
|
451
|
+
if (msg.error) {
|
|
452
|
+
waiting.reject(new Error(`codex app-server refused ${waiting.method}: ${msg.error.message ?? JSON.stringify(msg.error)}`));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
// Synchronously, INSIDE the read loop, before the next line of this chunk
|
|
456
|
+
// is looked at. A promise resolves on a microtask and the rest of the chunk
|
|
457
|
+
// is read on this one, so registering the thread's handlers off the
|
|
458
|
+
// resolved promise would miss any notification the server packed in behind
|
|
459
|
+
// the response -- which for `thread/start` is the thread's own first event.
|
|
460
|
+
try { waiting.onResult?.(msg.result); } catch { /* the caller's bookkeeping, not ours */ }
|
|
461
|
+
waiting.resolve(msg.result);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function notification(msg) {
|
|
465
|
+
const id = threadOf(msg.params);
|
|
466
|
+
const handlers = id === null ? null : threads.get(id);
|
|
467
|
+
if (handlers?.onNotification) call(() => handlers.onNotification(msg.method, msg.params));
|
|
468
|
+
else call(() => onNotification?.(msg.method, msg.params));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* ------------------------------- approvals ------------------------------ */
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Answer a server request. NEVER awaits: the handler's promise is left to
|
|
475
|
+
* settle on its own, because the founder may take fifteen minutes and every
|
|
476
|
+
* other thread on this process reads through the same loop.
|
|
477
|
+
*/
|
|
478
|
+
function serverRequest(msg) {
|
|
479
|
+
// A SECOND REQUEST ON AN ID THAT IS STILL OPEN IS NOT A SECOND CARD.
|
|
480
|
+
//
|
|
481
|
+
// `openCards` is keyed by the server's request id, so a duplicate used to
|
|
482
|
+
// overwrite the entry without clearing the timer it replaced. The first
|
|
483
|
+
// card's deadline then went on running and denied the SECOND card at its
|
|
484
|
+
// own hour, and the first card's real answer -- her click -- landed on a map
|
|
485
|
+
// entry that had already been swapped and was dropped.
|
|
486
|
+
//
|
|
487
|
+
// There is no way to answer two open requests that share one id: whichever
|
|
488
|
+
// we write, the server matches it to one of them and the other hangs. That
|
|
489
|
+
// is the same unresolvable input as an unreadable frame, so it gets the same
|
|
490
|
+
// answer this file gives every one of those. Loud, and with `finish` denying
|
|
491
|
+
// every card that was open, so nothing is left waiting on a decision it can
|
|
492
|
+
// never receive.
|
|
493
|
+
if (openCards.has(msg.id)) {
|
|
494
|
+
return fail('protocol', { note: `codex app-server raised a second request on id ${msg.id} while the first was still open, and one answer cannot settle two` });
|
|
495
|
+
}
|
|
496
|
+
const shape = APPROVAL_SHAPE.get(msg.method);
|
|
497
|
+
if (!shape) return write({ jsonrpc: '2.0', id: msg.id, error: { ...REFUSAL_ERROR } });
|
|
498
|
+
|
|
499
|
+
const id = threadOf(msg.params);
|
|
500
|
+
const handler = id === null ? null : threads.get(id)?.onApproval;
|
|
501
|
+
// A refusal that is already certain does not wait out the clock: there is
|
|
502
|
+
// nobody to ask, so the turn hears no now rather than in fifteen minutes.
|
|
503
|
+
if (typeof handler !== 'function') return write({ jsonrpc: '2.0', id: msg.id, ...refusal(shape) });
|
|
504
|
+
|
|
505
|
+
openCard(msg.id, shape);
|
|
506
|
+
Promise.resolve()
|
|
507
|
+
.then(() => handler(msg.method, msg.params))
|
|
508
|
+
.then((answered) => answerCard(msg.id, translate(shape, answered) ?? refusal(shape)))
|
|
509
|
+
.catch(() => answerCard(msg.id, refusal(shape)));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Start her deadline. Expiry denies, once, and closes the card. */
|
|
513
|
+
function openCard(id, shape) {
|
|
514
|
+
const timer = setTimeout(() => {
|
|
515
|
+
if (openCards.delete(id)) write({ jsonrpc: '2.0', id, ...refusal(shape) });
|
|
516
|
+
}, approvalTimeoutMs);
|
|
517
|
+
timer?.unref?.();
|
|
518
|
+
openCards.set(id, timer);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The one answer this card will ever get.
|
|
523
|
+
*
|
|
524
|
+
* A card the deadline has already denied is CLOSED, so her late click lands
|
|
525
|
+
* here and is dropped rather than approving something the turn stopped
|
|
526
|
+
* waiting for twenty minutes ago. Same for a card whose connection died.
|
|
527
|
+
*/
|
|
528
|
+
function answerCard(id, body) {
|
|
529
|
+
if (!openCards.has(id)) return;
|
|
530
|
+
clearTimeout(openCards.get(id));
|
|
531
|
+
openCards.delete(id);
|
|
532
|
+
write({ jsonrpc: '2.0', id, ...body });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// A REFUSAL IS SAID IN THE VOCABULARY THE METHOD TAKES, which for an
|
|
536
|
+
// elicitation is `decline` rather than an error frame. Both reach the model
|
|
537
|
+
// as a no; the difference is that a declined elicitation leaves the tool call
|
|
538
|
+
// declined and the turn running, while the error frame is a protocol failure
|
|
539
|
+
// on a request the server expected an answer to.
|
|
540
|
+
const refusal = (shape) => {
|
|
541
|
+
if (shape === 'decision') return { result: { decision: REFUSAL_DECISION } };
|
|
542
|
+
if (shape === 'elicitation') return { result: { action: REFUSAL_DECISION } };
|
|
543
|
+
return { error: { ...REFUSAL_ERROR } };
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The handler's answer, in the shape the method actually takes, or null if it
|
|
548
|
+
* is not one this protocol version can express. Null is the whole
|
|
549
|
+
* deny-by-default rule: everything unrecognised lands there.
|
|
550
|
+
*
|
|
551
|
+
* The elicitation's three actions are `accept`, `decline` and `cancel`, read
|
|
552
|
+
* off `McpServerElicitationRequestResponse` in the CLI's own schema dump on
|
|
553
|
+
* 2026-09-22. `acceptForSession` is NOT among them, so a handler that answers
|
|
554
|
+
* a tool call with the word a command approval uses is refused rather than
|
|
555
|
+
* quietly turned into a yes. `content` is omitted: the accept this app sends
|
|
556
|
+
* answers a permission question whose `requestedSchema` has no properties,
|
|
557
|
+
* and a form that really does ask for typed input is not something Agentbox can
|
|
558
|
+
* fill in for her.
|
|
559
|
+
*/
|
|
560
|
+
function translate(shape, answered) {
|
|
561
|
+
if (shape === 'decision') {
|
|
562
|
+
return APPROVAL_DECISIONS.includes(answered) ? { result: { decision: answered } } : null;
|
|
563
|
+
}
|
|
564
|
+
if (shape === 'elicitation') {
|
|
565
|
+
return ELICITATION_ACTIONS.includes(answered) ? { result: { action: answered } } : null;
|
|
566
|
+
}
|
|
567
|
+
const grant = grantFrom(answered);
|
|
568
|
+
return grant ? { result: grant } : null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* A permissions grant, CHECKED FIELD BY FIELD AND THEN BUILT FRESH.
|
|
573
|
+
*
|
|
574
|
+
* This is the one server request whose answer is a document rather than a
|
|
575
|
+
* word, and the document widens a sandbox. Forwarding the caller's object
|
|
576
|
+
* would mean checking one value and sending another: a `toJSON`, or a getter,
|
|
577
|
+
* runs again inside JSON.stringify, and what reaches Codex is whatever it
|
|
578
|
+
* returns the second time. So nothing the handler returned reaches the wire
|
|
579
|
+
* by reference, every value is read exactly once, and an unknown key anywhere
|
|
580
|
+
* -- on the object, in the profile, or in either area -- is a refusal rather
|
|
581
|
+
* than something to pass along. Inherited properties do not count, so a grant
|
|
582
|
+
* on a prototype is not a grant.
|
|
583
|
+
*
|
|
584
|
+
* The `entries` form of the filesystem profile is deliberately NOT accepted.
|
|
585
|
+
* Its shape has three nested variants, none of them measured, and a grant
|
|
586
|
+
* this file cannot check is a grant it has no business sending. The slice
|
|
587
|
+
* that needs it measures it.
|
|
588
|
+
*/
|
|
589
|
+
function grantFrom(answered) {
|
|
590
|
+
if (!plain(answered)) return null;
|
|
591
|
+
const top = Object.keys(answered);
|
|
592
|
+
if (!top.includes('permissions') || !only(top, ['permissions', 'scope', 'strictAutoReview'])) return null;
|
|
593
|
+
|
|
594
|
+
const profile = answered.permissions;
|
|
595
|
+
if (!plain(profile)) return null;
|
|
596
|
+
const areas = Object.keys(profile);
|
|
597
|
+
if (!areas.length || !only(areas, ['fileSystem', 'network'])) return null;
|
|
598
|
+
|
|
599
|
+
const permissions = {};
|
|
600
|
+
if (areas.includes('fileSystem')) {
|
|
601
|
+
const asked = profile.fileSystem;
|
|
602
|
+
if (!plain(asked)) return null;
|
|
603
|
+
const keys = Object.keys(asked);
|
|
604
|
+
if (!keys.length || !only(keys, ['read', 'write', 'globScanMaxDepth'])) return null;
|
|
605
|
+
const fileSystem = {};
|
|
606
|
+
for (const list of ['read', 'write']) {
|
|
607
|
+
if (!keys.includes(list)) continue;
|
|
608
|
+
const paths = asked[list];
|
|
609
|
+
if (!Array.isArray(paths) || !paths.length) return null;
|
|
610
|
+
if (!paths.every((p) => typeof p === 'string' && p.length > 0)) return null;
|
|
611
|
+
fileSystem[list] = [...paths];
|
|
612
|
+
}
|
|
613
|
+
if (keys.includes('globScanMaxDepth')) {
|
|
614
|
+
const depth = asked.globScanMaxDepth;
|
|
615
|
+
if (!Number.isInteger(depth) || depth < 1) return null;
|
|
616
|
+
fileSystem.globScanMaxDepth = depth;
|
|
617
|
+
}
|
|
618
|
+
permissions.fileSystem = fileSystem;
|
|
619
|
+
}
|
|
620
|
+
if (areas.includes('network')) {
|
|
621
|
+
const asked = profile.network;
|
|
622
|
+
if (!plain(asked)) return null;
|
|
623
|
+
const keys = Object.keys(asked);
|
|
624
|
+
if (keys.length !== 1 || keys[0] !== 'enabled' || typeof asked.enabled !== 'boolean') return null;
|
|
625
|
+
permissions.network = { enabled: asked.enabled };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const grant = { permissions };
|
|
629
|
+
// `turn` when nobody said, because it is the narrower of the two and a
|
|
630
|
+
// grant that outlives the question it answered is not that answer.
|
|
631
|
+
if (top.includes('scope')) {
|
|
632
|
+
if (answered.scope !== 'turn' && answered.scope !== 'session') return null;
|
|
633
|
+
grant.scope = answered.scope;
|
|
634
|
+
} else {
|
|
635
|
+
grant.scope = 'turn';
|
|
636
|
+
}
|
|
637
|
+
if (top.includes('strictAutoReview')) {
|
|
638
|
+
if (typeof answered.strictAutoReview !== 'boolean') return null;
|
|
639
|
+
grant.strictAutoReview = answered.strictAutoReview;
|
|
640
|
+
}
|
|
641
|
+
return grant;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Almost every line carries `params.threadId`. `thread/started` is the
|
|
645
|
+
// exception and keeps it at `params.thread.id`, which is the thread's own
|
|
646
|
+
// first event and therefore the worst one to lose.
|
|
647
|
+
function threadOf(params) {
|
|
648
|
+
if (!plain(params)) return null;
|
|
649
|
+
if (typeof params.threadId === 'string') return params.threadId;
|
|
650
|
+
if (typeof params.thread?.id === 'string') return params.thread.id;
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// A listener that throws must not take the read loop with it: one thread's
|
|
655
|
+
// bad handler would otherwise stop every other thread on this process.
|
|
656
|
+
function call(fn) {
|
|
657
|
+
try { fn(); } catch { /* the caller's problem, and not worth the fleet */ }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/* ------------------------------ the ending ------------------------------ */
|
|
661
|
+
|
|
662
|
+
/** Why this connection ended, in a sentence that names something actionable. */
|
|
663
|
+
function cause() {
|
|
664
|
+
const tail = stderrText.trim().split('\n').slice(-3).join(' ').trim();
|
|
665
|
+
const said = tail ? `: ${tail}` : '';
|
|
666
|
+
if (!gone) return `the connection to codex app-server failed on write${said}`;
|
|
667
|
+
switch (gone.reason) {
|
|
668
|
+
case 'write': return `the connection to codex app-server failed on write (${gone.error?.message ?? 'no detail'})${said}`;
|
|
669
|
+
case 'protocol': return `the connection to codex app-server is corrupt, ${gone.note}${said}`;
|
|
670
|
+
case 'error': return `codex app-server failed to run (${gone.error?.message ?? 'no detail'})${said}`;
|
|
671
|
+
case 'closed': return `codex app-server was closed by ${NAME} (${gone.note})${said}`;
|
|
672
|
+
default: return `codex app-server exited (code ${gone.code}, signal ${gone.signal})${said}`;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* The connection is over, whatever ended it.
|
|
678
|
+
*
|
|
679
|
+
* Marked SYNCHRONOUSLY, so that from this instant no further request is
|
|
680
|
+
* accepted and no late approval can be written. Finished once stdout has
|
|
681
|
+
* drained, so the tracing tail that explains it is complete rather than
|
|
682
|
+
* whatever happened to have arrived by the moment `exit` fired -- exit
|
|
683
|
+
* routinely beats the last of the pipe, and "exited with code 1" on its own
|
|
684
|
+
* is not a cause anybody can act on.
|
|
685
|
+
*/
|
|
686
|
+
function fail(reason, detail = {}) {
|
|
687
|
+
if (gone) return;
|
|
688
|
+
gone = { reason, code: null, signal: null, error: null, note: null, ...detail, stderr: stderrText };
|
|
689
|
+
if (reason !== 'exit') stop();
|
|
690
|
+
if (drained) finish();
|
|
691
|
+
else if (typeof setImmediate === 'function') setImmediate(finish);
|
|
692
|
+
else finish();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function stop() {
|
|
696
|
+
try { child.stdin.end(); } catch { /* already gone */ }
|
|
697
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Everything that was waiting finds out, once, and nothing stays registered.
|
|
702
|
+
*
|
|
703
|
+
* A promise nobody settles is exactly how a work item strands, so an
|
|
704
|
+
* in-flight request rejects rather than staying pending, and every watched
|
|
705
|
+
* thread is told rather than left listening to a dead pipe. Deadlines are
|
|
706
|
+
* cancelled and registrations dropped: a card that can never be answered and
|
|
707
|
+
* a thread that can never be spoken to are only holding this client alive.
|
|
708
|
+
*/
|
|
709
|
+
function finish() {
|
|
710
|
+
if (finished) return;
|
|
711
|
+
finished = true;
|
|
712
|
+
gone.stderr = stderrText;
|
|
713
|
+
const why = cause();
|
|
714
|
+
|
|
715
|
+
for (const timer of openCards.values()) clearTimeout(timer);
|
|
716
|
+
openCards.clear();
|
|
717
|
+
|
|
718
|
+
for (const [id, waiting] of pending) {
|
|
719
|
+
pending.delete(id);
|
|
720
|
+
clearTimeout(waiting.timer);
|
|
721
|
+
waiting.reject(Object.assign(new Error(`${waiting.method}: ${why}`), { cause: gone }));
|
|
722
|
+
}
|
|
723
|
+
const listening = [...threads.values()];
|
|
724
|
+
threads.clear();
|
|
725
|
+
for (const handlers of listening) call(() => handlers.onClosed?.(gone));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/* -------------------------------- the API ------------------------------- */
|
|
729
|
+
|
|
730
|
+
const watch = (threadId, handlers = {}) => { threads.set(threadId, handlers); return threadId; };
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
/** Any client request, for the ones with no wrapper (`config/read`). */
|
|
734
|
+
request: (method, params) => send(method, params),
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The whole handshake, not half of it: the request, and then `initialized`
|
|
738
|
+
* once the server has answered.
|
|
739
|
+
*
|
|
740
|
+
* `initialized` is the only client notification in the 0.148.0 schema and
|
|
741
|
+
* the documented lifecycle puts it before any other operation. 0.148
|
|
742
|
+
* tolerates its absence, which is why the first draft never noticed it was
|
|
743
|
+
* missing; a later one need not. It is sent from the response hook, so it
|
|
744
|
+
* can never overtake the response, and it is never sent at all if the
|
|
745
|
+
* server refused the handshake.
|
|
746
|
+
*/
|
|
747
|
+
initialize: () => send('initialize', { clientInfo }, () => notify('initialized')),
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Start a thread and watch it from the instant the server names it.
|
|
751
|
+
*
|
|
752
|
+
* `ephemeral` defaults to false so the thread leaves a rollout on disk and
|
|
753
|
+
* a later `thread/resume` can find it; pass true only for work that will
|
|
754
|
+
* never be continued.
|
|
755
|
+
*/
|
|
756
|
+
startThread: (params = {}, handlers = {}) => send(
|
|
757
|
+
'thread/start',
|
|
758
|
+
{ ephemeral: false, ...params },
|
|
759
|
+
(result) => { const id = result?.thread?.id; if (typeof id === 'string') watch(id, handlers); },
|
|
760
|
+
).then((result) => ({ threadId: result?.thread?.id ?? null, thread: result?.thread ?? null, result })),
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Continue a thread we already know the id of, so the watch goes on first
|
|
764
|
+
* and no event of the resumed thread can arrive unowned.
|
|
765
|
+
*
|
|
766
|
+
* A resume the server REFUSES never became a thread, so the watch is rolled
|
|
767
|
+
* back. Left installed, it would hand every later line carrying that id to
|
|
768
|
+
* a listener for a session that does not exist.
|
|
769
|
+
*/
|
|
770
|
+
resumeThread: (params = {}, handlers = {}) => {
|
|
771
|
+
const id = typeof params.threadId === 'string' ? params.threadId : null;
|
|
772
|
+
if (id) watch(id, handlers);
|
|
773
|
+
return send('thread/resume', params).catch((err) => {
|
|
774
|
+
if (id && threads.get(id) === handlers) threads.delete(id);
|
|
775
|
+
throw err;
|
|
776
|
+
});
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
startTurn: (threadId, params = {}) => send('turn/start', { threadId, ...params }),
|
|
780
|
+
|
|
781
|
+
/** Both ids: a turn is not identified by either one alone. */
|
|
782
|
+
interruptTurn: (threadId, turnId) => send('turn/interrupt', { threadId, turnId }),
|
|
783
|
+
steerTurn: (threadId, expectedTurnId, text) => send('turn/steer', {threadId, expectedTurnId, input:[{type:'text',text}]}),
|
|
784
|
+
|
|
785
|
+
watch,
|
|
786
|
+
unwatch: (threadId) => threads.delete(threadId),
|
|
787
|
+
|
|
788
|
+
/** Stop on purpose. Same settling as a crash, so callers have one ending. */
|
|
789
|
+
close: (note = 'no reason given') => fail('closed', { note }),
|
|
790
|
+
|
|
791
|
+
isClosed: () => gone !== null,
|
|
792
|
+
/** The tracing tail, for a cause report. Never protocol. */
|
|
793
|
+
stderr: () => stderrText,
|
|
794
|
+
stats: () => ({ pending: pending.size, threads: threads.size, approvals: openCards.size }),
|
|
795
|
+
};
|
|
796
|
+
}
|