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.
Files changed (60) hide show
  1. package/README.md +24 -16
  2. package/bin/agentbox.mjs +18 -9
  3. package/briefs/message-rules.md +15 -4
  4. package/main/account-tooling.mjs +2 -1
  5. package/main/agent-updates.mjs +3 -1
  6. package/main/approvals.mjs +14 -5
  7. package/main/claude-bin.mjs +1 -0
  8. package/main/claude-plan.mjs +14 -2
  9. package/main/codex-bin.mjs +1 -0
  10. package/main/codex-launch-env.mjs +31 -0
  11. package/main/config.mjs +14 -9
  12. package/main/demo.mjs +17 -14
  13. package/main/engine-setup.mjs +23 -12
  14. package/main/fresh-user.mjs +1 -1
  15. package/main/git-change.mjs +8 -2
  16. package/main/instruction-settings.mjs +47 -11
  17. package/main/ipc.mjs +19 -0
  18. package/main/machine.mjs +1 -1
  19. package/main/main.mjs +20 -1
  20. package/main/memory-gate.mjs +1 -1
  21. package/main/notify.mjs +1 -1
  22. package/main/refresh-agent-models.mjs +2 -1
  23. package/main/row-label.mjs +2 -1
  24. package/main/runs-on.mjs +93 -0
  25. package/main/serve.mjs +11 -1
  26. package/main/settings.mjs +23 -1
  27. package/main/ship-queue.mjs +1 -1
  28. package/main/store/home.mjs +37 -5
  29. package/main/store/project.mjs +15 -19
  30. package/main/store.mjs +10 -4
  31. package/main/supervisor.mjs +205 -26
  32. package/main/task-folders.mjs +20 -3
  33. package/main/task-terminals.mjs +12 -1
  34. package/main/team/index.mjs +2 -2
  35. package/main/team/projects.mjs +1 -1
  36. package/mcp/core/page.mjs +1 -1
  37. package/mcp/core/work.mjs +34 -3
  38. package/mcp/tools.mjs +2 -1
  39. package/package.json +1 -1
  40. package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
  41. package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
  42. package/renderer/dist/index.html +2 -2
  43. package/scripts/after-install.mjs +14 -0
  44. package/scripts/approval-server.sh +7 -2
  45. package/shared/agent-import.mjs +1 -1
  46. package/shared/bridge-map.mjs +1 -0
  47. package/shared/claude-commands.generated.mjs +2 -2
  48. package/shared/claude-models.generated.mjs +4 -4
  49. package/shared/codex-modes.mjs +1 -1
  50. package/shared/demo-world.mjs +1 -1
  51. package/shared/fresh-user-home.mjs +12 -1
  52. package/shared/instruction-defaults.json +1 -1
  53. package/shared/project-folder-check.mjs +3 -3
  54. package/shared/runs-on.d.mts +20 -0
  55. package/shared/runs-on.mjs +79 -0
  56. package/shared/shortcuts.mjs +1 -1
  57. package/shared/spawn-trouble.mjs +1 -1
  58. package/shared/work-items.mjs +3 -1
  59. package/renderer/dist/assets/index-B-2Az952.css +0 -32
  60. 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
- // THERE ARE THREE OF THEM NOW, NOT FOUR (w-3dc46f3a67). Five editable layers of
5
- // prompts was too many for a person to manage, so some were merged.
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
- fileFor(id);
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
- fileFor(id);
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 Mac has ${memGb} GB and ${cores} cores, so we suggest ${slots} at once. Go higher if you want to.`;
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: '' });
@@ -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 Mac is short of memory, so ${NAME} held this command for ${minutes} minute${minutes === 1 ? '' : 's'} `
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: macOS refused the notification (${err}). Check ${NAME} in System Settings > Notifications.`);
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();
@@ -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 {
@@ -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 `~/.agentbox`), each worker
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 Mac is coordinating.`;
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;
@@ -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 Mac runs a dozen agents at once, so',
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
  ] : []),
@@ -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. Production never passes it.
78
- export function appHome(homeDir = os.homedir()) {
79
- const override = readEnv('HOME');
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
 
@@ -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 { Name, readEnv } from '../../shared/product-name.mjs';
14
-
15
- // The machine-wide root: ASTRAL_HOME, else the folder named below. This is
16
- // where device-local files live (settings.json, ui-state.json, usage.json,
17
- // .runtime-pids, ...) and where a founder's PRE-account projects sit until they
18
- // are migrated into an account space. It is NEVER account-scoped: those files
19
- // stay global (Phase 1).
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 THE APP'S OWN NAME NOW, where it used to be an older
22
- // product's folder.
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 was never the path she runs on; it
28
- // was only ever
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') || path.join(os.homedir(), Name);
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
- // This is the one write behind both gestures: deferring an agent's proposal
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.