agentbox-app 0.1.11 → 0.1.13
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 -16
- package/bin/agentbox.mjs +18 -9
- package/briefs/message-rules.md +15 -4
- package/main/account-tooling.mjs +2 -1
- package/main/acp.mjs +296 -0
- package/main/agent-homes.mjs +158 -0
- package/main/agent-sessions.mjs +57 -20
- package/main/agent-updates.mjs +9 -20
- package/main/analytics.mjs +30 -0
- package/main/approvals.mjs +14 -5
- package/main/claude-bin.mjs +45 -310
- package/main/claude-input.mjs +9 -3
- package/main/claude-launch-env.mjs +54 -0
- package/main/claude-models.mjs +39 -7
- package/main/claude-plan.mjs +14 -2
- package/main/claude-usage.mjs +82 -25
- package/main/codex-bin.mjs +51 -248
- package/main/codex-launch-env.mjs +31 -0
- package/main/codex-session.mjs +38 -3
- package/main/codex-threads.mjs +41 -8
- package/main/codex-watch.mjs +67 -3
- package/main/config.mjs +40 -13
- package/main/corner-tag.mjs +489 -0
- package/main/crash-report.mjs +26 -3
- package/main/demo.mjs +17 -14
- package/main/engine-bin.mjs +595 -0
- package/main/engine-setup.mjs +27 -27
- package/main/feedback.mjs +11 -6
- package/main/fresh-user.mjs +1 -1
- package/main/git-change.mjs +8 -2
- package/main/harnesses/claude-stream.mjs +208 -0
- package/main/harnesses/claude.mjs +84 -0
- package/main/harnesses/codex.mjs +65 -0
- package/main/harnesses/common.mjs +78 -0
- package/main/harnesses/instructions-file.mjs +116 -0
- package/main/harnesses/opencode.mjs +234 -0
- package/main/harnesses.mjs +65 -0
- package/main/importable-threads.mjs +38 -0
- package/main/instruction-settings.mjs +47 -11
- package/main/ipc.mjs +175 -22
- package/main/local-app-frames.mjs +82 -0
- package/main/machine.mjs +1 -1
- package/main/main.mjs +120 -5
- package/main/memory-gate-history.mjs +51 -12
- package/main/memory-gate-server.mjs +67 -11
- package/main/memory-gate.mjs +49 -8
- package/main/memory-log.mjs +124 -0
- package/main/notify.mjs +48 -5
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-link.mjs +551 -0
- package/main/refresh-agent-models.mjs +2 -1
- package/main/relay-address.mjs +28 -0
- package/main/relay-client.mjs +246 -0
- package/main/row-label.mjs +8 -7
- package/main/runs-on.mjs +90 -0
- package/main/serve.mjs +124 -10
- package/main/settings.mjs +171 -11
- package/main/shell-path.mjs +86 -0
- package/main/ship-queue.mjs +25 -7
- package/main/sign-in-files.mjs +3 -7
- package/main/source-updater.mjs +27 -1
- package/main/start-hold.mjs +73 -0
- package/main/store/home.mjs +37 -5
- package/main/store/project.mjs +15 -19
- package/main/store.mjs +10 -4
- package/main/supervisor.mjs +1030 -447
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +491 -82
- package/main/task-terminals.mjs +49 -8
- package/main/team/index.mjs +2 -2
- package/main/team/projects.mjs +1 -1
- package/main/updater.mjs +71 -9
- package/main/worktree-include.mjs +494 -0
- package/mcp/core/page.mjs +1 -1
- package/mcp/core/work.mjs +34 -3
- package/mcp/tools.mjs +2 -1
- package/package.json +10 -1
- package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
- package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
- package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
- package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
- package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
- package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
- package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
- package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
- package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
- package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
- package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
- package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
- package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
- package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
- package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
- package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
- package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
- package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
- package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
- package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
- package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
- package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
- package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
- package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
- package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
- package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
- package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
- package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
- package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
- package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
- package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
- package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
- package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
- package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
- package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
- package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
- package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
- package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
- package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
- package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
- package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
- package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
- package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
- package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
- package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
- package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
- package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
- package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
- package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
- package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
- package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
- package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
- package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
- package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
- package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
- package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
- package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
- package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
- package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
- package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
- package/renderer/dist/corner-tag.html +13 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +297 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/index.html +16 -3
- package/scripts/after-install.mjs +14 -0
- package/scripts/approval-server.sh +31 -7
- package/scripts/before-publish.mjs +12 -10
- package/shared/agent-import.mjs +3 -1
- package/shared/agents.d.mts +1 -0
- package/shared/bridge-map.mjs +19 -0
- package/shared/claude-commands.generated.mjs +8 -6
- package/shared/claude-models.generated.mjs +10 -8
- package/shared/codex-modes.mjs +1 -1
- package/shared/corner-tag.d.mts +14 -0
- package/shared/corner-tag.mjs +141 -0
- package/shared/demo-world.mjs +1 -1
- package/shared/engines.mjs +20 -5
- package/shared/fresh-user-home.mjs +12 -1
- package/shared/harness-definitions.mjs +103 -0
- package/shared/instruction-defaults.json +1 -1
- package/shared/notify-rules.d.mts +25 -0
- package/shared/notify-rules.mjs +19 -0
- package/shared/project-folder-check.mjs +3 -3
- package/shared/provider-commands.mjs +10 -2
- package/shared/rank.d.mts +2 -0
- package/shared/rank.mjs +50 -0
- package/shared/relay-address.mjs +6 -0
- package/shared/relay-crypto.mjs +165 -0
- package/shared/runs-on.d.mts +10 -0
- package/shared/runs-on.mjs +55 -0
- package/shared/shortcuts.mjs +11 -5
- package/shared/spawn-trouble.mjs +58 -7
- package/shared/terminal-input.d.mts +1 -0
- package/shared/terminal-input.mjs +30 -0
- package/shared/terminal-keys.d.mts +1 -0
- package/shared/terminal-keys.mjs +16 -0
- package/shared/terminal-size.d.mts +4 -0
- package/shared/terminal-size.mjs +29 -0
- package/shared/usage.d.mts +3 -0
- package/shared/usage.mjs +7 -0
- package/shared/where-it-runs-from.d.mts +43 -0
- package/shared/where-it-runs-from.mjs +155 -0
- package/shared/work-items.mjs +28 -2
- package/shared/work-lines.mjs +1 -1
- package/shared/xterm-invalid-range.d.mts +2 -0
- package/shared/xterm-invalid-range.mjs +58 -0
- package/renderer/dist/assets/index-B-2Az952.css +0 -32
- package/renderer/dist/assets/index-D6qncgai.js +0 -1168
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// A LINE A MINUTE: HOW SHORT MEMORY IS, AND WHICH TASK IS HOLDING IT (w-ec27ff1a51).
|
|
2
|
+
//
|
|
3
|
+
// 2026-10-11 the Mac ran out of memory with Agentbox at 22 GB. By the time
|
|
4
|
+
// anyone looked it had calmed down, and nothing had been written down, so which
|
|
5
|
+
// task had been holding the memory could only be guessed. This writes one JSON
|
|
6
|
+
// line a minute to `memory-<day>.jsonl` in the app's own folder, kept a week:
|
|
7
|
+
// the kernel's reading, the agents running and the limit, whether new agents
|
|
8
|
+
// were being held (main/start-hold.mjs), the app's own processes, every task's
|
|
9
|
+
// programs with what they hold, and the biggest programs that are not ours.
|
|
10
|
+
//
|
|
11
|
+
// It stays on this computer. Nothing here is sent anywhere.
|
|
12
|
+
//
|
|
13
|
+
// A program belongs to the task of the nearest process at or above it that
|
|
14
|
+
// carries ZERO_ITEM (main/leftovers.mjs reads the tag), which is what catches a
|
|
15
|
+
// dev server that renamed itself and hid its environment.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { listProcesses } from './leftovers.mjs';
|
|
20
|
+
import { readMacPressure } from './memory-gate-server.mjs';
|
|
21
|
+
import { pressureFrom } from './memory-gate.mjs';
|
|
22
|
+
|
|
23
|
+
/** Days of files kept, today included. */
|
|
24
|
+
export const KEEP_DAYS = 7;
|
|
25
|
+
const FILE = /^memory-(\d{4}-\d{2}-\d{2})\.jsonl$/;
|
|
26
|
+
|
|
27
|
+
const name = (exe) => String(exe ?? '').split('/').filter(Boolean).pop() || String(exe ?? '');
|
|
28
|
+
const top = (list, n) => [...list].sort((a, b) => b.mb - a.mb).slice(0, n).map((p) => ({ name: name(p.exe), mb: Math.round(p.mb) }));
|
|
29
|
+
|
|
30
|
+
/** One minute's line, from one listing. Pure. */
|
|
31
|
+
export function summarizeMemory(procs, { appPid = process.pid, at = Date.now(), reading = {} } = {}) {
|
|
32
|
+
const byPid = new Map(procs.map((p) => [p.pid, p]));
|
|
33
|
+
const owner = new Map();
|
|
34
|
+
const ownerOf = (p) => {
|
|
35
|
+
if (owner.has(p.pid)) return owner.get(p.pid);
|
|
36
|
+
let found = null;
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (let q = p; q && !seen.has(q.pid); q = byPid.get(q.ppid)) {
|
|
39
|
+
seen.add(q.pid);
|
|
40
|
+
if (q.item) { found = { item: q.item, product: q.product ?? null }; break; }
|
|
41
|
+
if (q.pid === appPid) { found = 'app'; break; }
|
|
42
|
+
}
|
|
43
|
+
owner.set(p.pid, found);
|
|
44
|
+
return found;
|
|
45
|
+
};
|
|
46
|
+
const tasks = new Map();
|
|
47
|
+
const app = [];
|
|
48
|
+
const others = [];
|
|
49
|
+
for (const p of procs) {
|
|
50
|
+
const o = ownerOf(p);
|
|
51
|
+
if (o === 'app') app.push(p);
|
|
52
|
+
else if (o) {
|
|
53
|
+
const t = tasks.get(o.item) ?? { item: o.item, product: o.product, list: [] };
|
|
54
|
+
t.product ??= o.product;
|
|
55
|
+
t.list.push(p);
|
|
56
|
+
tasks.set(o.item, t);
|
|
57
|
+
} else others.push(p);
|
|
58
|
+
}
|
|
59
|
+
const sum = (list) => Math.round(list.reduce((n, p) => n + (Number(p.mb) || 0), 0));
|
|
60
|
+
const level = Number.isFinite(reading?.level) ? reading.level : null;
|
|
61
|
+
const freePct = Number.isFinite(reading?.freePct) ? reading.freePct : null;
|
|
62
|
+
return {
|
|
63
|
+
at: new Date(at).toISOString(),
|
|
64
|
+
pressure: level === null && freePct === null ? null : pressureFrom({ level, freePct }),
|
|
65
|
+
level,
|
|
66
|
+
freePct,
|
|
67
|
+
totalMb: sum(procs),
|
|
68
|
+
app: { mb: sum(app), procs: app.length },
|
|
69
|
+
tasks: [...tasks.values()]
|
|
70
|
+
.map((t) => ({ item: t.item, product: t.product, mb: sum(t.list), procs: t.list.length, top: top(t.list, 3) }))
|
|
71
|
+
.sort((a, b) => b.mb - a.mb),
|
|
72
|
+
others: top(others, 5),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const dayOf = (ms) => {
|
|
77
|
+
const d = new Date(ms);
|
|
78
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export class MemoryLog {
|
|
82
|
+
constructor({
|
|
83
|
+
dir, appPid = process.pid, list = listProcesses, readPressure = readMacPressure,
|
|
84
|
+
now = Date.now, facts = () => ({}), keepDays = KEEP_DAYS,
|
|
85
|
+
}) {
|
|
86
|
+
Object.assign(this, { dir, appPid, list, readPressure, now, facts, keepDays });
|
|
87
|
+
this._pruned = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Writes this minute's line. Never throws: a log that breaks the app is worse than a gap in the log. */
|
|
91
|
+
async tick() {
|
|
92
|
+
if (this._busy) return;
|
|
93
|
+
this._busy = true;
|
|
94
|
+
try {
|
|
95
|
+
const [procs, reading] = await Promise.all([
|
|
96
|
+
Promise.resolve().then(() => this.list()).catch(() => []),
|
|
97
|
+
Promise.resolve().then(() => this.readPressure()).catch(() => ({})),
|
|
98
|
+
]);
|
|
99
|
+
const at = this.now();
|
|
100
|
+
let facts = {};
|
|
101
|
+
try { facts = this.facts() ?? {}; } catch { /* the line goes without them */ }
|
|
102
|
+
const line = { ...summarizeMemory(procs, { appPid: this.appPid, at, reading }), ...facts };
|
|
103
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
104
|
+
fs.appendFileSync(path.join(this.dir, `memory-${dayOf(at)}.jsonl`), `${JSON.stringify(line)}\n`);
|
|
105
|
+
this._prune(at);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn('zero: memory log:', err.message);
|
|
108
|
+
} finally {
|
|
109
|
+
this._busy = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Once a day: removes this log's own files older than `keepDays`, and nothing else. */
|
|
114
|
+
_prune(at) {
|
|
115
|
+
const today = dayOf(at);
|
|
116
|
+
if (this._pruned === today) return;
|
|
117
|
+
this._pruned = today;
|
|
118
|
+
const oldest = dayOf(new Date(at).setDate(new Date(at).getDate() - (this.keepDays - 1)));
|
|
119
|
+
for (const f of fs.readdirSync(this.dir)) {
|
|
120
|
+
const m = f.match(FILE);
|
|
121
|
+
if (m && m[1] < oldest) fs.rmSync(path.join(this.dir, f), { force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/main/notify.mjs
CHANGED
|
@@ -13,16 +13,46 @@
|
|
|
13
13
|
// AND ONCE, NOT TWENTY TIMES. One at a time is not once. `spoke` below is the
|
|
14
14
|
// fix, and shared/notify-rules.mjs carries the rule.
|
|
15
15
|
|
|
16
|
+
import path from 'node:path';
|
|
16
17
|
import { atTheApp, banner, shouldSpeak } from '../shared/notify-rules.mjs';
|
|
17
18
|
import { NAME } from '../shared/product-name.mjs';
|
|
18
19
|
|
|
20
|
+
// THE MARK THE BANNER WEARS, and why it is a png and not the .icns every other
|
|
21
|
+
// part of this repo uses.
|
|
22
|
+
//
|
|
23
|
+
// macOS draws the app's OWN bundle icon down the left of a banner, and no app
|
|
24
|
+
// can override that. From a source checkout the bundle being asked is the
|
|
25
|
+
// Electron.app in node_modules, which scripts/brand-electron.mjs renames and
|
|
26
|
+
// re-icons, and macOS still drew a generic grey square: "it shows a plain
|
|
27
|
+
// placeholder", looking at a real one, 2026-10-07.
|
|
28
|
+
//
|
|
29
|
+
// An image handed to the notification is the half we DO control; macOS attaches
|
|
30
|
+
// it to the banner. Measured the same day in the Electron the app runs from:
|
|
31
|
+
// nativeImage.createFromPath on build/icon.icns comes back empty, 0x0, so the
|
|
32
|
+
// icns is not a candidate however convenient it would be. The png beside it,
|
|
33
|
+
// drawn from the same art, reads 256x256.
|
|
34
|
+
//
|
|
35
|
+
// AN EMPTY IMAGE IS NOT A MARK. createFromPath answers a missing or unreadable
|
|
36
|
+
// file with an empty image rather than an error, so "it returned something" is
|
|
37
|
+
// the wrong question, and handing that something to macOS is how this goes
|
|
38
|
+
// quietly back to a grey square. Returns null when there is nothing to show,
|
|
39
|
+
// and the banner then looks exactly as it did before any of this.
|
|
40
|
+
export const MARK_FILE = path.join('build', 'notification-icon.png');
|
|
41
|
+
|
|
42
|
+
export function markFor({ root, createFromPath }) {
|
|
43
|
+
try {
|
|
44
|
+
const img = createFromPath(path.join(root, MARK_FILE));
|
|
45
|
+
return img && !img.isEmpty() ? img : null;
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
19
49
|
// A BURST IS ONE PIECE OF NEWS. Four workers finishing inside the same
|
|
20
50
|
// supervisor tick is the ordinary case, and it arrives as four pushes over a
|
|
21
51
|
// second or two. Waiting a beat before speaking turns that into one banner
|
|
22
52
|
// with one sound instead of four rewrites and four chimes.
|
|
23
53
|
const COALESCE_MS = 1500;
|
|
24
54
|
|
|
25
|
-
export function createNotifier({ window, Notification, powerMonitor, coalesceMs = COALESCE_MS }) {
|
|
55
|
+
export function createNotifier({ window, Notification, powerMonitor, icon = null, coalesceMs = COALESCE_MS }) {
|
|
26
56
|
// Everything she has not seen since she last had the window, keyed by id so
|
|
27
57
|
// a repeated push is idempotent. Insertion order is arrival order.
|
|
28
58
|
const unseen = new Map();
|
|
@@ -81,6 +111,9 @@ export function createNotifier({ window, Notification, powerMonitor, coalesceMs
|
|
|
81
111
|
title: say.title,
|
|
82
112
|
body: say.body,
|
|
83
113
|
silent: false,
|
|
114
|
+
// Omitted rather than passed empty when there is nothing to show, so a
|
|
115
|
+
// banner with no mark is byte for byte the banner that shipped before.
|
|
116
|
+
...(icon ? { icon } : {}),
|
|
84
117
|
});
|
|
85
118
|
} catch { return; }
|
|
86
119
|
// A banner she can act on. Clicking it brings Agentbox forward and opens the
|
|
@@ -104,7 +137,7 @@ export function createNotifier({ window, Notification, powerMonitor, coalesceMs
|
|
|
104
137
|
// two-minute diagnosis.
|
|
105
138
|
n.on('failed', (_e, err) => {
|
|
106
139
|
live = null;
|
|
107
|
-
console.warn(`zero:
|
|
140
|
+
console.warn(`zero: the system refused the notification (${err}). Check ${NAME} in the system notification settings.`);
|
|
108
141
|
});
|
|
109
142
|
live = n;
|
|
110
143
|
try {
|
|
@@ -138,18 +171,28 @@ export function createNotifier({ window, Notification, powerMonitor, coalesceMs
|
|
|
138
171
|
};
|
|
139
172
|
}
|
|
140
173
|
|
|
141
|
-
|
|
174
|
+
// `onArrivals` hears every arrival too, for the phone's alerts
|
|
175
|
+
// (main/phone-link.mjs), which make their own call about speaking.
|
|
176
|
+
export function installNotifier({ app, window, Notification, powerMonitor, ipcMain, nativeImage, onArrivals = () => {} }) {
|
|
142
177
|
if (Notification.isSupported && !Notification.isSupported()) {
|
|
178
|
+
ipcMain.handle('zero:notify', (_e, payload = {}) => { try { onArrivals(Array.isArray(payload.arrivals) ? payload.arrivals : []); } catch {} });
|
|
143
179
|
return { add() {}, seen() {} };
|
|
144
180
|
}
|
|
145
|
-
|
|
181
|
+
// app.getAppPath() is the repo from source and the asar inside the bundle
|
|
182
|
+
// when it is packaged, and the mark sits at the same place in both.
|
|
183
|
+
const icon = nativeImage
|
|
184
|
+
? markFor({ root: app.getAppPath(), createFromPath: (p) => nativeImage.createFromPath(p) })
|
|
185
|
+
: null;
|
|
186
|
+
const notifier = createNotifier({ window, Notification, powerMonitor, icon });
|
|
146
187
|
window.on('focus', () => notifier.seen());
|
|
147
188
|
// Unlocking with Agentbox already the front app never fires 'focus' (it never
|
|
148
189
|
// lost it), so without this the banner that fired while the screen was
|
|
149
190
|
// locked would sit in Notification Center after she is back at the row.
|
|
150
191
|
try { powerMonitor.on('unlock-screen', () => { if (window.isFocused()) notifier.seen(); }); } catch {}
|
|
151
192
|
ipcMain.handle('zero:notify', (_e, payload = {}) => {
|
|
152
|
-
|
|
193
|
+
const arrivals = Array.isArray(payload.arrivals) ? payload.arrivals : [];
|
|
194
|
+
try { onArrivals(arrivals); } catch {}
|
|
195
|
+
notifier.add(arrivals);
|
|
153
196
|
});
|
|
154
197
|
return notifier;
|
|
155
198
|
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// OPENCODE'S OWN TRANSPORT, AND WHY IT IS THE SERVER AND NOT THE CLI.
|
|
2
|
+
//
|
|
3
|
+
// Measured against a real OpenCode 1.18.35 on 2026-10-07. `opencode run` is
|
|
4
|
+
// the obvious road -- it is non-interactive, `--format json` prints one JSON
|
|
5
|
+
// object per line, and that is nearly the shape main/harnesses/claude-stream
|
|
6
|
+
// already reads. It is also the wrong road, and the measurement says so in one
|
|
7
|
+
// line. Run with `permission: { bash: "ask" }` in config, the CLI printed:
|
|
8
|
+
//
|
|
9
|
+
// ! permission requested: bash (echo probe-run-mode); auto-rejecting
|
|
10
|
+
//
|
|
11
|
+
// and handed the model "The user rejected permission to use this specific tool
|
|
12
|
+
// call." With no permission config at all it ran the command with no card.
|
|
13
|
+
// `--auto` approves everything not explicitly denied. So the CLI offers
|
|
14
|
+
// approve-everything or refuse-everything, and an Agentbox card is neither.
|
|
15
|
+
//
|
|
16
|
+
// The HTTP server does carry a real approval. `opencode serve` raises
|
|
17
|
+
// `permission.asked` on its `/event` stream with the command in
|
|
18
|
+
// `metadata.command`, and `POST /session/:id/permissions/:id` with
|
|
19
|
+
// `{ response: "once" }` releases it while `{ response: "reject" }` refuses it.
|
|
20
|
+
// Both measured end to end against the real binary, both answers, 200 each
|
|
21
|
+
// time, the tool running in the first case and coming back
|
|
22
|
+
// "The user rejected permission to use this specific tool call." in the second.
|
|
23
|
+
// That is the same card contract main/codex-approvals.mjs already fills, which
|
|
24
|
+
// is why this file exists and a second stream parser does not.
|
|
25
|
+
//
|
|
26
|
+
// AND IT IS WHY WE DO NOT PASS `--auto` ANYWHERE. A grep for it is a test
|
|
27
|
+
// (tests/opencode-asks-before-it-runs-a-command), because the flag would make
|
|
28
|
+
// every run quietly approve its own file writes and shell commands.
|
|
29
|
+
import { EventEmitter } from 'node:events';
|
|
30
|
+
import { Name } from '../shared/product-name.mjs';
|
|
31
|
+
|
|
32
|
+
/** How long a single HTTP call to the local server may take. */
|
|
33
|
+
const CALL_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A client for one `opencode serve`.
|
|
37
|
+
*
|
|
38
|
+
* `password` is the server's own basic-auth secret, which Agentbox generates
|
|
39
|
+
* per server and which OpenCode reads from `OPENCODE_SERVER_PASSWORD`. It is
|
|
40
|
+
* NOT a provider credential: OpenCode loads those itself, from its own
|
|
41
|
+
* `~/.local/share/opencode/auth.json`. Agentbox never has one to pass, which
|
|
42
|
+
* is the rule in CLAUDE.md and the reason this harness can exist at all.
|
|
43
|
+
*/
|
|
44
|
+
export function openCodeClient({ baseUrl, password = null, username = 'opencode', fetchImpl = globalThis.fetch } = {}) {
|
|
45
|
+
if (!baseUrl) throw Error('openCodeClient needs the address of a running OpenCode server');
|
|
46
|
+
const root = String(baseUrl).replace(/\/+$/, '');
|
|
47
|
+
const authorization = password ? `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` : null;
|
|
48
|
+
|
|
49
|
+
const request = async (path, { method = 'GET', body, signal, stream = false } = {}) => {
|
|
50
|
+
const response = await fetchImpl(root + path, {
|
|
51
|
+
method,
|
|
52
|
+
headers: {
|
|
53
|
+
...(authorization ? { authorization } : {}),
|
|
54
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
55
|
+
...(stream ? { accept: 'text/event-stream' } : {}),
|
|
56
|
+
},
|
|
57
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
58
|
+
signal: signal ?? AbortSignal.timeout(CALL_MS),
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) throw Error(`OpenCode answered ${response.status} for ${method} ${path}`);
|
|
61
|
+
if (stream) return response;
|
|
62
|
+
const text = await response.text();
|
|
63
|
+
if (!text) return null;
|
|
64
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
url: root,
|
|
69
|
+
request,
|
|
70
|
+
createSession: (body = {}) => request('/session', { method: 'POST', body }),
|
|
71
|
+
prompt: (sessionId, body) => request(`/session/${encodeURIComponent(sessionId)}/prompt_async`, { method: 'POST', body }),
|
|
72
|
+
abort: sessionId => request(`/session/${encodeURIComponent(sessionId)}/abort`, { method: 'POST', body: {} }),
|
|
73
|
+
// `{ response }` and not `{ action }`: the field name is off the real
|
|
74
|
+
// OpenAPI document the running server serves at /doc.
|
|
75
|
+
replyPermission: (sessionId, permissionId, response) =>
|
|
76
|
+
request(`/session/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(permissionId)}`, { method: 'POST', body: { response } }),
|
|
77
|
+
summarize: (sessionId, model) =>
|
|
78
|
+
request(`/session/${encodeURIComponent(sessionId)}/summarize`, { method: 'POST', body: splitModel(model) }),
|
|
79
|
+
runCommand: (sessionId, body) => request(`/session/${encodeURIComponent(sessionId)}/command`, { method: 'POST', body }),
|
|
80
|
+
providers: () => request('/config/providers'),
|
|
81
|
+
commands: () => request('/command'),
|
|
82
|
+
config: () => request('/config'),
|
|
83
|
+
health: () => request('/global/health'),
|
|
84
|
+
events: ({ signal } = {}) => eventStream(request, signal),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `provider/model` as the two fields OpenCode's API wants.
|
|
90
|
+
*
|
|
91
|
+
* Its model ids are provider-qualified and the slash is part of the id people
|
|
92
|
+
* see (`opencode/big-pickle`, `anthropic/claude-sonnet-4-5`), but every server
|
|
93
|
+
* endpoint takes `providerID` and `modelID` apart. A model with no slash is not
|
|
94
|
+
* guessed at: it is left for the server's own default to answer, because
|
|
95
|
+
* inventing a provider here is how a run dies on a model nobody chose.
|
|
96
|
+
*/
|
|
97
|
+
export function splitModel(model) {
|
|
98
|
+
const text = typeof model === 'string' ? model.trim() : '';
|
|
99
|
+
const at = text.indexOf('/');
|
|
100
|
+
if (at <= 0 || at === text.length - 1) return {};
|
|
101
|
+
return { providerID: text.slice(0, at), modelID: text.slice(at + 1) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The `/event` stream, as an async iterable of `{ type, properties }`. */
|
|
105
|
+
function eventStream(request, outerSignal) {
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
outerSignal?.addEventListener('abort', () => controller.abort(), { once: true });
|
|
108
|
+
const opened = request('/event', { stream: true, signal: controller.signal });
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
close: () => controller.abort(),
|
|
112
|
+
async *[Symbol.asyncIterator]() {
|
|
113
|
+
const response = await opened;
|
|
114
|
+
const reader = response.body.getReader();
|
|
115
|
+
const decoder = new TextDecoder();
|
|
116
|
+
let buffer = '';
|
|
117
|
+
try {
|
|
118
|
+
while (true) {
|
|
119
|
+
const { value, done } = await reader.read();
|
|
120
|
+
if (done) return;
|
|
121
|
+
buffer += decoder.decode(value, { stream: true });
|
|
122
|
+
let index;
|
|
123
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
124
|
+
const line = buffer.slice(0, index).trim();
|
|
125
|
+
buffer = buffer.slice(index + 1);
|
|
126
|
+
if (!line.startsWith('data:')) continue;
|
|
127
|
+
const payload = line.slice(5).trim();
|
|
128
|
+
if (!payload) continue;
|
|
129
|
+
try { yield JSON.parse(payload); } catch { /* a half-written frame is not news */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
try { await reader.cancel(); } catch { /* already gone */ }
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The permission events, old and new, that mean "a card is wanted". */
|
|
140
|
+
const ASKED = new Set(['permission.asked', 'permission.v2.asked']);
|
|
141
|
+
/** What the reply endpoint calls yes and no. */
|
|
142
|
+
const ALLOW = 'once';
|
|
143
|
+
const DENY = 'reject';
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* ONE OPENCODE RUN, SHAPED LIKE A CHILD PROCESS.
|
|
147
|
+
*
|
|
148
|
+
* The supervisor's spawn path wants something with `stderr`, `on('event')` and
|
|
149
|
+
* one `exit`, because that is what a Claude Code child and a Codex worker both
|
|
150
|
+
* look like from there (main/supervisor.mjs, `_spawnWorker`). This is the third
|
|
151
|
+
* one, and it owns nothing the other two own: its events are OpenCode's own
|
|
152
|
+
* names, passed through untranslated, exactly as the Codex adapter passes its
|
|
153
|
+
* notifications through rather than pretending to be Claude's JSON lines.
|
|
154
|
+
*
|
|
155
|
+
* DENY IS THE ANSWER NOBODY HAS TO CHOOSE. A card that is never answered, a
|
|
156
|
+
* handler that throws, a spool that has gone away, a worker killed mid-question
|
|
157
|
+
* -- every one of those refuses the tool call. The opposite default would mean
|
|
158
|
+
* a crashed inbox silently approves shell commands, which is the one failure
|
|
159
|
+
* here that cannot be taken back.
|
|
160
|
+
*/
|
|
161
|
+
export function createOpenCodeWorker({
|
|
162
|
+
client,
|
|
163
|
+
plan = {},
|
|
164
|
+
cwd = null,
|
|
165
|
+
resumeSessionId = null,
|
|
166
|
+
onApproval = null,
|
|
167
|
+
title = null,
|
|
168
|
+
} = {}) {
|
|
169
|
+
if (!client) throw Error('createOpenCodeWorker needs a client for a running OpenCode server');
|
|
170
|
+
|
|
171
|
+
const worker = new EventEmitter();
|
|
172
|
+
worker.stderr = new EventEmitter();
|
|
173
|
+
worker.stdout = new EventEmitter();
|
|
174
|
+
worker.sessionId = resumeSessionId ?? null;
|
|
175
|
+
|
|
176
|
+
const say = text => {
|
|
177
|
+
const line = String(text ?? '').trim();
|
|
178
|
+
if (line) worker.stderr.emit('data', line);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
let ended = false;
|
|
182
|
+
let stream = null;
|
|
183
|
+
const answered = new Set();
|
|
184
|
+
|
|
185
|
+
const stop = async () => {
|
|
186
|
+
if (ended) return;
|
|
187
|
+
ended = true;
|
|
188
|
+
try { stream?.close(); } catch { /* already closed */ }
|
|
189
|
+
if (worker.sessionId) { try { await client.abort(worker.sessionId); } catch { /* the run is over either way */ } }
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One card, and the reply that releases or refuses the tool.
|
|
194
|
+
*
|
|
195
|
+
* The id is remembered BEFORE the card is raised, so a request the stream
|
|
196
|
+
* repeats -- which it does, `permission.asked` can arrive again for a request
|
|
197
|
+
* still open -- costs one card and not two.
|
|
198
|
+
*/
|
|
199
|
+
const decide = async (type, properties) => {
|
|
200
|
+
const id = properties?.id;
|
|
201
|
+
const session = properties?.sessionID;
|
|
202
|
+
if (!id || session !== worker.sessionId || answered.has(id)) return;
|
|
203
|
+
answered.add(id);
|
|
204
|
+
|
|
205
|
+
let allowed = false;
|
|
206
|
+
try {
|
|
207
|
+
allowed = onApproval ? await onApproval(type, properties) === true : false;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
say(`${Name} could not ask about ${String(properties?.metadata?.command ?? 'a tool call')}: ${error?.message ?? error}`);
|
|
210
|
+
allowed = false;
|
|
211
|
+
}
|
|
212
|
+
if (ended) allowed = false;
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await client.replyPermission(session, id, allowed ? ALLOW : DENY);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
say(`OpenCode did not take the answer to that question: ${error?.message ?? error}`);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const run = async () => {
|
|
222
|
+
try {
|
|
223
|
+
if (!worker.sessionId) {
|
|
224
|
+
const session = await client.createSession({ ...(title ? { title } : {}), ...(cwd ? { directory: cwd } : {}) });
|
|
225
|
+
worker.sessionId = session?.id ?? null;
|
|
226
|
+
if (!worker.sessionId) throw Error('OpenCode did not name the session it opened');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The stream is opened BEFORE the prompt. Opening it after is a race the
|
|
230
|
+
// fast answers win: `permission.asked` for the first tool call can beat
|
|
231
|
+
// our own subscription, and nothing would ever answer that card.
|
|
232
|
+
stream = client.events();
|
|
233
|
+
const reading = (async () => {
|
|
234
|
+
for await (const event of stream) {
|
|
235
|
+
if (ended) return;
|
|
236
|
+
const type = String(event?.type ?? '');
|
|
237
|
+
const properties = event?.properties ?? {};
|
|
238
|
+
worker.emit('event', type, properties);
|
|
239
|
+
if (ASKED.has(type)) { await decide(type, properties); continue; }
|
|
240
|
+
if (properties?.sessionID && properties.sessionID !== worker.sessionId) continue;
|
|
241
|
+
if (type === 'session.error') {
|
|
242
|
+
worker.error = properties?.error ?? 'OpenCode reported an error';
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (type === 'session.idle') return;
|
|
246
|
+
}
|
|
247
|
+
})();
|
|
248
|
+
|
|
249
|
+
await client.prompt(worker.sessionId, {
|
|
250
|
+
...splitModel(plan.model),
|
|
251
|
+
...(plan.agent ? { agent: plan.agent } : {}),
|
|
252
|
+
parts: [{ type: 'text', text: String(plan.prompt ?? '') }],
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await reading;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
worker.error = error?.message ?? String(error);
|
|
258
|
+
say(worker.error);
|
|
259
|
+
} finally {
|
|
260
|
+
const already = ended;
|
|
261
|
+
ended = true;
|
|
262
|
+
try { stream?.close(); } catch { /* already closed */ }
|
|
263
|
+
if (!already && worker.sessionId) { try { await client.abort(worker.sessionId); } catch { /* nothing left to stop */ } }
|
|
264
|
+
worker.emit('exit', worker.error ? 1 : 0, null);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
worker.kill = () => { void stop(); };
|
|
269
|
+
worker.done = run();
|
|
270
|
+
return worker;
|
|
271
|
+
}
|