agentbox-app 0.1.13 → 0.1.16
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/README.md +24 -9
- package/bin/agentbox.mjs +23 -1
- package/briefs/message-rules.md +36 -8
- package/briefs/worker.md +46 -23
- package/main/account-discovery.mjs +122 -30
- package/main/account-tooling.mjs +29 -1
- package/main/account-usage.mjs +46 -0
- package/main/agent-sessions.mjs +282 -268
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +12 -2
- package/main/answer-modes.mjs +103 -37
- package/main/approval-prompt-core.mjs +197 -0
- package/main/approval-prompt-server.mjs +4 -138
- package/main/approvals.mjs +101 -297
- package/main/artifact-path.mjs +47 -18
- package/main/claude-input.mjs +48 -4
- package/main/claude-plan.mjs +48 -0
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +18 -40
- package/main/cloud/account.mjs +179 -0
- package/main/cloud/aws.mjs +127 -0
- package/main/cloud/browser-return.mjs +58 -0
- package/main/cloud/clients.mjs +15 -0
- package/main/cloud/codex-cloud.mjs +53 -0
- package/main/cloud/config.mjs +107 -0
- package/main/cloud/docs.mjs +219 -0
- package/main/cloud/exec.mjs +27 -0
- package/main/cloud/host.mjs +165 -0
- package/main/cloud/kit.mjs +80 -0
- package/main/cloud/logins.mjs +543 -0
- package/main/cloud/managed.mjs +115 -0
- package/main/cloud/marker.mjs +104 -0
- package/main/cloud/org.mjs +53 -0
- package/main/cloud/reuse.mjs +208 -0
- package/main/cloud/run-cli.mjs +177 -0
- package/main/cloud/run.mjs +1934 -0
- package/main/cloud/usage.mjs +76 -0
- package/main/cloud-permission.mjs +51 -0
- package/main/codex-account.mjs +78 -0
- package/main/codex-app-server.mjs +276 -409
- package/main/codex-approvals.mjs +148 -433
- package/main/codex-models.mjs +20 -2
- package/main/codex-session.mjs +212 -507
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +71 -3
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +17 -3
- package/main/codex.mjs +95 -363
- package/main/config.mjs +11 -1
- package/main/corner-tag.mjs +340 -19
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +232 -282
- package/main/engine-setup.mjs +21 -9
- package/main/feedback.mjs +3 -25
- package/main/folder-pictures.mjs +2 -2
- package/main/freeze-recorder-thread.mjs +86 -0
- package/main/freeze-recorder.mjs +146 -0
- package/main/fresh-user.mjs +20 -9
- package/main/git-change-offthread.mjs +17 -3
- package/main/git-change-thread.mjs +5 -2
- package/main/harnesses/claude.mjs +32 -11
- package/main/harnesses/codex.mjs +9 -2
- package/main/importable-threads.mjs +64 -6
- package/main/ipc.mjs +362 -111
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +199 -84
- package/main/memory-gate-history.mjs +36 -10
- package/main/memory-gate-server.mjs +62 -0
- package/main/memory-gate.mjs +14 -1
- package/main/menu.mjs +4 -2
- package/main/message-priority.mjs +2 -2
- package/main/mic.mjs +38 -0
- package/main/native/liquid-glass/build/liquid-glass.node +0 -0
- package/main/new-chats-watch.mjs +110 -0
- package/main/new-user-window.mjs +25 -0
- package/main/notify.mjs +24 -8
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +29 -9
- package/main/phone-uploads.mjs +476 -0
- package/main/picture-shape.mjs +114 -0
- package/main/quit-sequence.mjs +143 -0
- package/main/refresh-agent-models.mjs +15 -2
- package/main/relay-client.mjs +69 -1
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +12 -41
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +45 -3
- package/main/settings.mjs +220 -44
- package/main/shell-path.mjs +185 -37
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +115 -19
- package/main/source-updater.mjs +18 -10
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -0
- package/main/store/home.mjs +87 -232
- package/main/store/paths.mjs +11 -0
- package/main/store/teammates-files.mjs +99 -0
- package/main/store/work-items.mjs +231 -13
- package/main/store.mjs +226 -52
- package/main/supervisor.mjs +3103 -341
- package/main/task-folders.mjs +430 -30
- package/main/task-terminals.mjs +110 -34
- package/main/team/index.mjs +114 -10
- package/main/team/memory-cloud.mjs +183 -1
- package/main/team/room-hours.mjs +48 -0
- package/main/team/room-setup.mjs +31 -0
- package/main/team/rooms-memory.mjs +454 -0
- package/main/team/rooms-shape.mjs +42 -0
- package/main/team/rooms-supabase.mjs +324 -0
- package/main/team/rooms.mjs +575 -0
- package/main/team/session.mjs +96 -26
- package/main/team/supabase-backend.mjs +180 -0
- package/main/team/sync.mjs +109 -1
- package/main/teammates.mjs +580 -0
- package/main/terminal-host.mjs +143 -0
- package/main/terminal-hosts.mjs +261 -0
- package/main/transcript-search.mjs +80 -0
- package/main/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +90 -219
- package/mcp/core/documents.mjs +3 -1
- package/mcp/core/repeats.mjs +88 -0
- package/mcp/core/teammates.mjs +222 -0
- package/mcp/core/work.mjs +76 -4
- package/mcp/stdio.mjs +24 -11
- package/mcp/tools.mjs +116 -8
- package/package.json +21 -5
- package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
- package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
- package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
- package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
- package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
- package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
- package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
- package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
- package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
- package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
- package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
- package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
- package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
- package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
- package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
- package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
- package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
- package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
- package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
- package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
- package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
- package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
- package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
- package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
- package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
- package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
- package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
- package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
- package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
- package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
- package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
- package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
- package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
- package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
- package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
- package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
- package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
- package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
- package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
- package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
- package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
- package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
- package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
- package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
- package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
- package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
- package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
- package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
- package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
- package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
- package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
- package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
- package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
- package/renderer/dist/corner-tag.html +3 -3
- package/renderer/dist/index.html +3 -3
- package/scripts/after-install.mjs +30 -8
- package/scripts/cloud-shim.pl +57 -0
- package/shared/account-names.d.mts +6 -0
- package/shared/account-names.mjs +15 -0
- package/shared/agent-hours.mjs +136 -0
- package/shared/agent-import.mjs +60 -34
- package/shared/agents.mjs +126 -522
- package/shared/analytics-events.mjs +12 -0
- package/shared/approval-card.mjs +36 -0
- package/shared/bridge-map.mjs +27 -1
- package/shared/claude-code-shape.mjs +6 -0
- package/shared/claude-usage.d.mts +1 -0
- package/shared/claude-usage.mjs +42 -1
- package/shared/cloud-hand-back.d.mts +7 -0
- package/shared/cloud-hand-back.mjs +20 -0
- package/shared/cloud-kit.d.mts +219 -0
- package/shared/cloud-kit.mjs +341 -0
- package/shared/cloud-login-name.mjs +4 -0
- package/shared/cloud-start.mjs +17 -0
- package/shared/cloud-versions.mjs +23 -0
- package/shared/codex-import.mjs +2 -2
- package/shared/codex-modes.d.mts +22 -9
- package/shared/codex-modes.mjs +67 -11
- package/shared/codex-usage.mjs +108 -2
- package/shared/corner-tag.d.mts +23 -1
- package/shared/corner-tag.mjs +284 -29
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +70 -309
- package/shared/feedback.d.mts +7 -0
- package/shared/feedback.mjs +25 -0
- package/shared/first-run-practice.mjs +17 -24
- package/shared/harness-definitions.mjs +8 -0
- package/shared/instruction-defaults.json +2 -2
- package/shared/key-caps.d.mts +14 -0
- package/shared/key-caps.mjs +68 -0
- package/shared/message-edits.d.mts +7 -0
- package/shared/message-edits.mjs +35 -0
- package/shared/move-thread.d.mts +24 -0
- package/shared/move-thread.mjs +112 -0
- package/shared/named-threads.mjs +72 -0
- package/shared/not-shipped.d.mts +22 -0
- package/shared/not-shipped.mjs +89 -0
- package/shared/notify-rules.d.mts +6 -0
- package/shared/notify-rules.mjs +36 -8
- package/shared/phone-files.mjs +28 -0
- package/shared/phone-settings.mjs +118 -0
- package/shared/phone-snapshot.d.mts +16 -0
- package/shared/phone-snapshot.mjs +348 -0
- package/shared/picture-formats.d.mts +1 -0
- package/shared/picture-formats.mjs +13 -0
- package/shared/rank.d.mts +2 -1
- package/shared/rank.mjs +32 -19
- package/shared/repeats.d.mts +8 -0
- package/shared/repeats.mjs +33 -2
- package/shared/room-cards.mjs +167 -0
- package/shared/room-links.d.mts +4 -0
- package/shared/room-links.mjs +21 -0
- package/shared/runs-on.d.mts +3 -3
- package/shared/runs-on.mjs +8 -5
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +14 -6
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +443 -40
- package/shared/team-rules.mjs +9 -2
- package/shared/teammates.d.mts +19 -0
- package/shared/teammates.mjs +756 -0
- package/shared/terminal-focus.d.mts +1 -0
- package/shared/terminal-focus.mjs +13 -0
- package/shared/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +9 -0
- package/shared/usage.mjs +49 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -0
- package/shared/work-items.mjs +127 -30
- package/shared/work-lines.mjs +92 -244
- package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
- package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
- package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
|
@@ -0,0 +1,1934 @@
|
|
|
1
|
+
// ONE CLOUD RUN, AS SEEN FROM THE MAC: A LOCAL PROCESS THAT COMPUTES ELSEWHERE.
|
|
2
|
+
//
|
|
3
|
+
// docs/cloud/PLAN.md. For a cloud run the supervisor spawns this in place of
|
|
4
|
+
// `claude`, with the same argv, env and cwd. To the supervisor it is a worker:
|
|
5
|
+
// what the agent prints arrives on stdout byte for byte, what the supervisor
|
|
6
|
+
// writes to stdin (the prompt, live replies, interrupts, the close at the end)
|
|
7
|
+
// reaches the agent in order, and it exits with the agent's exit code once the
|
|
8
|
+
// agent's work is back in the task folder.
|
|
9
|
+
//
|
|
10
|
+
// What it does in between:
|
|
11
|
+
// 1. Brings home any earlier cloud run in this folder that never came back,
|
|
12
|
+
// before it does anything else; one the app left going when it quit is
|
|
13
|
+
// joined and served until it finishes.
|
|
14
|
+
// 2. Packs the task folder (its branch, main, and any uncommitted work) and
|
|
15
|
+
// the files the agent will need at the same absolute paths: the settings
|
|
16
|
+
// file without the Mac-only memory hooks, an MCP config whose servers are
|
|
17
|
+
// the sandbox's stubs, the transcript a `--resume` needs, the account's
|
|
18
|
+
// skills, commands and agents, her CLAUDE.md and those of her own hooks
|
|
19
|
+
// that can run there, and any file the prompt names.
|
|
20
|
+
// 3. Starts the sandbox, then pumps: events out (stdout, stderr, MCP lines),
|
|
21
|
+
// input in (stdin, MCP replies), every input numbered and kept until the
|
|
22
|
+
// sandbox has acknowledged it, so a resend after a lost reply is applied
|
|
23
|
+
// once and nothing is skipped.
|
|
24
|
+
// 4. Runs the app's MCP servers HERE, from the original config, with the
|
|
25
|
+
// worker's own environment, exactly as Claude Code would have, and joins
|
|
26
|
+
// each to its stub in the sandbox. A call to `look_at_page` is refused:
|
|
27
|
+
// it would look at this Mac's localhost, not the sandbox's.
|
|
28
|
+
// 5. When the sandbox reports its results uploaded, fetches them from S3 and,
|
|
29
|
+
// only if the folder is exactly as it was when the run left, lays them in:
|
|
30
|
+
// the branch moved to the cloud's tip, uncommitted work reapplied,
|
|
31
|
+
// transcripts unpacked. Only then does the marker go. A folder that
|
|
32
|
+
// changed here meanwhile keeps its own work, and the run's goes on a
|
|
33
|
+
// branch `cloud/<item>` (`park`).
|
|
34
|
+
// 6. On SIGTERM (Stop, archive, preemption) it cancels the remote command
|
|
35
|
+
// and still does 5, so the work and the transcript come home and the
|
|
36
|
+
// app's ordinary resume can take it from there. On SIGHUP (the app
|
|
37
|
+
// quitting) it leaves the run going instead (`leave`).
|
|
38
|
+
// 7. Lays what the agent writes in the project's documents folder into it
|
|
39
|
+
// here, during the run and at its end (./docs.mjs).
|
|
40
|
+
//
|
|
41
|
+
// Exit codes: the agent's own, when its work came home. 75 (EX_TEMPFAIL) when
|
|
42
|
+
// the agent may have finished but its work is not here; the marker stays, so
|
|
43
|
+
// shipping and cleanup leave the folder alone, and the app tries again. 77
|
|
44
|
+
// (EX_NOPERM) when the account's cloud sign-in was missing, refused or ran
|
|
45
|
+
// out: the work that was done is home, and the account needs signing in (or
|
|
46
|
+
// its Codex login renewing) before its threads go to the cloud again.
|
|
47
|
+
//
|
|
48
|
+
// Reviewed by Codex in three rounds (2026-10-13); the rules each finding left
|
|
49
|
+
// behind are written where they apply.
|
|
50
|
+
|
|
51
|
+
import fs from 'node:fs';
|
|
52
|
+
import os from 'node:os';
|
|
53
|
+
import path from 'node:path';
|
|
54
|
+
import crypto from 'node:crypto';
|
|
55
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
56
|
+
import { EventEmitter } from 'node:events';
|
|
57
|
+
import { PassThrough } from 'node:stream';
|
|
58
|
+
import { pipeline } from 'node:stream/promises';
|
|
59
|
+
import { spawn } from 'node:child_process';
|
|
60
|
+
import { run } from './exec.mjs';
|
|
61
|
+
import { readMarker, writeMarker, clearMarker, addCloudNews, commonGitDirOf } from './marker.mjs';
|
|
62
|
+
import { Name, nameSlug } from '../../shared/product-name.mjs';
|
|
63
|
+
import { KEPT_ASIDE } from '../../shared/not-shipped.mjs';
|
|
64
|
+
import { codexTranscriptFile } from '../codex-session.mjs';
|
|
65
|
+
import { CLIENT_INFO } from '../codex-app-server.mjs';
|
|
66
|
+
import { appendCloudUsage } from './usage.mjs';
|
|
67
|
+
import { docsFolderFor, docsToSend, tarDocs, layDocs } from './docs.mjs';
|
|
68
|
+
import { troubleCause, limitResetMoment } from '../../shared/spawn-trouble.mjs';
|
|
69
|
+
import { HAND_BACK } from '../../shared/cloud-hand-back.mjs';
|
|
70
|
+
import { sentLine } from '../../shared/cloud-start.mjs';
|
|
71
|
+
import { referencedFiles } from '../../shared/referenced-files.mjs';
|
|
72
|
+
import { serveApprovals } from '../approval-prompt-core.mjs';
|
|
73
|
+
import { cacheKey, folderKey, sendOnce, pickBase, bundlePast, refsAt, sendConversation, sentRecordPath } from './reuse.mjs';
|
|
74
|
+
import { KIT_VERSION, checkKit, kitKey } from '../../shared/cloud-kit.mjs';
|
|
75
|
+
import { NEW_ITEM_MARK } from '../../shared/cloud-kit.mjs';
|
|
76
|
+
import { APPROVALS_SERVER } from '../../shared/cloud-kit.mjs';
|
|
77
|
+
import { asksPermission, approvalsHandshakes } from './kit.mjs';
|
|
78
|
+
|
|
79
|
+
// WHAT A CLOUD RUN IS TOLD ABOUT WHERE IT IS. Without it an agent there found
|
|
80
|
+
// its film's files, ffmpeg and a key missing, and asked the person to move the
|
|
81
|
+
// thread (2026-10-16). Kept true to cloud/sandbox/Dockerfile and ./docs.mjs.
|
|
82
|
+
export const CLOUD_NOTE = [
|
|
83
|
+
'# You are running on a cloud machine, not on the person\'s own computer',
|
|
84
|
+
'',
|
|
85
|
+
'This turn runs in a Linux cloud sandbox (Debian, 2 cores, 8 GB) that is yours for this run and thrown away after it. It has: this task folder (its git branch and main, at the same path as on their computer); the project\'s documents folder at its usual path, but only its small files (up to 256 KB each), plus this thread\'s own designs folder (its newest files, up to 50 MB) and any attachment the request names; the store tools (look_at_page excepted); git, node, npm, python3 and pip, make, g++, curl, wget, unzip; and Playwright with Chromium already installed (`require(\'playwright\')` works from any folder).',
|
|
86
|
+
'',
|
|
87
|
+
'Install whatever else the work needs: you have sudo (`sudo apt-get update && sudo apt-get install -y <package>`, `pip install --user`, `npm install`). When you install a program the project will need again, add the same lines to `.agentbox/cloud-setup.sh` in the task folder and commit it: that script runs before every cloud run of this project, and its output from this run is in /tmp/agentbox-cloud-setup.log.',
|
|
88
|
+
'',
|
|
89
|
+
'It does not have: the project\'s other branches (only this task\'s branch and main came up); any other file from their computer (other threads\' design folders, large media, files under the home folder); files an earlier turn left outside git (node_modules, gitignored folders such as scripts/scratch, /tmp); API keys, key files or tokens kept on their computer; their computer\'s apps (Xcode, a simulator, their browser and its sign-ins). Run `npm ci --ignore-scripts` before anything that needs node_modules. Open files are capped at 1024 here (the cloud fixes that ceiling), so a test runner that watches the whole project (`vitest related`, `npm run test:changed`) fails with EMFILE: run the test files you changed and the ones that import them by name with `npx vitest run <files>`.',
|
|
90
|
+
'',
|
|
91
|
+
`Only this task's own branch comes back to their computer. A commit, merge or reset on main or any other branch stays in this sandbox and is thrown away with it, so a merge done here has not happened. When the request includes merging into main, or changing any branch but this task's, do the rest of the work and commit it on this branch, then end your turn with the one line \`${HAND_BACK} merging into main\` (as below): the app merges on their computer.`,
|
|
92
|
+
'',
|
|
93
|
+
`If this request needs something only their computer has (one of those files, a key, one of their apps, a private repository this machine cannot reach), stop at once. Do not work around it, do not write to the work item, do not give the person commands to run, and do not offer to move the thread: the app moves it. End your turn with one line and nothing else: ${HAND_BACK} followed by what was missing, in a few words they would use (your AWS sign-in, the iOS simulator), since the app shows those words to them. A person who asks to have this thread moved to their computer is answered with this line as well. That line replaces your usual final answer and its options. The app then runs the same request again on their computer, where all of it is. A program you can install is not a reason to stop. Otherwise work as usual, and never write that line.`,
|
|
94
|
+
].join('\n');
|
|
95
|
+
|
|
96
|
+
// The same, for a project with no repository, whose agent works in the
|
|
97
|
+
// documents folder itself.
|
|
98
|
+
const notes = CLOUD_NOTE.split('\n\n');
|
|
99
|
+
export const PLAIN_CLOUD_NOTE = [
|
|
100
|
+
notes[0],
|
|
101
|
+
'This turn runs in a Linux cloud sandbox (Debian, 2 cores, 8 GB) that is yours for this run and thrown away after it. It has: the project\'s documents folder, which is the folder you work in, at the same path as on their computer, but only its small files (up to 256 KB each), plus this thread\'s own designs folder (its newest files, up to 50 MB) and any attachment the request names; what you write there goes back to their computer as you work. There is no git repository here. It also has the store tools (look_at_page excepted); git, node, npm, python3 and pip, make, g++, curl, wget, unzip; and Playwright with Chromium already installed (`require(\'playwright\')` works from any folder).',
|
|
102
|
+
'Install whatever else the work needs: you have sudo (`sudo apt-get update && sudo apt-get install -y <package>`, `pip install --user`, `npm install`).',
|
|
103
|
+
'It does not have: any other file from their computer (documents over 256 KB, other threads\' design folders, large media, files under the home folder, /tmp from an earlier turn); API keys, key files or tokens kept on their computer; their computer\'s apps (Xcode, a simulator, their browser and its sign-ins).',
|
|
104
|
+
notes[notes.length - 1],
|
|
105
|
+
].join('\n\n');
|
|
106
|
+
|
|
107
|
+
// The app's own instructions with the cloud note after them.
|
|
108
|
+
export function argvForTheCloud(argv, { plain = false } = {}) {
|
|
109
|
+
const note = plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE;
|
|
110
|
+
const i = argv.indexOf('--append-system-prompt');
|
|
111
|
+
if (i < 0 || i + 1 >= argv.length) return [...argv, '--append-system-prompt', note];
|
|
112
|
+
const out = [...argv];
|
|
113
|
+
out[i + 1] = `${argv[i + 1]}\n\n${note}`;
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Claude Code keeps a conversation's system text from its first turn, so a
|
|
118
|
+
// conversation begun on the Mac never reads the note on a cloud turn's command
|
|
119
|
+
// line: a resumed run is told at the top of its first message instead.
|
|
120
|
+
export function claudeLineForTheCloud(line, { note }) {
|
|
121
|
+
let msg;
|
|
122
|
+
try { msg = JSON.parse(line); } catch { return line; }
|
|
123
|
+
if (msg?.type !== 'user' || msg.message?.role !== 'user') return line;
|
|
124
|
+
const said = msg.message.content;
|
|
125
|
+
const content = Array.isArray(said) ? [{ type: 'text', text: note }, ...said] : `${note}\n\n${said ?? ''}`;
|
|
126
|
+
return JSON.stringify({ ...msg, message: { ...msg.message, content } }) + (line.endsWith('\n') ? '\n' : '');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// How long a cloud account whose limit named no hour sits out.
|
|
130
|
+
const LIMIT_REST_MS = 30 * 60_000;
|
|
131
|
+
|
|
132
|
+
// THE CLOUD ACCOUNT'S LIMIT, off one line of Claude Code's stream: the moment
|
|
133
|
+
// it resets when the line is a failed result that says the account is at its
|
|
134
|
+
// limit, else null. A result that worked is never a limit, whatever it quotes.
|
|
135
|
+
export function limitInResultLine(line, now = Date.now()) {
|
|
136
|
+
let msg;
|
|
137
|
+
try { msg = JSON.parse(line); } catch { return null; }
|
|
138
|
+
if (msg?.type !== 'result' || msg.is_error !== true) return null;
|
|
139
|
+
const text = String(msg.result ?? '');
|
|
140
|
+
if (troubleCause(text) !== 'at-limit') return null;
|
|
141
|
+
return limitResetMoment(text, now) ?? now + LIMIT_REST_MS;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const IMPORT_FAILED = 75;
|
|
145
|
+
export const LOGIN_TROUBLE = 77;
|
|
146
|
+
// A folder the cloud cannot take: the thread runs on this Mac instead, and is
|
|
147
|
+
// not tried in the cloud again (Supervisor#_cloudKeptOnMac).
|
|
148
|
+
export const KEEP_ON_MAC = 78;
|
|
149
|
+
// The cloud server had no room for this person (no plan, hours spent, every
|
|
150
|
+
// agent running, paused, terms not accepted): not a failure of the thread or
|
|
151
|
+
// the cloud, so the thread runs on this Mac meanwhile (Supervisor#_noteCloudNoRoom).
|
|
152
|
+
export const CLOUD_NO_ROOM = 79;
|
|
153
|
+
const NO_ROOM = new Set(['plan', 'hours', 'agents', 'paused', 'terms', 'storage']);
|
|
154
|
+
/** A refused start that is about room, as its exit and what it says; else null. */
|
|
155
|
+
export function refusedForRoom(answer) {
|
|
156
|
+
if (!answer || answer.ok !== false || !NO_ROOM.has(answer.error)) return null;
|
|
157
|
+
return { code: CLOUD_NO_ROOM, line: `no room in the cloud (${answer.error}): ${answer.message || ''}`.trim() };
|
|
158
|
+
}
|
|
159
|
+
// A link to a login is good for half an hour: a run fetches it while it is
|
|
160
|
+
// being prepared, never later.
|
|
161
|
+
const LOGIN_LINK_SECONDS = 30 * 60;
|
|
162
|
+
const STUB = '/opt/agentbox/mcp-stub.mjs';
|
|
163
|
+
// Where the image's sandbox server listens for stubs (cloud/sandbox/server.mjs).
|
|
164
|
+
const BRIDGE_SOCK = '/tmp/agentbox-bridge.sock';
|
|
165
|
+
// The image's node, by its full path: Codex starts an MCP server with a bare
|
|
166
|
+
// environment, in which a bare `node` may not be found.
|
|
167
|
+
const SANDBOX_NODE = '/usr/local/bin/node';
|
|
168
|
+
// Refused when called, but still listed: a tool missing from the list reads
|
|
169
|
+
// to the agent as its whole server having disconnected.
|
|
170
|
+
const WITHHELD_TOOLS = new Set(['look_at_page']);
|
|
171
|
+
const MAX_ATTACHMENT = 25 * 1024 * 1024;
|
|
172
|
+
const RESULTS_WAIT_MS = 3 * 60_000;
|
|
173
|
+
// How often a run's new documents are fetched while it works.
|
|
174
|
+
const DOCS_EVERY_MS = 10_000;
|
|
175
|
+
// Four missed reads of the run's events: asleep, or off the network for good.
|
|
176
|
+
const OFFLINE_AFTER_MS = 90_000;
|
|
177
|
+
export const INPUT_CLOSED_IN_CLOUD = 'agentbox_input_closed_in_cloud';
|
|
178
|
+
// A run the app left going when it quit, and the process bringing it home.
|
|
179
|
+
// The store's writes an agent may make while the Mac is away, kept in the cloud
|
|
180
|
+
// and made when it wakes: what it writes on its thread and its documents.
|
|
181
|
+
// Never a read, which has nothing to answer with, nor a claim, stale by then.
|
|
182
|
+
export const KEPT_WHILE_AWAY = { [nameSlug]: ['update_work_item', 'create_work_item', 'write_document'] };
|
|
183
|
+
// What a server opened afresh to make a kept write is told first.
|
|
184
|
+
const HANDSHAKE_LINES = [
|
|
185
|
+
JSON.stringify({ jsonrpc: '2.0', id: 'kept-hello', method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: `${nameSlug}-cloud`, version: '1' } } }),
|
|
186
|
+
JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
187
|
+
];
|
|
188
|
+
const LEFT_RUNNING = new Set(['detached', 'attached']);
|
|
189
|
+
const MAC_GONE = 'The app on the Mac restarted during this call, so its answer was lost. Check whether it took effect, then try again.';
|
|
190
|
+
// How long a run that is being left to carry on waits for its last inputs to
|
|
191
|
+
// reach the sandbox.
|
|
192
|
+
const LEAVE_FLUSH_MS = 10_000;
|
|
193
|
+
// The project's setup in the sandbox (cloud/sandbox/server.mjs), and how long
|
|
194
|
+
// it may take before it is worth saying.
|
|
195
|
+
const SETUP_FILE = '.agentbox/cloud-setup.sh';
|
|
196
|
+
const SETUP_LOG = '/tmp/agentbox-cloud-setup.log';
|
|
197
|
+
const SLOW_SETUP_MS = 3 * 60_000;
|
|
198
|
+
|
|
199
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
200
|
+
|
|
201
|
+
// Where Claude Code keeps a folder's transcripts: every character that is not
|
|
202
|
+
// a letter or a digit becomes '-'. The supervisor's transcriptFile reads the
|
|
203
|
+
// same rule.
|
|
204
|
+
export function transcriptFolderName(cwd) { return cwd.replace(/[^a-zA-Z0-9]/g, '-'); }
|
|
205
|
+
|
|
206
|
+
// What the cloud sends home of a conversation it was given: only what the run
|
|
207
|
+
// added, listed in this file (the same name in cloud/sandbox/server.mjs).
|
|
208
|
+
const TAILS = '.agentbox-tails.json';
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Each tail the cloud sent into `dir`, added to the file it continues. A file
|
|
212
|
+
* that still starts as it was sent becomes the cloud's copy exactly, as when
|
|
213
|
+
* the whole file came home; one that changed here keeps its start and gains
|
|
214
|
+
* the run's lines after it, once: an import that is tried again untars the
|
|
215
|
+
* same tail, and a changed file that already ends with it is left alone. A
|
|
216
|
+
* file gone from here starts again from the copy this Mac sent (`sentCopy`);
|
|
217
|
+
* without that copy the tail stays beside it rather than pose as the whole.
|
|
218
|
+
*/
|
|
219
|
+
export async function layTails(dir, say = () => {}, { sentCopy = async () => false } = {}) {
|
|
220
|
+
const list = path.join(dir, TAILS);
|
|
221
|
+
let tails;
|
|
222
|
+
try { tails = JSON.parse(fs.readFileSync(list, 'utf8')); } catch { return; }
|
|
223
|
+
for (const t of Array.isArray(tails) ? tails : []) {
|
|
224
|
+
const tail = plainFileIn(dir, t?.tail);
|
|
225
|
+
if (!tail) continue;
|
|
226
|
+
const file = plainFileIn(dir, t.file, { mayBeMissing: true });
|
|
227
|
+
if (file && Number.isSafeInteger(t.from) && t.from >= 0 && typeof t.sha256 === 'string' && await layTail(file, tail, t, say, sentCopy) === 'kept') continue;
|
|
228
|
+
fs.rmSync(tail, { force: true });
|
|
229
|
+
}
|
|
230
|
+
fs.rmSync(list, { force: true });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function layTail(file, tail, t, say, sentCopy) {
|
|
234
|
+
const adds = fs.statSync(tail).size;
|
|
235
|
+
const size = fs.statSync(file, { throwIfNoEntry: false })?.size ?? -1;
|
|
236
|
+
const name = path.basename(file);
|
|
237
|
+
// Replaced in one rename, so a laying cut off part way leaves the old file.
|
|
238
|
+
const next = `${file}.agentbox-laying`;
|
|
239
|
+
try {
|
|
240
|
+
if (size >= t.from && await sha256OfStart(file, t.from) === t.sha256) {
|
|
241
|
+
fs.copyFileSync(file, next, fs.constants.COPYFILE_FICLONE);
|
|
242
|
+
fs.truncateSync(next, t.from);
|
|
243
|
+
} else if (size < 0) {
|
|
244
|
+
const back = await sentCopy(t, next).catch(() => false) && fs.statSync(next).size === t.from && await sha256OfStart(next, t.from) === t.sha256;
|
|
245
|
+
if (!back) {
|
|
246
|
+
say(`${name} was gone from this Mac while its run was in the cloud; the run's new lines are kept beside it in ${path.basename(tail)}`);
|
|
247
|
+
return 'kept';
|
|
248
|
+
}
|
|
249
|
+
say(`${name} was gone from this Mac while its run was in the cloud; it is laid again from the copy sent with the run`);
|
|
250
|
+
} else if (size >= adds && await endsWith(file, size, tail, adds)) {
|
|
251
|
+
return;
|
|
252
|
+
} else {
|
|
253
|
+
fs.copyFileSync(file, next, fs.constants.COPYFILE_FICLONE);
|
|
254
|
+
say(`${name} changed on this Mac while its run was in the cloud; the run's new lines are added after it`);
|
|
255
|
+
}
|
|
256
|
+
await pipeline(fs.createReadStream(tail), fs.createWriteStream(next, { flags: 'a' }));
|
|
257
|
+
fs.renameSync(next, file);
|
|
258
|
+
} finally {
|
|
259
|
+
fs.rmSync(next, { force: true });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// `rel` inside `dir` as a plain file, reached through no link; or, when it may
|
|
264
|
+
// be missing, where one would go.
|
|
265
|
+
function plainFileIn(dir, rel, { mayBeMissing = false } = {}) {
|
|
266
|
+
if (typeof rel !== 'string' || !rel) return null;
|
|
267
|
+
const p = path.resolve(dir, rel);
|
|
268
|
+
try {
|
|
269
|
+
const parent = fs.realpathSync(path.dirname(p));
|
|
270
|
+
const inside = path.relative(fs.realpathSync(dir), path.join(parent, path.basename(p)));
|
|
271
|
+
if (!inside || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return null;
|
|
272
|
+
const st = fs.lstatSync(p, { throwIfNoEntry: false });
|
|
273
|
+
if (!st) return mayBeMissing ? p : null;
|
|
274
|
+
return st.isFile() ? p : null;
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function sha256OfStart(file, bytes) {
|
|
281
|
+
const h = crypto.createHash('sha256');
|
|
282
|
+
if (bytes > 0) for await (const chunk of fs.createReadStream(file, { start: 0, end: bytes - 1 })) h.update(chunk);
|
|
283
|
+
return h.digest('hex');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function endsWith(file, size, tail, bytes) {
|
|
287
|
+
const [a, b] = await Promise.all([fs.promises.open(file, 'r'), fs.promises.open(tail, 'r')]);
|
|
288
|
+
try {
|
|
289
|
+
const x = Buffer.alloc(Math.min(4 * 1024 * 1024, bytes));
|
|
290
|
+
const y = Buffer.alloc(x.length);
|
|
291
|
+
for (let at = 0; at < bytes; at += x.length) {
|
|
292
|
+
const n = Math.min(x.length, bytes - at);
|
|
293
|
+
await a.read(x, 0, n, size - bytes + at);
|
|
294
|
+
await b.read(y, 0, n, at);
|
|
295
|
+
if (!x.subarray(0, n).equals(y.subarray(0, n))) return false;
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
} finally {
|
|
299
|
+
await a.close();
|
|
300
|
+
await b.close();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// THE PROJECT'S SAVED MEMORIES on this Mac, for a task folder that is a
|
|
305
|
+
// linked worktree: Claude Code keeps them under the main checkout's folder
|
|
306
|
+
// name. In the cloud the task folder is a repository of its own, so Claude
|
|
307
|
+
// Code looks under the task folder's name instead (its transcripts' folder).
|
|
308
|
+
// Null when there are none, or the folder is the main checkout.
|
|
309
|
+
export function memoryFolderFor(cwd, configDir) {
|
|
310
|
+
const common = commonGitDirOf(cwd);
|
|
311
|
+
if (!common) return null;
|
|
312
|
+
const root = path.dirname(common);
|
|
313
|
+
if (realOrNull(root) === realOrNull(cwd)) return null;
|
|
314
|
+
const dir = path.join(configDir, 'projects', transcriptFolderName(root), 'memory');
|
|
315
|
+
try { return fs.readdirSync(dir).length ? dir : null; } catch { return null; }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function folderWithFiles(dir) {
|
|
319
|
+
try { return fs.readdirSync(dir).length ? dir : null; } catch { return null; }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function argValue(argv, flag) {
|
|
323
|
+
const i = argv.indexOf(flag);
|
|
324
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function git(cwd, args, { buffer = false, env, input } = {}) {
|
|
328
|
+
return run('git', args, { cwd, env, input, encoding: buffer ? null : 'utf8' });
|
|
329
|
+
}
|
|
330
|
+
const gitOk = (cwd, args) => git(cwd, args).then(() => true, () => false);
|
|
331
|
+
|
|
332
|
+
// The tree the folder's files make right now, committed or not, without
|
|
333
|
+
// touching the branch, the index or the files.
|
|
334
|
+
async function treeOfFolder(cwd) {
|
|
335
|
+
const index = path.join(os.tmpdir(), `agentbox-cloud-index-${process.pid}-${crypto.randomUUID()}`);
|
|
336
|
+
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
337
|
+
try {
|
|
338
|
+
await git(cwd, ['read-tree', 'HEAD'], { env });
|
|
339
|
+
await git(cwd, ['add', '-A'], { env });
|
|
340
|
+
return (await git(cwd, ['write-tree'], { env })).trim();
|
|
341
|
+
} finally {
|
|
342
|
+
fs.rmSync(index, { force: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// What a folder is: its branch, its commit, and its files. A cloud run's work
|
|
347
|
+
// is laid into a folder only if all three are still what the run left with.
|
|
348
|
+
export async function folderState(cwd) {
|
|
349
|
+
return {
|
|
350
|
+
branch: (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim(),
|
|
351
|
+
head: (await git(cwd, ['rev-parse', 'HEAD'])).trim(),
|
|
352
|
+
tree: await treeOfFolder(cwd),
|
|
353
|
+
// What is staged, apart from what is on disk: a change staged and then
|
|
354
|
+
// undone on disk would otherwise read as no change at all.
|
|
355
|
+
index: (await git(cwd, ['write-tree'])).trim(),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Commit what the folder holds that is not committed, onto its own ref,
|
|
360
|
+
// without touching the branch, the index or the files.
|
|
361
|
+
async function snapshotUncommitted(cwd, ref) {
|
|
362
|
+
if (!(await git(cwd, ['status', '--porcelain', '--untracked-files=all'])).trim()) return false;
|
|
363
|
+
const tree = await treeOfFolder(cwd);
|
|
364
|
+
const commit = (await git(cwd, ['-c', `user.name=${Name}`, '-c', `user.email=${nameSlug}@localhost`, 'commit-tree', tree, '-p', 'HEAD', '-m', 'Uncommitted work kept by a cloud run'])).trim();
|
|
365
|
+
await git(cwd, ['update-ref', ref, commit]);
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The remote the sandbox may fetch from: https only, never with credentials
|
|
370
|
+
// in it. An ssh address is turned into its https twin; anything else is left
|
|
371
|
+
// out, and `git fetch origin` in the sandbox simply fails, as it would offline.
|
|
372
|
+
export function remoteForTheCloud(url) {
|
|
373
|
+
const s = String(url || '').trim();
|
|
374
|
+
const ssh = /^(?:ssh:\/\/)?git@([^:/]+)[:/](.+)$/.exec(s);
|
|
375
|
+
let u;
|
|
376
|
+
try { u = new URL(ssh ? `https://${ssh[1]}/${ssh[2]}` : s); } catch { return null; }
|
|
377
|
+
if (u.protocol !== 'https:') return null;
|
|
378
|
+
u.username = ''; u.password = '';
|
|
379
|
+
return u.toString();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ------------------------------ the launch ------------------------------- */
|
|
383
|
+
|
|
384
|
+
// The settings file the supervisor wrote, minus anything that runs a program
|
|
385
|
+
// on THIS Mac: the memory gate's hooks call a local script over a local socket.
|
|
386
|
+
export function settingsForTheCloud(text) {
|
|
387
|
+
let s;
|
|
388
|
+
try { s = JSON.parse(text); } catch { return text; }
|
|
389
|
+
delete s.hooks;
|
|
390
|
+
return JSON.stringify(s, null, 2);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// HER OWN HOOKS, the ones that can run in the cloud: one program from the
|
|
394
|
+
// system running a script in her Claude hooks folder, which goes up with the
|
|
395
|
+
// run. Anything else (an app, a script somewhere else, a chain of commands)
|
|
396
|
+
// needs this Mac and stays here. `~` and $HOME become the Mac's home, where the
|
|
397
|
+
// folder is laid, because the agent there has a home of its own. Null when
|
|
398
|
+
// nothing travels. Her words, on the guard that keeps her email out of web
|
|
399
|
+
// requests (2026-10-23): "Can it cover the cloud too?"
|
|
400
|
+
const SYSTEM_PROGRAMS = ['/usr/bin/', '/bin/', '/usr/local/bin/', '/usr/sbin/', '/sbin/'];
|
|
401
|
+
export function hooksForTheCloud(text, { hooksDir, homeDir }) {
|
|
402
|
+
let s;
|
|
403
|
+
try { s = JSON.parse(text); } catch { return null; }
|
|
404
|
+
const kept = {};
|
|
405
|
+
for (const [event, groups] of Object.entries(s?.hooks || {})) {
|
|
406
|
+
if (!Array.isArray(groups)) continue;
|
|
407
|
+
const left = [];
|
|
408
|
+
for (const g of groups) {
|
|
409
|
+
const hooks = (Array.isArray(g?.hooks) ? g.hooks : [])
|
|
410
|
+
.filter((h) => h?.type === 'command' && typeof h.command === 'string')
|
|
411
|
+
.map((h) => ({ ...h, command: h.command.replace(/(^|\s|["'])(?:~|\$HOME|\$\{HOME\})\//g, `$1${homeDir}/`) }))
|
|
412
|
+
.filter((h) => runsOnlyFrom(h.command, hooksDir));
|
|
413
|
+
if (hooks.length) left.push({ ...g, hooks });
|
|
414
|
+
}
|
|
415
|
+
if (left.length) kept[event] = left;
|
|
416
|
+
}
|
|
417
|
+
return Object.keys(kept).length ? kept : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function runsOnlyFrom(command, hooksDir) {
|
|
421
|
+
if (/[;&|`]|\$\(/.test(command)) return false;
|
|
422
|
+
let script = false;
|
|
423
|
+
for (const word of command.trim().split(/\s+/)) {
|
|
424
|
+
const w = word.replace(/^["']|["']$/g, '');
|
|
425
|
+
if (!w.startsWith('/')) continue;
|
|
426
|
+
const p = path.resolve(w);
|
|
427
|
+
if (p !== hooksDir && inside(p, hooksDir)) script = true;
|
|
428
|
+
else if (!SYSTEM_PROGRAMS.some((d) => w.startsWith(d))) return false;
|
|
429
|
+
}
|
|
430
|
+
return script;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function mcpConfigForTheCloud(config, stubPath = STUB) {
|
|
434
|
+
const servers = {};
|
|
435
|
+
for (const name of Object.keys(config?.mcpServers || {})) servers[name] = { command: 'node', args: [stubPath, name], env: {} };
|
|
436
|
+
return { mcpServers: servers };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function realOrNull(p) { try { return fs.realpathSync(p); } catch { return null; } }
|
|
440
|
+
const sameFolder = (a, b) => !!a && !!b && realOrNull(a) !== null && realOrNull(a) === realOrNull(b);
|
|
441
|
+
const inside = (p, root) => p === root || p.startsWith(root + path.sep);
|
|
442
|
+
|
|
443
|
+
// Absolute paths a prompt names that are real files the sandbox cannot have:
|
|
444
|
+
// outside the task folder (which travels in the bundle), under one of the
|
|
445
|
+
// roots the agent is allowed to read, and not too big to send. Containment is
|
|
446
|
+
// decided on the RESOLVED path, after `..` and symlinks, so `<root>/../secret`
|
|
447
|
+
// and a link inside the root pointing out of it are both refused.
|
|
448
|
+
// With no `cwd` (a run whose folder is the documents folder, which travels only
|
|
449
|
+
// in part), a file inside it is sent like any other.
|
|
450
|
+
//
|
|
451
|
+
// Given `docs`, a picture named only by its short link, ``,
|
|
452
|
+
// is sent too: the documents folder travels without its attachments (./docs.mjs),
|
|
453
|
+
// and the app writes every pasted picture there.
|
|
454
|
+
export function filesNamedIn(text, { cwd, roots, docs = null }) {
|
|
455
|
+
const realRoots = roots.map(realOrNull).filter(Boolean);
|
|
456
|
+
const realCwd = cwd ? realOrNull(cwd) || cwd : null;
|
|
457
|
+
const found = new Set();
|
|
458
|
+
const take = (named) => {
|
|
459
|
+
const real = realOrNull(named);
|
|
460
|
+
if (!real) return;
|
|
461
|
+
if (realCwd && inside(real, realCwd)) return;
|
|
462
|
+
if (!realRoots.some((r) => inside(real, r))) return;
|
|
463
|
+
try {
|
|
464
|
+
const st = fs.statSync(real);
|
|
465
|
+
if (st.isFile() && st.size <= MAX_ATTACHMENT) found.add(named);
|
|
466
|
+
} catch { /* not a file */ }
|
|
467
|
+
};
|
|
468
|
+
for (const m of String(text || '').matchAll(/(\/(?:[^\s"'`<>()\[\]{},;\\]|\\ )+)/g)) take(m[1].replace(/[.:]+$/, ''));
|
|
469
|
+
const pasted = docs && path.isAbsolute(docs) ? realOrNull(path.join(docs, 'attachments')) : null;
|
|
470
|
+
if (pasted) {
|
|
471
|
+
for (const rel of referencedFiles(...stringsIn(text), { dir: docs })) {
|
|
472
|
+
if (!rel.startsWith('attachments/')) continue;
|
|
473
|
+
const named = path.join(docs, rel);
|
|
474
|
+
const real = realOrNull(named);
|
|
475
|
+
if (real && inside(real, pasted)) take(named);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return [...found];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// The strings of a stream-json line, unescaped: in the raw line a path after a
|
|
482
|
+
// line break reads as `\nattachments/…`.
|
|
483
|
+
function stringsIn(text) {
|
|
484
|
+
let msg;
|
|
485
|
+
try { msg = JSON.parse(text); } catch { return [String(text || '')]; }
|
|
486
|
+
const out = [];
|
|
487
|
+
const walk = (v) => {
|
|
488
|
+
if (typeof v === 'string') out.push(v);
|
|
489
|
+
else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x);
|
|
490
|
+
};
|
|
491
|
+
walk(msg);
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// COPYFILE_DISABLE: a Mac's tar can add a `._name` beside a file with
|
|
496
|
+
// extended attributes, which the cloud reads as one more skill or command.
|
|
497
|
+
export function tarOf(dir) {
|
|
498
|
+
return run('tar', ['-czf', '-', '-C', dir, '-h', '.'], { encoding: null, env: { ...process.env, COPYFILE_DISABLE: '1' } });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* -------------------------------- the run -------------------------------- */
|
|
502
|
+
|
|
503
|
+
// What a sandbox is sent of a file: its link, not the key it was put under.
|
|
504
|
+
const withoutKeys = (list) => list.map(({ key: _k, ...rest }) => rest);
|
|
505
|
+
|
|
506
|
+
// A NEW THREAD'S KIT is packed in the project's checkout and named for the
|
|
507
|
+
// task folder the thread will have (shared/cloud-kit.mjs fillNewThreadKit):
|
|
508
|
+
// its transcripts' folder, and what was sent into the checkout's, are that
|
|
509
|
+
// folder's.
|
|
510
|
+
function newThreadNames(n, { cwd, startSha, files, joins, bringDirs }) {
|
|
511
|
+
const from = `${path.sep}${transcriptFolderName(cwd)}`;
|
|
512
|
+
const to = `${path.sep}${transcriptFolderName(n.cwd)}`;
|
|
513
|
+
const rename = (p) => (typeof p === 'string' ? p.split(from).join(to) : p);
|
|
514
|
+
const dirs = bringDirs.map((b) => ({ ...b, dir: rename(b.dir) }));
|
|
515
|
+
return {
|
|
516
|
+
files: files.map((f) => ({ ...f, path: rename(f.path), ...(f.untar ? { untar: rename(f.untar) } : {}) })),
|
|
517
|
+
joins: { ...joins, bringDirs: dirs },
|
|
518
|
+
made: { cwd: n.cwd, bringDirs: dirs, newThread: { mainSha: startSha, brief: n.brief, model: n.model, effort: n.effort, ...(n.defaultEngine ? { defaultEngine: n.defaultEngine } : {}) } },
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// THE KIT, WRITTEN: what the run would have started with, by key, with the
|
|
523
|
+
// reply's place in the turn's prompt and what a computer joining the run
|
|
524
|
+
// needs to write its marker. A copy stays on this computer for that.
|
|
525
|
+
async function writeKit({ storage, kit, say, files, home, joins, made }) {
|
|
526
|
+
const whole = {
|
|
527
|
+
v: KIT_VERSION, madeAt: Date.now(), device: kit.device, project: kit.project, item: kit.item,
|
|
528
|
+
...made,
|
|
529
|
+
files: files.map((f) => ({ path: f.path, key: f.key, ...(f.untar ? { untar: f.untar } : {}) })),
|
|
530
|
+
homeFiles: home.map((h) => ({ rel: h.rel, key: h.key })),
|
|
531
|
+
frame: kit.frame,
|
|
532
|
+
marker: joins,
|
|
533
|
+
};
|
|
534
|
+
const problem = checkKit(whole);
|
|
535
|
+
if (problem) { say(`no kit for the phone: ${problem}`); return 1; }
|
|
536
|
+
await storage.put(kitKey(kit.device, kit.project, kit.item), JSON.stringify(whole));
|
|
537
|
+
if (kit.copy) {
|
|
538
|
+
fs.mkdirSync(path.dirname(kit.copy), { recursive: true });
|
|
539
|
+
const tmp = `${kit.copy}.${process.pid}.tmp`;
|
|
540
|
+
fs.writeFileSync(tmp, JSON.stringify(whole), { mode: 0o600 });
|
|
541
|
+
fs.renameSync(tmp, kit.copy);
|
|
542
|
+
}
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export async function cloudRun({
|
|
547
|
+
argv, env = process.env, cwd,
|
|
548
|
+
transport, storage,
|
|
549
|
+
// THE ACCOUNT'S CLOUD SIGN-IN, by name only: { engine, id, key }, the
|
|
550
|
+
// object the sandbox fetches it from (main/cloud/logins.mjs). The login
|
|
551
|
+
// itself is never read here (docs/cloud/PLAN.md, "Sign-ins live in the
|
|
552
|
+
// cloud").
|
|
553
|
+
login = null,
|
|
554
|
+
// Other sign-ins the run carries, in the same shape (./run-cli.mjs
|
|
555
|
+
// `loginsCarriedBy`). The sandbox places them if it can and starts anyway.
|
|
556
|
+
also = [],
|
|
557
|
+
// Told when the sandbox says the login was missing, refused or ran out.
|
|
558
|
+
onLoginTrouble = () => {},
|
|
559
|
+
// Told the moment the account's limit resets, when a run ends on it.
|
|
560
|
+
onLimit = () => {},
|
|
561
|
+
stdin = process.stdin, stdout = process.stdout, stderr = process.stderr,
|
|
562
|
+
remoteCommand = 'claude',
|
|
563
|
+
homeDir = os.homedir(),
|
|
564
|
+
pollWaitMs = 20_000,
|
|
565
|
+
resultsWaitMs = RESULTS_WAIT_MS,
|
|
566
|
+
watchParent = true,
|
|
567
|
+
stubPath = STUB,
|
|
568
|
+
signals = process,
|
|
569
|
+
// Only bring an earlier run home, and start nothing: what the supervisor
|
|
570
|
+
// runs for a folder whose cloud work is still outstanding.
|
|
571
|
+
importOnly = false,
|
|
572
|
+
// 'claude' or 'codex'. A Codex run is `codex app-server` in the sandbox,
|
|
573
|
+
// spoken to over stdin/stdout by the supervisor's own Codex client.
|
|
574
|
+
engine = 'claude',
|
|
575
|
+
docsEveryMs = DOCS_EVERY_MS,
|
|
576
|
+
leaveFlushMs = LEAVE_FLUSH_MS,
|
|
577
|
+
// How long the sandbox waits without word from this Mac before it counts
|
|
578
|
+
// it asleep (cloud/sandbox/server.mjs "WHILE THE MAC IS AWAY").
|
|
579
|
+
offlineAfterMs = OFFLINE_AFTER_MS,
|
|
580
|
+
keptWhileAway = KEPT_WHILE_AWAY,
|
|
581
|
+
// The process that stands for this run, written in its marker so another
|
|
582
|
+
// can tell whether it is still alive: this one, or, when many runs share a
|
|
583
|
+
// host process, the run's own small stand-in (main/cloud/host.mjs).
|
|
584
|
+
pid: selfPid = process.pid,
|
|
585
|
+
// What the cloud image said it can do at its last start (its `caps`), and
|
|
586
|
+
// who to tell what this one says. 'base' lets a run send only the commits
|
|
587
|
+
// past a base the bucket already has (./reuse.mjs).
|
|
588
|
+
imageCaps = [],
|
|
589
|
+
onCaps = () => {},
|
|
590
|
+
// A KIT FOR THE PHONE, not a run (shared/cloud-kit.mjs): { device, project,
|
|
591
|
+
// item, frame, copy }. The pack goes up under the thread's kit and the kit
|
|
592
|
+
// names it; nothing starts, and this folder's marker is left alone.
|
|
593
|
+
kit = null,
|
|
594
|
+
}) {
|
|
595
|
+
const say = (msg) => stderr.write(`agentbox cloud: ${msg}\n`);
|
|
596
|
+
const begunAt = Date.now();
|
|
597
|
+
// Whether the sandbox keeps this run going while the Mac sleeps, so its
|
|
598
|
+
// tool calls are asked about before they are made (`claimCalls`).
|
|
599
|
+
let offlineOn = false;
|
|
600
|
+
const codex = engine === 'codex';
|
|
601
|
+
// A project with no repository: the agent works in the documents folder,
|
|
602
|
+
// and the thread's marker is the file the supervisor named (./marker.mjs).
|
|
603
|
+
const plain = !!env.AGENTBOX_CLOUD_MARKER;
|
|
604
|
+
const here = plain ? { folder: cwd, file: env.AGENTBOX_CLOUD_MARKER } : cwd;
|
|
605
|
+
const configDir = env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude');
|
|
606
|
+
const codexHome = env.CODEX_HOME || path.join(homeDir, '.codex');
|
|
607
|
+
const projectsFor = (dir) => path.join(dir || configDir, 'projects', transcriptFolderName(cwd));
|
|
608
|
+
const projectsDir = projectsFor(configDir);
|
|
609
|
+
// WHAT COMES HOME BESIDES THE FOLDER: the conversation, wherever the
|
|
610
|
+
// harness keeps it. Claude Code: this folder's transcripts under the
|
|
611
|
+
// account. Codex: its sessions folder, which in the sandbox holds only
|
|
612
|
+
// what this run wrote (and the one rollout a resume sent up).
|
|
613
|
+
const bringDirs = codex
|
|
614
|
+
? [{ name: 'sessions.tgz', dir: path.join(codexHome, 'sessions') }]
|
|
615
|
+
: [{ name: 'projects.tgz', dir: projectsDir }];
|
|
616
|
+
const keyOf = (runId, name) => `runs/${runId}/${name}`;
|
|
617
|
+
// The links a run uploads its results to. The folders are the RUN's, which
|
|
618
|
+
// for a run being brought home are the ones its marker names, not whichever
|
|
619
|
+
// account this process happens to have been started for.
|
|
620
|
+
const resultUrls = async (runId, dirs = bringDirs) => ({
|
|
621
|
+
bundleUrl: await storage.putUrl(keyOf(runId, 'out.bundle')),
|
|
622
|
+
changesUrl: await storage.putUrl(keyOf(runId, 'changes.bundle')),
|
|
623
|
+
docsUrl: await storage.putUrl(keyOf(runId, 'docs.tgz')),
|
|
624
|
+
eventsUrl: await storage.putUrl(keyOf(runId, 'events.jsonl')),
|
|
625
|
+
doneUrl: await storage.putUrl(keyOf(runId, 'done.json')),
|
|
626
|
+
bring: await Promise.all(dirs.map(async (b) => ({ dir: b.dir, url: await storage.putUrl(keyOf(runId, b.name)) }))),
|
|
627
|
+
});
|
|
628
|
+
// A marker written before Codex runs existed names its account folder only.
|
|
629
|
+
const dirsOf = (rec) => rec.bringDirs || [{ name: 'projects.tgz', dir: projectsFor(rec.configDir) }];
|
|
630
|
+
|
|
631
|
+
// 1. An earlier run that never came home comes home first. One the app
|
|
632
|
+
// left going when it quit is JOINED instead, by the process the supervisor
|
|
633
|
+
// starts to bring it home: it serves the run's tools again and waits for it
|
|
634
|
+
// to finish, rather than stopping it.
|
|
635
|
+
if (kit && readMarker(here)) { say('this thread\'s cloud work is still on its way back, so no kit for the phone yet'); return IMPORT_FAILED; }
|
|
636
|
+
const earlier = kit ? null : readMarker(here);
|
|
637
|
+
let joining = null;
|
|
638
|
+
let joinedAt = null;
|
|
639
|
+
// A run whose relay died without a word (the shared host crashed) is left
|
|
640
|
+
// going too, not stopped.
|
|
641
|
+
const orphaned = earlier?.state === 'running' && earlier.pid && earlier.pid !== selfPid && !alive(earlier.pid);
|
|
642
|
+
if (earlier && (LEFT_RUNNING.has(earlier.state) || orphaned)) {
|
|
643
|
+
if (!importOnly) { say('an earlier cloud run in this folder is still going; it comes home before another starts'); return IMPORT_FAILED; }
|
|
644
|
+
if (earlier.pid && earlier.pid !== selfPid && alive(earlier.pid)) { say('an earlier cloud run in this folder is already being brought home'); return IMPORT_FAILED; }
|
|
645
|
+
const st = await retry(() => transport.invoke(earlier.sessionId, { action: 'status', runId: earlier.runId }), { what: 'reach the run the app left going', forever: false });
|
|
646
|
+
if (!st) { say('the cloud could not be reached; the run the app left going is tried again later'); return IMPORT_FAILED; }
|
|
647
|
+
if (st.runId === earlier.runId && (st.state === 'preparing' || st.state === 'running')) { joining = earlier; joinedAt = st; offlineOn = !!earlier.offline; }
|
|
648
|
+
}
|
|
649
|
+
// A run whose own relay is alive is that relay's to bring home. Two laying
|
|
650
|
+
// it in at once each see the other's work as a change made on this Mac.
|
|
651
|
+
if (importOnly && !joining && earlier?.pid && earlier.pid !== selfPid && alive(earlier.pid)) {
|
|
652
|
+
say('this cloud run is already being brought home by its own process');
|
|
653
|
+
return IMPORT_FAILED;
|
|
654
|
+
}
|
|
655
|
+
if (earlier && !joining) {
|
|
656
|
+
// One the app left going that ended while it was away still has its
|
|
657
|
+
// answer to give, from where the app stopped reading.
|
|
658
|
+
const leftGoing = importOnly && (LEFT_RUNNING.has(earlier.state) || orphaned);
|
|
659
|
+
const passOn = (e) => (e.kind === 'stdout' ? write(stdout, Buffer.from(e.data, 'base64')) : undefined);
|
|
660
|
+
const back = await bringHome(earlier, { recovering: true, ...(leftGoing ? { cursor: earlier.cursor ?? 0, replay: passOn } : {}) });
|
|
661
|
+
if (!back.ok) { say(`an earlier cloud run in this folder could not be brought back yet (${back.why}); not starting another`); return IMPORT_FAILED; }
|
|
662
|
+
}
|
|
663
|
+
if (importOnly && !joining) return 0;
|
|
664
|
+
if (!joining && (!login || login.engine !== engine)) { say(`this ${engine === 'codex' ? 'Codex' : 'Claude'} account has not signed in for the cloud`); return LOGIN_TROUBLE; }
|
|
665
|
+
|
|
666
|
+
let runId; let sessionId; let marker; let docs; let sentFiles; let sendFile;
|
|
667
|
+
// Files that are a conversation, sent joined to the copy the bucket has.
|
|
668
|
+
const conversations = new Set();
|
|
669
|
+
let mcpConfig = { mcpServers: {} };
|
|
670
|
+
let serverEnv = env;
|
|
671
|
+
let startCursor = 0; let startN = 0; let stdinClosed = false; let startConns = {};
|
|
672
|
+
const kitPrefix = kit ? kitKey(kit.device, kit.project, kit.item).replace(/\.json$/, '') : null;
|
|
673
|
+
const key = (name) => (kitPrefix ? `${kitPrefix}/${name}` : keyOf(runId, name));
|
|
674
|
+
const roots = [env.AGENTBOX_HOME, env.ZERO_STORE_ROOT, env.STORE_HOME].filter(Boolean).map((r) => path.resolve(r));
|
|
675
|
+
// A QUIT WHILE THE RUN IS STILL STARTING is held until it has started, and
|
|
676
|
+
// then leaves it going like any other (`leave`); one whose start never
|
|
677
|
+
// answered is marked to be joined, not stopped.
|
|
678
|
+
let quitEarly = false;
|
|
679
|
+
const early = () => { quitEarly = true; };
|
|
680
|
+
signals.on('SIGHUP', early);
|
|
681
|
+
// The agent's input is taken from the first moment, so what the app wrote
|
|
682
|
+
// before the run started is kept in order, and saved if the app goes.
|
|
683
|
+
const outbox = [];
|
|
684
|
+
let wake = null;
|
|
685
|
+
const enqueue = (msg) => { outbox.push(msg); wake?.(); };
|
|
686
|
+
// Stdin: the supervisor's stream-json lines, in order, each preceded by the
|
|
687
|
+
// files it names. Decoded as one stream, so a character split across two
|
|
688
|
+
// chunks is never broken.
|
|
689
|
+
const inDecoder = new StringDecoder('utf8');
|
|
690
|
+
let stdinBuf = '';
|
|
691
|
+
let untold = !codex && !!argValue(argv, '--resume');
|
|
692
|
+
if (!joining && !kit) stdin.on('data', (d) => {
|
|
693
|
+
stdinBuf += typeof d === 'string' ? d : inDecoder.write(d);
|
|
694
|
+
let i;
|
|
695
|
+
while ((i = stdinBuf.indexOf('\n')) >= 0) {
|
|
696
|
+
let line = stdinBuf.slice(0, i + 1);
|
|
697
|
+
stdinBuf = stdinBuf.slice(i + 1);
|
|
698
|
+
const named = filesNamedIn(line, { cwd: plain ? null : cwd, roots, docs: env.AGENTBOX_DOCS });
|
|
699
|
+
if (codex) {
|
|
700
|
+
const c = codexLineForTheCloud(line, { stubPath, codexHome, keep: mcpConfig.mcpServers, note: plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE });
|
|
701
|
+
line = c.line;
|
|
702
|
+
if (c.conversation) conversations.add(c.conversation);
|
|
703
|
+
named.push(...c.files.filter((f) => !named.includes(f)));
|
|
704
|
+
} else if (untold) {
|
|
705
|
+
const was = line;
|
|
706
|
+
line = claudeLineForTheCloud(line, { note: plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE });
|
|
707
|
+
untold = line === was;
|
|
708
|
+
}
|
|
709
|
+
if (named.length) enqueue({ action: 'files', paths: named });
|
|
710
|
+
enqueue({ action: 'stdin', data: Buffer.from(line).toString('base64') });
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
if (!joining && !kit) stdin.on('end', () => {
|
|
714
|
+
if (stdinClosed) return;
|
|
715
|
+
stdinClosed = true;
|
|
716
|
+
stdinBuf += inDecoder.end();
|
|
717
|
+
if (stdinBuf) enqueue({ action: 'stdin', data: Buffer.from(stdinBuf).toString('base64') });
|
|
718
|
+
enqueue({ action: 'stdin', end: true });
|
|
719
|
+
});
|
|
720
|
+
// The app may be gone before the run is: a write to its closed pipe is
|
|
721
|
+
// dropped rather than taking this process down.
|
|
722
|
+
stdout.on?.('error', () => {});
|
|
723
|
+
if (joining) {
|
|
724
|
+
({ runId, sessionId } = joining);
|
|
725
|
+
const { state: _s, pid: _p, at: _a, cursor: c, n: sent, conns, ...kept } = joining;
|
|
726
|
+
marker = kept;
|
|
727
|
+
docs = joining.docs || null;
|
|
728
|
+
// The app's servers as they are now win over the ones the run started
|
|
729
|
+
// with (the approvals key is new at every start); the run's own record
|
|
730
|
+
// fills in any the app no longer names.
|
|
731
|
+
let now = {};
|
|
732
|
+
try { now = JSON.parse(fs.readFileSync(env.AGENTBOX_MCP_CONFIG, 'utf8')).mcpServers || {}; } catch { /* none given */ }
|
|
733
|
+
mcpConfig = { mcpServers: { ...(joining.mcp || {}), ...now } };
|
|
734
|
+
serverEnv = { ...env, ...(joining.toolEnv || {}) };
|
|
735
|
+
// Numbered on from what the sandbox has applied, which a process that
|
|
736
|
+
// died without saying knows better than its marker.
|
|
737
|
+
void sent;
|
|
738
|
+
startCursor = c || 0; startN = Number(joinedAt.inSeq) || 0; startConns = conns || {};
|
|
739
|
+
stdinClosed = true;
|
|
740
|
+
sentFiles = new Set();
|
|
741
|
+
sendFile = async () => { throw new Error('a joined run sends no files'); };
|
|
742
|
+
writeMarker(here,{ ...marker, pid: selfPid, state: 'attached', cursor: startCursor, n: startN, conns: startConns });
|
|
743
|
+
say('joining the cloud run the app left going, to bring it home when it finishes');
|
|
744
|
+
} else {
|
|
745
|
+
runId = crypto.randomUUID();
|
|
746
|
+
sessionId = `agentbox-${(env.ZERO_ITEM || 'run').replace(/[^a-zA-Z0-9-]/g, '')}-${runId}`;
|
|
747
|
+
|
|
748
|
+
// 2. The pack.
|
|
749
|
+
const docsDir = plain ? (sameFolder(env.AGENTBOX_DOCS, cwd) ? cwd : null) : docsFolderFor(env.AGENTBOX_DOCS, cwd);
|
|
750
|
+
// The supervisor checks the image first (`_cloudTakesPlain`), so this is an
|
|
751
|
+
// image that changed in between.
|
|
752
|
+
if (plain && (!docsDir || !imageCaps.includes('plain'))) {
|
|
753
|
+
say(docsDir ? 'this cloud image cannot take a project with no repository yet; rebuild it (node scripts/cloud-setup.mjs)' : 'a project with no repository runs in its documents folder, and this is not it');
|
|
754
|
+
return IMPORT_FAILED;
|
|
755
|
+
}
|
|
756
|
+
const sentAt = Date.now();
|
|
757
|
+
let sent = null; let branch = null; let startSha = null;
|
|
758
|
+
const outgoing = `refs/agentbox/outgoing/${runId}`;
|
|
759
|
+
let hasOutgoing = false;
|
|
760
|
+
const refs = [];
|
|
761
|
+
if (!plain && kit?.newThread) {
|
|
762
|
+
// A NEW THREAD'S KIT: main, as a task folder is branched from it
|
|
763
|
+
// (main/task-folders.mjs baseRef), under the name of the thread to come.
|
|
764
|
+
// None of the checkout's own branch or work goes.
|
|
765
|
+
const main = (await gitOk(cwd, ['rev-parse', '--verify', '-q', 'refs/heads/main'])) ? 'refs/heads/main'
|
|
766
|
+
: (await gitOk(cwd, ['rev-parse', '--verify', '-q', 'refs/heads/master'])) ? 'refs/heads/master' : null;
|
|
767
|
+
if (!main) { say('this project has no main to start a new thread from'); return 1; }
|
|
768
|
+
startSha = (await git(cwd, ['rev-parse', main])).trim();
|
|
769
|
+
const tree = (await git(cwd, ['rev-parse', `${startSha}^{tree}`])).trim();
|
|
770
|
+
branch = `agentbox/${NEW_ITEM_MARK}`;
|
|
771
|
+
sent = { branch, head: startSha, tree, index: tree };
|
|
772
|
+
refs.push(main);
|
|
773
|
+
if (await gitOk(cwd, ['rev-parse', '--verify', '-q', 'refs/remotes/origin/main'])) refs.push('refs/remotes/origin/main');
|
|
774
|
+
} else if (!plain) {
|
|
775
|
+
try {
|
|
776
|
+
if (await gitOk(cwd, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'])) throw new Error('a merge in it is not finished');
|
|
777
|
+
sent = await folderState(cwd);
|
|
778
|
+
} catch (err) {
|
|
779
|
+
say(`this folder cannot go to the cloud (${String(err?.message || err).split('\n')[0]}), so the thread runs on this Mac`);
|
|
780
|
+
return KEEP_ON_MAC;
|
|
781
|
+
}
|
|
782
|
+
({ branch, head: startSha } = sent);
|
|
783
|
+
if (!branch || branch === 'HEAD') { say('the task folder is not on a branch'); return 1; }
|
|
784
|
+
hasOutgoing = await snapshotUncommitted(cwd, outgoing);
|
|
785
|
+
refs.push(`refs/heads/${branch}`);
|
|
786
|
+
if (hasOutgoing) refs.push(outgoing);
|
|
787
|
+
for (const r of ['refs/heads/main', 'refs/remotes/origin/main']) if (r !== `refs/heads/${branch}` && await gitOk(cwd, ['rev-parse', '--verify', '-q', r])) refs.push(r);
|
|
788
|
+
}
|
|
789
|
+
const bundleFile = path.join(os.tmpdir(), `agentbox-cloud-${runId}.bundle`);
|
|
790
|
+
// Against a base the bucket already has, when the image can take one:
|
|
791
|
+
// a reply then sends the commits past it, not the project's whole history.
|
|
792
|
+
const thin = imageCaps.includes('base') && !!storage.exists;
|
|
793
|
+
const packed = (async () => {
|
|
794
|
+
if (plain) return { bundle: false, base: null, refs: null };
|
|
795
|
+
try {
|
|
796
|
+
if (!thin) {
|
|
797
|
+
await git(cwd, ['bundle', 'create', '-q', bundleFile, ...refs]);
|
|
798
|
+
await sendFromFile(storage, key('in.bundle'), bundleFile);
|
|
799
|
+
return { bundle: true, base: null, refs: null };
|
|
800
|
+
}
|
|
801
|
+
const base = await pickBase(cwd, storage, { runId });
|
|
802
|
+
const at = await refsAt(cwd, refs);
|
|
803
|
+
const bundle = !!(await bundlePast(cwd, refs, base.sha, bundleFile));
|
|
804
|
+
if (bundle) await sendFromFile(storage, key('in.bundle'), bundleFile);
|
|
805
|
+
return { bundle, base, refs: at };
|
|
806
|
+
} finally {
|
|
807
|
+
fs.rmSync(bundleFile, { force: true });
|
|
808
|
+
if (hasOutgoing) await gitOk(cwd, ['update-ref', '-d', outgoing]);
|
|
809
|
+
}
|
|
810
|
+
})();
|
|
811
|
+
|
|
812
|
+
const sending = [];
|
|
813
|
+
let fileCount = 0;
|
|
814
|
+
sendFile = async (localPath, remotePath = localPath, body = null, untar = null) => {
|
|
815
|
+
if (!body && conversations.has(localPath)) {
|
|
816
|
+
const { key: k } = await sendConversation(storage, localPath, { record: sentRecordPath(here) });
|
|
817
|
+
return { path: remotePath, url: await storage.getUrl(k), key: k };
|
|
818
|
+
}
|
|
819
|
+
fileCount += 1;
|
|
820
|
+
const k = key(`files/${fileCount}`);
|
|
821
|
+
await storage.put(k, body ?? fs.readFileSync(localPath));
|
|
822
|
+
return { path: remotePath, url: await storage.getUrl(k), key: k, ...(untar ? { untar } : {}) };
|
|
823
|
+
};
|
|
824
|
+
// A folder unchanged since an earlier run is not sent again (./reuse.mjs).
|
|
825
|
+
const sendFolder = async (dir, remotePath, untar) => {
|
|
826
|
+
if (!storage.exists) return sendFile(null, remotePath, await tarOf(dir), untar);
|
|
827
|
+
const { key: k } = await sendOnce(storage, cacheKey(`folder-${await folderKey(dir)}.tgz`), () => tarOf(dir));
|
|
828
|
+
return { path: remotePath, url: await storage.getUrl(k), key: k, untar };
|
|
829
|
+
};
|
|
830
|
+
const settingsPath = argValue(argv, '--settings');
|
|
831
|
+
if (settingsPath && fs.existsSync(settingsPath)) sending.push(sendFile(settingsPath, settingsPath, Buffer.from(settingsForTheCloud(fs.readFileSync(settingsPath, 'utf8')))));
|
|
832
|
+
const mcpPath = argValue(argv, '--mcp-config');
|
|
833
|
+
if (mcpPath && fs.existsSync(mcpPath)) {
|
|
834
|
+
mcpConfig = JSON.parse(fs.readFileSync(mcpPath, 'utf8'));
|
|
835
|
+
sending.push(sendFile(mcpPath, mcpPath, Buffer.from(JSON.stringify(mcpConfigForTheCloud(mcpConfig, stubPath)))));
|
|
836
|
+
}
|
|
837
|
+
const resumeId = argValue(argv, '--resume');
|
|
838
|
+
if (resumeId) {
|
|
839
|
+
const t = path.join(projectsDir, `${resumeId}.jsonl`);
|
|
840
|
+
if (!fs.existsSync(t)) say(`no transcript for ${resumeId} on this Mac; the cloud run starts without it`);
|
|
841
|
+
else { conversations.add(t); sending.push(sendFile(t)); }
|
|
842
|
+
}
|
|
843
|
+
// Her saved memories for this project, where Claude Code in the cloud looks.
|
|
844
|
+
const saved = codex ? null : plain ? folderWithFiles(path.join(projectsDir, 'memory')) : memoryFolderFor(cwd, configDir);
|
|
845
|
+
if (saved) sending.push(sendFolder(saved, path.join(projectsDir, '.memory.tgz'), path.join(projectsDir, 'memory')));
|
|
846
|
+
// The account's own skills, commands and agents, as real folders.
|
|
847
|
+
if (!codex) {
|
|
848
|
+
const tooling = path.join(homeDir, '.claude');
|
|
849
|
+
const own = (name) => (fs.existsSync(path.join(configDir, name)) ? path.join(configDir, name) : path.join(tooling, name));
|
|
850
|
+
for (const sub of ['skills', 'commands', 'agents']) {
|
|
851
|
+
const dir = own(sub);
|
|
852
|
+
if (fs.existsSync(dir)) sending.push(sendFolder(dir, path.join(configDir, `.${sub}.tgz`), path.join(configDir, sub)));
|
|
853
|
+
}
|
|
854
|
+
// Her own hooks that can run there (`hooksForTheCloud`), with the folder
|
|
855
|
+
// their scripts are in at its own path, so a guard she set on this Mac
|
|
856
|
+
// guards the cloud run too. The sandbox's config folder has no settings of
|
|
857
|
+
// its own, so these are all of them.
|
|
858
|
+
const hooksDir = own('hooks');
|
|
859
|
+
let mine = null;
|
|
860
|
+
try { mine = hooksForTheCloud(fs.readFileSync(own('settings.json'), 'utf8'), { hooksDir, homeDir }); } catch { /* no settings */ }
|
|
861
|
+
if (mine && fs.existsSync(hooksDir)) {
|
|
862
|
+
sending.push(sendFolder(hooksDir, path.join(path.dirname(hooksDir), '.hooks.tgz'), hooksDir));
|
|
863
|
+
sending.push(sendFile(null, path.join(configDir, 'settings.json'), Buffer.from(JSON.stringify({ hooks: mine }, null, 2))));
|
|
864
|
+
}
|
|
865
|
+
// Her own instructions to Claude, from the home folder whichever account
|
|
866
|
+
// it runs on, laid in the config folder, which is where Claude Code reads
|
|
867
|
+
// them: in the home folder there they were reported gone (2026-10-23).
|
|
868
|
+
const memory = path.join(tooling, 'CLAUDE.md');
|
|
869
|
+
if (fs.existsSync(memory) && fs.statSync(memory).size <= MAX_ATTACHMENT) sending.push(sendFile(memory, path.join(configDir, 'CLAUDE.md')));
|
|
870
|
+
}
|
|
871
|
+
// The few Codex settings that change how it runs a turn (model, effort,
|
|
872
|
+
// tier), so a run in the cloud behaves as one here would. Nothing else of
|
|
873
|
+
// her config goes: no MCP servers, hooks, providers or sockets, and never
|
|
874
|
+
// her sign-in: the cloud has its own.
|
|
875
|
+
if (codex) {
|
|
876
|
+
let toml = '';
|
|
877
|
+
try { toml = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); } catch { /* none */ }
|
|
878
|
+
sending.push(sendFile(null, path.join(codexHome, 'config.toml'), Buffer.from(codexConfigForTheCloud(toml))));
|
|
879
|
+
}
|
|
880
|
+
// The project's documents folder (main/cloud/docs.mjs), at its own path.
|
|
881
|
+
let docsSent = [];
|
|
882
|
+
if (docsDir) {
|
|
883
|
+
docsSent = docsToSend(docsDir, { item: env.ZERO_ITEM });
|
|
884
|
+
if (docsSent.length) sending.push(tarDocs(docsDir, docsSent).then((tgz) => sendFile(null, path.join(docsDir, '.agentbox-docs.tgz'), tgz, docsDir)));
|
|
885
|
+
}
|
|
886
|
+
const system = argValue(argv, '--append-system-prompt') || '';
|
|
887
|
+
for (const f of filesNamedIn(system, { cwd: plain ? null : cwd, roots })) sending.push(sendFile(f));
|
|
888
|
+
const codexOpen = kit?.codex && codex ? codexKitOpen(kit.codex, { stubPath, codexHome, keep: mcpConfig.mcpServers, note: plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE }) : null;
|
|
889
|
+
if (codexOpen) {
|
|
890
|
+
for (const c of codexOpen.conversations) conversations.add(c);
|
|
891
|
+
for (const f of codexOpen.files) sending.push(sendFile(f));
|
|
892
|
+
}
|
|
893
|
+
let pack; let files;
|
|
894
|
+
try {
|
|
895
|
+
[pack, files] = await Promise.all([packed, Promise.all(sending)]);
|
|
896
|
+
} catch (err) {
|
|
897
|
+
// The cloud server refuses a link that writes for the same reasons it
|
|
898
|
+
// refuses a start, and before anything has gone up.
|
|
899
|
+
const noRoom = refusedForRoom({ ok: false, error: err?.error, message: err?.said });
|
|
900
|
+
if (!noRoom) throw err;
|
|
901
|
+
say(noRoom.line);
|
|
902
|
+
return noRoom.code;
|
|
903
|
+
}
|
|
904
|
+
const packedAt = Date.now();
|
|
905
|
+
sentFiles = new Set(files.map((f) => f.path));
|
|
906
|
+
|
|
907
|
+
const results = kit ? null : await resultUrls(runId);
|
|
908
|
+
let remote = null;
|
|
909
|
+
if (!plain) try { remote = remoteForTheCloud((await git(cwd, ['remote', 'get-url', 'origin'])).trim()); } catch { /* no origin */ }
|
|
910
|
+
|
|
911
|
+
const remoteEnv = {
|
|
912
|
+
...(codex ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: configDir }),
|
|
913
|
+
MCP_TOOL_TIMEOUT: String(8 * 60 * 60 * 1000),
|
|
914
|
+
DISABLE_AUTOUPDATER: '1',
|
|
915
|
+
...(env.ZERO_PRODUCT ? { ZERO_PRODUCT: env.ZERO_PRODUCT } : {}),
|
|
916
|
+
...(env.ZERO_ITEM ? { ZERO_ITEM: env.ZERO_ITEM } : {}),
|
|
917
|
+
...(await gitIdentity(cwd)),
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// What this run has laid into the documents folder so far, kept with the
|
|
921
|
+
// marker so a run brought home after a restart knows its own files.
|
|
922
|
+
// `sent` is what it took, so a file it did not take is never written over.
|
|
923
|
+
docs = docsDir ? { dir: docsDir, sentAt, sent: docsSent, laid: {}, ack: null } : null;
|
|
924
|
+
// And what a process joining it after a restart needs to serve its tools:
|
|
925
|
+
// the servers, and the worker's own variables they ran with.
|
|
926
|
+
marker = { runId, sessionId, branch, startSha, sent, configDir, engine, bringDirs, docs, mcp: mcpConfig.mcpServers, toolEnv: toolVariables(env), ...(plain ? { plain: true } : {}) };
|
|
927
|
+
if (kit) {
|
|
928
|
+
signals.off('SIGHUP', early);
|
|
929
|
+
const { runId: _r, sessionId: _s, ...joins } = marker;
|
|
930
|
+
const named = kit.newThread ? newThreadNames(kit.newThread, { cwd, startSha, files, joins, bringDirs }) : null;
|
|
931
|
+
return writeKit({
|
|
932
|
+
storage, kit, say, files: named?.files ?? files, home: [], joins: named?.joins ?? joins,
|
|
933
|
+
made: {
|
|
934
|
+
cwd, engine, ...(plain ? { plain: true } : { branch }), startSha,
|
|
935
|
+
bundleKey: pack.bundle ? key('in.bundle') : null,
|
|
936
|
+
...(pack.base ? { base: { key: pack.base.key, sha: pack.base.sha }, refs: pack.refs } : {}),
|
|
937
|
+
argv: [remoteCommand, ...(remoteCommand === 'claude' ? argvForTheCloud(argv, { plain }) : argv)], env: remoteEnv,
|
|
938
|
+
...(hasOutgoing ? { changesRef: outgoing } : {}),
|
|
939
|
+
...(remote ? { remote } : {}),
|
|
940
|
+
...(docsDir ? { docs: docsDir } : {}),
|
|
941
|
+
login: { engine: login.engine, id: login.id, key: login.key },
|
|
942
|
+
bringDirs, kept: keptWhileAway,
|
|
943
|
+
// A conversation begun on the Mac is told it is in the cloud at the top of its message.
|
|
944
|
+
note: untold ? (plain ? PLAIN_CLOUD_NOTE : CLOUD_NOTE) : null,
|
|
945
|
+
// A run that asks permission has its cards held for her phone while the computer is shut.
|
|
946
|
+
...(!codex && asksPermission(argv) ? { hold: [APPROVALS_SERVER], handshakes: { [APPROVALS_SERVER]: await approvalsHandshakes() } } : {}),
|
|
947
|
+
...(codexOpen ? { codex: { open: codexOpen.lines, [kit.codex.start ? 'startId' : 'resumeId']: CODEX_RESUME_ID, firstTurnId: CODEX_RESUME_ID + 1, turn: kit.codex.turn } } : {}),
|
|
948
|
+
...(named ? named.made : {}),
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
writeMarker(here,{ ...marker, pid: selfPid, state: 'starting' });
|
|
953
|
+
|
|
954
|
+
// 3. Start. Starting the same run twice is answered "already", so a start
|
|
955
|
+
// whose reply was lost is simply asked again. A start that was REFUSED
|
|
956
|
+
// started nothing; one whose answer never came may have, so its marker
|
|
957
|
+
// stays and the next run in this folder finds out which.
|
|
958
|
+
const bundleUrl = pack.bundle ? await storage.getUrl(key('in.bundle')) : undefined;
|
|
959
|
+
const base = pack.base ? { url: await storage.getUrl(pack.base.key), sha: pack.base.sha } : null;
|
|
960
|
+
const loginUrl = await storage.getUrl(login.key, LOGIN_LINK_SECONDS);
|
|
961
|
+
const carried = await Promise.all(also.map(async (l) => ({ engine: l.engine, id: l.id, url: await storage.getUrl(l.key, LOGIN_LINK_SECONDS) })));
|
|
962
|
+
const started = await retry(() => transport.invoke(sessionId, {
|
|
963
|
+
action: 'start', runId, cwd, branch, bundleUrl,
|
|
964
|
+
...(plain ? { plain: true } : {}),
|
|
965
|
+
...(base ? { base, refs: pack.refs } : {}),
|
|
966
|
+
argv: [remoteCommand, ...(remoteCommand === 'claude' ? argvForTheCloud(argv, { plain }) : argv)], env: remoteEnv, files: withoutKeys(files),
|
|
967
|
+
changesRef: hasOutgoing ? outgoing : undefined,
|
|
968
|
+
...(remote ? { remote } : {}),
|
|
969
|
+
...(docsDir ? { docs: docsDir } : {}),
|
|
970
|
+
login: { engine: login.engine, id: login.id, url: loginUrl },
|
|
971
|
+
...(carried.length ? { also: carried } : {}),
|
|
972
|
+
results,
|
|
973
|
+
// A conversation comes home as only what the run added (`layTails`). An
|
|
974
|
+
// image that does not know this ignores it and sends it whole.
|
|
975
|
+
tails: true,
|
|
976
|
+
// The run carries on while this Mac sleeps: a tool call is told it could
|
|
977
|
+
// not reach the app, and a Claude Code turn that has ended has its input
|
|
978
|
+
// closed there. A Codex turn ends only with the Mac, as before.
|
|
979
|
+
offline: { afterMs: offlineAfterMs, endTurn: codex ? null : 'claude-stream-json', kept: keptWhileAway },
|
|
980
|
+
}), { what: 'start the cloud run', forever: false });
|
|
981
|
+
if (!started) {
|
|
982
|
+
signals.off('SIGHUP', early);
|
|
983
|
+
if (quitEarly) {
|
|
984
|
+
const unsent = outbox.filter((m) => m.action === 'stdin');
|
|
985
|
+
writeMarker(here,{ ...marker, pid: null, state: 'detached', cursor: 0, conns: {}, unsent: stdinClosed ? unsent : [...unsent, { action: 'stdin', end: true }] });
|
|
986
|
+
say('the app quit while the cloud run was starting; it is joined when the app next opens');
|
|
987
|
+
return IMPORT_FAILED;
|
|
988
|
+
}
|
|
989
|
+
writeMarker(here,{ ...marker, pid: null, state: 'start-unknown' });
|
|
990
|
+
say('the cloud did not answer the start; whether it began will be checked before the next run here');
|
|
991
|
+
return IMPORT_FAILED;
|
|
992
|
+
}
|
|
993
|
+
if (!started.ok) {
|
|
994
|
+
signals.off('SIGHUP', early);
|
|
995
|
+
// An image that refused a start sent against a base, or with no
|
|
996
|
+
// repository, may not take one.
|
|
997
|
+
// Such a start is tried again without them, so the reply is not spent.
|
|
998
|
+
const outgrown = (base || plain) && started.error === 'bad-start';
|
|
999
|
+
if (outgrown) { try { onCaps(started.caps || []); } catch { /* a hint */ } }
|
|
1000
|
+
const noRoom = refusedForRoom(started);
|
|
1001
|
+
say(noRoom ? noRoom.line : `the cloud run could not start: ${started.error || 'no answer'} ${started.message || ''}`.trim());
|
|
1002
|
+
clearMarker(here);
|
|
1003
|
+
if (noRoom) return noRoom.code;
|
|
1004
|
+
return outgrown ? IMPORT_FAILED : 1;
|
|
1005
|
+
}
|
|
1006
|
+
stderr.write(sentLine({ packMs: packedAt - begunAt, startMs: Date.now() - packedAt }));
|
|
1007
|
+
try { onCaps(started.caps || []); } catch { /* only a hint for the next run */ }
|
|
1008
|
+
offlineOn = (started.caps || []).includes('offline');
|
|
1009
|
+
if (!offlineOn) say('this cloud image stops at its next tool call while this Mac sleeps; rebuild it (node scripts/cloud-setup.mjs) to keep runs going');
|
|
1010
|
+
marker.offline = offlineOn;
|
|
1011
|
+
writeMarker(here,{ ...marker, pid: selfPid, state: 'running' });
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// 4. The pump.
|
|
1015
|
+
let cursor = startCursor;
|
|
1016
|
+
let n = startN;
|
|
1017
|
+
let leaving = false;
|
|
1018
|
+
let cancelled = false;
|
|
1019
|
+
let cancelledAt = 0;
|
|
1020
|
+
let finished = null; // 'collected' | 'collect-failed' | 'failed' | 'lost'
|
|
1021
|
+
let loginTrouble = null; // { state: 'missing'|'refused'|'expired', id }
|
|
1022
|
+
const servers = new Map(); // conn -> { proc, name, init, pending, swallow }
|
|
1023
|
+
let awayNoted = false;
|
|
1024
|
+
// Calls in this page the sandbox said not to make, by event number.
|
|
1025
|
+
const refused = new Set();
|
|
1026
|
+
async function claimCalls(events) {
|
|
1027
|
+
const asks = [];
|
|
1028
|
+
for (const e of events) {
|
|
1029
|
+
if (e.kind !== 'mcp' || e.seq <= cursor) continue;
|
|
1030
|
+
try { const msg = JSON.parse(e.line); if (msg?.method !== undefined && msg.id !== undefined) asks.push({ seq: e.seq, conn: e.conn, id: msg.id }); } catch { /* not a call */ }
|
|
1031
|
+
}
|
|
1032
|
+
if (!asks.length) return;
|
|
1033
|
+
const res = await retry(() => transport.invoke(sessionId, { action: 'mcp-claim', runId, calls: asks.map(({ conn, id }) => ({ conn, id })) }), { what: 'ask about its tool calls', forever: true, stop: () => overdue() || leaving });
|
|
1034
|
+
const said = new Map((res?.calls || []).map((c) => [`${c.conn}\u0000${JSON.stringify(c.id)}`, c.go]));
|
|
1035
|
+
for (const a of asks) if (said.get(`${a.conn}\u0000${JSON.stringify(a.id)}`) === false) refused.add(a.seq);
|
|
1036
|
+
}
|
|
1037
|
+
const outDecoder = new StringDecoder('utf8');
|
|
1038
|
+
let outLine = '';
|
|
1039
|
+
const overdue = () => cancelled && Date.now() - cancelledAt > resultsWaitMs;
|
|
1040
|
+
|
|
1041
|
+
// ONE INPUT AT A TIME, KEPT UNTIL ACKNOWLEDGED. Each gets its number once,
|
|
1042
|
+
// and is sent again with that same number until the sandbox has applied it
|
|
1043
|
+
// (or says it was consumed with nothing to apply it to). A gap means the two
|
|
1044
|
+
// sides disagree about what was sent, which nothing can repair: the run is
|
|
1045
|
+
// stopped rather than left talking past itself.
|
|
1046
|
+
const sender = (async () => {
|
|
1047
|
+
for (;;) {
|
|
1048
|
+
if (!outbox.length) {
|
|
1049
|
+
if (finished || leaving) return;
|
|
1050
|
+
await new Promise((r) => { wake = r; setTimeout(r, 1000); });
|
|
1051
|
+
wake = null;
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
const msg = outbox[0];
|
|
1055
|
+
if (msg.action === 'files' && !msg.files) {
|
|
1056
|
+
// An attachment that cannot be uploaded is left out and said so; the
|
|
1057
|
+
// prompt behind it still goes, rather than everything stopping here.
|
|
1058
|
+
const list = [];
|
|
1059
|
+
for (const f of msg.paths) {
|
|
1060
|
+
if (sentFiles.has(f)) continue;
|
|
1061
|
+
const sentOne = await retry(() => sendFile(f), { what: `send ${path.basename(f)}`, forever: false, stop: () => !!finished });
|
|
1062
|
+
if (sentOne) { list.push(sentOne); sentFiles.add(f); } else say(`${f} could not be sent to the cloud run; it goes on without it`);
|
|
1063
|
+
}
|
|
1064
|
+
if (!list.length) { outbox.shift(); continue; }
|
|
1065
|
+
msg.files = withoutKeys(list);
|
|
1066
|
+
}
|
|
1067
|
+
if (msg.n === undefined) { n += 1; msg.n = n; }
|
|
1068
|
+
const { paths: _p, onAck: _a, ...payload } = msg;
|
|
1069
|
+
let failures = 0;
|
|
1070
|
+
for (;;) {
|
|
1071
|
+
const res = await retry(() => transport.invoke(sessionId, { ...payload, runId }), { what: msg.action, forever: true, stop: () => !!finished || overdue() });
|
|
1072
|
+
if (!res) return;
|
|
1073
|
+
if (res.ok || res.dup || res.consumed) { msg.onAck?.(); break; }
|
|
1074
|
+
if (res.error === 'no-run') return;
|
|
1075
|
+
if (res.error === 'gap') { say(`the sandbox and this Mac disagree about what was sent (it expected ${res.expect}, this was ${msg.n}); stopping the run`); outbox.length = 0; cancel('input out of step'); return; }
|
|
1076
|
+
failures += 1;
|
|
1077
|
+
if (failures >= 5) { say(`could not ${msg.action} after 5 tries (${res.error} ${res.message || ''}); stopping the run`); outbox.length = 0; cancel('input refused'); return; }
|
|
1078
|
+
await sleep(1000 * failures);
|
|
1079
|
+
}
|
|
1080
|
+
outbox.shift();
|
|
1081
|
+
}
|
|
1082
|
+
})().catch((err) => say(`the input to the cloud run stopped: ${err.message}`));
|
|
1083
|
+
|
|
1084
|
+
// THE DOCUMENTS, WHILE IT WORKS. A picture the agent draws reaches the Mac
|
|
1085
|
+
// within seconds rather than when the run ends. Each archive holds every
|
|
1086
|
+
// file the Mac has not yet said it laid (`ack`), so an archive lost on the
|
|
1087
|
+
// way is simply in the next one, and the last one at the end.
|
|
1088
|
+
let docsCount = 0;
|
|
1089
|
+
const docsLoop = docs ? (async () => {
|
|
1090
|
+
for (;;) {
|
|
1091
|
+
const until = Date.now() + docsEveryMs;
|
|
1092
|
+
while (Date.now() < until && !finished && !cancelled && !leaving) await sleep(Math.min(500, docsEveryMs));
|
|
1093
|
+
if (finished || cancelled || leaving) return;
|
|
1094
|
+
docsCount += 1;
|
|
1095
|
+
// Named by this process as well, so a process joining the run later
|
|
1096
|
+
// never writes over an archive an earlier one has yet to fetch.
|
|
1097
|
+
const k = key(`docs/${process.pid}-${Date.now()}-${docsCount}.tgz`);
|
|
1098
|
+
const res = await retry(async () => transport.invoke(sessionId, { action: 'docs', runId, id: k, ack: docs.ack, url: await storage.putUrl(k) }), { what: 'read the run\'s documents', forever: false, stop: () => !!finished || leaving });
|
|
1099
|
+
if (!res?.ok || !res.files?.length) continue;
|
|
1100
|
+
const tgz = await storage.get(k).catch(() => null);
|
|
1101
|
+
if (!tgz) continue;
|
|
1102
|
+
// Confirmed only once laid: a write that failed here goes again.
|
|
1103
|
+
if (!(await layArchive(docs, tgz))) continue;
|
|
1104
|
+
docs.ack = k;
|
|
1105
|
+
saveMarker();
|
|
1106
|
+
}
|
|
1107
|
+
})().catch((err) => say(`the run's documents stopped coming back early: ${err.message}`)) : null;
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
// `init` is the handshake an earlier process had with this connection's
|
|
1111
|
+
// server, said again to a fresh one so a joined run's tools answer as before;
|
|
1112
|
+
// its replies are not passed on, the agent had them long ago.
|
|
1113
|
+
function openServer(conn, name, init = []) {
|
|
1114
|
+
const cfg = mcpConfig.mcpServers?.[name];
|
|
1115
|
+
if (!cfg) { enqueue({ action: 'mcp-close', conn }); return; }
|
|
1116
|
+
const proc = servedHere(cfg, toolEnv({ ...serverEnv, ...(cfg.env || {}) }), `${runId}-${conn}`)
|
|
1117
|
+
?? spawn(cfg.command, cfg.args || [], { cwd, env: toolEnv({ ...serverEnv, ...(cfg.env || {}) }), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1118
|
+
const s = { proc, name, buf: '', closed: false, init: [...init], pending: new Set(), swallow: new Set() };
|
|
1119
|
+
servers.set(conn, s);
|
|
1120
|
+
for (const line of init) {
|
|
1121
|
+
try { const m = JSON.parse(line); if (m?.id !== undefined && m.method) s.swallow.add(m.id); } catch { /* sent as it is */ }
|
|
1122
|
+
proc.stdin.write(line + '\n');
|
|
1123
|
+
}
|
|
1124
|
+
const closeOnce = () => { if (s.closed) return; s.closed = true; if (servers.get(conn) === s) servers.delete(conn); enqueue({ action: 'mcp-close', conn }); };
|
|
1125
|
+
proc.stdout.setEncoding('utf8');
|
|
1126
|
+
proc.stdout.on('data', (d) => {
|
|
1127
|
+
s.buf += d;
|
|
1128
|
+
let i;
|
|
1129
|
+
while ((i = s.buf.indexOf('\n')) >= 0) {
|
|
1130
|
+
const line = s.buf.slice(0, i);
|
|
1131
|
+
s.buf = s.buf.slice(i + 1);
|
|
1132
|
+
let reply = null;
|
|
1133
|
+
try { reply = JSON.parse(line); } catch { /* passed on as it is */ }
|
|
1134
|
+
// A call stays owed until the sandbox has its answer, so a restart
|
|
1135
|
+
// before then answers it rather than forgetting it.
|
|
1136
|
+
let onAck;
|
|
1137
|
+
if (reply && reply.id !== undefined && !reply.method) {
|
|
1138
|
+
if (s.swallow.delete(reply.id)) {
|
|
1139
|
+
const kept = keptOut.get(`${conn}\0${reply.id}`);
|
|
1140
|
+
if (kept) { kept(); if (reply.error || reply.result?.isError) say(`a write the run made while this Mac was asleep was turned down: ${reply.error?.message ?? reply.result?.content?.map((c) => c.text).join(' ') ?? 'no reason given'}`); }
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
onAck = () => s.pending.delete(reply.id);
|
|
1144
|
+
}
|
|
1145
|
+
enqueue({ action: 'mcp', conn, line, onAck });
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
proc.stderr.on('data', (d) => stderr.write(d));
|
|
1149
|
+
proc.stdin.on('error', () => {});
|
|
1150
|
+
// `close`, after its stdout has ended, so the server's last reply is
|
|
1151
|
+
// queued before the stub is told it has gone.
|
|
1152
|
+
proc.on('close', closeOnce);
|
|
1153
|
+
proc.on('error', (err) => { say(`the ${name} server could not run here: ${err.message}`); closeOnce(); });
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// A write the run made while this Mac was away, kept in the cloud: made now
|
|
1157
|
+
// through the same server, its answer dropped (the agent had one).
|
|
1158
|
+
const keptOut = new Map();
|
|
1159
|
+
let keptMade = 0;
|
|
1160
|
+
function makeKept(e) {
|
|
1161
|
+
let msg = null;
|
|
1162
|
+
try { msg = JSON.parse(e.line); } catch { return; }
|
|
1163
|
+
if (!msg || msg.method !== 'tools/call') return;
|
|
1164
|
+
if (!servers.get(e.conn) || servers.get(e.conn).closed) openServer(e.conn, e.server, HANDSHAKE_LINES);
|
|
1165
|
+
const s = servers.get(e.conn);
|
|
1166
|
+
if (!s) return;
|
|
1167
|
+
const id = `kept-${e.seq}`;
|
|
1168
|
+
s.swallow.add(id);
|
|
1169
|
+
keptOut.set(`${e.conn}\0${id}`, () => keptOut.delete(`${e.conn}\0${id}`));
|
|
1170
|
+
s.proc.stdin.write(`${JSON.stringify({ ...msg, id })}\n`);
|
|
1171
|
+
keptMade += 1;
|
|
1172
|
+
}
|
|
1173
|
+
async function keptSettled(ms) {
|
|
1174
|
+
for (const end = Date.now() + ms; keptOut.size && Date.now() < end;) await sleep(50);
|
|
1175
|
+
if (keptMade) say(`${keptMade} ${keptMade === 1 ? 'thing' : 'things'} the run wrote while this Mac was asleep ${keptMade === 1 ? 'was' : 'were'} made now`);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function toServer(conn, line) {
|
|
1179
|
+
const s = servers.get(conn);
|
|
1180
|
+
if (!s) return;
|
|
1181
|
+
let msg = null;
|
|
1182
|
+
try { msg = JSON.parse(line); } catch { /* pass through as is */ }
|
|
1183
|
+
if ((msg?.method === 'initialize' || msg?.method === 'notifications/initialized') && s.init.length < 2) s.init.push(line);
|
|
1184
|
+
if (msg?.method && msg.id !== undefined) s.pending.add(msg.id);
|
|
1185
|
+
if (msg?.method === 'tools/call' && WITHHELD_TOOLS.has(msg.params?.name)) {
|
|
1186
|
+
enqueue({ action: 'mcp', conn, line: JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { isError: true, content: [{ type: 'text', text: `${msg.params.name} is not available to a cloud run: it would look at the Mac's own pages, not the sandbox's.` }] } }) });
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
s.proc.stdin.write(line + '\n');
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function noteLimit(buf) {
|
|
1193
|
+
outLine += outDecoder.write(buf);
|
|
1194
|
+
let i;
|
|
1195
|
+
while ((i = outLine.indexOf('\n')) >= 0) {
|
|
1196
|
+
const line = outLine.slice(0, i);
|
|
1197
|
+
outLine = outLine.slice(i + 1);
|
|
1198
|
+
if (!line.includes('"result"')) continue;
|
|
1199
|
+
const until = limitInResultLine(line);
|
|
1200
|
+
if (until) { try { onLimit({ until }); } catch { /* noted elsewhere */ } }
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function write(stream, buf) {
|
|
1205
|
+
if (!stream.write(buf)) await new Promise((r) => stream.once('drain', r));
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
async function absorb(e) {
|
|
1209
|
+
if (e.seq <= cursor) return;
|
|
1210
|
+
cursor = e.seq;
|
|
1211
|
+
// AGENTBOX_CLOUD_DEBUG=1 traces the tool traffic, names and sizes only.
|
|
1212
|
+
if (env.AGENTBOX_CLOUD_DEBUG && e.kind.startsWith('mcp')) say(`${e.kind} ${e.conn}${e.server ? ` ${e.server}` : ''}${e.line ? ` ${String(e.line).slice(0, 80)}` : ''}`);
|
|
1213
|
+
// A joined run's stream goes to the app too: it is how its thread hears
|
|
1214
|
+
// the steps and the answer made while the app was away
|
|
1215
|
+
// (main/supervisor.mjs `_followComingHome`).
|
|
1216
|
+
if (e.kind === 'stdout') {
|
|
1217
|
+
const buf = Buffer.from(e.data, 'base64');
|
|
1218
|
+
if (!codex) noteLimit(buf);
|
|
1219
|
+
await write(stdout, buf);
|
|
1220
|
+
}
|
|
1221
|
+
else if (e.kind === 'stderr') { if (!joining) await write(stderr, Buffer.from(e.data, 'base64')); }
|
|
1222
|
+
else if (e.kind === 'mcp-open') openServer(e.conn, e.server);
|
|
1223
|
+
else if (e.kind === 'mcp') { if (!refused.has(e.seq)) toServer(e.conn, e.line); refused.delete(e.seq); }
|
|
1224
|
+
else if (e.kind === 'mcp-kept') makeKept(e);
|
|
1225
|
+
else if (e.kind === 'mcp-offline' || e.kind === 'mcp-abandoned') {
|
|
1226
|
+
if (!awayNoted) { awayNoted = true; say('this Mac was asleep; the cloud run carried on, and the tool calls it made meanwhile did not reach the app'); }
|
|
1227
|
+
}
|
|
1228
|
+
// Its input closed in the cloud at the end of a turn while this Mac was
|
|
1229
|
+
// away: said to the app's own reader (main/claude-input.mjs) as the end
|
|
1230
|
+
// of the run it is.
|
|
1231
|
+
else if (e.kind === 'input-closed') { if (!joining) await write(stdout, Buffer.from(`${JSON.stringify({ type: 'system', subtype: INPUT_CLOSED_IN_CLOUD })}\n`)); }
|
|
1232
|
+
else if (e.kind === 'mcp-close') { const s = servers.get(e.conn); servers.delete(e.conn); if (s) { s.closed = true; s.proc.stdin.end(); setTimeout(() => s.proc.kill(), 2000); } }
|
|
1233
|
+
else if (e.kind === 'error') say(e.message);
|
|
1234
|
+
else if (e.kind === 'setup') noteSetup(e);
|
|
1235
|
+
else if (e.kind === 'login') loginTrouble = { state: e.state, id: e.id ?? login?.id ?? null };
|
|
1236
|
+
else if (e.kind === 'state' && ['collected', 'collect-failed', 'failed'].includes(e.state)) finished = e.state;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// THE PROJECT'S OWN SETUP (.agentbox/cloud-setup.sh), which the sandbox
|
|
1240
|
+
// runs before the agent and goes on past when it fails or overruns. Said
|
|
1241
|
+
// here, or a broken or slow setup is invisible from the thread.
|
|
1242
|
+
let setupAt = null;
|
|
1243
|
+
function noteSetup(e) {
|
|
1244
|
+
if (e.state === 'running') { setupAt = e.at ?? Date.now(); return; }
|
|
1245
|
+
const where = `its output is in ${SETUP_LOG} on the cloud machine`;
|
|
1246
|
+
if (e.state === 'failed') {
|
|
1247
|
+
const how = e.message || (e.signal === 'SIGKILL' ? `${SETUP_FILE} ran past its time limit and was stopped` : e.signal ? `${SETUP_FILE} was stopped by ${e.signal}` : `${SETUP_FILE} exited with ${e.code}`);
|
|
1248
|
+
say(`the project's cloud setup failed: ${how}; the agent went on without it, and ${where}`);
|
|
1249
|
+
} else if (setupAt && (e.at ?? Date.now()) - setupAt > SLOW_SETUP_MS) {
|
|
1250
|
+
say(`the project's cloud setup took ${Math.round(((e.at ?? Date.now()) - setupAt) / 60_000)} minutes before the agent could start; ${where}`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function cancel(why) {
|
|
1255
|
+
if (cancelled || finished) return;
|
|
1256
|
+
cancelled = true;
|
|
1257
|
+
cancelledAt = Date.now();
|
|
1258
|
+
say(`stopping the cloud run (${why})`);
|
|
1259
|
+
retry(() => transport.invoke(sessionId, { action: 'signal', runId, signal: 'SIGTERM' }), { what: 'stop the cloud run', forever: false }).catch(() => {});
|
|
1260
|
+
}
|
|
1261
|
+
// THE APP IS QUITTING, NOT STOPPING THE THREAD: the run in the cloud
|
|
1262
|
+
// carries on. It is told no more is coming, so it ends after the turn it is
|
|
1263
|
+
// in; a tool call left unanswered here is answered with why; and the marker
|
|
1264
|
+
// says where this process got to, for the one that joins it when the app
|
|
1265
|
+
// next opens. A Codex run is stopped as before: its app-server would end at
|
|
1266
|
+
// once, mid-turn, on the same signal.
|
|
1267
|
+
function leave(why) {
|
|
1268
|
+
if (leaving || finished || cancelled) return;
|
|
1269
|
+
if (codex) { cancel(why); return; }
|
|
1270
|
+
leaving = true;
|
|
1271
|
+
say(`leaving the cloud run going (${why}); it comes home when the app next opens`);
|
|
1272
|
+
for (const [conn, sv] of servers) answerUnanswered(conn, sv.pending);
|
|
1273
|
+
if (!stdinClosed) { stdinClosed = true; enqueue({ action: 'stdin', end: true }); }
|
|
1274
|
+
// Said at once, in case this process is cut off before it finishes: the
|
|
1275
|
+
// run is then still joined later rather than stopped. Its pid holds a
|
|
1276
|
+
// joiner back until its last inputs are in.
|
|
1277
|
+
saveMarker();
|
|
1278
|
+
}
|
|
1279
|
+
// A call the app can no longer answer is answered with why. It counts as
|
|
1280
|
+
// answered once the sandbox has the reply; until then a joiner says it.
|
|
1281
|
+
function answerUnanswered(conn, ids) {
|
|
1282
|
+
for (const id of [...ids]) {
|
|
1283
|
+
servers.get(conn)?.pending.add(id);
|
|
1284
|
+
enqueue({ action: 'mcp', conn, line: JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message: MAC_GONE } }), onAck: () => servers.get(conn)?.pending.delete(id) });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
function connsNow() {
|
|
1288
|
+
const conns = {};
|
|
1289
|
+
for (const [conn, sv] of servers) conns[conn] = { name: sv.name, init: sv.init, pending: [...sv.pending] };
|
|
1290
|
+
return conns;
|
|
1291
|
+
}
|
|
1292
|
+
// WHERE THIS PROCESS HAS GOT TO, for whichever process carries the run on:
|
|
1293
|
+
// its place in the events, the connections and the calls they still owe,
|
|
1294
|
+
// and anything for the agent's input not yet sent.
|
|
1295
|
+
function saveMarker(extra = {}) {
|
|
1296
|
+
const unsent = outbox.filter((m) => m.action === 'stdin').map(({ n: num, ...m }) => ({ ...m, ...(num ? { n: num } : {}) }));
|
|
1297
|
+
const state = leaving ? 'detached' : joining ? 'attached' : 'running';
|
|
1298
|
+
try {
|
|
1299
|
+
writeMarker(here,{ ...marker, pid: selfPid, state, cursor, conns: connsNow(), unsent, ...extra });
|
|
1300
|
+
} catch (err) { say(`could not note where the cloud run got to: ${err.message}`); }
|
|
1301
|
+
}
|
|
1302
|
+
// A joined run is left going by a SIGTERM, which is the Mac or the app
|
|
1303
|
+
// going. Its thread shows it running, so its Stop arrives as SIGINT.
|
|
1304
|
+
const onTerm = () => (joining ? leave('the app is going') : cancel('asked to stop'));
|
|
1305
|
+
const onInt = () => cancel('asked to stop');
|
|
1306
|
+
const onHup = () => leave('the app is quitting');
|
|
1307
|
+
signals.on('SIGTERM', onTerm);
|
|
1308
|
+
signals.on('SIGINT', onInt);
|
|
1309
|
+
signals.on('SIGHUP', onHup);
|
|
1310
|
+
signals.off('SIGHUP', early);
|
|
1311
|
+
const parentWatch = watchParent ? setInterval(() => { if (process.ppid === 1) leave('the app is gone'); }, 5000) : null;
|
|
1312
|
+
// A joined run's connections come back, the calls they still owed are
|
|
1313
|
+
// answered with why, and the agent's input the last process never sent goes.
|
|
1314
|
+
for (const [conn, c] of Object.entries(startConns)) { openServer(conn, c.name, c.init || []); answerUnanswered(conn, c.pending || []); }
|
|
1315
|
+
for (const m of joining?.unsent || []) { const { n: num, ...rest } = m; if (!(num && num <= startN)) enqueue(rest); }
|
|
1316
|
+
if (joining?.state === 'running') enqueue({ action: 'stdin', end: true });
|
|
1317
|
+
if (quitEarly) leave('the app quit while the run was starting');
|
|
1318
|
+
|
|
1319
|
+
try {
|
|
1320
|
+
while (!finished) {
|
|
1321
|
+
if (leaving) break;
|
|
1322
|
+
if (overdue()) { finished = 'lost'; break; }
|
|
1323
|
+
// Abandoned, not retried forever, once a cancel has waited long enough:
|
|
1324
|
+
// a Stop must end even when AWS cannot be reached.
|
|
1325
|
+
const page = await retry(() => transport.invoke(sessionId, { action: 'events', runId, since: cursor, waitMs: pollWaitMs }), { what: 'read the cloud run', forever: true, stop: () => overdue() || leaving });
|
|
1326
|
+
if (!page) continue;
|
|
1327
|
+
if (!page.ok && page.error === 'no-run') { finished = 'lost'; break; }
|
|
1328
|
+
// A call the sandbox may have given up on while this Mac was away is
|
|
1329
|
+
// made only once the sandbox says it has not: the agent was told it
|
|
1330
|
+
// did not reach the app (cloud/sandbox/server.mjs `mcp-claim`).
|
|
1331
|
+
if (offlineOn) await claimCalls(page.events || []);
|
|
1332
|
+
for (const e of page.events || []) { if (leaving) break; await absorb(e); }
|
|
1333
|
+
// Every run keeps its place as it goes: a process that died without
|
|
1334
|
+
// saying must not hand its tools the same calls twice.
|
|
1335
|
+
if (!leaving && page.events?.some((e) => e.kind.startsWith('mcp'))) saveMarker();
|
|
1336
|
+
}
|
|
1337
|
+
} finally {
|
|
1338
|
+
signals.off('SIGTERM', onTerm);
|
|
1339
|
+
signals.off('SIGINT', onInt);
|
|
1340
|
+
signals.off('SIGHUP', onHup);
|
|
1341
|
+
if (parentWatch) clearInterval(parentWatch);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (leaving && !finished) {
|
|
1345
|
+
// The sender stops by itself once nothing is left to send; `finished`
|
|
1346
|
+
// waits until then, or its retries would give up at once.
|
|
1347
|
+
wake?.();
|
|
1348
|
+
await Promise.race([sender, sleep(leaveFlushMs)]);
|
|
1349
|
+
finished = 'left';
|
|
1350
|
+
wake?.();
|
|
1351
|
+
await Promise.race([docsLoop, sleep(leaveFlushMs)]);
|
|
1352
|
+
saveMarker({ pid: null });
|
|
1353
|
+
for (const sv of servers.values()) { sv.closed = true; sv.proc.stdin.end(); sv.proc.kill(); }
|
|
1354
|
+
return IMPORT_FAILED;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// A collect that failed in the sandbox is asked once more, with fresh links.
|
|
1358
|
+
if (finished === 'collect-failed') {
|
|
1359
|
+
const again = await retry(async () => transport.invoke(sessionId, { action: 'collect', runId, ...(await resultUrls(runId)) }), { what: 'collect the results', forever: false });
|
|
1360
|
+
if (again?.ok) finished = 'collected';
|
|
1361
|
+
}
|
|
1362
|
+
wake?.();
|
|
1363
|
+
await sender;
|
|
1364
|
+
await docsLoop;
|
|
1365
|
+
await keptSettled(10_000);
|
|
1366
|
+
for (const s of servers.values()) { s.closed = true; s.proc.stdin.end(); s.proc.kill(); }
|
|
1367
|
+
|
|
1368
|
+
const troubleWithLogin = () => {
|
|
1369
|
+
if (!loginTrouble) return false;
|
|
1370
|
+
say(loginTrouble.state === 'expired' ? 'this account\'s cloud sign-in needs renewing' : 'this account\'s cloud sign-in no longer works; sign in again from the cloud menu');
|
|
1371
|
+
try { onLoginTrouble(loginTrouble); } catch { /* noted elsewhere */ }
|
|
1372
|
+
return true;
|
|
1373
|
+
};
|
|
1374
|
+
// A run that failed or came home needs its machine no more; the cloud
|
|
1375
|
+
// server counts a run until it is stopped (cloud/gateway/).
|
|
1376
|
+
const letGo = () => Promise.race([Promise.resolve(transport.stop?.(sessionId)).catch(() => {}), sleep(15_000)]);
|
|
1377
|
+
if (finished === 'failed') { clearMarker(here); await letGo(); return troubleWithLogin() ? LOGIN_TROUBLE : 1; }
|
|
1378
|
+
const back = await bringHome({ ...marker }, { cursor, replay: (e) => absorb(e) });
|
|
1379
|
+
if (back.ok) await letGo();
|
|
1380
|
+
if (!back.ok) {
|
|
1381
|
+
say(`the cloud run's work could not be brought back (${back.why}); it is kept in the cloud for 7 days and will be tried again`);
|
|
1382
|
+
writeMarker(here,{ ...marker, pid: null, state: back.state || 'import-failed', ...(back.laying ? { laying: back.laying } : {}) });
|
|
1383
|
+
// Its marker stays either way, so nothing ships the folder until its work
|
|
1384
|
+
// is home; a login that failed is still said, so no account here is
|
|
1385
|
+
// struck for it.
|
|
1386
|
+
return troubleWithLogin() ? LOGIN_TROUBLE : IMPORT_FAILED;
|
|
1387
|
+
}
|
|
1388
|
+
if (troubleWithLogin()) return LOGIN_TROUBLE;
|
|
1389
|
+
return exitCodeOf(back.record);
|
|
1390
|
+
|
|
1391
|
+
/* --------------------------- bringing it home -------------------------- */
|
|
1392
|
+
// Never throws: every failure is an answer, { ok: false, why }, and the
|
|
1393
|
+
// marker stays, because a run whose work is not here is not finished here.
|
|
1394
|
+
async function bringHome(rec, { recovering = false, cursor: from = 0, replay = null } = {}) {
|
|
1395
|
+
try {
|
|
1396
|
+
return await bringHomeOnce(rec, { recovering, from, replay });
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
return { ok: false, why: String(err?.message || err).split('\n')[0] };
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
async function bringHomeOnce(rec, { recovering, from, replay }) {
|
|
1403
|
+
const k = (name) => keyOf(rec.runId, name);
|
|
1404
|
+
if (rec.state === 'importing' && rec.pid && rec.pid !== selfPid && alive(rec.pid)) return { ok: false, why: 'its own process is still bringing it back' };
|
|
1405
|
+
let done = await storage.get(k('done.json'));
|
|
1406
|
+
// Marked by an older app, whose results may have gone from the bucket:
|
|
1407
|
+
// what it kept here goes on a branch of its own.
|
|
1408
|
+
if (!done && rec.state === 'diverged') return park(rec, { why: rec.why || 'it could not be laid into this folder' });
|
|
1409
|
+
if (!done && recovering) {
|
|
1410
|
+
if (rec.pid && rec.pid !== selfPid && alive(rec.pid)) return { ok: false, why: 'its own process is still bringing it back' };
|
|
1411
|
+
// Ask the sandbox where the run stands. No answer is not an answer: the
|
|
1412
|
+
// run may be alive, so nothing is given up.
|
|
1413
|
+
const st = await retry(() => transport.invoke(rec.sessionId, { action: 'status', runId: rec.runId }), { what: 'reach the earlier run', forever: false });
|
|
1414
|
+
if (!st) return { ok: false, why: 'the cloud could not be reached' };
|
|
1415
|
+
if (st.state === 'none' || st.error === 'no-run') {
|
|
1416
|
+
// The session holds no run: it ended, and AgentCore has just made a
|
|
1417
|
+
// fresh one for this id by being asked. Let that one go, and look for
|
|
1418
|
+
// the results one last time; without them the run is gone for good.
|
|
1419
|
+
await transport.stop?.(rec.sessionId).catch(() => {});
|
|
1420
|
+
done = await storage.get(k('done.json'));
|
|
1421
|
+
if (!done) {
|
|
1422
|
+
clearMarker(here);
|
|
1423
|
+
say('an earlier cloud run in this folder ended without its results; its work is lost');
|
|
1424
|
+
return { ok: true, exitCode: null, lost: true };
|
|
1425
|
+
}
|
|
1426
|
+
} else {
|
|
1427
|
+
if (st.state === 'preparing' || st.state === 'running') {
|
|
1428
|
+
await retry(() => transport.invoke(rec.sessionId, { action: 'signal', runId: rec.runId, signal: 'SIGTERM' }), { what: 'stop the earlier run', forever: false });
|
|
1429
|
+
}
|
|
1430
|
+
const until = Date.now() + resultsWaitMs;
|
|
1431
|
+
let asked = false;
|
|
1432
|
+
while (!done && Date.now() < until) {
|
|
1433
|
+
const now = await retry(() => transport.invoke(rec.sessionId, { action: 'status', runId: rec.runId }), { what: 'watch the earlier run', forever: false });
|
|
1434
|
+
if (!asked && (now?.state === 'exited' || now?.state === 'collect-failed')) {
|
|
1435
|
+
// Finished but never uploaded (or its links ran out): fresh links.
|
|
1436
|
+
asked = true;
|
|
1437
|
+
await retry(async () => transport.invoke(rec.sessionId, { action: 'collect', runId: rec.runId, ...(await resultUrls(rec.runId, dirsOf(rec))) }), { what: 'collect the earlier run', forever: false });
|
|
1438
|
+
}
|
|
1439
|
+
done = await storage.get(k('done.json'));
|
|
1440
|
+
if (!done) await sleep(3000);
|
|
1441
|
+
}
|
|
1442
|
+
if (!done) return { ok: false, why: 'its results have not arrived yet' };
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (!done) return { ok: false, why: 'its results never arrived' };
|
|
1446
|
+
const record = JSON.parse(done.toString('utf8'));
|
|
1447
|
+
// The heavy half, two runs at a time across every cloud run on this Mac,
|
|
1448
|
+
// and through files rather than memory: twenty runs finishing together
|
|
1449
|
+
// must not unpack twenty sets of bundles and transcripts at once.
|
|
1450
|
+
const release = await takeImportSlot();
|
|
1451
|
+
try {
|
|
1452
|
+
const spool = fs.mkdtempSync(path.join(os.tmpdir(), `${nameSlug}-cloud-home-`));
|
|
1453
|
+
try {
|
|
1454
|
+
return await layHome(rec, record, { recovering, replay, from, spool });
|
|
1455
|
+
} finally {
|
|
1456
|
+
try { fs.rmSync(spool, { recursive: true, force: true }); } catch { /* the tmp folder's own cleaning */ }
|
|
1457
|
+
}
|
|
1458
|
+
} finally {
|
|
1459
|
+
release();
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
async function layHome(rec, record, { recovering, replay, from, spool }) {
|
|
1464
|
+
const k = (name) => keyOf(rec.runId, name);
|
|
1465
|
+
// WHAT THE RUN USED, for setting the plan limits from real work
|
|
1466
|
+
// (main/cloud/usage.mjs). Recorded as soon as its results are here,
|
|
1467
|
+
// whatever laying them into the folder does next: the machine ran either
|
|
1468
|
+
// way. Once per run, however many times this is tried.
|
|
1469
|
+
appendCloudUsage({ runId: rec.runId, item: env.ZERO_ITEM, engine: rec.engine || engine, exitCode: record.exitCode, usage: record.usage }, { home: homeDir });
|
|
1470
|
+
|
|
1471
|
+
// Anything the stream never delivered (the session was gone before the
|
|
1472
|
+
// Mac could read it) is replayed from the copy in S3, and so is what the
|
|
1473
|
+
// sandbox said about the login, even for an earlier run brought home now
|
|
1474
|
+
// (Codex's review, finding 4).
|
|
1475
|
+
if (replay || recovering) {
|
|
1476
|
+
const logFile = path.join(spool, 'events.jsonl');
|
|
1477
|
+
const lines = (await fetchToFile(storage, k('events.jsonl'), logFile)) ? linesOf(logFile) : [];
|
|
1478
|
+
for await (const l of lines) {
|
|
1479
|
+
if (!l) continue;
|
|
1480
|
+
const e = JSON.parse(l);
|
|
1481
|
+
if (replay && e.seq > from && (e.kind === 'stdout' || e.kind === 'stderr' || e.kind === 'login' || e.kind === 'input-closed')) await replay(e);
|
|
1482
|
+
else if (recovering && e.kind === 'login') { try { onLoginTrouble({ state: e.state, id: e.id ?? null }); } catch { /* noted elsewhere */ } }
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// EVERYTHING IS FETCHED BEFORE ANYTHING IN THE FOLDER IS TOUCHED. An import
|
|
1487
|
+
// that moved the branch and then failed to download the rest would leave a
|
|
1488
|
+
// folder that no longer matches what the run left with, and its own retry
|
|
1489
|
+
// would read that as somebody else's work (Codex, 2026-10-13). So: the
|
|
1490
|
+
// commits into refs, the uncommitted work into a ref, the transcripts into
|
|
1491
|
+
// memory, and only then, with nothing left that can be missing, the folder.
|
|
1492
|
+
const keep = `refs/agentbox/cloud/${rec.runId}`;
|
|
1493
|
+
const keepChanges = `refs/agentbox/cloud-changes/${rec.runId}`;
|
|
1494
|
+
const fetchBundle = async (name, specs, why) => {
|
|
1495
|
+
const f = path.join(spool, name);
|
|
1496
|
+
if (!(await fetchToFile(storage, k(name), f))) throw new Error(why);
|
|
1497
|
+
try { await git(cwd, ['fetch', '-q', f, ...specs]); } finally { fs.rmSync(f, { force: true }); }
|
|
1498
|
+
};
|
|
1499
|
+
if (record.commits > 0 || record.strayHead) {
|
|
1500
|
+
const specs = [];
|
|
1501
|
+
if (record.commits > 0) specs.push(`refs/heads/${rec.branch}:${keep}`);
|
|
1502
|
+
if (record.strayHead) specs.push(`refs/agentbox/head:${keep}-head`);
|
|
1503
|
+
await fetchBundle('out.bundle', specs, 'the commits bundle is missing');
|
|
1504
|
+
}
|
|
1505
|
+
if (record.uncommitted) await fetchBundle('changes.bundle', [`refs/agentbox/changes:${keepChanges}`], 'the uncommitted work is missing');
|
|
1506
|
+
// The conversation's folders, each one the run said it uploaded.
|
|
1507
|
+
const conversations = [];
|
|
1508
|
+
for (const [i, b] of dirsOf(rec).entries()) {
|
|
1509
|
+
const file = path.join(spool, `conversation-${i}.tgz`);
|
|
1510
|
+
if (await fetchToFile(storage, k(b.name), file)) conversations.push({ dir: b.dir, file });
|
|
1511
|
+
else if ((record.brought || []).includes(b.dir)) return { ok: false, why: 'the transcript is missing' };
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// The documents go in whatever happens to the task folder below: they are
|
|
1515
|
+
// not in it.
|
|
1516
|
+
if (rec.docs?.dir && (record.docs || []).length) {
|
|
1517
|
+
const tgz = await storage.get(k('docs.tgz'));
|
|
1518
|
+
if (!tgz) return { ok: false, why: 'the documents it wrote are missing' };
|
|
1519
|
+
if (!(await layArchive(rec.docs, tgz))) return { ok: false, why: 'the documents it wrote could not be laid in' };
|
|
1520
|
+
}
|
|
1521
|
+
// With no repository, the documents and the conversation are all of it.
|
|
1522
|
+
if (rec.plain) {
|
|
1523
|
+
await layConversations(conversations);
|
|
1524
|
+
clearMarker(here);
|
|
1525
|
+
return { ok: true, exitCode: record.exitCode, record };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// The agent must have ended on the branch it was given.
|
|
1529
|
+
if (record.strayHead || (record.headRef && record.headRef !== rec.branch)) {
|
|
1530
|
+
return park(rec, { record, conversations, why: `the agent finished on ${record.headRef || 'a detached commit'} instead of ${rec.branch}` });
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Where the branch ends: the cloud's tip, which an agent that amended or
|
|
1534
|
+
// rebased has taken off the line it started on.
|
|
1535
|
+
const startHead = rec.sent?.head ?? (await git(cwd, ['rev-parse', 'HEAD'])).trim();
|
|
1536
|
+
let target = startHead;
|
|
1537
|
+
if (record.commits > 0) target = (await git(cwd, ['rev-parse', keep])).trim();
|
|
1538
|
+
else if (record.tip && record.tip !== startHead && await gitOk(cwd, ['cat-file', '-e', `${record.tip}^{commit}`])) target = record.tip;
|
|
1539
|
+
|
|
1540
|
+
// THE FOLDER MUST BE AS THE RUN LEFT IT: same branch, commit, files and
|
|
1541
|
+
// staged changes; or as an import cut off part way left it, at one of its
|
|
1542
|
+
// own steps. Anything else is work done here while the run was away.
|
|
1543
|
+
if (rec.sent) {
|
|
1544
|
+
const now = await folderState(cwd);
|
|
1545
|
+
const at = (head, tree, index = tree) => now.branch === rec.sent.branch && now.head === head && now.tree === tree && now.index === index;
|
|
1546
|
+
let fits = now.branch === rec.sent.branch && now.head === rec.sent.head && now.tree === rec.sent.tree && (!rec.sent.index || now.index === rec.sent.index);
|
|
1547
|
+
if (!fits && rec.laying) {
|
|
1548
|
+
const treeOf = async (c) => (await git(cwd, ['rev-parse', `${c}^{tree}`])).trim();
|
|
1549
|
+
const startTree = await treeOf(startHead);
|
|
1550
|
+
const targetTree = await treeOf(target);
|
|
1551
|
+
const endTree = record.uncommitted ? await treeOf(keepChanges) : targetTree;
|
|
1552
|
+
fits = at(startHead, startTree) || at(target, targetTree) || at(target, endTree, targetTree);
|
|
1553
|
+
}
|
|
1554
|
+
if (!fits) return park(rec, { record, conversations, why: 'this folder changed on this Mac while the run was in the cloud' });
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// Said before the folder is touched, so an import cut off part way is
|
|
1558
|
+
// known for one when it is tried again.
|
|
1559
|
+
const tries = (rec.laying?.tries ?? 0) + 1;
|
|
1560
|
+
rec.laying = { tries };
|
|
1561
|
+
writeMarker(here,{ ...rec, pid: selfPid, state: 'importing' });
|
|
1562
|
+
|
|
1563
|
+
// What it held when the run began went up with it and comes back in the
|
|
1564
|
+
// run's own state; a copy of the files AND of what was staged is set aside
|
|
1565
|
+
// first anyway, under names no later run reuses, and so is a tip the
|
|
1566
|
+
// cloud's does not descend from.
|
|
1567
|
+
const stamp = `${rec.runId}-${Date.now()}`;
|
|
1568
|
+
try {
|
|
1569
|
+
await snapshotUncommitted(cwd, `refs/agentbox/before-cloud/${stamp}`);
|
|
1570
|
+
const stagedTree = (await git(cwd, ['write-tree'])).trim();
|
|
1571
|
+
const stagedCommit = (await git(cwd, ['-c', `user.name=${Name}`, '-c', `user.email=${nameSlug}@localhost`, 'commit-tree', stagedTree, '-p', 'HEAD', '-m', 'What was staged before a cloud run came home'])).trim();
|
|
1572
|
+
await git(cwd, ['update-ref', `refs/agentbox/before-cloud/${stamp}-staged`, stagedCommit]);
|
|
1573
|
+
if (!(await gitOk(cwd, ['merge-base', '--is-ancestor', startHead, target]))) await git(cwd, ['update-ref', `refs/agentbox/before-cloud/${rec.runId}`, startHead]);
|
|
1574
|
+
await git(cwd, ['reset', '-q', '--hard', target]);
|
|
1575
|
+
await git(cwd, ['clean', '-q', '-fd']);
|
|
1576
|
+
if (record.uncommitted) {
|
|
1577
|
+
const patch = await git(cwd, ['diff', '--binary', 'HEAD', keepChanges], { buffer: true });
|
|
1578
|
+
if (patch.length) await git(cwd, ['apply', '--whitespace=nowarn'], { input: patch });
|
|
1579
|
+
}
|
|
1580
|
+
await layConversations(conversations);
|
|
1581
|
+
} catch (err) {
|
|
1582
|
+
const said = String(err.message).split('\n')[0];
|
|
1583
|
+
if (tries >= 3) return park(rec, { record, conversations, why: `laying it into this folder failed ${tries} times (${said}); what the folder held before is on refs/agentbox/before-cloud/${stamp}` });
|
|
1584
|
+
writeMarker(here,{ ...rec, pid: null, state: 'import-failed' });
|
|
1585
|
+
return { ok: false, state: 'import-failed', laying: rec.laying, why: `laying the run into the folder failed (${said}); the earlier state is on refs/agentbox/before-cloud/${stamp}` };
|
|
1586
|
+
}
|
|
1587
|
+
await gitOk(cwd, ['update-ref', '-d', keepChanges]);
|
|
1588
|
+
if (record.commits > 0) await gitOk(cwd, ['update-ref', '-d', keep]);
|
|
1589
|
+
clearMarker(here);
|
|
1590
|
+
return { ok: true, exitCode: record.exitCode, record };
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
async function layConversations(conversations) {
|
|
1594
|
+
// The conversation as this Mac sent it, for one gone from here.
|
|
1595
|
+
const sentCopy = async (t, into) => {
|
|
1596
|
+
const had = JSON.parse(fs.readFileSync(sentRecordPath(here), 'utf8'));
|
|
1597
|
+
return had?.sha === t.sha256 && had.size === t.from && fetchToFile(storage, had.key, into);
|
|
1598
|
+
};
|
|
1599
|
+
for (const c of conversations) {
|
|
1600
|
+
fs.mkdirSync(c.dir, { recursive: true });
|
|
1601
|
+
await run('tar', ['-xzf', c.file, '-C', c.dir]);
|
|
1602
|
+
await layTails(c.dir, say, { sentCopy });
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// A RUN WHOSE WORK CANNOT GO INTO ITS FOLDER WITHOUT UNDOING WORK DONE HERE
|
|
1607
|
+
// goes on a branch of its own, `cloud/<item>`, and the folder is left as it
|
|
1608
|
+
// is. Neither side's work is lost, the marker goes so the thread can run
|
|
1609
|
+
// again, and the row is told (`addCloudNews`). A run that changed nothing
|
|
1610
|
+
// the folder does not already hold needs no branch and no word.
|
|
1611
|
+
async function park(rec, { record = null, conversations = [], why }) {
|
|
1612
|
+
const keep = `refs/agentbox/cloud/${rec.runId}`;
|
|
1613
|
+
const keepChanges = `refs/agentbox/cloud-changes/${rec.runId}`;
|
|
1614
|
+
const has = (ref) => gitOk(cwd, ['rev-parse', '--verify', '-q', ref]);
|
|
1615
|
+
let tip = null;
|
|
1616
|
+
for (const ref of [keepChanges, `${keep}-head`, keep]) if (!tip && await has(ref)) tip = ref;
|
|
1617
|
+
let nothingNew = !tip;
|
|
1618
|
+
if (tip && rec.sent) {
|
|
1619
|
+
const rev = async (spec) => (await git(cwd, ['rev-parse', spec])).trim();
|
|
1620
|
+
nothingNew = (await rev(`${tip}^{tree}`)) === rec.sent.tree && (await rev(tip === keepChanges ? `${tip}^` : tip)) === rec.sent.head;
|
|
1621
|
+
}
|
|
1622
|
+
await layConversations(conversations);
|
|
1623
|
+
const item = rec.toolEnv?.ZERO_ITEM || env.ZERO_ITEM || null;
|
|
1624
|
+
const product = rec.toolEnv?.ZERO_PRODUCT || env.ZERO_PRODUCT || null;
|
|
1625
|
+
let branch = null;
|
|
1626
|
+
if (!nothingNew) {
|
|
1627
|
+
const base = `cloud/${String(item || rec.branch.replace(/^agentbox\//, '') || rec.runId.slice(0, 8)).replace(/[^A-Za-z0-9._-]/g, '-')}`;
|
|
1628
|
+
branch = base;
|
|
1629
|
+
for (let i = 2; await has(`refs/heads/${branch}`); i += 1) branch = `${base}-${i}`;
|
|
1630
|
+
await git(cwd, ['branch', branch, tip]);
|
|
1631
|
+
}
|
|
1632
|
+
for (const ref of [keepChanges, `${keep}-head`, keep]) {
|
|
1633
|
+
if (await has(ref) && (nothingNew || await gitOk(cwd, ['merge-base', '--is-ancestor', ref, branch]))) await gitOk(cwd, ['update-ref', '-d', ref]);
|
|
1634
|
+
}
|
|
1635
|
+
if (branch) {
|
|
1636
|
+
const note = `${KEPT_ASIDE}${why}. Nothing is lost. The folder is as this Mac left it, and the run's version is on the branch ${branch}. Reply to have the agent bring the two together; it will not ship until then.`;
|
|
1637
|
+
say(note);
|
|
1638
|
+
addCloudNews(cwd, { runId: rec.runId, item, product, branch, note });
|
|
1639
|
+
}
|
|
1640
|
+
clearMarker(here);
|
|
1641
|
+
return { ok: true, exitCode: record?.exitCode ?? null, record, parked: branch };
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
async function layArchive(d, tgz) {
|
|
1645
|
+
try {
|
|
1646
|
+
const out = await layDocs({ tgz, dir: d.dir, sentAt: d.sentAt, laid: d.laid, sent: d.sent ?? null });
|
|
1647
|
+
for (const b of out.beside) say(`${b} was changed on this Mac while the run had it; the run's copy is beside it`);
|
|
1648
|
+
for (const m of out.moved) say(`the run's ${m} could not go where it was written, through a link out of the folder, so it is there instead`);
|
|
1649
|
+
return true;
|
|
1650
|
+
} catch (err) {
|
|
1651
|
+
say(`some of the run's documents could not be laid in yet (${String(err.message).split('\n')[0]})`);
|
|
1652
|
+
return false;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Calls that throw (network, AWS) are retried with backoff; `forever` for
|
|
1657
|
+
// the ones a run cannot do without, bounded otherwise; `stop` ends either.
|
|
1658
|
+
// Returns null when it gave up, never throws.
|
|
1659
|
+
async function retry(fn, { what, forever = false, stop = () => false }) {
|
|
1660
|
+
let delay = 500;
|
|
1661
|
+
for (let i = 0; ; i += 1) {
|
|
1662
|
+
try { return await fn(); } catch (err) {
|
|
1663
|
+
if (stop()) return null;
|
|
1664
|
+
if (!forever && i >= 5) { say(`could not ${what}: ${err.message}`); return null; }
|
|
1665
|
+
if (i === 3) say(`still trying to ${what} (${err.message})`);
|
|
1666
|
+
await sleep(delay);
|
|
1667
|
+
delay = Math.min(delay * 2, 30_000);
|
|
1668
|
+
if (stop()) return null;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// A CODEX KIT'S OPENING LINES (shared/cloud-kit.mjs): what the computer's own
|
|
1675
|
+
// client (main/codex-app-server.mjs) sends to open the thread, the resume
|
|
1676
|
+
// rewritten here as a run's is, so the store is the sandbox's stub and the
|
|
1677
|
+
// rollout the resume needs goes with the pack. The ids are the kit's own.
|
|
1678
|
+
export const CODEX_RESUME_ID = 2;
|
|
1679
|
+
// A new thread's kit starts its thread, as the client's `startThread` does.
|
|
1680
|
+
export function codexKitOpen({ resume, start }, { stubPath = STUB, codexHome, keep, note = null, findRollout = codexTranscriptFile }) {
|
|
1681
|
+
const asked = start
|
|
1682
|
+
? { jsonrpc: '2.0', id: CODEX_RESUME_ID, method: 'thread/start', params: { ephemeral: false, ...start } }
|
|
1683
|
+
: { jsonrpc: '2.0', id: CODEX_RESUME_ID, method: 'thread/resume', params: resume };
|
|
1684
|
+
const rewritten = codexLineForTheCloud(`${JSON.stringify(asked)}\n`, { stubPath, codexHome, keep, note, findRollout });
|
|
1685
|
+
return {
|
|
1686
|
+
lines: [
|
|
1687
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: CLIENT_INFO } }),
|
|
1688
|
+
JSON.stringify({ jsonrpc: '2.0', method: 'initialized' }),
|
|
1689
|
+
rewritten.line.replace(/\n$/, ''),
|
|
1690
|
+
],
|
|
1691
|
+
files: rewritten.files,
|
|
1692
|
+
conversations: rewritten.conversation ? [rewritten.conversation] : [],
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// ONE LINE FROM THE SUPERVISOR'S CODEX CLIENT, MADE READY FOR THE SANDBOX.
|
|
1697
|
+
//
|
|
1698
|
+
// Codex is told its MCP servers inside `thread/start` and `thread/resume`
|
|
1699
|
+
// (`config.mcp_servers`), not on a command line. Each server that has a
|
|
1700
|
+
// command (the store; her own come switched off, `{ enabled: false }`, and
|
|
1701
|
+
// pass untouched) is swapped for the sandbox's stub, and the original is kept
|
|
1702
|
+
// in `keep` to run here when the stub calls. A `thread/resume` also names the
|
|
1703
|
+
// one file Codex will need to find that thread: its rollout, sent up first
|
|
1704
|
+
// (`conversation`, sent as a Claude Code transcript is).
|
|
1705
|
+
// Any other line, or one that is not JSON, passes exactly as it came.
|
|
1706
|
+
// `note` is what the run is told about the cloud, which Claude Code gets on its
|
|
1707
|
+
// command line (`argvForTheCloud`) and Codex only here.
|
|
1708
|
+
export function codexLineForTheCloud(line, { stubPath = STUB, codexHome, keep, findRollout = codexTranscriptFile, note = null }) {
|
|
1709
|
+
let msg;
|
|
1710
|
+
try { msg = JSON.parse(line); } catch { return { line, files: [] }; }
|
|
1711
|
+
// A picture rides in a turn as `localImage` with a path on this Mac; the
|
|
1712
|
+
// file goes up first, at that same path (Codex's review, finding 5).
|
|
1713
|
+
if (msg?.method === 'turn/start' || msg?.method === 'turn/steer') {
|
|
1714
|
+
const files = (Array.isArray(msg.params?.input) ? msg.params.input : [])
|
|
1715
|
+
.filter((i) => i && i.type === 'localImage' && typeof i.path === 'string' && path.isAbsolute(i.path))
|
|
1716
|
+
.map((i) => i.path);
|
|
1717
|
+
return { line, files };
|
|
1718
|
+
}
|
|
1719
|
+
if (msg?.method !== 'thread/start' && msg?.method !== 'thread/resume') return { line, files: [] };
|
|
1720
|
+
const files = [];
|
|
1721
|
+
const servers = msg.params?.config?.mcp_servers;
|
|
1722
|
+
if (servers && typeof servers === 'object') {
|
|
1723
|
+
for (const name of Object.keys(servers)) {
|
|
1724
|
+
const s = servers[name];
|
|
1725
|
+
if (!s || typeof s !== 'object' || !s.command) continue;
|
|
1726
|
+
keep[name] = { command: s.command, args: s.args || [], env: s.env || {} };
|
|
1727
|
+
// Codex starts an MCP server with a bare environment, so the stub is
|
|
1728
|
+
// told where the sandbox's bridge is in its own entry (measured
|
|
1729
|
+
// 2026-10-13: without it the stub never connected).
|
|
1730
|
+
servers[name] = { ...s, command: SANDBOX_NODE, args: [stubPath, name], env: { AGENTBOX_BRIDGE_SOCK: BRIDGE_SOCK } };
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
// THE CLOUD MACHINE IS CODEX'S SANDBOX (her decision, 2026-10-13: "Yes, let
|
|
1734
|
+
// the cloud machine be Codex's sandbox"). Codex's own command sandbox
|
|
1735
|
+
// (bubblewrap) cannot nest inside an AgentCore microVM: measured that day,
|
|
1736
|
+
// every write and every command was refused ("bwrap: Can't mkdir parents
|
|
1737
|
+
// ... Permission denied"). OpenAI's docs say to use full access inside a
|
|
1738
|
+
// container or VM, which is then the boundary. This one holds only the
|
|
1739
|
+
// task's folder and a Codex sign-in that cannot renew, and is thrown away
|
|
1740
|
+
// after the run. A read-only run stays read-only; approvals are untouched.
|
|
1741
|
+
if (msg.params && msg.params.sandbox === 'workspace-write') msg.params.sandbox = 'danger-full-access';
|
|
1742
|
+
if (note && msg.params) {
|
|
1743
|
+
const mine = msg.params.developerInstructions;
|
|
1744
|
+
msg.params.developerInstructions = mine ? `${mine}\n\n${note}` : note;
|
|
1745
|
+
}
|
|
1746
|
+
let conversation = null;
|
|
1747
|
+
if (msg.method === 'thread/resume' && msg.params?.threadId) {
|
|
1748
|
+
conversation = findRollout(msg.params.threadId, { home: codexHome });
|
|
1749
|
+
if (conversation) files.push(conversation);
|
|
1750
|
+
}
|
|
1751
|
+
return { line: JSON.stringify(msg) + (line.endsWith('\n') ? '\n' : ''), files, ...(conversation ? { conversation } : {}) };
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// THE PART OF HER CODEX CONFIG A CLOUD RUN TAKES: the top-level model, effort
|
|
1755
|
+
// and service tier, each a plain quoted string, and nothing else. Read by
|
|
1756
|
+
// line rather than by a TOML parser because only these three are wanted, and
|
|
1757
|
+
// anything that is not exactly `key = "value"` before the first [section] is
|
|
1758
|
+
// left behind rather than guessed at.
|
|
1759
|
+
const CODEX_SETTINGS = new Set(['model', 'model_reasoning_effort', 'service_tier']);
|
|
1760
|
+
export function codexConfigForTheCloud(toml) {
|
|
1761
|
+
const out = [];
|
|
1762
|
+
for (const raw of String(toml || '').split('\n')) {
|
|
1763
|
+
const line = raw.trim();
|
|
1764
|
+
if (line.startsWith('[')) break;
|
|
1765
|
+
const m = /^([A-Za-z_]+)\s*=\s*"([^"\\\n]*)"\s*(#.*)?$/.exec(line);
|
|
1766
|
+
if (m && CODEX_SETTINGS.has(m[1])) out.push(`${m[1]} = "${m[2]}"`);
|
|
1767
|
+
}
|
|
1768
|
+
return out.join('\n') + (out.length ? '\n' : '');
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// THE ENVIRONMENT THE APP'S OWN TOOLS RUN IN, HERE. The worker's, as Claude
|
|
1772
|
+
// Code would have given it, minus anything that signs in to AWS: this process
|
|
1773
|
+
// signs as the cloud runs' own AWS user, and an app started from a shell that
|
|
1774
|
+
// was signed in to AWS would otherwise hand that sign-in to every tool.
|
|
1775
|
+
export function toolEnv(env) {
|
|
1776
|
+
const out = { ...env };
|
|
1777
|
+
for (const k of Object.keys(out)) {
|
|
1778
|
+
if (/^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|SESSION_TOKEN|SECURITY_TOKEN|PROFILE|DEFAULT_PROFILE|CREDENTIAL_EXPIRATION|WEB_IDENTITY_TOKEN_FILE|ROLE_ARN)$/.test(k)) delete out[k];
|
|
1779
|
+
if (k === 'CLAUDE_CODE_OAUTH_TOKEN') delete out[k];
|
|
1780
|
+
}
|
|
1781
|
+
return out;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// The remote command's exit, as one number: its own code, or for a command a
|
|
1785
|
+
// signal ended, 128 plus the signal's number, the way a shell reports it.
|
|
1786
|
+
export function exitCodeOf(record) {
|
|
1787
|
+
if (Number.isInteger(record?.exitCode)) return record.exitCode;
|
|
1788
|
+
const num = record?.exitSignal ? os.constants.signals[record.exitSignal] : null;
|
|
1789
|
+
return num ? 128 + num : 1;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// The worker's own variables its tools ran with, kept for a process that joins
|
|
1793
|
+
// the run after a restart and so was not started with them. Names only this
|
|
1794
|
+
// app sets; never a key or a login.
|
|
1795
|
+
export function toolVariables(env) {
|
|
1796
|
+
const out = {};
|
|
1797
|
+
for (const [k, v] of Object.entries(env || {})) {
|
|
1798
|
+
if (/^(ZERO_|STORE_|AGENTBOX_)/.test(k) && !/(KEY|TOKEN|SECRET|PASSWORD)/.test(k)) out[k] = v;
|
|
1799
|
+
}
|
|
1800
|
+
return out;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
async function gitIdentity(cwd) {
|
|
1805
|
+
const get = (k) => git(cwd, ['config', k]).then((v) => v.trim(), () => '');
|
|
1806
|
+
const name = await get('user.name'); const email = await get('user.email');
|
|
1807
|
+
return {
|
|
1808
|
+
...(name ? { GIT_AUTHOR_NAME: name, GIT_COMMITTER_NAME: name } : {}),
|
|
1809
|
+
...(email ? { GIT_AUTHOR_EMAIL: email, GIT_COMMITTER_EMAIL: email } : {}),
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function alive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }
|
|
1814
|
+
|
|
1815
|
+
// A stored object straight to a file, and a file straight to storage, when the
|
|
1816
|
+
// storage can (main/cloud/aws.mjs); a stand-in that cannot goes through memory.
|
|
1817
|
+
async function fetchToFile(storage, k, file) {
|
|
1818
|
+
if (storage.getToFile) return storage.getToFile(k, file);
|
|
1819
|
+
const body = await storage.get(k);
|
|
1820
|
+
if (!body) return false;
|
|
1821
|
+
fs.writeFileSync(file, body);
|
|
1822
|
+
return true;
|
|
1823
|
+
}
|
|
1824
|
+
async function sendFromFile(storage, k, file) {
|
|
1825
|
+
if (storage.putFile) return storage.putFile(k, file);
|
|
1826
|
+
return storage.put(k, fs.readFileSync(file));
|
|
1827
|
+
}
|
|
1828
|
+
async function* linesOf(file) {
|
|
1829
|
+
const { createInterface } = await import('node:readline');
|
|
1830
|
+
yield* createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// AT MOST TWO CLOUD RUNS BRING THEIR WORK HOME AT ONCE on this Mac, whichever
|
|
1834
|
+
// process each runs in. The slots are files in one folder, looked at and
|
|
1835
|
+
// changed only while holding that folder's guard (a directory made only if
|
|
1836
|
+
// absent, held for milliseconds), so two processes never both reclaim one.
|
|
1837
|
+
// A slot names its holder's process and a token of its own, and its holder
|
|
1838
|
+
// touches it while it works: one whose process is gone, or that nobody has
|
|
1839
|
+
// touched for ten minutes (a process id reused by something else), is free.
|
|
1840
|
+
// If the folder cannot be used at all, the import goes ahead unlimited rather
|
|
1841
|
+
// than never.
|
|
1842
|
+
export const IMPORT_SLOTS = 2;
|
|
1843
|
+
const SLOT_STALE_MS = 10 * 60_000;
|
|
1844
|
+
const GUARD_STALE_MS = 30_000;
|
|
1845
|
+
export async function takeImportSlot({ dir = path.join(os.tmpdir(), `${nameSlug}-cloud-imports`), slots = IMPORT_SLOTS, everyMs = 500, staleMs = SLOT_STALE_MS } = {}) {
|
|
1846
|
+
const token = `${process.pid}:${crypto.randomUUID()}`;
|
|
1847
|
+
const guard = path.join(dir, 'guard');
|
|
1848
|
+
const unlimited = () => () => {};
|
|
1849
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { return unlimited(); }
|
|
1850
|
+
const tryOnce = () => {
|
|
1851
|
+
try { fs.mkdirSync(guard); } catch (err) {
|
|
1852
|
+
if (err?.code !== 'EEXIST') throw err;
|
|
1853
|
+
try { if (Date.now() - fs.statSync(guard).mtimeMs > GUARD_STALE_MS) fs.rmSync(guard, { recursive: true, force: true }); } catch { /* freed meanwhile */ }
|
|
1854
|
+
return null;
|
|
1855
|
+
}
|
|
1856
|
+
try {
|
|
1857
|
+
for (let i = 0; i < slots; i += 1) {
|
|
1858
|
+
const file = path.join(dir, `slot-${i}`);
|
|
1859
|
+
let held = null;
|
|
1860
|
+
try { held = { text: fs.readFileSync(file, 'utf8'), at: fs.statSync(file).mtimeMs }; } catch { /* free */ }
|
|
1861
|
+
const pid = held ? Number(held.text.split(':')[0]) : 0;
|
|
1862
|
+
if (held && pid && alive(pid) && Date.now() - held.at < staleMs) continue;
|
|
1863
|
+
fs.writeFileSync(file, token);
|
|
1864
|
+
return file;
|
|
1865
|
+
}
|
|
1866
|
+
return null;
|
|
1867
|
+
} finally {
|
|
1868
|
+
fs.rmSync(guard, { recursive: true, force: true });
|
|
1869
|
+
}
|
|
1870
|
+
};
|
|
1871
|
+
for (let failures = 0; ;) {
|
|
1872
|
+
let file = null;
|
|
1873
|
+
try { file = tryOnce(); } catch {
|
|
1874
|
+
failures += 1;
|
|
1875
|
+
if (failures >= 20) return unlimited();
|
|
1876
|
+
}
|
|
1877
|
+
if (file) {
|
|
1878
|
+
const touch = setInterval(() => { try { const t = new Date(); fs.utimesSync(file, t, t); } catch { /* gone */ } }, 30_000);
|
|
1879
|
+
touch.unref?.();
|
|
1880
|
+
return () => {
|
|
1881
|
+
clearInterval(touch);
|
|
1882
|
+
try { if (fs.readFileSync(file, 'utf8') === token) fs.rmSync(file, { force: true }); } catch { /* gone */ }
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
await sleep(everyMs);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// THE APP'S OWN TWO TOOL SERVERS, SERVED IN THIS PROCESS. Each was a process of
|
|
1890
|
+
// its own per cloud run (the approvals server about 46 MB, measured on her Mac
|
|
1891
|
+
// 2026-10-17), most of it the runtime starting up, not work. In here each
|
|
1892
|
+
// connection gets its own channel: the approvals for its own row, the store
|
|
1893
|
+
// with its own claims. Any other server is still started as configured.
|
|
1894
|
+
export function servedHere(cfg, env, holder) {
|
|
1895
|
+
const cmd = String(cfg?.command || '');
|
|
1896
|
+
if (cmd.endsWith('/approval-server.sh')) return channel((io) => serveApprovals({ env, ...io, warn: (m) => io.output.emit('warn', m) }));
|
|
1897
|
+
if (cmd.endsWith(`/${nameSlug}-mcp.sh`)) {
|
|
1898
|
+
// One store, one account, for every run on this Mac: the store reads where
|
|
1899
|
+
// it is and whose lines it writes from the environment, so this run's are
|
|
1900
|
+
// set on it, the server's own winning as they would for a server of its
|
|
1901
|
+
// own, and a person id this run has none of is taken away.
|
|
1902
|
+
const want = { AGENTBOX_PERSON_ID: env.AGENTBOX_PERSON_ID, ...cfg.env };
|
|
1903
|
+
for (const [k, v] of Object.entries(want)) { if (v == null) delete process.env[k]; else process.env[k] = v; }
|
|
1904
|
+
// Named here: runs share this process, so its own ZERO_ITEM is whichever run set it last.
|
|
1905
|
+
const ownItem = cfg.env?.ZERO_ITEM || env.ZERO_ITEM || null;
|
|
1906
|
+
return channel(async (io) => (await import('../../mcp/stdio.mjs')).serveStore({ ...io, holder: `mcp-cloud-${holder}`, ownItem }));
|
|
1907
|
+
}
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// A server in this process, shaped like the child process the relay would
|
|
1912
|
+
// otherwise have started: lines in on `stdin`, lines out on `stdout`, `close`
|
|
1913
|
+
// once it has said its last.
|
|
1914
|
+
function channel(serve) {
|
|
1915
|
+
const proc = new EventEmitter();
|
|
1916
|
+
proc.stdin = new PassThrough();
|
|
1917
|
+
proc.stdout = new PassThrough();
|
|
1918
|
+
proc.stderr = new PassThrough();
|
|
1919
|
+
proc.stdout.on('warn', (m) => proc.stderr.write(`${m}\n`));
|
|
1920
|
+
let handle = null;
|
|
1921
|
+
let ended = false;
|
|
1922
|
+
const end = () => {
|
|
1923
|
+
if (ended) return;
|
|
1924
|
+
ended = true;
|
|
1925
|
+
Promise.resolve(handle?.close?.()).catch(() => {}).finally(() => proc.stdout.end());
|
|
1926
|
+
};
|
|
1927
|
+
proc.stdout.on('end', () => proc.emit('close', 0, null));
|
|
1928
|
+
proc.stdin.on('finish', end);
|
|
1929
|
+
proc.kill = () => { end(); return true; };
|
|
1930
|
+
Promise.resolve().then(() => serve({ input: proc.stdin, output: proc.stdout }))
|
|
1931
|
+
.then((h) => { handle = h; if (ended) Promise.resolve(h?.close?.()).catch(() => {}); })
|
|
1932
|
+
.catch((err) => { proc.stderr.write(`${err?.message ?? err}\n`); if (proc.listenerCount('error')) proc.emit('error', err); end(); });
|
|
1933
|
+
return proc;
|
|
1934
|
+
}
|