agentbox-app 0.1.7 → 0.1.11
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 +209 -55
- package/bin/agentbox.mjs +6 -0
- package/briefs/message-rules.md +13 -3
- package/briefs/worker.md +63 -49
- package/briefs/writing-rules.md +90 -131
- package/main/account-discovery.mjs +21 -20
- package/main/account-tooling.mjs +9 -10
- package/main/agent-activity.mjs +45 -5
- package/main/agent-schedule.mjs +14 -14
- package/main/agent-sessions.mjs +94 -10
- package/main/agent-updates.mjs +114 -0
- package/main/agents.mjs +52 -46
- package/main/analytics.mjs +27 -0
- package/main/approval-prompt-server.mjs +2 -2
- package/main/artifact-path.mjs +11 -0
- package/main/claude-bin.mjs +31 -5
- package/main/claude-input.mjs +45 -3
- package/main/claude-plan.mjs +11 -11
- package/main/code-change.mjs +3 -3
- package/main/codex-account.mjs +1 -1
- package/main/codex-bin.mjs +17 -0
- package/main/codex-memory-gate.mjs +260 -0
- package/main/codex-models.mjs +12 -7
- package/main/codex-threads.mjs +46 -23
- package/main/codex-usage-file.mjs +7 -7
- package/main/codex-usage.mjs +4 -4
- package/main/codex-watch.mjs +12 -12
- package/main/config.mjs +68 -8
- package/main/dev-window.mjs +9 -9
- package/main/doc-scheme.mjs +2 -0
- package/main/engine-setup.mjs +193 -0
- package/main/feedback.mjs +55 -0
- package/main/folder-pictures.mjs +48 -0
- package/main/frame-scrollbars.mjs +90 -0
- package/main/fresh-user.mjs +6 -3
- package/main/git-change-offthread.mjs +70 -0
- package/main/git-change-thread.mjs +11 -0
- package/main/git-change.mjs +1 -1
- package/main/img-scheme.mjs +70 -1
- package/main/instruction-settings.mjs +92 -54
- package/main/ipc.mjs +316 -42
- package/main/leftovers.mjs +518 -0
- package/main/live-replies.mjs +43 -5
- package/main/machine.mjs +47 -0
- package/main/main.mjs +119 -15
- package/main/memory-gate-history.mjs +254 -0
- package/main/memory-gate-server.mjs +461 -0
- package/main/memory-gate.mjs +206 -0
- package/main/message-priority.mjs +87 -0
- package/main/rail-note.mjs +2 -2
- package/main/refresh-agent-models.mjs +20 -0
- package/main/repeats.mjs +4 -4
- package/main/row-label.mjs +114 -51
- package/main/serve.mjs +67 -5
- package/main/settings.mjs +223 -26
- package/main/ship-queue.mjs +279 -0
- package/main/sign-in-files.mjs +39 -0
- package/main/source-updater.mjs +298 -0
- package/main/store/home.mjs +47 -2
- package/main/store/work-items.mjs +150 -6
- package/main/store.mjs +235 -9
- package/main/supervisor.mjs +1444 -406
- package/main/task-commands.mjs +8 -10
- package/main/task-folders.mjs +74 -13
- package/main/task-remote-control.mjs +3 -3
- package/main/task-terminals.mjs +2 -1
- package/main/team/index.mjs +440 -0
- package/main/team/memory-cloud.mjs +247 -0
- package/main/team/projects.mjs +162 -0
- package/main/team/session.mjs +225 -0
- package/main/team/sign-in.mjs +106 -0
- package/main/team/supabase-backend.mjs +231 -0
- package/main/team/sync.mjs +163 -0
- package/main/updater.mjs +15 -5
- package/main/write-before-reload.mjs +50 -0
- package/mcp/core/page.mjs +9 -11
- package/mcp/core/work.mjs +91 -25
- package/mcp/tools.mjs +12 -6
- package/package.json +21 -9
- package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
- package/renderer/dist/assets/index-B-2Az952.css +32 -0
- package/renderer/dist/assets/index-D6qncgai.js +1168 -0
- package/renderer/dist/index.html +2 -2
- package/scripts/memory-gate-hook.sh +59 -0
- package/shared/agent-import.mjs +38 -15
- package/shared/agents.d.mts +26 -8
- package/shared/agents.mjs +100 -108
- package/shared/analytics-events.mjs +19 -2
- package/shared/answers.mjs +12 -0
- package/shared/bridge-map.d.mts +12 -0
- package/shared/bridge-map.mjs +37 -1
- package/shared/claude-commands.generated.mjs +2 -2
- package/shared/claude-models.generated.mjs +4 -4
- package/shared/claude-usage.mjs +6 -6
- package/shared/codex-import.mjs +7 -7
- package/shared/codex-modes.d.mts +50 -0
- package/shared/codex-usage.mjs +7 -8
- package/shared/demo-world.mjs +5 -5
- package/shared/engines.mjs +27 -10
- package/shared/feedback.d.mts +14 -0
- package/shared/feedback.mjs +44 -0
- package/shared/first-run-examples.mjs +4 -4
- package/shared/first-run-practice.d.mts +16 -0
- package/shared/first-run-practice.mjs +176 -70
- package/shared/fresh-user-home.mjs +22 -3
- package/shared/instruction-defaults.json +2 -2
- package/shared/product-name.mjs +4 -2
- package/shared/project-folder-check.mjs +6 -0
- package/shared/rank.d.mts +1 -0
- package/shared/rank.mjs +22 -0
- package/shared/referenced-files.mjs +1 -1
- package/shared/schemes.d.mts +11 -0
- package/shared/schemes.mjs +1 -1
- package/shared/shortcuts.d.mts +41 -0
- package/shared/shortcuts.mjs +236 -0
- package/shared/spawn-trouble.d.mts +2 -0
- package/shared/spawn-trouble.mjs +39 -18
- package/shared/team-rules.d.mts +17 -0
- package/shared/team-rules.mjs +230 -0
- package/shared/team-status.d.mts +11 -0
- package/shared/team-status.mjs +90 -0
- package/shared/terminal-links.d.mts +2 -0
- package/shared/terminal-links.mjs +55 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +16 -0
- package/shared/thread-cards.d.mts +19 -0
- package/shared/thread-cards.mjs +254 -0
- package/shared/usage.d.mts +1 -1
- package/shared/usage.mjs +6 -7
- package/shared/work-items.mjs +255 -27
- package/shared/work-lines.mjs +393 -16
- package/worker-permissions.json +1 -1
- package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
- package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
- package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
- package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
- package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
- package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
- package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
- package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
- package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
- package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
- package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
- package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
- package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
- package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
- package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
- package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
- package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
- package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
- package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
- package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
- package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
- package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
- package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
- package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
- package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
- package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
- package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
- package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
- package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
- package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
- package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
- package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
- package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
- package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
- package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
- package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
- package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
- package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
- package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
- package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
- package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
- package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
- package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
- package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
- package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
- package/renderer/dist/emulator/audio-rules.mjs +0 -74
- package/renderer/dist/emulator/data/compression/README.md +0 -11
- package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
- package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
- package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +0 -22
- 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 +0 -65
- package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
- package/renderer/dist/emulator/data/emulator.css +0 -1614
- package/renderer/dist/emulator/data/loader.js +0 -170
- package/renderer/dist/emulator/data/localization/README.md +0 -63
- package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
- package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
- package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
- package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
- package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
- package/renderer/dist/emulator/data/localization/en-US.json +0 -339
- package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
- package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
- package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
- package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
- package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
- package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
- package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
- package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
- package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
- package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
- package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
- package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
- package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
- package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
- package/renderer/dist/emulator/data/src/GameManager.js +0 -464
- package/renderer/dist/emulator/data/src/compression.js +0 -142
- package/renderer/dist/emulator/data/src/emulator.js +0 -6158
- package/renderer/dist/emulator/data/src/gamepad.js +0 -167
- package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
- package/renderer/dist/emulator/data/src/shaders.js +0 -236
- package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
- package/renderer/dist/emulator/data/src/storage.js +0 -125
- package/renderer/dist/emulator/data/version.json +0 -5
- package/renderer/dist/emulator/host.js +0 -299
- package/renderer/dist/emulator/index.html +0 -28
- package/renderer/dist/emulator/provenance.json +0 -6
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
// WHAT FINISHED AGENTS LEAVE RUNNING, AND STOPPING IT ON A RULE THE PERSON TURNED ON (w-3958c3753d).
|
|
2
|
+
//
|
|
3
|
+
// THE NIGHT THIS IS FOR. 2026-10-05, a 16 GB Mac after a night of agents:
|
|
4
|
+
// "Your system has run out of application memory", and every agent was
|
|
5
|
+
// already done. Measured that morning: programs finished agents had started
|
|
6
|
+
// and never stopped held 3.3 GB (two Next.js dev servers, one almost 3 days
|
|
7
|
+
// old; three video preview servers with headless browsers; three copies of a
|
|
8
|
+
// test script), and Docker Desktop, started by one of those agents, 4.5 GB more
|
|
9
|
+
// in its virtual machine. Each night's leftovers stacked on the last. Stopping
|
|
10
|
+
// the 40 programs of those 7 tasks by hand took swap from 7.4 GB to 4.2 GB.
|
|
11
|
+
//
|
|
12
|
+
// THE RULES, AGREED WITH CODEX OVER FOUR ROUNDS. The first version stopped
|
|
13
|
+
// anything carrying a task id whenever memory got tight, under the memory
|
|
14
|
+
// switch; Codex took it apart (a tag is provenance, not ownership; silence is
|
|
15
|
+
// not death; a timer is not consent), and this is what survived:
|
|
16
|
+
//
|
|
17
|
+
// 1. IT IS ITS OWN SWITCH, OFF BY DEFAULT. With it off nothing is stopped and
|
|
18
|
+
// leftovers are only reported. Its text states the whole policy.
|
|
19
|
+
// 2. ONLY THIS APP'S OWN TASKS. A task only ever runs in the app whose store
|
|
20
|
+
// it is in, so the app knows for certain when its runs start and end: a
|
|
21
|
+
// run's start is registered here, synchronously, before the worker is
|
|
22
|
+
// spawned, which cancels anything pending for that task, and its end is
|
|
23
|
+
// registered when the session ends. Records are kept on disk.
|
|
24
|
+
// 3. A TASK IS FINISHED ONLY ON AN ENDED RUN: the session ended here, or a run
|
|
25
|
+
// left "running" by a crash whose worker process is seen gone. A task with
|
|
26
|
+
// leftovers and no record at all is recorded as ended when this app, whose
|
|
27
|
+
// store it is in, has no session for it; the clock starts then, never
|
|
28
|
+
// back-dated.
|
|
29
|
+
// 4. ITS PROGRAMS STOP `idleMs` (2 h) AFTER THE RUN ENDED, or `tightIdleMs`
|
|
30
|
+
// (10 min) while memory is tight or critical, and never sooner than that
|
|
31
|
+
// long after the switch went on.
|
|
32
|
+
// 5. NOTHING PROTECTED IS EVER STOPPED: AGENTBOX_KEEP=1 in its environment,
|
|
33
|
+
// an executable inside an installed app or the system (Docker Desktop and
|
|
34
|
+
// its VM, a browser an agent opened), an executable that cannot be read,
|
|
35
|
+
// and anything started under one of those. Protection is remembered per
|
|
36
|
+
// process and kept on disk, so it survives its parent exiting.
|
|
37
|
+
// 6. A PROCESS MUST BE THE SAME PROGRAM ON TWO LOOKS `confirmMs` (1 min)
|
|
38
|
+
// APART, and again on a fresh look taken immediately before the signal,
|
|
39
|
+
// which also rechecks the switch, the task's runs and protection. Same
|
|
40
|
+
// program means pid, start time (to the second, from `lstart`) and
|
|
41
|
+
// executable path all equal. That is approximate: a pid reused within the
|
|
42
|
+
// same second by the same executable would pass, and a program could exit
|
|
43
|
+
// in the milliseconds between the last look and the signal.
|
|
44
|
+
// 7. SIGTERM ONLY, NEVER SIGKILL. What is still running `settleMs` (30 s)
|
|
45
|
+
// later is reported, and the next agent started on that task is told those
|
|
46
|
+
// programs are shutting down and must not be reused.
|
|
47
|
+
//
|
|
48
|
+
// HOW A PROGRAM IS TIED TO A TASK. Every worker is spawned with ZERO_ITEM=<task>
|
|
49
|
+
// (main/supervisor.mjs) and everything it starts inherits it; all 40 leftovers
|
|
50
|
+
// that morning carried it, Docker's backend included. The id is read only from
|
|
51
|
+
// the ENVIRONMENT, which is the part of `ps -E` after the arguments `ps` prints
|
|
52
|
+
// without it, and only when both looks agree it is the same process; a tag in a
|
|
53
|
+
// command's own words counts for nothing. A program that renames itself
|
|
54
|
+
// (next-server) hides its environment, so a process under a tagged one belongs
|
|
55
|
+
// to the same task. Codex threads share one app-server and their commands carry
|
|
56
|
+
// no task id yet, so a Codex task's leftovers are not found: coverage is
|
|
57
|
+
// incomplete and the switch says so.
|
|
58
|
+
|
|
59
|
+
import fs from 'node:fs';
|
|
60
|
+
import os from 'node:os';
|
|
61
|
+
import path from 'node:path';
|
|
62
|
+
import { execFile } from 'node:child_process';
|
|
63
|
+
import { pressureFrom } from './memory-gate.mjs';
|
|
64
|
+
|
|
65
|
+
const LSTART = String.raw`\w{3} \w{3} [ \d]\d \d\d:\d\d:\d\d \d{4}`;
|
|
66
|
+
|
|
67
|
+
/** `ps` elapsed time, `[[dd-]hh:]mm:ss`, in seconds. */
|
|
68
|
+
export function etimeSeconds(etime) {
|
|
69
|
+
const m = String(etime).match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
|
|
70
|
+
if (!m) return null;
|
|
71
|
+
return (+(m[1] ?? 0)) * 86_400 + (+(m[2] ?? 0)) * 3_600 + (+m[3]) * 60 + (+m[4]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `ps` lstart, "Mon Oct 5 11:11:52 2026" in local time, as epoch seconds. */
|
|
75
|
+
export function lstartSeconds(text) {
|
|
76
|
+
const m = String(text).trim().match(/^\w{3} (\w{3}) +(\d+) (\d\d):(\d\d):(\d\d) (\d{4})$/);
|
|
77
|
+
if (!m) return null;
|
|
78
|
+
const month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].indexOf(m[1]);
|
|
79
|
+
if (month < 0) return null;
|
|
80
|
+
return Math.floor(new Date(+m[6], month, +m[2], +m[3], +m[4], +m[5]).getTime() / 1000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Three listings of the same moment into one record per process:
|
|
85
|
+
* base: `ps -axo pid=,ppid=,rss=,lstart=,comm=` (comm is the exec'd path)
|
|
86
|
+
* argv: `ps -ww -axo pid=,lstart=,command=`
|
|
87
|
+
* full: `ps -E -ww -axo pid=,lstart=,command=` (arguments, then environment)
|
|
88
|
+
*/
|
|
89
|
+
export function parseListings({ base, argv, full }) {
|
|
90
|
+
const lines = (text, re) => {
|
|
91
|
+
const map = new Map();
|
|
92
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
93
|
+
const m = line.match(re);
|
|
94
|
+
if (m) map.set(+m[1], m);
|
|
95
|
+
}
|
|
96
|
+
return map;
|
|
97
|
+
};
|
|
98
|
+
const cmdRe = new RegExp(`^\\s*(\\d+)\\s+(${LSTART})\\s+(.*)$`);
|
|
99
|
+
const a = lines(argv, cmdRe);
|
|
100
|
+
const f = lines(full, cmdRe);
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const m of lines(base, new RegExp(`^\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(${LSTART})\\s*(.*)$`)).values()) {
|
|
103
|
+
const pid = +m[1];
|
|
104
|
+
const start = lstartSeconds(m[4]);
|
|
105
|
+
const args = a.get(pid);
|
|
106
|
+
const all = f.get(pid);
|
|
107
|
+
let env = null;
|
|
108
|
+
// The environment is only what `ps -E` adds after the arguments, and only
|
|
109
|
+
// when both looks are of the same program.
|
|
110
|
+
if (args && all && lstartSeconds(args[2]) === start && lstartSeconds(all[2]) === start
|
|
111
|
+
&& all[3].startsWith(args[3])) {
|
|
112
|
+
env = all[3].slice(args[3].length);
|
|
113
|
+
}
|
|
114
|
+
out.push({
|
|
115
|
+
pid,
|
|
116
|
+
ppid: +m[2],
|
|
117
|
+
mb: +m[3] / 1024,
|
|
118
|
+
start,
|
|
119
|
+
exe: m[5].trim(),
|
|
120
|
+
cmd: (args?.[3] ?? '').slice(0, 300),
|
|
121
|
+
item: env?.match(/(?:^|\s)ZERO_ITEM=(w-[0-9a-f]+)(?=\s|$)/)?.[1] ?? null,
|
|
122
|
+
product: env?.match(/(?:^|\s)ZERO_PRODUCT=(\S+)/)?.[1] ?? null,
|
|
123
|
+
keep: env ? /(?:^|\s)AGENTBOX_KEEP=1(?=\s|$)/.test(env) : false,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function ps(args) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
execFile('/bin/ps', args, { timeout: 10_000, maxBuffer: 256 * 1024 * 1024 }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A program that renames itself (Next.js's dev server sets process.title)
|
|
137
|
+
* makes macOS report that name as its executable, "next-server (e2e" where the
|
|
138
|
+
* path should be. Found on the real Mac, 2026-10-05: read as an executable we
|
|
139
|
+
* could not see, every dev server was kept forever. `lsof -d txt` names the
|
|
140
|
+
* real file; its first `n` line per process is the executable. A process lsof
|
|
141
|
+
* cannot read keeps its name, and so stays protected.
|
|
142
|
+
*/
|
|
143
|
+
export function applyExecutables(procs, lsofText) {
|
|
144
|
+
const real = new Map();
|
|
145
|
+
let pid = null;
|
|
146
|
+
for (const line of String(lsofText ?? '').split('\n')) {
|
|
147
|
+
if (line[0] === 'p') pid = Number(line.slice(1));
|
|
148
|
+
else if (line[0] === 'n' && pid !== null && !real.has(pid) && line.slice(1).startsWith('/')) real.set(pid, line.slice(1));
|
|
149
|
+
}
|
|
150
|
+
for (const p of procs) if (!p.exe?.startsWith('/') && real.has(p.pid)) p.exe = real.get(p.pid);
|
|
151
|
+
return procs;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Every process on the Mac, tagged with its task where it can be. About 100 ms. */
|
|
155
|
+
export async function listProcesses() {
|
|
156
|
+
const [base, argv, full] = await Promise.all([
|
|
157
|
+
ps(['-axo', 'pid=,ppid=,rss=,lstart=,comm=']),
|
|
158
|
+
ps(['-ww', '-axo', 'pid=,lstart=,command=']),
|
|
159
|
+
ps(['-E', '-ww', '-axo', 'pid=,lstart=,command=']),
|
|
160
|
+
]);
|
|
161
|
+
const procs = parseListings({ base, argv, full });
|
|
162
|
+
const renamed = procs.filter((p) => !p.exe.startsWith('/')).map((p) => p.pid);
|
|
163
|
+
if (renamed.length) {
|
|
164
|
+
const out = await new Promise((resolve) => {
|
|
165
|
+
execFile('/usr/sbin/lsof', ['-a', '-p', renamed.join(','), '-d', 'txt', '-Fpn'], { timeout: 10_000, maxBuffer: 16 * 1024 * 1024 },
|
|
166
|
+
(_err, stdout) => resolve(stdout ?? ''));
|
|
167
|
+
});
|
|
168
|
+
applyExecutables(procs, out);
|
|
169
|
+
}
|
|
170
|
+
return procs;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function readMacPressureWord() {
|
|
174
|
+
const out = await new Promise((resolve) => {
|
|
175
|
+
execFile('/usr/sbin/sysctl', ['-n', 'kern.memorystatus_vm_pressure_level', 'kern.memorystatus_level'], { timeout: 5_000 },
|
|
176
|
+
(err, stdout) => resolve(err ? '' : stdout));
|
|
177
|
+
});
|
|
178
|
+
const [level, freePct] = String(out).trim().split(/\s+/).map(Number);
|
|
179
|
+
return pressureFrom({ level: Number.isFinite(level) ? level : null, freePct: Number.isFinite(freePct) ? freePct : null });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* An installed app or macOS itself: never stopped, and neither is anything an
|
|
184
|
+
* installed app starts. Only these, and not every system binary: launchd
|
|
185
|
+
* adopts every orphan and Claude Code runs commands through /bin/zsh, so
|
|
186
|
+
* treating /sbin or /bin as protective protected every dev server an agent ever
|
|
187
|
+
* left (found by Codex in review, 2026-10-05).
|
|
188
|
+
*/
|
|
189
|
+
export function isAppPath(exe, home = os.homedir()) {
|
|
190
|
+
if (!exe || !exe.startsWith('/')) return false;
|
|
191
|
+
return /^\/Applications\/[^/]+\.app\//.test(exe)
|
|
192
|
+
|| /^\/System\//.test(exe)
|
|
193
|
+
|| (exe.startsWith(`${home}/Applications/`) && /\.app\//.test(exe));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** An executable we could not read is kept, for itself only. */
|
|
197
|
+
const unreadable = (exe) => !exe || !exe.startsWith('/');
|
|
198
|
+
|
|
199
|
+
/** An agent itself, which is never stopped and keeps its task alive. */
|
|
200
|
+
export function isAgentProcess(p) {
|
|
201
|
+
const first = String(p.cmd ?? '').split(/\s/)[0];
|
|
202
|
+
return [p.exe, first].some((s) => /(^|\/)(claude|codex)$/.test(String(s ?? '')) || /\/claude\/versions\//.test(String(s ?? '')));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const idOf = (p) => `${p.pid}:${p.start}:${p.exe}`;
|
|
206
|
+
const FORMAT = 2;
|
|
207
|
+
|
|
208
|
+
export class LeftoverCleaner {
|
|
209
|
+
constructor({
|
|
210
|
+
list = listProcesses,
|
|
211
|
+
kill = (pid, sig) => process.kill(pid, sig),
|
|
212
|
+
clock = Date.now,
|
|
213
|
+
readPressure = readMacPressureWord,
|
|
214
|
+
file = null,
|
|
215
|
+
enabledSince = Date.now(),
|
|
216
|
+
live = () => false,
|
|
217
|
+
owns = () => false,
|
|
218
|
+
idleMs = 2 * 60 * 60_000,
|
|
219
|
+
tightIdleMs = 10 * 60_000,
|
|
220
|
+
confirmMs = 60_000,
|
|
221
|
+
settleMs = 30_000,
|
|
222
|
+
home = os.homedir(),
|
|
223
|
+
// Off means look and report, never signal (agreed with Codex: the person
|
|
224
|
+
// sees what turning it on would stop before they do).
|
|
225
|
+
enabled = true,
|
|
226
|
+
log = () => {},
|
|
227
|
+
} = {}) {
|
|
228
|
+
Object.assign(this, { list, kill, clock, readPressure, file, enabledSince, live, owns, idleMs, tightIdleMs, confirmMs, settleMs, home, enabled, log });
|
|
229
|
+
this.firstSeen = new Map();
|
|
230
|
+
this.lastStatus = { leftovers: [], survivors: [], stopped: null, pressure: 'normal' };
|
|
231
|
+
const saved = this._load();
|
|
232
|
+
this.runs = saved.runs;
|
|
233
|
+
this.protectedIds = new Set(saved.protectedIds);
|
|
234
|
+
this.taskOf = new Map(saved.taskOf);
|
|
235
|
+
this.stopping = saved.stopping;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ----------------------------- what the app says ----------------------------- */
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A run is about to start on `item`. Called synchronously before the worker
|
|
242
|
+
* is spawned, so nothing for this task can be stopped from here on. Returns a
|
|
243
|
+
* sentence for the agent when programs of this task are still shutting down,
|
|
244
|
+
* or null.
|
|
245
|
+
*/
|
|
246
|
+
starting(item) {
|
|
247
|
+
if (!item) return null;
|
|
248
|
+
this.runs[item] = { state: 'running', since: this.clock(), worker: null };
|
|
249
|
+
this._save();
|
|
250
|
+
return this.stoppingNote(item);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The sentence for an agent about this task's programs still shutting down, or null. */
|
|
254
|
+
stoppingNote(item) {
|
|
255
|
+
const left = Object.values(this.stopping).filter((s) => s.item === item);
|
|
256
|
+
if (!left.length) return null;
|
|
257
|
+
return `Programs from an earlier run of this task were asked to stop and may still be shutting down: ${left.map((s) => `pid ${s.pid} (${s.cmd || s.exe})`).join(', ')}. Do not reuse them; start what you need afresh.`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The worker process of the run just started, so a crash can be told from a run. */
|
|
261
|
+
worker(item, pid) {
|
|
262
|
+
const run = this.runs[item];
|
|
263
|
+
if (!run || run.state !== 'running') return;
|
|
264
|
+
run.worker = { pid, id: null };
|
|
265
|
+
this._save();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The run on `item` ended in this app. */
|
|
269
|
+
ended(item) {
|
|
270
|
+
if (!item) return;
|
|
271
|
+
this.runs[item] = { state: 'ended', endedAt: this.clock() };
|
|
272
|
+
this._save();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
status() { return this.lastStatus; }
|
|
276
|
+
|
|
277
|
+
/** Stop signalling from this moment, even in a look already under way. */
|
|
278
|
+
disable() { this.enabled = false; }
|
|
279
|
+
|
|
280
|
+
enable(since = this.clock()) {
|
|
281
|
+
this.enabled = true;
|
|
282
|
+
this.enabledSince = since;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* How long a run on `item` should wait before starting, because programs of
|
|
287
|
+
* this task were asked to stop under `settleMs` ago and may still be on their
|
|
288
|
+
* way out. 0 when there is nothing to wait for.
|
|
289
|
+
*/
|
|
290
|
+
holdMs(item) {
|
|
291
|
+
const now = this.clock();
|
|
292
|
+
let wait = 0;
|
|
293
|
+
for (const s of Object.values(this.stopping)) {
|
|
294
|
+
if (s.item === item) wait = Math.max(wait, s.at + this.settleMs - now);
|
|
295
|
+
}
|
|
296
|
+
return Math.max(0, wait);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* ---------------------------------- a look ---------------------------------- */
|
|
300
|
+
|
|
301
|
+
async tick() {
|
|
302
|
+
const now = this.clock();
|
|
303
|
+
let pressure = 'normal';
|
|
304
|
+
try { pressure = await this.readPressure(); } catch {}
|
|
305
|
+
let procs;
|
|
306
|
+
try { procs = await this.list(); } catch (err) {
|
|
307
|
+
this.log(`leftovers: could not list processes: ${err.message}`);
|
|
308
|
+
return this.lastStatus;
|
|
309
|
+
}
|
|
310
|
+
const view = this._view(procs, now);
|
|
311
|
+
this._reconcileRuns(view, now);
|
|
312
|
+
this._trackStopping(view, now);
|
|
313
|
+
const due = this._due(view, now, pressure);
|
|
314
|
+
let stopped = null;
|
|
315
|
+
if (due.length) stopped = await this._stop(due, pressure);
|
|
316
|
+
this.lastStatus = {
|
|
317
|
+
pressure,
|
|
318
|
+
leftovers: this._report(view, now, pressure),
|
|
319
|
+
survivors: Object.values(this.stopping).filter((s) => now - s.at >= this.settleMs),
|
|
320
|
+
stopped: stopped ?? this.lastStatus.stopped,
|
|
321
|
+
};
|
|
322
|
+
this._save();
|
|
323
|
+
return this.lastStatus;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Processes by task, with protection worked out. Remembers when each
|
|
328
|
+
* identity was first seen, and every identity found protected.
|
|
329
|
+
*/
|
|
330
|
+
_view(procs, now) {
|
|
331
|
+
const byPid = new Map(procs.map((p) => [p.pid, p]));
|
|
332
|
+
const memo = new Map();
|
|
333
|
+
const none = { item: null, inherits: false };
|
|
334
|
+
const resolve = (p, depth = 0) => {
|
|
335
|
+
if (memo.has(p.pid)) return memo.get(p.pid);
|
|
336
|
+
memo.set(p.pid, none);
|
|
337
|
+
const parent = depth < 64 ? byPid.get(p.ppid) : null;
|
|
338
|
+
const up = parent && parent.pid !== p.pid ? resolve(parent, depth + 1) : none;
|
|
339
|
+
// A process keeps the task it was first found under: when its parent
|
|
340
|
+
// exits first, launchd adopts it and the parent is no longer there to
|
|
341
|
+
// say whose it is.
|
|
342
|
+
const item = p.item ?? up.item ?? this.taskOf.get(idOf(p)) ?? null;
|
|
343
|
+
// Protection passes down only from a process that belongs to a task and
|
|
344
|
+
// is kept, an installed app, or an executable that cannot be read (it
|
|
345
|
+
// may be a renamed app lsof could not resolve), never from launchd, a
|
|
346
|
+
// shell or the app that spawned the agent, none of which carry a task
|
|
347
|
+
// (Codex's reviews, 2026-10-05).
|
|
348
|
+
const inherits = !!(p.keep || isAppPath(p.exe, this.home) || this.protectedIds.has(idOf(p))
|
|
349
|
+
|| (item && unreadable(p.exe)) || (up.item && up.inherits));
|
|
350
|
+
const r = { item, inherits, guard: inherits || unreadable(p.exe), agent: isAgentProcess(p) };
|
|
351
|
+
memo.set(p.pid, r);
|
|
352
|
+
return r;
|
|
353
|
+
};
|
|
354
|
+
const seen = new Set();
|
|
355
|
+
const tasks = new Map();
|
|
356
|
+
for (const p of procs) {
|
|
357
|
+
const id = idOf(p);
|
|
358
|
+
seen.add(id);
|
|
359
|
+
if (!this.firstSeen.has(id)) this.firstSeen.set(id, now);
|
|
360
|
+
const r = resolve(p);
|
|
361
|
+
if (!r.item) continue;
|
|
362
|
+
if (r.inherits) this.protectedIds.add(id);
|
|
363
|
+
this.taskOf.set(id, r.item);
|
|
364
|
+
if (!tasks.has(r.item)) tasks.set(r.item, { item: r.item, product: null, procs: [], guarded: [], agents: [] });
|
|
365
|
+
const t = tasks.get(r.item);
|
|
366
|
+
if (p.product && !t.product) t.product = p.product;
|
|
367
|
+
// An agent is never a leftover: it is the run itself, alive.
|
|
368
|
+
if (r.agent) t.agents.push(p);
|
|
369
|
+
else if (r.guard) t.guarded.push(p);
|
|
370
|
+
else t.procs.push(p);
|
|
371
|
+
}
|
|
372
|
+
for (const id of [...this.firstSeen.keys()]) if (!seen.has(id)) this.firstSeen.delete(id);
|
|
373
|
+
for (const id of [...this.protectedIds]) if (!seen.has(id)) this.protectedIds.delete(id);
|
|
374
|
+
for (const id of [...this.taskOf.keys()]) if (!seen.has(id)) this.taskOf.delete(id);
|
|
375
|
+
return { procs, byPid, tasks, seen };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Crashes and unknown tasks, turned into records the rules can use. */
|
|
379
|
+
_reconcileRuns(view, now) {
|
|
380
|
+
for (const [item, run] of Object.entries(this.runs)) {
|
|
381
|
+
if (run.state !== 'running' || !run.worker) continue;
|
|
382
|
+
const p = view.byPid.get(run.worker.pid);
|
|
383
|
+
if (p && !run.worker.id) { run.worker.id = idOf(p); continue; }
|
|
384
|
+
const gone = !p || (run.worker.id && idOf(p) !== run.worker.id);
|
|
385
|
+
const t = view.tasks.get(item);
|
|
386
|
+
if (gone && !this.live(item) && !t?.agents.length) this.runs[item] = { state: 'ended', endedAt: now, why: 'worker gone' };
|
|
387
|
+
}
|
|
388
|
+
// A task of this app's own store with leftovers, no record, no session
|
|
389
|
+
// here and NO AGENT PROCESS carrying its id: its run is over. With an agent
|
|
390
|
+
// process still alive (a worker that outlived a crashed app) it is not,
|
|
391
|
+
// whatever this app remembers (Codex's review, 2026-10-05).
|
|
392
|
+
for (const t of view.tasks.values()) {
|
|
393
|
+
if (this.runs[t.item] || this._alive(t)) continue;
|
|
394
|
+
if (this.owns(t.item, t.product)) this.runs[t.item] = { state: 'ended', endedAt: now, why: 'no session here' };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** A task with a session in this app, or any agent process carrying its id. */
|
|
399
|
+
_alive(t) {
|
|
400
|
+
return this.live(t.item) || t.agents.length > 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
_trackStopping(view, now) {
|
|
404
|
+
for (const [id, s] of Object.entries(this.stopping)) {
|
|
405
|
+
if (!view.seen.has(id)) delete this.stopping[id];
|
|
406
|
+
else s.seenAt = now;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
_stopsAt(item, pressure) {
|
|
411
|
+
const run = this.runs[item];
|
|
412
|
+
if (!run || run.state !== 'ended') return null;
|
|
413
|
+
const from = Math.max(run.endedAt, this.enabledSince);
|
|
414
|
+
return from + (pressure === 'normal' ? this.idleMs : this.tightIdleMs);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Programs whose time has come and that have been seen long enough. */
|
|
418
|
+
_due(view, now, pressure) {
|
|
419
|
+
const due = [];
|
|
420
|
+
if (!this.enabled) return due;
|
|
421
|
+
for (const t of view.tasks.values()) {
|
|
422
|
+
if (this._alive(t)) continue;
|
|
423
|
+
const at = this._stopsAt(t.item, pressure);
|
|
424
|
+
if (at === null || now < at) continue;
|
|
425
|
+
for (const p of t.procs) {
|
|
426
|
+
const id = idOf(p);
|
|
427
|
+
if (this.stopping[id]) continue;
|
|
428
|
+
if (now - (this.firstSeen.get(id) ?? now) < this.confirmMs) continue;
|
|
429
|
+
due.push({ item: t.item, id, pid: p.pid });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return due;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** One fresh look, every rule again, then SIGTERM to what still passes. */
|
|
436
|
+
async _stop(due, pressure) {
|
|
437
|
+
let procs;
|
|
438
|
+
try { procs = await this.list(); } catch { return null; }
|
|
439
|
+
const now = this.clock();
|
|
440
|
+
const view = this._view(procs, now);
|
|
441
|
+
const byId = new Map(procs.map((p) => [idOf(p), p]));
|
|
442
|
+
let programs = 0;
|
|
443
|
+
let mb = 0;
|
|
444
|
+
const items = new Set();
|
|
445
|
+
for (const d of due) {
|
|
446
|
+
const p = byId.get(d.id);
|
|
447
|
+
if (!p) continue;
|
|
448
|
+
const t = view.tasks.get(d.item);
|
|
449
|
+
if (!t || !t.procs.includes(p)) continue; // protected now, or no longer this task's
|
|
450
|
+
if (this._alive(t)) continue;
|
|
451
|
+
const at = this._stopsAt(d.item, pressure);
|
|
452
|
+
if (at === null || now < at) continue; // a run started meanwhile
|
|
453
|
+
// Switched off while this look was under way: nothing more is signalled.
|
|
454
|
+
if (!this.enabled) break;
|
|
455
|
+
try { this.kill(p.pid, 'SIGTERM'); } catch { continue; }
|
|
456
|
+
this.stopping[d.id] = { item: d.item, pid: p.pid, cmd: p.cmd.slice(0, 80), exe: p.exe, at: now };
|
|
457
|
+
programs++;
|
|
458
|
+
mb += p.mb ?? 0;
|
|
459
|
+
items.add(d.item);
|
|
460
|
+
}
|
|
461
|
+
if (!programs) return null;
|
|
462
|
+
this.log(`leftovers: asked ${programs} program(s) of ${items.size} finished task(s) to stop`);
|
|
463
|
+
return { at: now, programs, tasks: items.size, mb, items: [...items] };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
_report(view, now, pressure) {
|
|
467
|
+
const out = [];
|
|
468
|
+
for (const t of view.tasks.values()) {
|
|
469
|
+
if (this._alive(t)) continue;
|
|
470
|
+
const run = this.runs[t.item];
|
|
471
|
+
if (run?.state === 'running') continue;
|
|
472
|
+
const all = [...t.procs, ...t.guarded];
|
|
473
|
+
out.push({
|
|
474
|
+
item: t.item,
|
|
475
|
+
product: t.product,
|
|
476
|
+
programs: t.procs.length,
|
|
477
|
+
kept: t.guarded.length,
|
|
478
|
+
mb: all.reduce((n, p) => n + (p.mb ?? 0), 0),
|
|
479
|
+
oldest: Math.min(...all.map((p) => p.start * 1000)),
|
|
480
|
+
stopsAt: t.procs.length ? this._stopsAt(t.item, pressure) : null,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return out.sort((a, b) => a.oldest - b.oldest);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/* --------------------------------- on disk --------------------------------- */
|
|
487
|
+
|
|
488
|
+
_load() {
|
|
489
|
+
const empty = { runs: {}, protectedIds: [], taskOf: [], stopping: {} };
|
|
490
|
+
if (!this.file) return empty;
|
|
491
|
+
try {
|
|
492
|
+
const raw = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
493
|
+
// Protection and attribution saved by an earlier format were worked out
|
|
494
|
+
// by rules since found wrong (protection spread from launchd and shells),
|
|
495
|
+
// so they are thrown away rather than trusted; run records stay.
|
|
496
|
+
const current = raw?.v === FORMAT;
|
|
497
|
+
return {
|
|
498
|
+
runs: raw?.runs && typeof raw.runs === 'object' ? raw.runs : {},
|
|
499
|
+
protectedIds: current && Array.isArray(raw?.protectedIds) ? raw.protectedIds : [],
|
|
500
|
+
taskOf: current && Array.isArray(raw?.taskOf) ? raw.taskOf.filter((e) => Array.isArray(e) && e.length === 2) : [],
|
|
501
|
+
stopping: raw?.stopping && typeof raw.stopping === 'object' ? raw.stopping : {},
|
|
502
|
+
};
|
|
503
|
+
} catch { return empty; }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
_save() {
|
|
507
|
+
if (!this.file) return;
|
|
508
|
+
// Old records go: an ended run older than a week cannot matter any more.
|
|
509
|
+
const week = this.clock() - 7 * 24 * 60 * 60_000;
|
|
510
|
+
for (const [item, run] of Object.entries(this.runs)) if (run.state === 'ended' && run.endedAt < week) delete this.runs[item];
|
|
511
|
+
try {
|
|
512
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
513
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
514
|
+
fs.writeFileSync(tmp, JSON.stringify({ v: FORMAT, runs: this.runs, protectedIds: [...this.protectedIds], taskOf: [...this.taskOf], stopping: this.stopping }));
|
|
515
|
+
fs.renameSync(tmp, this.file);
|
|
516
|
+
} catch (err) { this.log(`leftovers: could not save: ${err.message}`); }
|
|
517
|
+
}
|
|
518
|
+
}
|
package/main/live-replies.mjs
CHANGED
|
@@ -4,8 +4,36 @@
|
|
|
4
4
|
// neither an out-of-order response nor a rejected input reorders the history.
|
|
5
5
|
import { NAME } from '../shared/product-name.mjs';
|
|
6
6
|
import { TURN_CLOSED } from './claude-input.mjs';
|
|
7
|
+
// WHAT A WORKER IS TOLD WHEN THEY TYPE AT IT WHILE IT IS STILL WORKING.
|
|
8
|
+
//
|
|
9
|
+
// The other half of `replyBrief` in supervisor.mjs, which frames a reply handed
|
|
10
|
+
// to a session that has already finished. This one frames a reply steered into
|
|
11
|
+
// a session that is mid-turn, and it exists because that framing was missing:
|
|
12
|
+
// bare words arrived, the agent noted them and carried on, and a question typed
|
|
13
|
+
// into "Add to it while it works" was marked read and never answered
|
|
14
|
+
// (w-ac7f0c0cbb). The brief that session is holding tells it its last message
|
|
15
|
+
// is the result, so without this it has every reason to save the answer for an
|
|
16
|
+
// ending the person is not waiting through.
|
|
17
|
+
//
|
|
18
|
+
// Their words go LAST, as in `replyBrief`: the newest thing they said is the
|
|
19
|
+
// newest thing in its context, and nothing of ours is written after it.
|
|
20
|
+
export function liveReplyBrief(answer) {
|
|
21
|
+
return [
|
|
22
|
+
'This arrived in your thread just now, while you work, and they are watching',
|
|
23
|
+
'for a reply. Do what it asks. If it asks you anything, or says it did not',
|
|
24
|
+
'hear back, answer it in your very next message, in a sentence or two, before',
|
|
25
|
+
'you go back to what you were doing: the sentences you write while you work',
|
|
26
|
+
'are what they read, so an answer saved for the end reads as no answer at all.',
|
|
27
|
+
'Then carry on.',
|
|
28
|
+
'',
|
|
29
|
+
'What they said:',
|
|
30
|
+
'',
|
|
31
|
+
answer,
|
|
32
|
+
].join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
7
35
|
export function submitReply(supervisor, payload, commit) {
|
|
8
|
-
const { product, id, answer, status, permissionMode } = payload;
|
|
36
|
+
const { product, id, answer, status, permissionMode, now = false } = payload;
|
|
9
37
|
const key = JSON.stringify([product, id]);
|
|
10
38
|
const sends = supervisor._replySends ??= new Map();
|
|
11
39
|
const previous = sends.get(key) ?? Promise.resolve();
|
|
@@ -18,16 +46,24 @@ export function submitReply(supervisor, payload, commit) {
|
|
|
18
46
|
if (!live) return;
|
|
19
47
|
if (permissionMode != null) throw Error('A running turn keeps its current permissions. Stop it before sending with a different permission mode.');
|
|
20
48
|
if (answer.trimStart().startsWith('/') && !session.remoteIdle) throw Error('Wait for the current turn to finish before running a slash command.');
|
|
21
|
-
|
|
49
|
+
// A held Remote Control conversation is sitting idle rather than working,
|
|
50
|
+
// and the slash commands it takes stop being commands once prose is wrapped
|
|
51
|
+
// round them. Both reach it exactly as typed.
|
|
52
|
+
const taken = session.child.steer(session.remoteIdle ? answer : liveReplyBrief(answer));
|
|
53
|
+
// SENT NOW: her words go in line first, then the step is cut, in the same
|
|
54
|
+
// tick, so the cut turn's result can never close the input ahead of them.
|
|
55
|
+
// A cut that fails leaves the message waiting its turn, which is still sent.
|
|
56
|
+
if (now) session.child.interrupt?.()?.catch?.(() => {});
|
|
57
|
+
return taken;
|
|
22
58
|
}).then(() => ({ ok: true }), error => ({ ok: false, error }));
|
|
23
59
|
const operation = previous.catch(() => {}).then(async () => {
|
|
24
60
|
const accepted = await delivery;
|
|
25
61
|
// TOO LATE FOR THIS TURN IS NOT A FAILED SEND. The session closed its
|
|
26
|
-
// input between
|
|
62
|
+
// input between the user pressing send and the line reaching it, so the
|
|
27
63
|
// message is saved as an ordinary reply instead: the session's own exit
|
|
28
64
|
// (`deliverMidflightReply`) or the next tick starts the run that carries
|
|
29
|
-
// it. Throwing here put "The turn has ended" in front of
|
|
30
|
-
// back in the box, on whatever screen
|
|
65
|
+
// it. Throwing here put "The turn has ended" in front of the user and their
|
|
66
|
+
// words back in the box, on whatever screen they had moved on to (w-12730c6506).
|
|
31
67
|
const late = !accepted.ok && accepted.error?.code === TURN_CLOSED;
|
|
32
68
|
if (!accepted.ok && !late) throw accepted.error;
|
|
33
69
|
const steered = live && !late;
|
|
@@ -40,6 +76,8 @@ export function submitReply(supervisor, payload, commit) {
|
|
|
40
76
|
const item = saved ? { ...saved, product, id } : saved;
|
|
41
77
|
if (steered && item) {
|
|
42
78
|
supervisor._handledAnswers.add(supervisor._answerKey(item));
|
|
79
|
+
// Taken into this conversation, which a later stop does not undo.
|
|
80
|
+
supervisor._noteHeard?.(item, session.sessionId);
|
|
43
81
|
session.lastLiveReply = item;
|
|
44
82
|
supervisor._saveState();
|
|
45
83
|
if (session.exitFailed) {
|
package/main/machine.mjs
CHANGED
|
@@ -95,6 +95,53 @@ export function machineSlots({ memBytes = os.totalmem(), cores = os.cpus().lengt
|
|
|
95
95
|
return { slots, memGb, cores: cpu, byMemory, byCores, known: true };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* HOW MANY AGENTS THIS MACHINE CARRIES, which is the number Automatic answers
|
|
100
|
+
* with (w-e5225b62ba). `machineSlots` above suggests a number PER ACCOUNT; this
|
|
101
|
+
* one is for the MACHINE, because the machine is what runs out and because the
|
|
102
|
+
* page was showing both at once — a stepper reading 3 beside a sentence saying
|
|
103
|
+
* 6 run together.
|
|
104
|
+
*
|
|
105
|
+
* THE MEMORY CHECK IS WORTH HALF AS MANY AGAIN. The one-per-4 GB rule above
|
|
106
|
+
* comes from her own anchors, 2 at 8 GB and 4 at 16 GB, and those were set when
|
|
107
|
+
* every agent's build and test run competed for memory at the same moment. With
|
|
108
|
+
* the check on (w-3958c3753d) heavy commands take turns instead, so most of the
|
|
109
|
+
* time an extra agent costs its idle footprint rather than a build's.
|
|
110
|
+
*
|
|
111
|
+
* MEASURED ON A 16 GB, 10-CORE MAC with the check on and six agents running,
|
|
112
|
+
* over about an hour: 942 commands asked, 124 waited, NONE refused, three
|
|
113
|
+
* waiting at the busiest moment. Nothing was turned away and nothing was
|
|
114
|
+
* stopped, so six is what that Mac carries — and six is what this answers for
|
|
115
|
+
* it. More agents would have lengthened the queue rather than done more work.
|
|
116
|
+
* With the check off the answer is exactly what it was before, so nobody who
|
|
117
|
+
* has not turned it on is handed a bigger number than they had.
|
|
118
|
+
*
|
|
119
|
+
* `nudge` is how many times "Something felt slow" has been pressed. It only ever
|
|
120
|
+
* subtracts, and never below one: a person saying the Mac is struggling is a
|
|
121
|
+
* better reading than any arithmetic here.
|
|
122
|
+
*/
|
|
123
|
+
export function autoAgents({ memBytes, cores, gated = false, nudge = 0 } = {}) {
|
|
124
|
+
const base = machineSlots({ memBytes, cores }).slots;
|
|
125
|
+
const forGate = gated ? Math.floor((base * 3) / 2) : base;
|
|
126
|
+
const n = Number(nudge);
|
|
127
|
+
const down = Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
128
|
+
return Math.max(1, Math.min(MAX_SLOTS, forGate) - down);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The machine's number shared out, because the cap underneath is still per
|
|
133
|
+
* account and nothing in the supervisor had to change shape for this.
|
|
134
|
+
*
|
|
135
|
+
* ROUNDED DOWN, so the machine's number is a ceiling rather than a floor: three
|
|
136
|
+
* accounts on a Mac that carries six get two each and not three. At least one
|
|
137
|
+
* each, because an account that may start nothing is an account whose rows
|
|
138
|
+
* never move.
|
|
139
|
+
*/
|
|
140
|
+
export function perAccountAgents(total, accounts) {
|
|
141
|
+
const n = Math.max(1, Math.floor(Number(accounts) || 1));
|
|
142
|
+
return Math.max(1, Math.floor((Number(total) || 1) / n));
|
|
143
|
+
}
|
|
144
|
+
|
|
98
145
|
/**
|
|
99
146
|
* The sentence the Agents row puts under the stepper, in her vocabulary: what
|
|
100
147
|
* this Mac is, and what we would run on it. It SUGGESTS and never refuses, so
|