agentbox-app 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -16
- package/bin/agentbox.mjs +18 -9
- package/briefs/message-rules.md +15 -4
- package/main/account-tooling.mjs +2 -1
- package/main/agent-updates.mjs +3 -1
- package/main/approvals.mjs +14 -5
- package/main/claude-bin.mjs +1 -0
- package/main/claude-plan.mjs +14 -2
- package/main/codex-bin.mjs +1 -0
- package/main/codex-launch-env.mjs +31 -0
- package/main/config.mjs +14 -9
- package/main/demo.mjs +17 -14
- package/main/engine-setup.mjs +23 -12
- package/main/fresh-user.mjs +1 -1
- package/main/git-change.mjs +8 -2
- package/main/instruction-settings.mjs +47 -11
- package/main/ipc.mjs +19 -0
- package/main/machine.mjs +1 -1
- package/main/main.mjs +20 -1
- package/main/memory-gate.mjs +1 -1
- package/main/notify.mjs +1 -1
- package/main/refresh-agent-models.mjs +2 -1
- package/main/row-label.mjs +2 -1
- package/main/runs-on.mjs +93 -0
- package/main/serve.mjs +11 -1
- package/main/settings.mjs +23 -1
- package/main/ship-queue.mjs +1 -1
- package/main/store/home.mjs +37 -5
- package/main/store/project.mjs +15 -19
- package/main/store.mjs +10 -4
- package/main/supervisor.mjs +205 -26
- package/main/task-folders.mjs +20 -3
- package/main/task-terminals.mjs +12 -1
- package/main/team/index.mjs +2 -2
- package/main/team/projects.mjs +1 -1
- package/mcp/core/page.mjs +1 -1
- package/mcp/core/work.mjs +34 -3
- package/mcp/tools.mjs +2 -1
- package/package.json +1 -1
- package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
- package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
- package/renderer/dist/index.html +2 -2
- package/scripts/after-install.mjs +14 -0
- package/scripts/approval-server.sh +7 -2
- package/shared/agent-import.mjs +1 -1
- package/shared/bridge-map.mjs +1 -0
- package/shared/claude-commands.generated.mjs +2 -2
- package/shared/claude-models.generated.mjs +4 -4
- package/shared/codex-modes.mjs +1 -1
- package/shared/demo-world.mjs +1 -1
- package/shared/fresh-user-home.mjs +12 -1
- package/shared/instruction-defaults.json +1 -1
- package/shared/project-folder-check.mjs +3 -3
- package/shared/runs-on.d.mts +20 -0
- package/shared/runs-on.mjs +79 -0
- package/shared/shortcuts.mjs +1 -1
- package/shared/spawn-trouble.mjs +1 -1
- package/shared/work-items.mjs +3 -1
- package/renderer/dist/assets/index-B-2Az952.css +0 -32
- package/renderer/dist/assets/index-D6qncgai.js +0 -1168
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
// A fixed allowlist exposes the instruction files, never arbitrary paths.
|
|
2
2
|
// Defaults are shipped separately so an edited live file can always be reset.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// A person opening that page is answering two questions, what should agents do
|
|
8
|
-
// and how should they talk to me, so the writing rules and the finishing rules
|
|
9
|
-
// became ONE document: `message-rules.md`, "How agents write to you". In
|
|
10
|
-
// practice a rules file is mostly about how agents write to the person rather
|
|
11
|
-
// than what they do, which is why the two were one thing all along.
|
|
12
|
-
// `joinMessageRules` below carries the two old files into the new one.
|
|
4
|
+
// General and writing instructions are one user document (w-fb0fe5e85a).
|
|
5
|
+
// The old message id remains available for callers from an older renderer;
|
|
6
|
+
// once migrated, it reads, saves and restores the general instructions.
|
|
13
7
|
import fs from 'node:fs';
|
|
14
8
|
import path from 'node:path';
|
|
15
9
|
import {fileURLToPath} from 'node:url';
|
|
@@ -25,6 +19,14 @@ import {nameSlug} from '../shared/product-name.mjs';
|
|
|
25
19
|
const files = {rules:'founder.md',messages:'message-rules.md',adhd:'adhd-mode.md',system:'worker.md'};
|
|
26
20
|
// The two names the joined document replaced, read only by the migration.
|
|
27
21
|
const WAS_MESSAGES = ['writing-rules.md','finishing.md'];
|
|
22
|
+
const UNIFIED = '.unified-agent-instructions';
|
|
23
|
+
export function agentInstructionsUnified(appDir) {
|
|
24
|
+
return fs.existsSync(path.join(appDir,'briefs',UNIFIED));
|
|
25
|
+
}
|
|
26
|
+
function sectionFor(appDir,id) {
|
|
27
|
+
fileFor(id);
|
|
28
|
+
return id==='messages' && agentInstructionsUnified(appDir) ? 'rules' : id;
|
|
29
|
+
}
|
|
28
30
|
const defaults = JSON.parse(fs.readFileSync(fileURLToPath(new URL('../shared/instruction-defaults.json',import.meta.url)),'utf8'));
|
|
29
31
|
function fileFor(id) {
|
|
30
32
|
if (!Object.hasOwn(files,id)) throw new Error('Unknown instruction section');
|
|
@@ -36,6 +38,7 @@ function fileFor(id) {
|
|
|
36
38
|
// them, so they ride on every run from the checkout and never sit in the box.
|
|
37
39
|
const boxDefault=(id)=>(id==='rules'||id==='messages') ? '' : defaults[id];
|
|
38
40
|
export function readInstruction(appDir,id,shippedDir=appDir) {
|
|
41
|
+
id=sectionFor(appDir,id);
|
|
39
42
|
const file=fileFor(id);
|
|
40
43
|
const defaultText=boxDefault(id);
|
|
41
44
|
let text=defaultText;
|
|
@@ -68,7 +71,7 @@ const KEEP=20;
|
|
|
68
71
|
const QUIET_MS=10*60*1000;
|
|
69
72
|
function historyDir(appDir,id) {return path.join(appDir,HISTORY,id);}
|
|
70
73
|
export function listVersions(appDir,id) {
|
|
71
|
-
|
|
74
|
+
id=sectionFor(appDir,id);
|
|
72
75
|
const dir=historyDir(appDir,id);
|
|
73
76
|
let names;
|
|
74
77
|
try {names=fs.readdirSync(dir);} catch(e) {if(e.code!=='ENOENT') throw e; return [];}
|
|
@@ -79,7 +82,7 @@ export function listVersions(appDir,id) {
|
|
|
79
82
|
}).filter(Boolean).sort((a,b)=>b.ts-a.ts);
|
|
80
83
|
}
|
|
81
84
|
export function readVersion(appDir,id,ts) {
|
|
82
|
-
|
|
85
|
+
id=sectionFor(appDir,id);
|
|
83
86
|
const at=Number(ts);
|
|
84
87
|
if(!Number.isFinite(at)) throw new Error('Unknown version');
|
|
85
88
|
return {text:fs.readFileSync(path.join(historyDir(appDir,id),`${at}.md`),'utf8')};
|
|
@@ -102,6 +105,7 @@ function keepVersion(appDir,id,now=Date.now(),always=false) {
|
|
|
102
105
|
} catch(e) {console.warn(`${nameSlug}: could not keep a restore point for ${id}: ${e.message}`);}
|
|
103
106
|
}
|
|
104
107
|
export function writeInstruction(appDir,id,text,now=Date.now()) {
|
|
108
|
+
id=sectionFor(appDir,id);
|
|
105
109
|
const file=fileFor(id);
|
|
106
110
|
if(typeof text!=='string') throw new Error('Instructions must be text');
|
|
107
111
|
keepVersion(appDir,id,now);
|
|
@@ -150,6 +154,7 @@ export function carryHerBriefsAcross(fromDir,toDir) {
|
|
|
150
154
|
if(!fromDir||!toDir||path.resolve(fromDir)===path.resolve(toDir)) return carried;
|
|
151
155
|
for(const [id,file] of Object.entries(files)) {
|
|
152
156
|
try {
|
|
157
|
+
if(id==='messages' && agentInstructionsUnified(toDir)) continue;
|
|
153
158
|
const target=path.join(toDir,'briefs',file);
|
|
154
159
|
if(fs.existsSync(target)) continue;
|
|
155
160
|
let text;
|
|
@@ -196,6 +201,7 @@ export function carryHerBriefsAcross(fromDir,toDir) {
|
|
|
196
201
|
*/
|
|
197
202
|
export function joinMessageRules(shippedDir,toDir) {
|
|
198
203
|
try {
|
|
204
|
+
if(agentInstructionsUnified(toDir)) return null;
|
|
199
205
|
const target=path.join(toDir,'briefs',files.messages);
|
|
200
206
|
if(fs.existsSync(target)) return null;
|
|
201
207
|
const read=(name)=>{try {return fs.readFileSync(path.join(toDir,'briefs',name),'utf8');} catch {return null;}};
|
|
@@ -225,6 +231,7 @@ export function joinMessageRules(shippedDir,toDir) {
|
|
|
225
231
|
*/
|
|
226
232
|
export function setAsideShippedMessageRules(shippedDir,toDir) {
|
|
227
233
|
try {
|
|
234
|
+
if(agentInstructionsUnified(toDir)) return false;
|
|
228
235
|
const target=path.join(toDir,'briefs',files.messages);
|
|
229
236
|
if(path.resolve(shippedDir)===path.resolve(toDir)||!fs.existsSync(target)) return false;
|
|
230
237
|
const read=(name)=>{try {return fs.readFileSync(path.join(shippedDir,'briefs',name),'utf8').trim();} catch {return '';}};
|
|
@@ -241,3 +248,32 @@ export function setAsideShippedMessageRules(shippedDir,toDir) {
|
|
|
241
248
|
return false;
|
|
242
249
|
}
|
|
243
250
|
}
|
|
251
|
+
|
|
252
|
+
// Runs after the older migrations, before the supervisor or editors read.
|
|
253
|
+
// Original message files and their histories remain on disk for recovery.
|
|
254
|
+
// The completion marker prevents later boots reimporting them after an edit
|
|
255
|
+
// or an intentional empty save. A failed save leaves both originals active.
|
|
256
|
+
export function unifyAgentInstructions(shippedDir,toDir) {
|
|
257
|
+
try {
|
|
258
|
+
if(path.resolve(shippedDir)===path.resolve(toDir)||agentInstructionsUnified(toDir)) return null;
|
|
259
|
+
const general=readInstruction(toDir,'rules').text;
|
|
260
|
+
const writing=readInstruction(toDir,'messages').text;
|
|
261
|
+
if(writing.trim()) {
|
|
262
|
+
// If the combined save succeeded but completion was interrupted, the exact
|
|
263
|
+
// old text is already at the end. Retrying must not append it again.
|
|
264
|
+
const combined=general.endsWith(writing) ? general : general ? `${general}\n\n${writing}` : writing;
|
|
265
|
+
keepVersion(toDir,'rules',Date.now(),true);
|
|
266
|
+
keepVersion(toDir,'messages',Date.now(),true);
|
|
267
|
+
if(combined!==general) writeInstruction(toDir,'rules',combined);
|
|
268
|
+
}
|
|
269
|
+
const marker=path.join(toDir,'briefs',UNIFIED);
|
|
270
|
+
fs.mkdirSync(path.dirname(marker),{recursive:true});
|
|
271
|
+
const temporary=`${marker}.tmp-${process.pid}`;
|
|
272
|
+
fs.writeFileSync(temporary,'1\n','utf8');
|
|
273
|
+
fs.renameSync(temporary,marker);
|
|
274
|
+
return {general:general.length,writing:writing.length};
|
|
275
|
+
} catch(e) {
|
|
276
|
+
console.warn(`${nameSlug}: could not combine the agent instructions: ${e.message}`);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
package/main/ipc.mjs
CHANGED
|
@@ -400,6 +400,17 @@ export function registerIpc({ store, supervisor, config, window, analytics = NO_
|
|
|
400
400
|
// coding agent this is one entry and an empty map, and nothing anywhere
|
|
401
401
|
// draws anything new.
|
|
402
402
|
engines,
|
|
403
|
+
// WHICH SUBSCRIPTION ALL OF THIS IS RUNNING ON (w-e217e577e5, 2026-10-07).
|
|
404
|
+
// A new user watched agents work and could not tell what was powering
|
|
405
|
+
// them, because the app finds whichever sign-in is already on the Mac and
|
|
406
|
+
// spends it in silence.
|
|
407
|
+
//
|
|
408
|
+
// It rides the snapshot rather than the settings model because the SIDEBAR
|
|
409
|
+
// reads it, which is on screen the whole time, and the settings model is
|
|
410
|
+
// only fetched while that screen is open. It costs no file read per tick:
|
|
411
|
+
// `main/runs-on.mjs` caches on the login files' own modification times, so
|
|
412
|
+
// this re-parses only after a sign-in or a profile refetch.
|
|
413
|
+
runsOn: supervisor.runsOnAccount(engines.workspace),
|
|
403
414
|
// Whether this process is still the app on disk. It rides the snapshot
|
|
404
415
|
// rather than boot-info because the staleness DEVELOPS while the app
|
|
405
416
|
// runs: the case that cost her three times was a process left up for days
|
|
@@ -851,6 +862,14 @@ export function registerIpc({ store, supervisor, config, window, analytics = NO_
|
|
|
851
862
|
return store.scheduleItem(product, id, runAt);
|
|
852
863
|
});
|
|
853
864
|
|
|
865
|
+
// Snooze changes when a person sees the thread, never what an agent does.
|
|
866
|
+
ipcMain.handle('zero:snooze', (_e, { product, id, snoozedUntil }) => {
|
|
867
|
+
if (isAgentRow(id)) return { ok: false };
|
|
868
|
+
const item = store.snoozeItem(product, id, snoozedUntil);
|
|
869
|
+
push();
|
|
870
|
+
return item;
|
|
871
|
+
});
|
|
872
|
+
|
|
854
873
|
// said about an agent instead of about a task. The row it moves stands for
|
|
855
874
|
// somebody's terminal, so this writes to no ledger, sends no message and
|
|
856
875
|
// stops no process: it records the moment her inbox should raise the agent
|
package/main/machine.mjs
CHANGED
|
@@ -156,5 +156,5 @@ export function perAccountAgents(total, accounts) {
|
|
|
156
156
|
export function machineNote({ slots, memGb, cores, known = true }, at = 0) {
|
|
157
157
|
if (!known) return null;
|
|
158
158
|
if (at && at <= slots) return null;
|
|
159
|
-
return `This
|
|
159
|
+
return `This computer has ${memGb} GB and ${cores} cores, so we suggest ${slots} at once. Go higher if you want to.`;
|
|
160
160
|
}
|
package/main/main.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { storeRootEnv } from './store/home.mjs';
|
|
|
21
21
|
import { Supervisor } from './supervisor.mjs';
|
|
22
22
|
import { startCodexWatch } from './codex-watch.mjs';
|
|
23
23
|
import { registerIpc } from './ipc.mjs';
|
|
24
|
-
import { carryHerBriefsAcross, joinMessageRules, setAsideShippedMessageRules } from './instruction-settings.mjs';
|
|
24
|
+
import { carryHerBriefsAcross, joinMessageRules, setAsideShippedMessageRules, unifyAgentInstructions } from './instruction-settings.mjs';
|
|
25
25
|
import * as approvals from './approvals.mjs';
|
|
26
26
|
import { recoveryToast } from '../shared/recovery.mjs';
|
|
27
27
|
import { dataFolderName, isNewUserBuild } from '../shared/side-build.mjs';
|
|
@@ -206,6 +206,9 @@ try {
|
|
|
206
206
|
// AND A BOX THAT AN OLDER JOIN FILLED WITH ONLY OUR TEXT IS EMPTIED, ONCE
|
|
207
207
|
// (w-3ec9f07978). Kept as a restore point; our rules ride from the checkout.
|
|
208
208
|
if (setAsideShippedMessageRules(appDir, userDir)) console.log('zero: the message rules box held only the shipped rules; set aside as a restore point');
|
|
209
|
+
// Both kinds of user rules now live in founder.md. Only user content is
|
|
210
|
+
// appended; the app's message defaults continue to come from the bundle.
|
|
211
|
+
if (unifyAgentInstructions(appDir, userDir)) console.log('zero: combined the general and writing instructions');
|
|
209
212
|
} catch (err) {
|
|
210
213
|
console.warn(`zero: could not carry her instructions into ${userDir}: ${err.message}`);
|
|
211
214
|
}
|
|
@@ -603,6 +606,11 @@ async function createWindow() {
|
|
|
603
606
|
},
|
|
604
607
|
});
|
|
605
608
|
|
|
609
|
+
// THE MENU IS A BAR INSIDE THE WINDOW on Linux and Windows. On macOS it is
|
|
610
|
+
// the system menu, so it stays. The menu itself stays everywhere: the Ctrl
|
|
611
|
+
// chords are bound to it, and taking the menu out would take them with it.
|
|
612
|
+
if (process.platform !== 'darwin') window.setMenuBarVisibility(false);
|
|
613
|
+
|
|
606
614
|
// WHICH PICTURE SET THIS WINDOW IS ON, and a shout when it changes.
|
|
607
615
|
// main/screen-detail.mjs has the rule and the reasoning; this is the wiring.
|
|
608
616
|
// The initial answer rides bootInfo rather than being pushed, for the same
|
|
@@ -1014,6 +1022,17 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
1014
1022
|
}
|
|
1015
1023
|
});
|
|
1016
1024
|
app.whenReady().then(() => {
|
|
1025
|
+
// A from-source Linux launch keeps the launcher command installed. The
|
|
1026
|
+
// entry runs that command; this only refreshes where it points. A packaged
|
|
1027
|
+
// build ships its own entry and must not write one from here.
|
|
1028
|
+
// LOADED HERE AND NOT AT THE TOP: scripts/ is not in the packaged app or the
|
|
1029
|
+
// npx package, and a static import of it stopped main.mjs loading at all,
|
|
1030
|
+
// on every Mac (tests/the-mac-app-ships-every-file-it-starts-with).
|
|
1031
|
+
if (process.platform === 'linux' && !app.isPackaged) {
|
|
1032
|
+
import('../scripts/linux-launcher.mjs')
|
|
1033
|
+
.then(({ installLinuxLauncher }) => installLinuxLauncher({ appDir, env: process.env }))
|
|
1034
|
+
.catch(() => { /* a launcher is never a reason to stop a launch */ });
|
|
1035
|
+
}
|
|
1017
1036
|
// THE ABOUT BOX SAYS AGENTBOX AND ITS OWN VERSION (w-db6f5e331e). Run from
|
|
1018
1037
|
// source it read the bundle's, which is Electron's: "Electron 43.0.0".
|
|
1019
1038
|
app.setAboutPanelOptions({ applicationName: NAME, applicationVersion: app.getVersion(), version: '' });
|
package/main/memory-gate.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export const DEFAULTS = {
|
|
|
68
68
|
|
|
69
69
|
export function refusal(waitedMs) {
|
|
70
70
|
const minutes = Math.max(1, Math.round(waitedMs / 60_000));
|
|
71
|
-
return `This
|
|
71
|
+
return `This computer is short of memory, so ${NAME} held this command for ${minutes} minute${minutes === 1 ? '' : 's'} `
|
|
72
72
|
+ 'while heavier work ran, and it still cannot start it. Do not retry it straight away. Carry on with anything '
|
|
73
73
|
+ 'that does not need it, or end your turn and say you are waiting for memory.';
|
|
74
74
|
}
|
package/main/notify.mjs
CHANGED
|
@@ -104,7 +104,7 @@ export function createNotifier({ window, Notification, powerMonitor, coalesceMs
|
|
|
104
104
|
// two-minute diagnosis.
|
|
105
105
|
n.on('failed', (_e, err) => {
|
|
106
106
|
live = null;
|
|
107
|
-
console.warn(`zero:
|
|
107
|
+
console.warn(`zero: the system refused the notification (${err}). Check ${NAME} in the system notification settings.`);
|
|
108
108
|
});
|
|
109
109
|
live = n;
|
|
110
110
|
try {
|
|
@@ -3,11 +3,12 @@ import {createCodexAppServer} from './codex-app-server.mjs';
|
|
|
3
3
|
import {rememberCodexModels} from './codex-models.mjs';
|
|
4
4
|
import {forget,claudeModels} from './claude-models.mjs';
|
|
5
5
|
import {terminalEnv} from './task-terminals.mjs';
|
|
6
|
+
import {codexLaunchEnv} from './codex-launch-env.mjs';
|
|
6
7
|
export async function refreshAgentModels(engine,config,home){
|
|
7
8
|
if(engine==='claude'){forget();claudeModels({bin:config.claudeBin});return;}
|
|
8
9
|
// Codex is optional and codexBin is null without it: nothing to ask, and spawn(null) only threw into the terminal.
|
|
9
10
|
if(!config.codexBin)return;
|
|
10
|
-
const client=createCodexAppServer({spawn:()=>spawn(config.codexBin,['app-server'],{cwd:config.home,env:{...terminalEnv(),CODEX_HOME:home},stdio:['pipe','pipe','pipe']}),requestTimeoutMs:10000});
|
|
11
|
+
const client=createCodexAppServer({spawn:()=>spawn(config.codexBin,['app-server'],{cwd:config.home,env:codexLaunchEnv(config.codexBin,{...terminalEnv(),CODEX_HOME:home}),stdio:['pipe','pipe','pipe']}),requestTimeoutMs:10000});
|
|
11
12
|
const deadline=setTimeout(()=>client.close('Model refresh timed out'),15000);
|
|
12
13
|
try{
|
|
13
14
|
await client.initialize();let cursor=null;const models=[];const seen=new Set();
|
package/main/row-label.mjs
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
// words the moment they open the row (shared/work-items.mjs).
|
|
36
36
|
|
|
37
37
|
import { spawn } from 'node:child_process';
|
|
38
|
+
import { codexLaunchEnv } from './codex-launch-env.mjs';
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* The model this runs on. Small and fast on purpose: this is a naming call, it
|
|
@@ -225,7 +226,7 @@ export function askSmallModel(prompt, clean, { claudeBin, codexBin = null, engin
|
|
|
225
226
|
// Somewhere that is nobody's project. A naming call must not pick up a
|
|
226
227
|
// CLAUDE.md, a settings file or a hook from whatever folder it lands in.
|
|
227
228
|
cwd: '/tmp',
|
|
228
|
-
...(env ? { env } : {}),
|
|
229
|
+
...(engine === 'codex' ? { env: codexLaunchEnv(bin, env) } : env ? { env } : {}),
|
|
229
230
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
230
231
|
});
|
|
231
232
|
} catch {
|
package/main/runs-on.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// WHICH ACCOUNT THE AGENTS ARE ACTUALLY RUNNING ON, read off this Mac.
|
|
2
|
+
//
|
|
3
|
+
// w-e217e577e5, 2026-10-07, from a session with a new user: "He didn't realize
|
|
4
|
+
// it auto-connected to Claude/Codex; he wasn't sure how it was even running."
|
|
5
|
+
//
|
|
6
|
+
// Every fact here was already on the disk and already being read, each by the
|
|
7
|
+
// screen that needed it: main/claude-plan.mjs for the Claude plan (the Accounts
|
|
8
|
+
// page), main/codex-account.mjs for the ChatGPT one (the Codex card). What did
|
|
9
|
+
// not exist was ONE answer to "what is powering this", in a shape small enough
|
|
10
|
+
// to ride the snapshot and be drawn in a corner. That is all this is: the two
|
|
11
|
+
// readers, asked about the one account the fleet is spending, and null whenever
|
|
12
|
+
// the answer would be a guess.
|
|
13
|
+
//
|
|
14
|
+
// NULL IS AN ORDINARY ANSWER AND IT MEANS DRAW NOTHING. A Mac mid-setup, a
|
|
15
|
+
// config file being rewritten under us, a login that exists with no plan string
|
|
16
|
+
// in it: the first two are null, the third is the name with no plan. Nothing
|
|
17
|
+
// here ever reports an account that is not there, because the corner it feeds
|
|
18
|
+
// is read by somebody deciding whose money this is spending.
|
|
19
|
+
//
|
|
20
|
+
// IT IS CACHED ON THE LOGIN FILES' OWN TIMES, NOT ON A CLOCK. The snapshot is
|
|
21
|
+
// built every ten seconds and `~/.claude.json` is 148 KB on the machine this was
|
|
22
|
+
// written on, so parsing it per tick is work nobody asked for; it is also a file
|
|
23
|
+
// that genuinely changes, since Claude Code refetches its own profile as it
|
|
24
|
+
// runs. `signInFiles`/`signInStamp` (main/sign-in-files.mjs) already name the
|
|
25
|
+
// files a login writes and read their modification times and nothing else, so
|
|
26
|
+
// the cache key is the newest of those times. A moved time re-reads. A cache
|
|
27
|
+
// that aged out on a timer instead would be either stale after a sign-in or
|
|
28
|
+
// re-parsing for nothing, and the file itself knows which.
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import os from 'node:os';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { planFile, planOfAccount } from './claude-plan.mjs';
|
|
34
|
+
import { codexAccount, codexAuthFile } from './codex-account.mjs';
|
|
35
|
+
import { signInFiles, signInStamp } from './sign-in-files.mjs';
|
|
36
|
+
|
|
37
|
+
/** keyed by engine and folder, valued by the stamp it was read at. */
|
|
38
|
+
const remembered = new Map();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* WHAT THE FLEET IS RUNNING ON, or null because nothing readable is signed in.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} facts
|
|
44
|
+
* @param {string} facts.engine Which coding agent the workspace runs on, which
|
|
45
|
+
* the supervisor has already decided (`Supervisor#engineFacts`). Never worked
|
|
46
|
+
* out here: the capability gate lives there, and a corner that guessed could
|
|
47
|
+
* name the subscription the fleet is not spending.
|
|
48
|
+
* @param {string} [facts.claudeProfile] The Claude login, `default` or a folder.
|
|
49
|
+
* @param {string} [facts.codexHome] The CODEX_HOME this workspace runs on.
|
|
50
|
+
* @returns {{ engine: string, plan: string|null }|null} `plan` is the words the
|
|
51
|
+
* tool itself reports, never a label of ours.
|
|
52
|
+
*/
|
|
53
|
+
export function runsOn({ engine = null, home = os.homedir(), claudeProfile = 'default', codexHome = null } = {}) {
|
|
54
|
+
if (engine !== 'claude' && engine !== 'codex') return null;
|
|
55
|
+
const folder = engine === 'codex'
|
|
56
|
+
? (codexHome || path.join(home, '.codex'))
|
|
57
|
+
: (!claudeProfile || claudeProfile === 'default' ? null : claudeProfile);
|
|
58
|
+
// The home is in the key as well as the folder. A default Claude login has no
|
|
59
|
+
// folder of its own, so without it two homes share one entry, which is exactly
|
|
60
|
+
// what a test with a throwaway home does and what a packaged app would do if
|
|
61
|
+
// HOME ever moved under it.
|
|
62
|
+
const key = `${engine}:${home}:${folder ?? 'default'}`;
|
|
63
|
+
const stamp = signInStamp(signInFiles({ engine, folder, home }));
|
|
64
|
+
const seen = remembered.get(key);
|
|
65
|
+
if (seen && seen.stamp === stamp) return seen.value;
|
|
66
|
+
const value = stamp ? read(engine, { home, claudeProfile, folder }) : null;
|
|
67
|
+
remembered.set(key, { stamp, value });
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function read(engine, { home, claudeProfile, folder }) {
|
|
72
|
+
if (engine === 'codex') {
|
|
73
|
+
const id = codexAccount(null, { file: codexAuthFile(folder) });
|
|
74
|
+
return id ? { engine: 'codex', plan: id.plan ?? null } : null;
|
|
75
|
+
}
|
|
76
|
+
// THE LOGIN AND THE PLAN COME OUT OF ONE READ. `readPlan` answers only the
|
|
77
|
+
// second, and "not signed in" and "signed in with no plan string" are two
|
|
78
|
+
// different screens: the first draws nothing, the second draws the name.
|
|
79
|
+
let account = null;
|
|
80
|
+
try {
|
|
81
|
+
account = JSON.parse(fs.readFileSync(planFile(claudeProfile, home), 'utf8'))?.oauthAccount ?? null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!account || typeof account !== 'object') return null;
|
|
86
|
+
const plan = planOfAccount(account);
|
|
87
|
+
return { engine: 'claude', plan: plan.known ? plan.label : null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Forgets every cached reading. For tests, and for a sign-out that lands. */
|
|
91
|
+
export function forgetRunsOn() {
|
|
92
|
+
remembered.clear();
|
|
93
|
+
}
|
package/main/serve.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { spawn } from 'node:child_process';
|
|
|
32
32
|
import { loadConfig } from './config.mjs';
|
|
33
33
|
import { Store } from './store.mjs';
|
|
34
34
|
import { Supervisor } from './supervisor.mjs';
|
|
35
|
+
import { carryHerBriefsAcross, joinMessageRules, setAsideShippedMessageRules, unifyAgentInstructions } from './instruction-settings.mjs';
|
|
35
36
|
import { registerIpc } from './ipc.mjs';
|
|
36
37
|
import { reportFromRenderer } from './crash-report.mjs';
|
|
37
38
|
import * as workItemsDisk from './store/work-items.mjs';
|
|
@@ -152,11 +153,17 @@ function answerWhatOnlyTheWindowAnswered(ipcMain) {
|
|
|
152
153
|
*/
|
|
153
154
|
export async function bootHeadless({ dataDir = repoRoot, appDir = repoRoot, userDir = dataDir } = {}) {
|
|
154
155
|
const config = loadConfig(dataDir);
|
|
156
|
+
// The browser editor uses the same user files as the desktop. Finish the
|
|
157
|
+
// older migrations first so only user content enters the combined file.
|
|
158
|
+
carryHerBriefsAcross(appDir, userDir);
|
|
159
|
+
joinMessageRules(appDir, userDir);
|
|
160
|
+
setAsideShippedMessageRules(appDir, userDir);
|
|
161
|
+
unifyAgentInstructions(appDir, userDir);
|
|
155
162
|
// THE APP'S OWN HOME IS THE STORE ROOT, BEFORE ANYTHING OPENS THE STORE, as
|
|
156
163
|
// main/main.mjs does for the desktop. The ledgers live under the home the
|
|
157
164
|
// store reads from this variable, and every worker is handed `storeRoot` as
|
|
158
165
|
// that same variable. Without this line the two disagreed: the copy wrote its
|
|
159
|
-
// rows wherever an inherited variable pointed (or
|
|
166
|
+
// rows wherever an inherited variable pointed (or a dot-folder in $HOME), each worker
|
|
160
167
|
// looked under `storeRoot`, and every store call answered "no work item".
|
|
161
168
|
// tests/an-agent-in-a-browser-copy-can-reach-its-own-work-item.test.mjs.
|
|
162
169
|
// Rows already written under the old home come along, or they would vanish.
|
|
@@ -224,6 +231,9 @@ export function createServer({ channels, token, listeners = new Set(), dist = pa
|
|
|
224
231
|
// an EventSource. It guards the two doors that reach the store. The built
|
|
225
232
|
// screen is the same public files for everybody, and its own script and
|
|
226
233
|
// style requests carry no token, so asking for one there drew a blank tab.
|
|
234
|
+
// NOR DOES THE PAGE ITSELF ASK: the tab takes the token out of its address
|
|
235
|
+
// bar on load, so a reload asks for `/` bare, and refusing that drew
|
|
236
|
+
// "Wrong or missing token" on every reload (tests/a-browser-tab-survives-a-reload).
|
|
227
237
|
const guarded = url.pathname === '/events' || url.pathname.startsWith('/api/');
|
|
228
238
|
const given = req.headers['x-agentbox-token'] || url.searchParams.get('token');
|
|
229
239
|
if (guarded && given !== token) {
|
package/main/settings.mjs
CHANGED
|
@@ -449,6 +449,10 @@ export function readSettings({ config, supervisor, store }) {
|
|
|
449
449
|
permission: override ? permissionMode(override) : 'workspace',
|
|
450
450
|
permissionArgs: override ?? null,
|
|
451
451
|
codexMode: config.projectCodexMode?.[product.slug] ?? 'workspace',
|
|
452
|
+
// THE SUPERVISOR IS ASKED RATHER THAN THE CONFIG FILE, so the page shows
|
|
453
|
+
// what will actually happen: a tie naming a login that has been signed out
|
|
454
|
+
// is ignored when work is handed out, and reads 'any' here too.
|
|
455
|
+
account: supervisor._projectProfile?.(product.slug) ?? 'any',
|
|
452
456
|
instructions,
|
|
453
457
|
running: sessions.filter((s) => s.product === product.slug).length,
|
|
454
458
|
};
|
|
@@ -727,6 +731,24 @@ export function setProjectSetting({ config, supervisor }, { product, key, value
|
|
|
727
731
|
saveConfig(config, { projectCodexMode: Object.keys(map).length ? map : undefined });
|
|
728
732
|
break;
|
|
729
733
|
}
|
|
734
|
+
// WHICH CLAUDE LOGIN THIS PROJECT'S AGENTS RUN ON. 'any' is the absence of
|
|
735
|
+
// an opinion, like 'workspace' above, and it deletes the entry rather than
|
|
736
|
+
// writing a word: a config file with no `projectAccounts` is the file every
|
|
737
|
+
// Mac had before this existed.
|
|
738
|
+
//
|
|
739
|
+
// AND A NAME THIS MAC CANNOT HONOUR IS REFUSED AT THE DOOR rather than
|
|
740
|
+
// written and ignored later. The supervisor does ignore one (`_projectProfile`),
|
|
741
|
+
// because an account can be signed out after the fact, but a pick made
|
|
742
|
+
// against a list we just read has no excuse to be wrong.
|
|
743
|
+
case 'account': {
|
|
744
|
+
const known = effectiveProfiles(config.authProfiles, { home: config.home });
|
|
745
|
+
if (value !== 'any' && !known.includes(value)) throw new Error(`unknown Claude account: ${value}`);
|
|
746
|
+
const map = { ...(config.projectAccounts ?? {}) };
|
|
747
|
+
if (value === 'any') delete map[product];
|
|
748
|
+
else map[product] = value;
|
|
749
|
+
saveConfig(config, { projectAccounts: Object.keys(map).length ? map : undefined });
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
730
752
|
default:
|
|
731
753
|
throw new Error(`unknown project setting: ${key}`);
|
|
732
754
|
}
|
|
@@ -842,7 +864,7 @@ export function memoryGateSettings({ config, supervisor }) {
|
|
|
842
864
|
if (on) {
|
|
843
865
|
let s = null;
|
|
844
866
|
try { s = supervisor.memoryGateStatus?.() ?? null; } catch {}
|
|
845
|
-
if (s?.role === 'standby') now = `Another ${NAME} on this
|
|
867
|
+
if (s?.role === 'standby') now = `Another ${NAME} on this computer is coordinating.`;
|
|
846
868
|
else if (s) {
|
|
847
869
|
const heavy = (s.running ?? []).filter((r) => r.holdsSlot).length;
|
|
848
870
|
const waiting = (s.waiting ?? []).length;
|
package/main/ship-queue.mjs
CHANGED
|
@@ -188,7 +188,7 @@ export function failureReply(output) {
|
|
|
188
188
|
...(died ? [
|
|
189
189
|
'',
|
|
190
190
|
'Its test run DIED rather than going red: nothing it printed names a failing',
|
|
191
|
-
'test. Look for what killed the run. This
|
|
191
|
+
'test. Look for what killed the run. This computer runs a dozen agents at once, so',
|
|
192
192
|
'a worker that was starved or timed out is likelier than a bug in your change.',
|
|
193
193
|
'Run the tests for your change again before you go looking for one.',
|
|
194
194
|
] : []),
|
package/main/store/home.mjs
CHANGED
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
// and repeat rules all sat in it.
|
|
16
16
|
//
|
|
17
17
|
// So this is the same convention, one name over: a dot-folder in her home
|
|
18
|
-
// named after this app.
|
|
18
|
+
// named after this app. On Linux a fresh install does not get that dot-folder,
|
|
19
|
+
// and it does not get a visible folder of the app's name in $HOME. Application
|
|
20
|
+
// data belongs in $XDG_DATA_HOME (see defaultStoreRoot). An existing dot-folder
|
|
21
|
+
// is still read, so a store already on disk is not orphaned.
|
|
19
22
|
//
|
|
20
23
|
// WHAT MOVES AND WHAT DOES NOT, because the line is not "everything of ours".
|
|
21
24
|
// The app's own RECORDS move: the work item ledger, session traces, run
|
|
@@ -37,7 +40,7 @@ import fs from 'node:fs';
|
|
|
37
40
|
import os from 'node:os';
|
|
38
41
|
import path from 'node:path';
|
|
39
42
|
import crypto from 'node:crypto';
|
|
40
|
-
import { WAS, envNames, nameSlug, readEnv } from '../../shared/product-name.mjs';
|
|
43
|
+
import { Name, WAS, envNames, nameSlug, readEnv } from '../../shared/product-name.mjs';
|
|
41
44
|
|
|
42
45
|
const isDir = (dir) => { try { return fs.statSync(dir).isDirectory(); } catch { return false; } };
|
|
43
46
|
|
|
@@ -61,6 +64,25 @@ export function storeRootEnv(storeRoot) {
|
|
|
61
64
|
return env;
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
// Where a config that names no store keeps the inbox.
|
|
68
|
+
//
|
|
69
|
+
// macOS, and anything that is not Linux: a folder of the app's name in the
|
|
70
|
+
// home directory. That is the folder this app has used since 2026-09-22.
|
|
71
|
+
// Linux: $XDG_DATA_HOME/<slug>, or ~/.local/share/<slug> when the variable is
|
|
72
|
+
// unset, blank, or not an absolute path. A visible directory in $HOME is not
|
|
73
|
+
// where an application writes. Measured 2026-10-07: a config that named no
|
|
74
|
+
// store created one.
|
|
75
|
+
//
|
|
76
|
+
// The home and the environment are arguments so a test can ask about another
|
|
77
|
+
// machine. A caller that passes a stand-in home must pass an environment that
|
|
78
|
+
// belongs to it. This function does not decide that; it uses the env it is given.
|
|
79
|
+
export function defaultStoreRoot({ home = os.homedir(), platform = process.platform, env = process.env } = {}) {
|
|
80
|
+
if (platform !== 'linux') return path.join(home, Name);
|
|
81
|
+
const given = typeof env?.XDG_DATA_HOME === 'string' ? env.XDG_DATA_HOME.trim() : '';
|
|
82
|
+
const base = given && path.isAbsolute(given) ? given : path.join(home, '.local', 'share');
|
|
83
|
+
return path.join(base, nameSlug);
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
// One env var, for tests and for anyone who keeps their home somewhere else.
|
|
65
87
|
// Read at call time rather than captured, because a test sets it per case.
|
|
66
88
|
//
|
|
@@ -74,9 +96,10 @@ export function storeRootEnv(storeRoot) {
|
|
|
74
96
|
// store goes missing on the release that renames the app.
|
|
75
97
|
// `homeDir` is injected so a test can ask what this answers on a machine with
|
|
76
98
|
// an old dot-folder on it, without one having to exist on the machine running
|
|
77
|
-
// the test.
|
|
78
|
-
|
|
79
|
-
|
|
99
|
+
// the test. `platform` and `env` are the same kind of injection: this suite
|
|
100
|
+
// runs on a Mac and still has to answer a Linux home. Production passes neither.
|
|
101
|
+
export function appHome(homeDir = os.homedir(), { platform = process.platform, env } = {}) {
|
|
102
|
+
const override = readEnv('HOME', env);
|
|
80
103
|
if (override) return override;
|
|
81
104
|
const here = path.join(homeDir, `.${nameSlug}`);
|
|
82
105
|
if (isDir(here)) return here;
|
|
@@ -84,6 +107,15 @@ export function appHome(homeDir = os.homedir()) {
|
|
|
84
107
|
const older = path.join(homeDir, `.${was.toLowerCase()}`);
|
|
85
108
|
if (isDir(older)) return older;
|
|
86
109
|
}
|
|
110
|
+
// A fresh Linux directory has nothing of ours on disk yet. The answer has to
|
|
111
|
+
// be the same path loadConfig opens, or the desktop and this lookup write two
|
|
112
|
+
// stores. The machine's own XDG_DATA_HOME is an absolute path in the real
|
|
113
|
+
// home; applying it to a stand-in home writes that stand-in's store there.
|
|
114
|
+
// A caller that passes `env` is asking about that environment, not this one.
|
|
115
|
+
if (platform === 'linux') {
|
|
116
|
+
const real = env === undefined && path.resolve(homeDir) === path.resolve(os.homedir());
|
|
117
|
+
return defaultStoreRoot({ home: homeDir, platform: 'linux', env: real ? process.env : (env ?? {}) });
|
|
118
|
+
}
|
|
87
119
|
return here;
|
|
88
120
|
}
|
|
89
121
|
|
package/main/store/project.mjs
CHANGED
|
@@ -4,34 +4,30 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import os from 'node:os';
|
|
8
7
|
import crypto from 'node:crypto';
|
|
9
8
|
import { SCHEMA_VERSION, validateCreation, creationText, TEXT_CREATION_KINDS } from '../../shared/contracts.mjs';
|
|
10
9
|
import { assertInside, assertCreationDir, deniesGitOrEnvPath } from './paths.mjs';
|
|
11
10
|
import { migrateIndex } from './migrate.mjs';
|
|
12
11
|
import { withProjectLockSync } from './project-lock.mjs';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
12
|
+
import { readEnv } from '../../shared/product-name.mjs';
|
|
13
|
+
import { defaultStoreRoot } from './home.mjs';
|
|
14
|
+
|
|
15
|
+
// The machine-wide root: the store-home variable, else the same default
|
|
16
|
+
// loadConfig uses when a config names no store. This is where device-local
|
|
17
|
+
// files live (settings.json, ui-state.json, usage.json, .runtime-pids, ...)
|
|
18
|
+
// and where a founder's PRE-account projects sit until they are migrated into
|
|
19
|
+
// an account space. It is NEVER account-scoped: those files stay global
|
|
20
|
+
// (Phase 1).
|
|
20
21
|
//
|
|
21
|
-
// THE FALLBACK IS
|
|
22
|
-
//
|
|
22
|
+
// THE FALLBACK IS defaultStoreRoot. On macOS that is a folder of the app's
|
|
23
|
+
// name. On Linux it is the XDG data directory. A visible folder in $HOME was
|
|
24
|
+
// the fallback until 2026-10-07, and a config that named no store created one.
|
|
23
25
|
//
|
|
24
|
-
// NOTHING WAS DELETED AND NOTHING MOVED. That folder is still on disk exactly
|
|
25
|
-
// as it was. What changed is that this app stops reaching for it by default.
|
|
26
26
|
// Her live store is named in zero.config.json and handed to every worker as
|
|
27
|
-
// the app's own home variable, so this line
|
|
28
|
-
//
|
|
29
|
-
// the answer for an install carrying no config at all.
|
|
30
|
-
//
|
|
31
|
-
// An older product's HOME env var used to sit between these two. It is gone:
|
|
32
|
-
// nothing sets it any more now that the store server carries the app's own name.
|
|
27
|
+
// the app's own home variable, so this line is only ever the answer for an
|
|
28
|
+
// install carrying no config and no store-home variable.
|
|
33
29
|
export function legacyRoot() {
|
|
34
|
-
return readEnv('HOME') ||
|
|
30
|
+
return readEnv('HOME') || defaultStoreRoot();
|
|
35
31
|
}
|
|
36
32
|
|
|
37
33
|
// The account currently signed in, if any. Set once at boot and re-resolved on
|
package/main/store.mjs
CHANGED
|
@@ -471,10 +471,7 @@ export class Store {
|
|
|
471
471
|
// app restart, a reboot, and a store restored onto another machine, and so no
|
|
472
472
|
// agent line can quietly move a time she set. 0 clears it.
|
|
473
473
|
//
|
|
474
|
-
//
|
|
475
|
-
// out of the inbox and scheduling her own work to start at six. They were
|
|
476
|
-
// separate mechanisms before, and only the first existed: the old snooze was
|
|
477
|
-
// a renderer localStorage map that hid a row and deferred no work at all.
|
|
474
|
+
// Execution scheduling only. Inbox reminders use snoozeItem instead.
|
|
478
475
|
scheduleItem(slug, id, runAt) {
|
|
479
476
|
const { workItemsDisk } = this.modules;
|
|
480
477
|
return workItemsDisk.updateWorkItem(
|
|
@@ -482,6 +479,15 @@ export class Store {
|
|
|
482
479
|
);
|
|
483
480
|
}
|
|
484
481
|
|
|
482
|
+
// Put the existing thread back in the person's inbox later. This never
|
|
483
|
+
// changes its execution schedule, answer, status, claim or delivery marks.
|
|
484
|
+
snoozeItem(slug, id, snoozedUntil) {
|
|
485
|
+
if (!Number.isFinite(snoozedUntil)) throw new Error('Snooze needs a valid time');
|
|
486
|
+
return this.modules.workItemsDisk.updateWorkItem(
|
|
487
|
+
this.productDir(slug), id, { snoozedUntil: Math.max(0, Math.trunc(snoozedUntil)) }, { source: 'founder' },
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
485
491
|
// WHO A SHARED ROW IS WITH, changed by a person: handed to an agent, kept,
|
|
486
492
|
// handed back, or passed on by a reply. Only the team fields, on the
|
|
487
493
|
// person's own authority, so an agent writing afterwards cannot undo it.
|