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,39 @@
|
|
|
1
|
+
// THE FILES A LOGIN WRITES, SO THE APP CAN SEE ONE LAND.
|
|
2
|
+
//
|
|
3
|
+
// A signed-out account used to sit out a fixed half hour, and nothing but a
|
|
4
|
+
// session surviving on it could bring it back, which no session could do while
|
|
5
|
+
// it sat out. So typing /login fixed nothing a person could see: their agents
|
|
6
|
+
// stayed "Queued" until the half hour ran out on its own, and a /login and any
|
|
7
|
+
// "continue" replies in between did nothing.
|
|
8
|
+
//
|
|
9
|
+
// A login rewrites these. Only their modification times are read, never their
|
|
10
|
+
// contents: what is in them is her credentials and is none of our business.
|
|
11
|
+
// A moved time is a reason to TRY again, not proof; the try is the proof.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where one account's login is written.
|
|
18
|
+
* - Claude Code, its default login: `~/.claude.json` (where the CLI keeps the
|
|
19
|
+
* account it is signed in as) and the same files inside `~/.claude`.
|
|
20
|
+
* - Claude Code, a CLAUDE_CONFIG_DIR: those files inside that folder.
|
|
21
|
+
* - Codex: `auth.json` inside its CODEX_HOME.
|
|
22
|
+
*/
|
|
23
|
+
export function signInFiles({ engine = 'claude', folder = null, home }) {
|
|
24
|
+
if (engine === 'codex') return folder ? [path.join(folder, 'auth.json')] : [];
|
|
25
|
+
if (!folder) {
|
|
26
|
+
const dir = path.join(home, '.claude');
|
|
27
|
+
return [path.join(home, '.claude.json'), path.join(dir, '.claude.json'), path.join(dir, '.credentials.json')];
|
|
28
|
+
}
|
|
29
|
+
return [path.join(folder, '.claude.json'), path.join(folder, '.credentials.json')];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The newest modification time among them, or 0 when none can be read. */
|
|
33
|
+
export function signInStamp(files, stat = fs.statSync) {
|
|
34
|
+
let newest = 0;
|
|
35
|
+
for (const f of files ?? []) {
|
|
36
|
+
try { newest = Math.max(newest, stat(f).mtimeMs); } catch {}
|
|
37
|
+
}
|
|
38
|
+
return newest;
|
|
39
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// A COPY RUN FROM SOURCE KEEPS ITSELF CURRENT TOO (w-7a39dace23, 2026-10-01).
|
|
2
|
+
//
|
|
3
|
+
// Teammates run the team build from a git checkout (docs/team/PLAN.md), and
|
|
4
|
+
// main/updater.mjs cannot help them: electron-updater reads published releases
|
|
5
|
+
// and does nothing at all unpackaged. Their updates are pushes to main, several
|
|
6
|
+
// a day, so this watches the branch instead of a release feed.
|
|
7
|
+
//
|
|
8
|
+
// WHAT IT DOES. A few seconds after launch, and then every half hour, it runs
|
|
9
|
+
// `git fetch` and asks whether the branch's upstream is ahead of the code this
|
|
10
|
+
// process booted from. If it is, the state goes to `ready` and the sidebar
|
|
11
|
+
// shows the "New version ready" card (renderer/src/components/SidebarUpdate.tsx),
|
|
12
|
+
// which names what changed when pointed at.
|
|
13
|
+
// Pressing Restart fast-forwards the folder, reinstalls packages only when
|
|
14
|
+
// package.json or the lockfile changed, rebuilds the screens, and relaunches.
|
|
15
|
+
// Nothing happens until that button is pressed.
|
|
16
|
+
//
|
|
17
|
+
// IT NEVER TOUCHES WORK SOMEBODY HAS NOT PUSHED. Four cases get no offer at
|
|
18
|
+
// all, each stated in `error` so Settings can say why: a folder that is not a
|
|
19
|
+
// checkout, a branch that follows nothing, tracked files with edits in them,
|
|
20
|
+
// and a branch with commits of its own. The founder's own app runs from a
|
|
21
|
+
// local branch with no upstream, so it is untouched by this. Untracked files
|
|
22
|
+
// (zero.config.json, cloud/team.config.json) do not count as edits: they are
|
|
23
|
+
// ignored or new, and a fast-forward cannot overwrite them without failing.
|
|
24
|
+
//
|
|
25
|
+
// "AHEAD OF WHAT IS RUNNING", NOT "AHEAD OF THE FOLDER". The code in memory is
|
|
26
|
+
// what booted, so the folder brought up to date by hand without a restart is
|
|
27
|
+
// still an update waiting, and the row says so.
|
|
28
|
+
//
|
|
29
|
+
// IF THE BUILD FAILS IT DOES NOT RESTART. The row comes back with what went
|
|
30
|
+
// wrong, and pressing Restart again retries from where it stopped. Restarting
|
|
31
|
+
// onto screens that did not build would be the one outcome worse than waiting.
|
|
32
|
+
|
|
33
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import { describe } from './updater.mjs';
|
|
36
|
+
|
|
37
|
+
const FIRST_CHECK_MS = 20_000;
|
|
38
|
+
const EVERY_MS = 30 * 60 * 1000;
|
|
39
|
+
const FETCH_MS = 60_000;
|
|
40
|
+
const INSTALL_MS = 10 * 60 * 1000;
|
|
41
|
+
const BUILD_MS = 5 * 60 * 1000;
|
|
42
|
+
// How many change titles the row lists. The rest are counted, not named.
|
|
43
|
+
const LISTED = 5;
|
|
44
|
+
|
|
45
|
+
const SAY = {
|
|
46
|
+
notRepo: 'This folder is not a git checkout, so it does not update itself.',
|
|
47
|
+
noUpstream: 'This copy\'s branch does not follow a branch on GitHub, so it does not update itself.',
|
|
48
|
+
dirty: 'This folder has changes of your own in it, so it does not update itself.',
|
|
49
|
+
diverged: 'This copy has commits of its own, so it does not update itself.',
|
|
50
|
+
offline: 'Could not reach GitHub.',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// A FILE THE BUILD WRITES IS NOT AN EDIT OF HERS (2026-10-02). `npm run build`
|
|
54
|
+
// rewrites shared/*.generated.mjs with the version and date of the Claude Code
|
|
55
|
+
// on this Mac, and the restart below runs that build. Counted as her changes,
|
|
56
|
+
// they made every update after the first say "changes of your own" for good
|
|
57
|
+
// (tests/a-build-does-not-stop-the-next-update.test.mjs).
|
|
58
|
+
const isBuildOutput = (file) => /^shared\/[^/]+\.generated\.[cm]?[jt]s$/.test(file);
|
|
59
|
+
// The status code is one or two letters and `gitIn` trims the output, which
|
|
60
|
+
// takes the leading space off the first line, so it is matched, not sliced.
|
|
61
|
+
const changedPaths = (porcelain) => String(porcelain ?? '').split('\n').filter(Boolean).map((l) => l.replace(/^[ MADRCUT?!]{1,2} /, '').replace(/^"|"$/g, ''));
|
|
62
|
+
const theirEdits = (porcelain) => changedPaths(porcelain).filter((f) => !isBuildOutput(f));
|
|
63
|
+
|
|
64
|
+
function gitIn(appDir) {
|
|
65
|
+
return (args, { timeout = 30_000 } = {}) => new Promise((resolve) => {
|
|
66
|
+
execFile('git', args, {
|
|
67
|
+
cwd: appDir,
|
|
68
|
+
timeout,
|
|
69
|
+
// Never a password prompt from a background check. The repository is
|
|
70
|
+
// public; anything that asks for credentials is a failure to report.
|
|
71
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
72
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
73
|
+
}, (err, stdout, stderr) => {
|
|
74
|
+
resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, out: String(stdout).trim(), err: String(stderr).trim() });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collect(bin, args, { cwd, timeout }) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
let out = '';
|
|
82
|
+
let child;
|
|
83
|
+
try {
|
|
84
|
+
child = spawn(bin, args, { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
resolve({ code: 1, out: String(e?.message ?? e) });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const keep = (b) => { out = (out + b).slice(-64 * 1024); };
|
|
90
|
+
child.stdout.on('data', keep);
|
|
91
|
+
child.stderr.on('data', keep);
|
|
92
|
+
const timer = setTimeout(() => { keep('\nerror: timed out'); child.kill('SIGTERM'); }, timeout);
|
|
93
|
+
child.on('error', (e) => { keep(`\nerror: ${e.message}`); });
|
|
94
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? 1, out }); });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// WHICH npm. Started with `npm start`, the process carries npm's own path in
|
|
99
|
+
// its environment, so that one is used and nvm or a version manager never
|
|
100
|
+
// matters. Started any other way, her login shell is asked, the way
|
|
101
|
+
// main/claude-bin.mjs finds Claude Code. The arguments are fixed strings from
|
|
102
|
+
// this file, never anything read from disk.
|
|
103
|
+
export function defaultNpm(appDir, env = process.env) {
|
|
104
|
+
return (args) => {
|
|
105
|
+
const timeout = args[0] === 'install' ? INSTALL_MS : BUILD_MS;
|
|
106
|
+
const node = env.npm_node_execpath;
|
|
107
|
+
const cli = env.npm_execpath;
|
|
108
|
+
if (node && cli && fs.existsSync(node) && fs.existsSync(cli)) {
|
|
109
|
+
return collect(node, [cli, ...args], { cwd: appDir, timeout });
|
|
110
|
+
}
|
|
111
|
+
const shell = env.SHELL || '/bin/zsh';
|
|
112
|
+
return collect(shell, ['-lic', `npm ${args.join(' ')}`], { cwd: appDir, timeout });
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The line of a failed build worth showing a person: the first that says
|
|
117
|
+
// error, else the last thing it printed.
|
|
118
|
+
export function failureLine(out) {
|
|
119
|
+
const lines = String(out ?? '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
120
|
+
const named = lines.find((l) => /error/i.test(l));
|
|
121
|
+
const line = named ?? lines[lines.length - 1] ?? 'it stopped without saying why';
|
|
122
|
+
return line.length > 160 ? `${line.slice(0, 157)}…` : line;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createSourceUpdater({
|
|
126
|
+
appDir,
|
|
127
|
+
app = null,
|
|
128
|
+
log = console,
|
|
129
|
+
onChanged = () => {},
|
|
130
|
+
npm = defaultNpm(appDir),
|
|
131
|
+
relaunch = () => { app?.relaunch(); app?.quit(); },
|
|
132
|
+
} = {}) {
|
|
133
|
+
const git = gitIn(appDir);
|
|
134
|
+
// The commit this process booted from. Read once, now, because it is the
|
|
135
|
+
// code in memory and nothing on disk can change that.
|
|
136
|
+
let bootHead = null;
|
|
137
|
+
try {
|
|
138
|
+
bootHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: appDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
|
|
139
|
+
} catch { /* not a checkout; check() says so */ }
|
|
140
|
+
|
|
141
|
+
let state = {
|
|
142
|
+
phase: 'idle',
|
|
143
|
+
source: true,
|
|
144
|
+
currentVersion: bootHead ? bootHead.slice(0, 7) : null,
|
|
145
|
+
newVersion: null,
|
|
146
|
+
percent: null,
|
|
147
|
+
error: null,
|
|
148
|
+
checkedAt: null,
|
|
149
|
+
readyAt: null,
|
|
150
|
+
changes: [],
|
|
151
|
+
behind: null,
|
|
152
|
+
};
|
|
153
|
+
let timer = null;
|
|
154
|
+
let stopped = false;
|
|
155
|
+
let pending = Promise.resolve();
|
|
156
|
+
|
|
157
|
+
const set = (next) => {
|
|
158
|
+
state = { ...state, ...next };
|
|
159
|
+
try { onChanged(describe(state)); } catch {}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Everything that has to be true before an update is offered or applied.
|
|
163
|
+
// Returns the upstream commit, or the sentence saying why not.
|
|
164
|
+
async function preconditions() {
|
|
165
|
+
if (!bootHead) return { why: SAY.notRepo };
|
|
166
|
+
const upstream = await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
167
|
+
if (upstream.code !== 0) return { why: SAY.noUpstream };
|
|
168
|
+
const dirty = await git(['status', '--porcelain', '--untracked-files=no']);
|
|
169
|
+
if (dirty.code !== 0 || theirEdits(dirty.out).length) return { why: SAY.dirty };
|
|
170
|
+
const ahead = await git(['merge-base', '--is-ancestor', 'HEAD', '@{u}']);
|
|
171
|
+
if (ahead.code !== 0) return { why: SAY.diverged };
|
|
172
|
+
const up = await git(['rev-parse', '@{u}']);
|
|
173
|
+
if (up.code !== 0 || !up.out) return { why: SAY.noUpstream };
|
|
174
|
+
return { up: up.out };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function check() {
|
|
178
|
+
if (stopped) return describe(state);
|
|
179
|
+
if (state.phase === 'checking' || state.phase === 'installing') return describe(state);
|
|
180
|
+
if (!bootHead) {
|
|
181
|
+
set({ phase: 'unsupported', error: SAY.notRepo, checkedAt: Date.now() });
|
|
182
|
+
return describe(state);
|
|
183
|
+
}
|
|
184
|
+
const was = state;
|
|
185
|
+
set({ phase: 'checking' });
|
|
186
|
+
// A failed fetch is not the end of the check: what was fetched before is
|
|
187
|
+
// still on disk, and an update already found stays offered on a plane.
|
|
188
|
+
const fetched = await git(['fetch', '--quiet'], { timeout: FETCH_MS });
|
|
189
|
+
if (fetched.code !== 0) log.warn?.('[source-updater] fetch failed:', fetched.err.split('\n')[0]);
|
|
190
|
+
const ok = await preconditions();
|
|
191
|
+
if (ok.why) {
|
|
192
|
+
set({ phase: 'unsupported', error: ok.why, newVersion: null, changes: [], behind: null, readyAt: null, checkedAt: Date.now() });
|
|
193
|
+
return describe(state);
|
|
194
|
+
}
|
|
195
|
+
if (ok.up === bootHead) {
|
|
196
|
+
set(fetched.code === 0
|
|
197
|
+
? { phase: 'current', error: null, newVersion: null, changes: [], behind: null, readyAt: null, checkedAt: Date.now() }
|
|
198
|
+
: { phase: 'error', error: SAY.offline, checkedAt: Date.now() });
|
|
199
|
+
return describe(state);
|
|
200
|
+
}
|
|
201
|
+
const range = `${bootHead}..${ok.up}`;
|
|
202
|
+
const titles = await git(['log', '--no-merges', '--format=%s', `-n${LISTED}`, range]);
|
|
203
|
+
const count = await git(['rev-list', '--no-merges', '--count', range]);
|
|
204
|
+
const newVersion = ok.up.slice(0, 7);
|
|
205
|
+
set({
|
|
206
|
+
phase: 'ready',
|
|
207
|
+
newVersion,
|
|
208
|
+
changes: titles.code === 0 && titles.out ? titles.out.split('\n') : [],
|
|
209
|
+
behind: count.code === 0 ? Number(count.out) || 0 : null,
|
|
210
|
+
// Stamped when THIS version was first seen, so a recheck does not make
|
|
211
|
+
// old news young again, and a newer push does.
|
|
212
|
+
readyAt: was.phase === 'ready' && was.newVersion === newVersion && was.readyAt ? was.readyAt : Date.now(),
|
|
213
|
+
// A failed earlier restart keeps saying so until the next press.
|
|
214
|
+
error: was.phase === 'ready' && was.newVersion === newVersion ? was.error : null,
|
|
215
|
+
checkedAt: Date.now(),
|
|
216
|
+
});
|
|
217
|
+
return describe(state);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function apply() {
|
|
221
|
+
const ok = await preconditions();
|
|
222
|
+
if (ok.why) {
|
|
223
|
+
set({ phase: 'unsupported', error: ok.why, newVersion: null, changes: [], behind: null, readyAt: null });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const head = await git(['rev-parse', 'HEAD']);
|
|
227
|
+
// What the last build stamped goes back first, so the fast-forward can
|
|
228
|
+
// never trip on it. The build below writes it again.
|
|
229
|
+
const stamped = await git(['status', '--porcelain', '--untracked-files=no']);
|
|
230
|
+
const buildOutput = stamped.code === 0 ? changedPaths(stamped.out).filter(isBuildOutput) : [];
|
|
231
|
+
if (buildOutput.length) await git(['checkout', '--', ...buildOutput]);
|
|
232
|
+
if (head.out !== ok.up) {
|
|
233
|
+
const merged = await git(['merge', '--ff-only', '--quiet', '@{u}']);
|
|
234
|
+
if (merged.code !== 0) {
|
|
235
|
+
set({ phase: 'ready', error: `Could not bring the new code in: ${failureLine(merged.err || merged.out)}` });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Packages changed when either file's object id moved. Compared by id
|
|
240
|
+
// rather than by asking git for a diff, which this process does in exactly
|
|
241
|
+
// one file (tests/the-old-git-diff-screen-stays-gone.test.mjs).
|
|
242
|
+
let depsMoved = false;
|
|
243
|
+
for (const file of ['package.json', 'package-lock.json']) {
|
|
244
|
+
const was = await git(['rev-parse', '--verify', '--quiet', `${bootHead}:${file}`]);
|
|
245
|
+
const now = await git(['rev-parse', '--verify', '--quiet', `HEAD:${file}`]);
|
|
246
|
+
if (was.out !== now.out) depsMoved = true;
|
|
247
|
+
}
|
|
248
|
+
if (depsMoved) {
|
|
249
|
+
const installed = await npm(['install', '--no-audit', '--no-fund']);
|
|
250
|
+
if (installed.code !== 0) {
|
|
251
|
+
set({ phase: 'ready', error: `The new packages would not install: ${failureLine(installed.out)}` });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const built = await npm(['run', 'build']);
|
|
256
|
+
if (built.code !== 0) {
|
|
257
|
+
set({ phase: 'ready', error: `The new code would not build: ${failureLine(built.out)}` });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
log.info?.('[source-updater] built', state.newVersion, '- relaunching');
|
|
261
|
+
relaunch();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// THE BUTTON. Returns at once; the work takes about a minute and the row
|
|
265
|
+
// says "Updating" while it runs.
|
|
266
|
+
function install() {
|
|
267
|
+
if (stopped || state.phase !== 'ready') return false;
|
|
268
|
+
set({ phase: 'installing', error: null });
|
|
269
|
+
pending = apply().catch((e) => {
|
|
270
|
+
set({ phase: 'ready', error: `The update stopped: ${failureLine(e?.message ?? e)}` });
|
|
271
|
+
});
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function start() {
|
|
276
|
+
if (stopped || timer) return;
|
|
277
|
+
const tick = () => { void check(); };
|
|
278
|
+
setTimeout(tick, FIRST_CHECK_MS).unref?.();
|
|
279
|
+
timer = setInterval(tick, EVERY_MS);
|
|
280
|
+
timer.unref?.();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function stop() {
|
|
284
|
+
stopped = true;
|
|
285
|
+
if (timer) clearInterval(timer);
|
|
286
|
+
timer = null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
start,
|
|
291
|
+
stop,
|
|
292
|
+
check: () => check(),
|
|
293
|
+
install,
|
|
294
|
+
state: () => describe(state),
|
|
295
|
+
// For tests: resolves when a started install has finished, either way.
|
|
296
|
+
settled: () => pending,
|
|
297
|
+
};
|
|
298
|
+
}
|
package/main/store/home.mjs
CHANGED
|
@@ -88,8 +88,8 @@ export function appHome(homeDir = os.homedir()) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
// Claude Code's own encoding, checked against a real folder on her Mac:
|
|
91
|
-
// `/Users/you/Zero/accounts/
|
|
92
|
-
// `-Users-you-Zero-accounts-
|
|
91
|
+
// `/Users/you/Zero/accounts/00000.../agentbox` becomes
|
|
92
|
+
// `-Users-you-Zero-accounts-00000...-agentbox`. Every character that is not
|
|
93
93
|
// a letter or a digit becomes a dash, which is readable at a glance and safe on
|
|
94
94
|
// every filesystem.
|
|
95
95
|
export function encodeProjectPath(dir) {
|
|
@@ -303,6 +303,51 @@ export function machineryPath(projectDir, rel, home = appHome()) {
|
|
|
303
303
|
return target;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
/**
|
|
307
|
+
* RECORDS A COPY WROTE UNDER THE WRONG HOME, CARRIED TO ITS OWN.
|
|
308
|
+
*
|
|
309
|
+
* The headless door (main/serve.mjs) used to open the store without pointing
|
|
310
|
+
* the app's home at `storeRoot`, so it wrote its ledgers under whatever home it
|
|
311
|
+
* inherited: `~/.agentbox` for `npx agentbox-app`, and on the test Mac
|
|
312
|
+
* 2026-10-01 the founder's own store, because the copies were started from an
|
|
313
|
+
* agent session. Its workers were handed `storeRoot`, found nothing there, and
|
|
314
|
+
* answered "no work item" to every store call. Pointing the home at `storeRoot`
|
|
315
|
+
* fixes the next write; this is what keeps the rows already written from
|
|
316
|
+
* vanishing on the first boot after the fix.
|
|
317
|
+
*
|
|
318
|
+
* ONLY WHAT IS PROVABLY THIS STORE'S MOVES. A machinery directory names the
|
|
319
|
+
* product it belongs to in `.origin`; one is carried only when that product is
|
|
320
|
+
* inside `storeRoot`. Everything else under `fromHome` is somebody else's
|
|
321
|
+
* store and is not touched. Each record goes through `foldStray`, so a ledger
|
|
322
|
+
* is appended rather than overwritten and nothing is ever destroyed; the old
|
|
323
|
+
* directory is removed only once nothing but its marker is left in it.
|
|
324
|
+
*
|
|
325
|
+
* Returns the product folders whose records were carried.
|
|
326
|
+
*/
|
|
327
|
+
export function carryMisplacedMachinery(fromHome, storeRoot, toHome = storeRoot) {
|
|
328
|
+
if (!fromHome || !storeRoot) return [];
|
|
329
|
+
const from = path.resolve(fromHome);
|
|
330
|
+
if (from === path.resolve(toHome)) return [];
|
|
331
|
+
const inside = path.resolve(storeRoot) + path.sep;
|
|
332
|
+
let names = [];
|
|
333
|
+
try { names = fs.readdirSync(path.join(from, 'projects')); } catch { return []; }
|
|
334
|
+
const carried = [];
|
|
335
|
+
for (const name of names) {
|
|
336
|
+
const dir = path.join(from, 'projects', name);
|
|
337
|
+
const origin = readOrigin(dir);
|
|
338
|
+
if (!origin || !path.resolve(origin).startsWith(inside)) continue;
|
|
339
|
+
const target = ensureMachineryDir(origin, toHome);
|
|
340
|
+
for (const rel of MACHINERY) foldStray(path.join(dir, rel), path.join(target, rel), rel);
|
|
341
|
+
let left = [];
|
|
342
|
+
try { left = fs.readdirSync(dir); } catch { /* gone already */ }
|
|
343
|
+
if (left.length === 1 && left[0] === ORIGIN) {
|
|
344
|
+
try { fs.rmSync(path.join(dir, ORIGIN)); fs.rmdirSync(dir); } catch { /* best effort */ }
|
|
345
|
+
}
|
|
346
|
+
carried.push(origin);
|
|
347
|
+
}
|
|
348
|
+
return carried;
|
|
349
|
+
}
|
|
350
|
+
|
|
306
351
|
/**
|
|
307
352
|
* EVERY RECORD OF OURS FOR ONE PROJECT, GONE.
|
|
308
353
|
*
|
|
@@ -18,7 +18,7 @@ import crypto from 'node:crypto';
|
|
|
18
18
|
import { machineryPath } from './home.mjs';
|
|
19
19
|
import { withProjectLock } from './project-lock.mjs';
|
|
20
20
|
import {
|
|
21
|
-
foldWorkItems, buildLine, buildClaimLine, buildHeartbeatLine, buildReleaseLine,
|
|
21
|
+
foldWorkItems, normalizeLine, buildLine, buildClaimLine, buildHeartbeatLine, buildReleaseLine,
|
|
22
22
|
isClaimable, sortForPull, matchesFilter, isWorkItemId, MAX_LINE_BYTES, LEASE_MS,
|
|
23
23
|
} from '../../shared/work-items.mjs';
|
|
24
24
|
|
|
@@ -47,7 +47,29 @@ function ledgerPath(projectDir) {
|
|
|
47
47
|
// and the result is two corrupted records rather than one. A short write means
|
|
48
48
|
// the guarantee did not hold, so it throws rather than leaving a half line for
|
|
49
49
|
// the fold to skip and the founder to never hear about.
|
|
50
|
+
// WHO IS WRITING, on a Mac someone has signed in on (the team version).
|
|
51
|
+
//
|
|
52
|
+
// Main sets it on sign-in. The store's MCP server is another process, so it
|
|
53
|
+
// reads AGENTBOX_PERSON_ID, which the supervisor hands it. With neither, no line
|
|
54
|
+
// is stamped and the single-person app writes exactly what it always wrote.
|
|
55
|
+
let lineAuthor = null;
|
|
56
|
+
export function setLineAuthor(personId) {
|
|
57
|
+
lineAuthor = typeof personId === 'string' && personId ? personId : null;
|
|
58
|
+
}
|
|
59
|
+
function currentAuthor() {
|
|
60
|
+
return lineAuthor ?? (process.env.AGENTBOX_PERSON_ID || null);
|
|
61
|
+
}
|
|
62
|
+
const newLineUid = () => `l-${crypto.randomBytes(8).toString('hex')}`;
|
|
63
|
+
|
|
50
64
|
function appendLine(file, obj) {
|
|
65
|
+
const author = currentAuthor();
|
|
66
|
+
// `by` and `uid` are added here, the one place every line passes, so no
|
|
67
|
+
// writer can forget them. A line that already carries them keeps its own.
|
|
68
|
+
const line = author ? { ...obj, by: obj.by ?? author, uid: obj.uid ?? newLineUid() } : obj;
|
|
69
|
+
return writeLine(file, line);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeLine(file, obj) {
|
|
51
73
|
const buf = Buffer.from(JSON.stringify(obj) + '\n', 'utf8');
|
|
52
74
|
if (buf.length > MAX_LINE_BYTES) throw new Error(`work item line too large (${buf.length} bytes, max ${MAX_LINE_BYTES})`);
|
|
53
75
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -136,7 +158,7 @@ function foldedLedger(file) {
|
|
|
136
158
|
// Time zero, so the lease pass inside the fold expires nothing: a lease is
|
|
137
159
|
// only ever lapsed against the clock of the call asking, and that is `aged`.
|
|
138
160
|
const items = stamp === 'absent' ? new Map() : foldWorkItems(readLines(file), 0);
|
|
139
|
-
// One entry per ledger, so
|
|
161
|
+
// One entry per ledger, so a real store has a few dozen. The cap is for the
|
|
140
162
|
// long-lived MCP server, which walks temporary project dirs that never come
|
|
141
163
|
// back; a Map iterates in insertion order, so the oldest key goes first.
|
|
142
164
|
if (folded.size >= MAX_FOLDS_HELD) folded.delete(folded.keys().next().value);
|
|
@@ -167,6 +189,48 @@ export function readWorkItems(projectDir, now = Date.now()) {
|
|
|
167
189
|
return out;
|
|
168
190
|
}
|
|
169
191
|
|
|
192
|
+
/**
|
|
193
|
+
* THE FINISHED THREADS THE 8 MB READ CUT OFF, read only when somebody asks.
|
|
194
|
+
*
|
|
195
|
+
* `readLines` keeps a ledger's trailing 8 MB, so a thread whose lines all came
|
|
196
|
+
* before that is in no fold, and one whose first lines did is folded without
|
|
197
|
+
* them (a blank title, the wrong `createdAt`). Measured 2026-10-07
|
|
198
|
+
* (w-fda2165ec6): 155 finished threads gone and 21 blank on one 12.8 MB
|
|
199
|
+
* ledger. Her call was that these load as she scrolls rather than up front, so
|
|
200
|
+
* the snapshot keeps its window and this reads the whole file, once per
|
|
201
|
+
* change of the file, for the Done and All tabs' next page.
|
|
202
|
+
*
|
|
203
|
+
* Finished threads only: this is history, and it is where the tabs that ask
|
|
204
|
+
* for it go. A ledger that fits in the window has nothing to give.
|
|
205
|
+
*/
|
|
206
|
+
const wholeFolds = new Map();
|
|
207
|
+
export function readOlderWorkItems(projectDir, now = Date.now()) {
|
|
208
|
+
const file = ledgerPath(projectDir);
|
|
209
|
+
let size;
|
|
210
|
+
try { size = fs.statSync(file).size; } catch { return []; }
|
|
211
|
+
if (size <= MAX_LEDGER_BYTES) return [];
|
|
212
|
+
const stamp = ledgerStamp(file);
|
|
213
|
+
let whole = wholeFolds.get(file);
|
|
214
|
+
if (!whole || whole.stamp !== stamp) {
|
|
215
|
+
let lines;
|
|
216
|
+
try { lines = fs.readFileSync(file, 'utf8').split('\n').filter((l) => l && Buffer.byteLength(l, 'utf8') <= MAX_LINE_BYTES); } catch { return []; }
|
|
217
|
+
// Few ledgers are ever this big; hold the latest fold of each, no more.
|
|
218
|
+
if (wholeFolds.size >= 8) wholeFolds.delete(wholeFolds.keys().next().value);
|
|
219
|
+
whole = { stamp, items: foldWorkItems(lines, 0) };
|
|
220
|
+
wholeFolds.set(file, whole);
|
|
221
|
+
}
|
|
222
|
+
const windowed = foldedLedger(file);
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const item of whole.items.values()) {
|
|
225
|
+
if (item.status !== 'done') continue;
|
|
226
|
+
const seen = windowed.get(item.id);
|
|
227
|
+
// In the window whole: the snapshot already has it right.
|
|
228
|
+
if (seen && seen.createdAt === item.createdAt) continue;
|
|
229
|
+
out.push(aged(item, now));
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
170
234
|
/** One work item, or null. */
|
|
171
235
|
export function readWorkItem(projectDir, id, now = Date.now()) {
|
|
172
236
|
if (!isWorkItemId(id)) return null;
|
|
@@ -261,10 +325,10 @@ export async function claimWorkItem(projectDir, { id = null, filter = null, hold
|
|
|
261
325
|
// A LIVE CLAIM OF YOUR OWN IS NOT SOMEBODY ELSE'S. The holder was never
|
|
262
326
|
// compared here, so a session asking for the row it already holds was
|
|
263
327
|
// told "held by another worker" and handed back its own name, and the
|
|
264
|
-
// only way through was to sit out its own five minute lease
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
328
|
+
// only way through was to sit out its own five minute lease: after a
|
|
329
|
+
// reply, the session was refused by its own claim, waited out the lease,
|
|
330
|
+
// and the answer the user was waiting for sat in a parked file.
|
|
331
|
+
// Re-claiming what you hold is a renewal.
|
|
268
332
|
if (item.claim && !item.claimExpired && item.claim.holder !== holder) {
|
|
269
333
|
return { claimed: false, reason: 'held by another worker', heldBy: item.claim.holder };
|
|
270
334
|
}
|
|
@@ -295,5 +359,85 @@ export function releaseWorkItem(projectDir, id, { epoch, now = Date.now() } = {}
|
|
|
295
359
|
return readWorkItem(projectDir, id, now);
|
|
296
360
|
}
|
|
297
361
|
|
|
362
|
+
// Release only the uninterrupted claim acquired by this run. A tool server
|
|
363
|
+
// may outlive its turn, so process lifetime is not run lifetime. The first
|
|
364
|
+
// claim after the spawn snapshot identifies the holder; same-holder renewals
|
|
365
|
+
// are allowed, but a release or another holder ends this run's ownership.
|
|
366
|
+
export function releaseRunClaim(projectDir, id, { afterEpoch, startedAt, now = Date.now() } = {}) {
|
|
367
|
+
if (!Number.isFinite(afterEpoch) || !Number.isFinite(startedAt)) return null;
|
|
368
|
+
const lines = readLines(ledgerPath(projectDir)).map(normalizeLine).filter(line => line?.id === id);
|
|
369
|
+
const item = foldWorkItems(lines, now).get(id);
|
|
370
|
+
if (!item?.claim) return item;
|
|
371
|
+
const claims = lines.filter(line => line.claim && !line.heartbeat && line.epoch > afterEpoch);
|
|
372
|
+
const first = claims[0];
|
|
373
|
+
if (!first || first.epoch !== afterEpoch + 1 || first.ts < startedAt) return item;
|
|
374
|
+
if (claims.some(line => line.claim.holder !== first.claim.holder)) return item;
|
|
375
|
+
if (lines.some(line => line.release && line.epoch >= first.epoch)) return item;
|
|
376
|
+
if (item.claim.holder !== first.claim.holder) return item;
|
|
377
|
+
// A concurrent new claim fences this append out through its newer epoch.
|
|
378
|
+
return releaseWorkItem(projectDir, id, { epoch: item.epoch, now });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* ------------------------------ the team sync ----------------------------- */
|
|
382
|
+
// A TEAMMATE'S LINES, PULLED FROM THE CLOUD, ENTER THE LEDGER HERE AND ONLY
|
|
383
|
+
// HERE, which keeps this file the one write path even with several writers on
|
|
384
|
+
// several Macs. Each line goes in verbatim, still its author's, and a line
|
|
385
|
+
// whose uid the ledger already holds is skipped, so pulling the same page twice
|
|
386
|
+
// stores it once. A line with no uid or no writer is refused: nothing could tell
|
|
387
|
+
// it apart on the next pull. So is one too big to write, which used to throw
|
|
388
|
+
// and leave the pull cursor in front of it forever (review, 2026-10-01): a bad
|
|
389
|
+
// line is skipped and the pull moves past it. Returns how many were appended.
|
|
390
|
+
export function appendForeignLines(projectDir, lines) {
|
|
391
|
+
const file = ledgerPath(projectDir);
|
|
392
|
+
const have = new Set();
|
|
393
|
+
for (const text of readLines(file)) {
|
|
394
|
+
const uid = /"uid":"([^"]+)"/.exec(text)?.[1];
|
|
395
|
+
if (uid) have.add(uid);
|
|
396
|
+
}
|
|
397
|
+
let added = 0;
|
|
398
|
+
for (const raw of Array.isArray(lines) ? lines : []) {
|
|
399
|
+
const line = normalizeLine(raw);
|
|
400
|
+
if (!line || !line.uid || !line.by || have.has(line.uid)) continue;
|
|
401
|
+
if (Buffer.byteLength(JSON.stringify(raw) + '\n', 'utf8') > MAX_LINE_BYTES) continue;
|
|
402
|
+
writeLine(file, raw);
|
|
403
|
+
have.add(line.uid);
|
|
404
|
+
added += 1;
|
|
405
|
+
}
|
|
406
|
+
return added;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Every line after a byte offset, as written, and where the file ends. The
|
|
410
|
+
// ledger only grows, so an offset is a cursor: sync keeps the one it pushed to
|
|
411
|
+
// and asks from there next time.
|
|
412
|
+
export function readLinesFrom(projectDir, offset = 0) {
|
|
413
|
+
const file = ledgerPath(projectDir);
|
|
414
|
+
let size;
|
|
415
|
+
try { size = fs.statSync(file).size; } catch (err) {
|
|
416
|
+
if (err.code === 'ENOENT') return { lines: [], end: 0 };
|
|
417
|
+
throw err;
|
|
418
|
+
}
|
|
419
|
+
const start = Math.max(0, Math.min(Number(offset) || 0, size));
|
|
420
|
+
if (start >= size) return { lines: [], end: size };
|
|
421
|
+
const fd = fs.openSync(file, 'r');
|
|
422
|
+
let text;
|
|
423
|
+
try {
|
|
424
|
+
const buf = Buffer.allocUnsafe(size - start);
|
|
425
|
+
const read = fs.readSync(fd, buf, 0, size - start, start);
|
|
426
|
+
text = buf.toString('utf8', 0, read);
|
|
427
|
+
} finally {
|
|
428
|
+
fs.closeSync(fd);
|
|
429
|
+
}
|
|
430
|
+
// Stop at the last whole line: a line still being written is read next time.
|
|
431
|
+
const lastNewline = text.lastIndexOf('\n');
|
|
432
|
+
if (lastNewline < 0) return { lines: [], end: start };
|
|
433
|
+
const whole = text.slice(0, lastNewline + 1);
|
|
434
|
+
const lines = [];
|
|
435
|
+
for (const piece of whole.split('\n')) {
|
|
436
|
+
if (!piece || Buffer.byteLength(piece, 'utf8') > MAX_LINE_BYTES) continue;
|
|
437
|
+
try { lines.push(JSON.parse(piece)); } catch { /* a torn line reads as nothing, as in the fold */ }
|
|
438
|
+
}
|
|
439
|
+
return { lines, end: start + Buffer.byteLength(whole, 'utf8') };
|
|
440
|
+
}
|
|
441
|
+
|
|
298
442
|
export { LEASE_MS };
|
|
299
443
|
export const _internals = { appendLine, readLines, ledgerPath, forgetFolds: () => folded.clear() };
|