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
package/main/shell-path.mjs
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
// Finder launches inherit launchd's PATH rather than the user's shell tools.
|
|
2
|
-
// Read only
|
|
3
|
-
// and login overrides too.
|
|
2
|
+
// Read only named variables: importing a profile's whole environment would
|
|
3
|
+
// import keys and login overrides too. PATH, the two folders below, and the
|
|
4
|
+
// network and locale names under NETWORK and LOCALE are all that is ever read,
|
|
5
|
+
// and workers still pass through their billing scrub.
|
|
4
6
|
import { execFile } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs';
|
|
5
8
|
import path from 'node:path';
|
|
6
9
|
|
|
10
|
+
// The shell a person actually has. `$SHELL` when that file exists, then zsh,
|
|
11
|
+
// bash, and sh. A machine with no zsh used to spawn `/bin/zsh`: the terminal
|
|
12
|
+
// never came up, and the self-update never built.
|
|
13
|
+
export function loginShell(env = process.env, exists = fs.existsSync) {
|
|
14
|
+
const preferred = env.SHELL;
|
|
15
|
+
if (preferred && exists(preferred)) return preferred;
|
|
16
|
+
for (const candidate of ['/bin/zsh', '/bin/bash', '/bin/sh']) {
|
|
17
|
+
if (exists(candidate)) return candidate;
|
|
18
|
+
}
|
|
19
|
+
return preferred || '/bin/sh';
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
const MARKER = '\0agentbox-path\0';
|
|
8
23
|
function runShell(file, args, options) {
|
|
9
24
|
return new Promise((resolve, reject) => {
|
|
@@ -12,6 +27,52 @@ function runShell(file, args, options) {
|
|
|
12
27
|
});
|
|
13
28
|
}
|
|
14
29
|
|
|
30
|
+
/**
|
|
31
|
+
* WHICH SHELLS ARE ASKED, IN ORDER, AND WHY THERE IS MORE THAN ONE (2026-10-15).
|
|
32
|
+
*
|
|
33
|
+
* Their own, interactive and login, first: that is the one that reads .zshrc,
|
|
34
|
+
* where nvm and most PATH lines live.
|
|
35
|
+
*
|
|
36
|
+
* Then their own again as a login shell that is NOT interactive, which reads
|
|
37
|
+
* .zprofile and skips .zshrc. Measured with the real /bin/zsh: a .zshrc that
|
|
38
|
+
* runs `[ -z "$TMUX" ] && exec tmux` (tmux exits 1 with no terminal) or
|
|
39
|
+
* `exec fish` (exits 0 having read nothing) ended the shell before our
|
|
40
|
+
* printf, and the app fell back to launchd's four folders, losing the
|
|
41
|
+
* Homebrew line that .zprofile carries. Asking again without -i keeps it. A
|
|
42
|
+
* .zshrc slower than five seconds lands here too.
|
|
43
|
+
*
|
|
44
|
+
* Last, the system's own shell, when theirs is something else: a shell that
|
|
45
|
+
* cannot speak `printf ... "$PATH"` (nushell hands back the words `$PATH`)
|
|
46
|
+
* is a fact about that shell, not about where their tools are. The program
|
|
47
|
+
* finder (main/engine-bin.mjs shellProbes) falls back the same way.
|
|
48
|
+
*/
|
|
49
|
+
export function loginShellAttempts(env = process.env, platform = process.platform) {
|
|
50
|
+
const system = platform === 'darwin' ? '/bin/zsh' : '/bin/bash';
|
|
51
|
+
const theirs = env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/sh');
|
|
52
|
+
const out = [[theirs, '-ilc', 5000], [theirs, '-lc', 2500]];
|
|
53
|
+
if (system !== theirs) out.push([system, '-ilc', 2500]);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The first attempt whose output `read` accepts; the first failure otherwise. */
|
|
58
|
+
async function askLoginShell({ env, platform, run, script, read }) {
|
|
59
|
+
let first = null;
|
|
60
|
+
for (const [shell, flags, timeout] of loginShellAttempts(env, platform)) {
|
|
61
|
+
try {
|
|
62
|
+
const stdout = String(await run(shell, [flags, script(shell)], {
|
|
63
|
+
// SIGKILL: a shell's exit hook can outlive SIGTERM (main/engine-bin.mjs).
|
|
64
|
+
env: { ...env }, encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 64 * 1024,
|
|
65
|
+
}));
|
|
66
|
+
return read(stdout);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
first ??= error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw first;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isFish = (shell) => path.basename(shell) === 'fish';
|
|
75
|
+
|
|
15
76
|
/**
|
|
16
77
|
* WHERE THE SHELL SAYS CLAUDE CODE AND CODEX KEEP THEIR SESSIONS (w-e46f2e9cea).
|
|
17
78
|
*
|
|
@@ -26,59 +87,146 @@ function runShell(file, args, options) {
|
|
|
26
87
|
* Only an absolute path is kept. A quoted `~` the shell did not expand is
|
|
27
88
|
* expanded here; anything else, or no answer at all, is left out. */
|
|
28
89
|
const DIRS_MARKER = '\0agentbox-dirs\0';
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
90
|
+
const DIRS = ['CLAUDE_CONFIG_DIR', 'CODEX_HOME'];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* HOW THIS MACHINE REACHES THE NETWORK, WHEN ONLY THE SHELL PROFILE SAYS (2026-10-15).
|
|
94
|
+
*
|
|
95
|
+
* Behind a company proxy, people export HTTPS_PROXY and the company's
|
|
96
|
+
* certificate (NODE_EXTRA_CA_CERTS) in .zshrc, which is where Claude Code's
|
|
97
|
+
* own error tells them to. A Dock launch never sees it. Measured with the real
|
|
98
|
+
* Claude Code 2.1.301 against a stub model behind a CONNECT proxy and a
|
|
99
|
+
* self-signed certificate: with both variables it answered in 240 ms; with no
|
|
100
|
+
* proxy variable it retried for minutes (10 retries, about three) and failed;
|
|
101
|
+
* with the proxy and no certificate it died in 371 ms on "Self-signed
|
|
102
|
+
* certificate detected". So a worker started by the app failed on exactly the
|
|
103
|
+
* machine where the person's terminal worked.
|
|
104
|
+
*
|
|
105
|
+
* None of these is a key or a login, so the billing scrub has nothing to say
|
|
106
|
+
* about them, and they go into the app's own environment where every worker,
|
|
107
|
+
* terminal and git call inherits them. A name the launch environment already
|
|
108
|
+
* has is left alone, and the proxies are one setting: if any proxy name is
|
|
109
|
+
* already set, none is added.
|
|
110
|
+
*/
|
|
111
|
+
const PROXIES = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy'];
|
|
112
|
+
const CERTIFICATES = ['NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE'];
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* THE LANGUAGE SETTING A DOCK LAUNCH DOES NOT HAVE (2026-10-15).
|
|
116
|
+
*
|
|
117
|
+
* launchd gives an app no LANG and no LC_*: read off a running Dock-launched
|
|
118
|
+
* Electron app with `ps eww`, and off a bare Electron started with launchd's
|
|
119
|
+
* environment (process.env.LANG undefined, and none in its children).
|
|
120
|
+
* Terminal.app sets LANG itself, so the person's own commands always had one
|
|
121
|
+
* and their agents never did. Measured on this Mac with LANG unset: system
|
|
122
|
+
* Ruby reads files as US-ASCII (what CocoaPods and fastlane refuse to run
|
|
123
|
+
* under), `sed 's/./X/g'` turns "café" into five X's, `wc -m` counts five.
|
|
124
|
+
*
|
|
125
|
+
* So the shell's own, when it has one. On macOS 26 that is usually Apple's:
|
|
126
|
+
* /etc/zprofile exports LANG=C.UTF-8 when nothing else has, so a zsh login
|
|
127
|
+
* answers C.UTF-8 (measured on this Mac), which fixes both of the above and
|
|
128
|
+
* keeps every tool's messages in the English the app reads. Otherwise, on a
|
|
129
|
+
* Mac only, en_US.UTF-8, which exists on every macOS and is what Terminal
|
|
130
|
+
* sets for most people. Linux sessions set their own.
|
|
131
|
+
*/
|
|
132
|
+
const LOCALE = ['LANG', 'LC_ALL', 'LC_CTYPE'];
|
|
133
|
+
const MAC_LOCALE = 'en_US.UTF-8';
|
|
134
|
+
|
|
135
|
+
const SETTINGS = [...DIRS, ...PROXIES, ...CERTIFICATES, ...LOCALE];
|
|
136
|
+
|
|
137
|
+
/** Everything above from one login shell, as { dirs, network, locale }. */
|
|
138
|
+
export async function readShellSettings({ env = process.env, platform = process.platform, run = runShell } = {}) {
|
|
139
|
+
const none = { dirs: {}, network: {}, locale: {} };
|
|
140
|
+
if (platform !== 'darwin' && platform !== 'linux') return none;
|
|
141
|
+
const script = () => `printf '\\0agentbox-dirs\\0${'%s\\0'.repeat(SETTINGS.length)}' ${SETTINGS.map((n) => `"$${n}"`).join(' ')}`;
|
|
142
|
+
const read = (stdout) => {
|
|
36
143
|
const start = stdout.lastIndexOf(DIRS_MARKER);
|
|
37
|
-
if (start < 0)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (v === '~' || v.startsWith('~/')) {
|
|
44
|
-
if (!env.HOME) return;
|
|
45
|
-
v = path.join(env.HOME, v.slice(1));
|
|
46
|
-
}
|
|
47
|
-
if (path.isAbsolute(v)) out[name] = v;
|
|
48
|
-
};
|
|
49
|
-
keep('CLAUDE_CONFIG_DIR', claude);
|
|
50
|
-
keep('CODEX_HOME', codex);
|
|
51
|
-
return out;
|
|
144
|
+
if (start < 0) throw Error('Your shell did not answer.');
|
|
145
|
+
return stdout.slice(start + DIRS_MARKER.length).split('\0');
|
|
146
|
+
};
|
|
147
|
+
let values;
|
|
148
|
+
try {
|
|
149
|
+
values = await askLoginShell({ env, platform, run, script, read });
|
|
52
150
|
} catch {
|
|
53
|
-
return
|
|
151
|
+
return none;
|
|
54
152
|
}
|
|
153
|
+
const raw = Object.fromEntries(SETTINGS.map((name, i) => [name, String(values[i] ?? '').trim()]));
|
|
154
|
+
const single = (v) => v && !/[\r\n]/.test(v);
|
|
155
|
+
const folder = (v) => {
|
|
156
|
+
if (!single(v)) return null;
|
|
157
|
+
if (v === '~' || v.startsWith('~/')) {
|
|
158
|
+
if (!env.HOME) return null;
|
|
159
|
+
v = path.join(env.HOME, v.slice(1));
|
|
160
|
+
}
|
|
161
|
+
return path.isAbsolute(v) ? v : null;
|
|
162
|
+
};
|
|
163
|
+
const out = { dirs: {}, network: {}, locale: {} };
|
|
164
|
+
for (const name of DIRS) if (folder(raw[name])) out.dirs[name] = folder(raw[name]);
|
|
165
|
+
for (const name of PROXIES) if (single(raw[name])) out.network[name] = raw[name];
|
|
166
|
+
for (const name of CERTIFICATES) if (folder(raw[name])) out.network[name] = folder(raw[name]);
|
|
167
|
+
for (const name of LOCALE) if (single(raw[name]) && /^[A-Za-z0-9_.@-]+$/.test(raw[name])) out.locale[name] = raw[name];
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function readShellDataDirs(opts = {}) {
|
|
172
|
+
return (await readShellSettings(opts)).dirs;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What to add to a launch environment, given what the shell said. Never overwrites. */
|
|
176
|
+
export function launchAdditions({ network = {}, locale = {} } = {}, env = process.env, platform = process.platform) {
|
|
177
|
+
const set = (name) => env[name] !== undefined && env[name] !== '';
|
|
178
|
+
const add = {};
|
|
179
|
+
if (!PROXIES.some(set)) for (const name of PROXIES) if (network[name]) add[name] = network[name];
|
|
180
|
+
for (const name of CERTIFICATES) if (network[name] && !set(name)) add[name] = network[name];
|
|
181
|
+
if (!LOCALE.some(set)) {
|
|
182
|
+
for (const name of LOCALE) if (locale[name]) add[name] = locale[name];
|
|
183
|
+
if (!LOCALE.some((n) => add[n]) && platform === 'darwin') add.LANG = MAC_LOCALE;
|
|
184
|
+
}
|
|
185
|
+
return add;
|
|
55
186
|
}
|
|
56
187
|
|
|
57
188
|
/**
|
|
58
189
|
* What the shell answered, kept for the life of the app. Empty until it
|
|
59
|
-
* answers, which is long before setup reaches its last card.
|
|
190
|
+
* answers, which is long before setup reaches its last card.
|
|
191
|
+
*
|
|
192
|
+
* AND THE NETWORK AND LANGUAGE SETTINGS ARE PUT INTO `into` (the app's own
|
|
193
|
+
* environment) here, because this is the one shell read that both the desktop
|
|
194
|
+
* (main/main.mjs) and the browser copy (bin/agentbox.mjs) wait for before the
|
|
195
|
+
* first worker starts. */
|
|
60
196
|
let shellDataEnv = {};
|
|
61
197
|
export const shellDataDirsNow = () => shellDataEnv;
|
|
62
|
-
export function learnShellDataDirs(opts) {
|
|
63
|
-
return
|
|
198
|
+
export function learnShellDataDirs({ into = process.env, ...opts } = {}) {
|
|
199
|
+
return readShellSettings(opts).then((found) => {
|
|
200
|
+
shellDataEnv = found.dirs;
|
|
201
|
+
Object.assign(into, launchAdditions(found, opts.env ?? process.env, opts.platform ?? process.platform));
|
|
202
|
+
return found.dirs;
|
|
203
|
+
});
|
|
64
204
|
}
|
|
65
205
|
|
|
66
206
|
export async function recoverShellPath({ env = process.env, platform = process.platform, run = runShell } = {}) {
|
|
67
207
|
const original = env.PATH || '';
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
208
|
+
// LINUX TOO (2026-10-15). A launcher entry starts the app with the desktop
|
|
209
|
+
// session's environment, and that session read ~/.profile without -i, so
|
|
210
|
+
// the nvm lines its installer writes into ~/.bashrc (behind Ubuntu's "if not
|
|
211
|
+
// running interactively, return") never reached it. readShellDataDirs above
|
|
212
|
+
// already asked on Linux for the same reason.
|
|
213
|
+
if (platform !== 'darwin' && platform !== 'linux') return { path: original, error: null };
|
|
214
|
+
const script = (shell) => `printf '\\0agentbox-path\\0%s\\0' ${isFish(shell) ? '(string join : $PATH)' : '"$PATH"'}`;
|
|
215
|
+
const read = (stdout) => {
|
|
72
216
|
// NUL framing keeps messages printed by .zshrc out of the returned PATH.
|
|
73
|
-
const stdout = String(await run(shell, ['-ilc', `printf '\\0agentbox-path\\0%s\\0' ${value}`], {
|
|
74
|
-
env: { ...env }, encoding: 'utf8', timeout: 5000, maxBuffer: 64 * 1024,
|
|
75
|
-
}));
|
|
76
217
|
const start = stdout.lastIndexOf(MARKER);
|
|
77
218
|
const end = start < 0 ? -1 : stdout.indexOf('\0', start + MARKER.length);
|
|
78
219
|
const recovered = end < 0 ? '' : stdout.slice(start + MARKER.length, end);
|
|
79
220
|
if (!recovered || /[\r\n]/.test(recovered)) throw Error('Your shell did not return its command search path.');
|
|
80
|
-
|
|
81
|
-
|
|
221
|
+
// Only absolute folders. A shell that does not expand "$PATH" hands the
|
|
222
|
+
// words back, and a relative entry is a folder that moves with every cwd.
|
|
223
|
+
const entries = recovered.split(':').filter((p) => p.startsWith('/'));
|
|
224
|
+
if (!entries.length) throw Error('Your shell did not return its command search path.');
|
|
225
|
+
return entries;
|
|
226
|
+
};
|
|
227
|
+
try {
|
|
228
|
+
const recovered = await askLoginShell({ env, platform, run, script, read });
|
|
229
|
+
const entries = [...new Set([...recovered, ...original.split(':')].filter(Boolean))];
|
|
82
230
|
return { path: entries.join(':'), error: null };
|
|
83
231
|
} catch (error) {
|
|
84
232
|
return { path: original, error: String(error?.message || error) };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// What the other side of a merge conflict was for, so the thread that hit it
|
|
2
|
+
// can resolve it without asking the person. Read-only git: nothing here
|
|
3
|
+
// touches a working tree.
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const GIT_MS = 10_000;
|
|
8
|
+
const MAX_COMMITS = 8;
|
|
9
|
+
const SUMMARY_CHARS = 240;
|
|
10
|
+
|
|
11
|
+
export const KEEP_BOTH = 'Resolve it keeping both changes where they fit. Only ask the person if the two changes want opposite things, and then say which you kept and why.';
|
|
12
|
+
|
|
13
|
+
const CONFLICT_LINE = /^CONFLICT \([^)]*\): .*$/m;
|
|
14
|
+
// Main's own merges from `npm run ship`: "Merge origin/main into agentbox/<id>".
|
|
15
|
+
const SHIPPED_FROM = /\binto agentbox\/([A-Za-z0-9_-]+)\s*$/;
|
|
16
|
+
|
|
17
|
+
export const isMergeConflict = (output) => CONFLICT_LINE.test(String(output ?? ''));
|
|
18
|
+
|
|
19
|
+
/** The files the script's own output says conflicted. */
|
|
20
|
+
export function conflictedFilesIn(output) {
|
|
21
|
+
const files = new Set();
|
|
22
|
+
for (const line of String(output ?? '').split('\n')) {
|
|
23
|
+
const m = line.match(/^CONFLICT \([^)]*\): (?:Merge conflict in (.+)|(\S+) deleted in )/);
|
|
24
|
+
const file = m && (m[1] ?? m[2]);
|
|
25
|
+
if (file) files.add(file.trim());
|
|
26
|
+
}
|
|
27
|
+
return [...files];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function git(cwd, args) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
execFile('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: GIT_MS, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
|
33
|
+
resolve({ ok: !err, code: err ? (typeof err.code === 'number' ? err.code : -1) : 0, out: String(stdout ?? '') });
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Files that would conflict merging `upstream` into `head`, or null if git cannot say. */
|
|
39
|
+
export async function filesThatConflict(cwd, head, upstream) {
|
|
40
|
+
const r = await git(cwd, ['merge-tree', '--write-tree', '--name-only', '--no-messages', head, upstream]);
|
|
41
|
+
if (r.code !== 0 && r.code !== 1) return null;
|
|
42
|
+
return r.out.split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The thread whose ship brought `sha` onto main: the oldest of main's ship
|
|
46
|
+
// merges after it where `sha` came in on the branch side, not main's.
|
|
47
|
+
async function threadThatShipped(cwd, sha, upstream) {
|
|
48
|
+
const r = await git(cwd, ['log', '--merges', '--ancestry-path', '--reverse', '--format=%H %P%x09%s', `${sha}..${upstream}`]);
|
|
49
|
+
const merges = r.out.split('\n').filter(Boolean).slice(0, 3);
|
|
50
|
+
for (const line of merges) {
|
|
51
|
+
const [shas, subject = ''] = line.split('\t');
|
|
52
|
+
const id = subject.match(SHIPPED_FROM)?.[1];
|
|
53
|
+
const mainSide = shas.split(' ')[2];
|
|
54
|
+
if (!id || !mainSide) continue;
|
|
55
|
+
const onMainAlready = await git(cwd, ['merge-base', '--is-ancestor', sha, mainSide]);
|
|
56
|
+
if (onMainAlready.code === 1) return id;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const oneLine = (text) => String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
62
|
+
const clip = (text, n) => (text.length > n ? `${text.slice(0, n - 1).trimEnd()}…` : text);
|
|
63
|
+
|
|
64
|
+
// The conflicting files, then what changed them on `upstream`, by thread.
|
|
65
|
+
async function otherSide({ cwd, files, upstream, readThread }) {
|
|
66
|
+
const lines = [`Files: ${files.map((f) => `\`${f}\``).join(', ') || 'see the output below'}.`];
|
|
67
|
+
const since = cwd ? (await git(cwd, ['merge-base', 'HEAD', upstream])).out.trim() : '';
|
|
68
|
+
const log = since && files.length
|
|
69
|
+
? await git(cwd, ['log', '--no-merges', `-n${MAX_COMMITS}`, '--format=%h %H%x09%s', `${since}..${upstream}`, '--', ...files])
|
|
70
|
+
: { out: '' };
|
|
71
|
+
const byThread = new Map();
|
|
72
|
+
for (const entry of log.out.split('\n').filter(Boolean)) {
|
|
73
|
+
const [shas, subject = ''] = entry.split('\t');
|
|
74
|
+
const [short, full] = shas.split(' ');
|
|
75
|
+
const id = await threadThatShipped(cwd, full, upstream);
|
|
76
|
+
if (!byThread.has(id)) byThread.set(id, []);
|
|
77
|
+
byThread.get(id).push(`${short} ${oneLine(subject)}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!byThread.size) return lines;
|
|
81
|
+
lines.push('', `What changed them on ${upstream} since this branch began:`);
|
|
82
|
+
for (const [id, commits] of [...byThread].sort(([a], [b]) => (a === null) - (b === null))) {
|
|
83
|
+
if (!id) {
|
|
84
|
+
lines.push(`- From no thread: ${commits.join('; ')}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
let thread = null;
|
|
88
|
+
try { thread = readThread(id); } catch {}
|
|
89
|
+
const title = oneLine(thread?.title);
|
|
90
|
+
const about = clip(oneLine(thread?.progress || thread?.problem), SUMMARY_CHARS);
|
|
91
|
+
lines.push(`- Thread ${title ? `"${title}" ` : ''}(${id})${about ? `: ${about}` : ''}`, ` Commits: ${commits.join('; ')}`);
|
|
92
|
+
}
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The brief for a ship that failed, or null when it was not a merge conflict.
|
|
98
|
+
*
|
|
99
|
+
* @param {{ cwd: string, output: string, upstream?: string, readThread?: (id: string) => object|null }} opts
|
|
100
|
+
*/
|
|
101
|
+
export async function conflictBrief({ cwd, output, upstream = 'origin/main', readThread = () => null }) {
|
|
102
|
+
if (!isMergeConflict(output)) return null;
|
|
103
|
+
const fromGit = cwd ? await filesThatConflict(cwd, 'HEAD', upstream) : null;
|
|
104
|
+
const files = fromGit?.length ? fromGit : conflictedFilesIn(output);
|
|
105
|
+
return ['The other side of the conflict:', '', ...await otherSide({ cwd, files, upstream, readThread }), '', KEEP_BOTH].join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** For a branch that has not shipped yet: null unless `upstream` now conflicts with it. */
|
|
109
|
+
export async function earlyConflictNote({ cwd, upstream = 'origin/main', readThread = () => null }) {
|
|
110
|
+
const files = await filesThatConflict(cwd, 'HEAD', upstream);
|
|
111
|
+
if (!files?.length) return null;
|
|
112
|
+
return [
|
|
113
|
+
`${upstream} moved while this thread waited, and this branch no longer merges cleanly with it. Merge ${upstream} into it before you mark it ready to ship.`,
|
|
114
|
+
'',
|
|
115
|
+
...await otherSide({ cwd, files, upstream, readThread }),
|
|
116
|
+
'',
|
|
117
|
+
KEEP_BOTH,
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
package/main/ship-queue.mjs
CHANGED
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
// cannot change the code that ships it. With no `ship.json`, nothing here does
|
|
23
23
|
// anything at all.
|
|
24
24
|
|
|
25
|
-
import { spawn } from 'node:child_process';
|
|
25
|
+
import { spawn, execFile } from 'node:child_process';
|
|
26
26
|
import fs from 'node:fs';
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { Name } from '../shared/product-name.mjs';
|
|
29
|
+
import { SHIP_FAILED } from '../shared/not-shipped.mjs';
|
|
30
|
+
import { conflictBrief, earlyConflictNote } from './ship-conflict-brief.mjs';
|
|
29
31
|
|
|
30
32
|
export const SHIP_LABEL = 'ship';
|
|
31
33
|
const SHIP_MS = 20 * 60 * 1000;
|
|
@@ -83,11 +85,16 @@ export const withoutShipLabel = (item) => labelsOf(item).filter((l) => l !== SHI
|
|
|
83
85
|
* marking, and with a task folder to ship from. THE WINDOW READS THIS TOO
|
|
84
86
|
* (w-0c1ba766eb): a row on this list is waiting on the app, not on a person,
|
|
85
87
|
* so it is In progress and not Needs you until the queue has dealt with it.
|
|
88
|
+
*
|
|
89
|
+
* A CLOSED ROW STILL SHIPS, because a close is not a cancel. The window
|
|
90
|
+
* leaves closed rows out (`waitingIds`), so one stays in Done while it ships.
|
|
86
91
|
*/
|
|
92
|
+
export const owedAShip = (item, handled = {}) => wantsShipping(item) && !(markedAt(item) <= (handled[keyOf(item)] ?? -1));
|
|
93
|
+
|
|
87
94
|
export function waitingToShip(items, products, settings, { folderFor, handled = {} }) {
|
|
88
95
|
const bySlug = new Map(products.map((p) => [p.slug, p]));
|
|
89
96
|
return items
|
|
90
|
-
.filter((i) =>
|
|
97
|
+
.filter((i) => owedAShip(i, handled))
|
|
91
98
|
.map((item) => {
|
|
92
99
|
const product = bySlug.get(item.product);
|
|
93
100
|
const script = product ? shipScriptFor(product, settings) : null;
|
|
@@ -98,12 +105,24 @@ export function waitingToShip(items, products, settings, { folderFor, handled =
|
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
/**
|
|
101
|
-
* The next task to ship,
|
|
102
|
-
*
|
|
108
|
+
* The next task to ship, the most important first, or null.
|
|
109
|
+
*
|
|
110
|
+
* A session the row's own agent is still finishing holds its ship: its commits
|
|
111
|
+
* may not all be in. A session that started AFTER the label does not, because
|
|
112
|
+
* that is a reply, and a reply to a thread already waiting used to hold it for
|
|
113
|
+
* as long as the new session ran (2026-10-19). `liveSince` answering null
|
|
114
|
+
* means nobody can say when the live work started — a cloud run whose folder
|
|
115
|
+
* has not come home — and that waits, as it always did.
|
|
116
|
+
*
|
|
117
|
+
* Priority before age, the way `shared/work-items.mjs` orders the queue a
|
|
118
|
+
* puller sees: a thread the person is waiting on queued behind every older one.
|
|
103
119
|
*/
|
|
104
|
-
export function nextToShip(items, products, settings, {
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
export function nextToShip(items, products, settings, {
|
|
121
|
+
isLive = () => false, liveSince = () => null, folderFor, handled = {},
|
|
122
|
+
}) {
|
|
123
|
+
const ready = waitingToShip(items, products, settings, { folderFor, handled })
|
|
124
|
+
.filter(({ item }) => !isLive(item) || liveSince(item) > markedAt(item));
|
|
125
|
+
ready.sort((a, b) => (b.item.priority ?? 0) - (a.item.priority ?? 0) || markedAt(a.item) - markedAt(b.item));
|
|
107
126
|
return ready[0] ?? null;
|
|
108
127
|
}
|
|
109
128
|
|
|
@@ -185,7 +204,7 @@ function diagnosis({ why, failed }) {
|
|
|
185
204
|
}
|
|
186
205
|
|
|
187
206
|
/** What the agent is told when its branch did not ship. */
|
|
188
|
-
export function failureReply(output) {
|
|
207
|
+
export function failureReply(output, { brief = null } = {}) {
|
|
189
208
|
const tail = String(output ?? '').trim().slice(-KEEP);
|
|
190
209
|
const { died, ...rest } = whyItDidNotShip(output);
|
|
191
210
|
return [
|
|
@@ -202,6 +221,7 @@ export function failureReply(output) {
|
|
|
202
221
|
'a worker that was starved or timed out is likelier than a bug in your change.',
|
|
203
222
|
'Run the tests for your change again before you go looking for one.',
|
|
204
223
|
] : []),
|
|
224
|
+
...(brief ? ['', brief] : []),
|
|
205
225
|
'',
|
|
206
226
|
'Fix what the script says below in your task folder, commit, then add the label',
|
|
207
227
|
`\`${SHIP_LABEL}\` to this row again and end your turn. Do not push or touch any`,
|
|
@@ -217,16 +237,35 @@ export function failureReply(output) {
|
|
|
217
237
|
export function failureNote(output) {
|
|
218
238
|
const { died, last, ...rest } = whyItDidNotShip(output);
|
|
219
239
|
const said = died ? `${DIED}. It last said: ${last ?? 'nothing at all'}` : diagnosis(rest);
|
|
220
|
-
return
|
|
240
|
+
return `${SHIP_FAILED}${said.slice(0, 300)}`;
|
|
221
241
|
}
|
|
222
242
|
|
|
223
|
-
|
|
243
|
+
/**
|
|
244
|
+
* The base checkout's hooks, read off the task folder's shared git dir. The
|
|
245
|
+
* ship runs with cwd = the task folder, whose core.hooksPath may be the
|
|
246
|
+
* relative 'scripts/hooks' resolving to the branch's own, worker-edited copy.
|
|
247
|
+
* Pinning the base copy here is what keeps the gate the branch cannot change.
|
|
248
|
+
*/
|
|
249
|
+
function baseHooksPath(cwd) {
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
execFile('git', ['-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8', timeout: 10_000 }, (err, stdout) => {
|
|
252
|
+
const common = err ? '' : String(stdout).trim();
|
|
253
|
+
resolve(common ? path.resolve(common, '..', 'scripts', 'hooks') : null);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function runScript(script, cwd, { timeout = SHIP_MS, env = process.env } = {}) {
|
|
224
259
|
const node = env.npm_node_execpath && fs.existsSync(env.npm_node_execpath) ? env.npm_node_execpath : 'node';
|
|
260
|
+
const hooks = await baseHooksPath(cwd);
|
|
261
|
+
const baseGate = hooks
|
|
262
|
+
? { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.hooksPath', GIT_CONFIG_VALUE_0: hooks }
|
|
263
|
+
: {};
|
|
225
264
|
return new Promise((resolve) => {
|
|
226
265
|
let out = '';
|
|
227
266
|
let child;
|
|
228
267
|
try {
|
|
229
|
-
child = spawn(node, [script], { cwd, env: { ...env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
268
|
+
child = spawn(node, [script], { cwd, env: { ...env, GIT_TERMINAL_PROMPT: '0', ...baseGate }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
230
269
|
} catch (e) {
|
|
231
270
|
resolve({ code: 1, out: String(e?.message ?? e) });
|
|
232
271
|
return;
|
|
@@ -245,23 +284,26 @@ export function runScript(script, cwd, { timeout = SHIP_MS, env = process.env }
|
|
|
245
284
|
* supervisor's own tick and returns at once; the run happens beside it.
|
|
246
285
|
*/
|
|
247
286
|
export class ShipQueue {
|
|
248
|
-
constructor({ store, userDir, folderFor, isLive, handBack = () => {}, run = runScript, afterShip = () => {}, log = console }) {
|
|
249
|
-
Object.assign(this, { store, userDir, folderFor, isLive, handBack, run, afterShip, log });
|
|
287
|
+
constructor({ store, userDir, folderFor, isLive, liveSince = () => null, handBack = () => {}, run = runScript, afterShip = () => {}, log = console }) {
|
|
288
|
+
Object.assign(this, { store, userDir, folderFor, isLive, liveSince, handBack, run, afterShip, log });
|
|
250
289
|
this.busy = null;
|
|
290
|
+
this.earlyConflicts = new Map();
|
|
251
291
|
}
|
|
252
292
|
|
|
253
293
|
tick(items, products) {
|
|
254
294
|
if (this.busy) return null;
|
|
255
295
|
const settings = readShipSettings(this.userDir);
|
|
256
296
|
if (!Object.keys(settings).length) return null;
|
|
257
|
-
const next = nextToShip(items, products, settings, {
|
|
297
|
+
const next = nextToShip(items, products, settings, {
|
|
298
|
+
isLive: this.isLive, liveSince: this.liveSince, folderFor: this.folderFor, handled: readHandled(this.userDir),
|
|
299
|
+
});
|
|
258
300
|
if (!next) return null;
|
|
259
301
|
// Marked handled only once the run has ENDED, pass or fail. Marked before
|
|
260
302
|
// it, a run cut off by an app restart was never tried again
|
|
261
303
|
// (w-f37a34def6, 2026-10-04). Within one app, `busy` already stops a
|
|
262
304
|
// second start; across a restart, trying again is the point.
|
|
263
305
|
const done = () => writeHandled(this.userDir, { ...readHandled(this.userDir), [keyOf(next.item)]: markedAt(next.item) });
|
|
264
|
-
this.busy = this.ship(next).then(done, done).finally(() => { this.busy = null; });
|
|
306
|
+
this.busy = this.ship(next, products, items).then(done, done).finally(() => { this.busy = null; });
|
|
265
307
|
return this.busy;
|
|
266
308
|
}
|
|
267
309
|
|
|
@@ -270,24 +312,78 @@ export class ShipQueue {
|
|
|
270
312
|
const settings = readShipSettings(this.userDir);
|
|
271
313
|
if (!Object.keys(settings).length) return [];
|
|
272
314
|
return waitingToShip(items, products, settings, { folderFor: this.folderFor, handled: readHandled(this.userDir) })
|
|
315
|
+
.filter(({ item }) => item.status !== 'done')
|
|
273
316
|
.map(({ item }) => item.id);
|
|
274
317
|
}
|
|
275
318
|
|
|
276
|
-
|
|
319
|
+
/** Whether this row's folder must stay for a ship still to come. */
|
|
320
|
+
owes(item, products) {
|
|
321
|
+
const settings = readShipSettings(this.userDir);
|
|
322
|
+
if (!Object.keys(settings).length) return false;
|
|
323
|
+
return waitingToShip([item], products, settings, { folderFor: this.folderFor, handled: readHandled(this.userDir) }).length > 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// A thread id from a merge on main, read from this project or another one on
|
|
327
|
+
// the same checkout.
|
|
328
|
+
_readThread(id, product, products) {
|
|
329
|
+
const slugs = [product.slug, ...(products ?? []).filter((p) => p.slug !== product.slug && p.repoPath && p.repoPath === product.repoPath).map((p) => p.slug)];
|
|
330
|
+
for (const slug of slugs) {
|
|
331
|
+
const found = this.store.readItem?.(slug, id);
|
|
332
|
+
if (found) return found;
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// After a ship lands, every other open branch of a project that ships is
|
|
338
|
+
// checked against the new main with `git merge-tree`, which reads commits and
|
|
339
|
+
// touches no task folder. A conflict waits here for that thread's next run.
|
|
340
|
+
async _findNewConflicts(shipped, items, products) {
|
|
341
|
+
const settings = readShipSettings(this.userDir);
|
|
342
|
+
const bySlug = new Map((products ?? []).map((p) => [p.slug, p]));
|
|
343
|
+
for (const other of items ?? []) {
|
|
344
|
+
const product = bySlug.get(other.product);
|
|
345
|
+
if (other.id === shipped.id || other.status === 'done' || !product || !settings[product.slug]) continue;
|
|
346
|
+
const cwd = this.folderFor(other, product);
|
|
347
|
+
if (!cwd || !fs.existsSync(cwd)) continue;
|
|
348
|
+
try {
|
|
349
|
+
const note = await earlyConflictNote({ cwd, readThread: (id) => this._readThread(id, product, products) });
|
|
350
|
+
if (note) this.earlyConflicts.set(keyOf(other), note); else this.earlyConflicts.delete(keyOf(other));
|
|
351
|
+
} catch (e) { this.log.warn?.(`zero: could not check ${other.id} against main:`, e.message); }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** What main's last ship means for this thread's branch, once, or null. */
|
|
356
|
+
takeEarlyConflict(item) {
|
|
357
|
+
const note = this.earlyConflicts.get(keyOf(item)) ?? null;
|
|
358
|
+
this.earlyConflicts.delete(keyOf(item));
|
|
359
|
+
return note;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async ship({ item, product, script, cwd }, products = [], items = []) {
|
|
277
363
|
this.log.info?.(`zero: shipping ${item.id} from ${cwd}`);
|
|
278
364
|
const { code, out } = await this.run(script, cwd);
|
|
279
365
|
try {
|
|
280
366
|
// Already on main: there is nothing to fix, so nobody is woken.
|
|
281
367
|
const already = /There is nothing to ship/.test(out);
|
|
282
368
|
if (code === 0 || already) {
|
|
283
|
-
|
|
284
|
-
this.
|
|
369
|
+
const sha = shippedSha(out);
|
|
370
|
+
this.store.shipped(product.slug, item.id, { sha, labels: withoutShipLabel(item) });
|
|
371
|
+
// THE COMMIT TRAVELS WITH THE NEWS. What follows a ship wants to know
|
|
372
|
+
// which commit went out: main/main-went-red.mjs waits for that
|
|
373
|
+
// commit's test run on GitHub, because nothing else is still running
|
|
374
|
+
// five minutes after the agent ended.
|
|
375
|
+
this.afterShip({ item, product, sha });
|
|
376
|
+
await this._findNewConflicts(item, items, products);
|
|
285
377
|
} else {
|
|
286
378
|
this.store.shipFailed(product.slug, item.id, { note: failureNote(out), labels: withoutShipLabel(item) });
|
|
379
|
+
let brief = null;
|
|
380
|
+
try {
|
|
381
|
+
brief = await conflictBrief({ cwd, output: out, readThread: (id) => this._readThread(id, product, products) });
|
|
382
|
+
} catch (e) { this.log.warn?.(`zero: could not brief the conflict on ${item.id}:`, e.message); }
|
|
287
383
|
// Straight back to the session that wrote the change. Not through the
|
|
288
384
|
// reply on the row: that field is hers, and the app's words written
|
|
289
385
|
// there would be drawn as hers and could not replace hers anyway.
|
|
290
|
-
this.handBack(item, failureReply(out));
|
|
386
|
+
this.handBack(item, failureReply(out, { brief }));
|
|
291
387
|
}
|
|
292
388
|
} catch (e) {
|
|
293
389
|
this.log.warn?.(`zero: could not record the ship of ${item.id}:`, e.message);
|
package/main/source-updater.mjs
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
34
34
|
import fs from 'node:fs';
|
|
35
35
|
import { describe } from './updater.mjs';
|
|
36
|
+
import { loginShell } from './shell-path.mjs';
|
|
36
37
|
|
|
37
38
|
const FIRST_CHECK_MS = 20_000;
|
|
38
39
|
const EVERY_MS = 30 * 60 * 1000;
|
|
@@ -103,19 +104,26 @@ function collect(bin, args, { cwd, timeout }) {
|
|
|
103
104
|
|
|
104
105
|
// WHICH npm. Started with `npm start`, the process carries npm's own path in
|
|
105
106
|
// its environment, so that one is used and nvm or a version manager never
|
|
106
|
-
// matters. Started any other way,
|
|
107
|
-
// main/claude-bin.mjs finds Claude Code
|
|
108
|
-
//
|
|
107
|
+
// matters. Started any other way, their login shell is asked, the way
|
|
108
|
+
// main/claude-bin.mjs finds Claude Code, and only a shell this machine has: a
|
|
109
|
+
// Linux launcher start carries no SHELL, so the old `|| '/bin/zsh'` was a path
|
|
110
|
+
// to nothing and every update on a box without zsh failed in two seconds. With
|
|
111
|
+
// no shell at all, npm on PATH is the better guess. The arguments are fixed
|
|
112
|
+
// strings from this file, never anything read from disk.
|
|
113
|
+
export function npmCommand(args, { env = process.env, exists = fs.existsSync } = {}) {
|
|
114
|
+
const node = env.npm_node_execpath;
|
|
115
|
+
const cli = env.npm_execpath;
|
|
116
|
+
if (node && cli && exists(node) && exists(cli)) return [node, [cli, ...args]];
|
|
117
|
+
const shell = loginShell(env, exists);
|
|
118
|
+
if (!exists(shell)) return ['npm', args];
|
|
119
|
+
return [shell, ['-lic', `npm ${args.join(' ')}`]];
|
|
120
|
+
}
|
|
121
|
+
|
|
109
122
|
export function defaultNpm(appDir, env = process.env) {
|
|
110
123
|
return (args) => {
|
|
111
124
|
const timeout = args[0] === 'install' ? INSTALL_MS : BUILD_MS;
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
if (node && cli && fs.existsSync(node) && fs.existsSync(cli)) {
|
|
115
|
-
return collect(node, [cli, ...args], { cwd: appDir, timeout });
|
|
116
|
-
}
|
|
117
|
-
const shell = env.SHELL || '/bin/zsh';
|
|
118
|
-
return collect(shell, ['-lic', `npm ${args.join(' ')}`], { cwd: appDir, timeout });
|
|
125
|
+
const [bin, argv] = npmCommand(args, { env });
|
|
126
|
+
return collect(bin, argv, { cwd: appDir, timeout });
|
|
119
127
|
};
|
|
120
128
|
}
|
|
121
129
|
|