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
package/README.md CHANGED
@@ -39,11 +39,17 @@ There are two ways to run it: in the browser, or as a Mac app. Both need Node 22
39
39
  npx agentbox-app
40
40
  ```
41
41
 
42
- It starts on your machine and opens a tab. Nothing is hosted: it listens only
43
- on your own computer, and the address carries a key that is new every run. Add
44
- `--help` to see the options.
42
+ It starts on your machine and opens a tab. On Linux that open goes through `xdg-open`. Nothing is hosted: it listens only on `127.0.0.1`, and the address carries a key that is new every run. Add `--help` to see the options. `--no-open` prints the address and leaves the browser to you.
45
43
 
46
- **As a Mac app.** Build it from this repo:
44
+ From a checkout, build the screen once, then start it the same way:
45
+
46
+ ```
47
+ npm install
48
+ npm run build
49
+ npx agentbox-app
50
+ ```
51
+
52
+ **As a desktop app.** Build it from this repo. `npm start` is the desktop command on Omarchy and on a Mac:
47
53
 
48
54
  ```
49
55
  git clone https://github.com/savannahfeder/agentbox.git
@@ -52,11 +58,11 @@ npm install
52
58
  npm start
53
59
  ```
54
60
 
55
- `npm start` builds the app and opens it. On first run it walks you through
56
- connecting Claude Code or Codex and choosing a folder to work in.
61
+ On Linux, `npm install` puts `agentbox` on `~/.local/bin` and adds the app to the launcher. The entry is `$XDG_DATA_HOME/applications/agentbox.desktop` (`~/.local/share/applications` when that variable is unset), and it runs `agentbox`. That command starts this checkout. Open the app from the launcher.
62
+
63
+ `npm start` builds the app and opens it. On first run it walks you through connecting Claude Code or Codex and choosing a folder to work in.
57
64
 
58
- macOS may ask to let Terminal find devices on your local network. Agentbox
59
- does not need that, so Don't Allow is fine.
65
+ On macOS, the system may ask to let Terminal find devices on your local network. Agentbox does not need that, so Don't Allow is fine.
60
66
 
61
67
  ## The chat window is the bottleneck
62
68
 
@@ -130,7 +136,7 @@ asked. Code changes open right beside the row.
130
136
  Twenty agents means twenty things competing for your attention and for a limited
131
137
  number of running sessions. Agentbox keeps one order and uses it for both.
132
138
 
133
- - **Tag a task** Urgent, High, Medium or Low, when you file it or later from `⌘K`.
139
+ - **Tag a task** Urgent, High, Medium or Low, when you file it or later from `⌘K` or `Ctrl+K`.
134
140
  - **Rank your projects** once by dragging them into order. A project's place
135
141
  counts for more than any single task's tag, so your most important project
136
142
  never waits behind a side one.
@@ -146,7 +152,7 @@ number of running sessions. Agentbox keeps one order and uses it for both.
146
152
 
147
153
  <p align="center">
148
154
  <img src="docs/readme/priority-palette.png" alt="The command palette showing Priority Urgent, High, Medium and Low for the selected row" width="60%"><br>
149
- <sub>Any row's priority is a <code>⌘K</code> away.</sub>
155
+ <sub>Any row's priority is a <code>⌘K</code> or <code>Ctrl+K</code> away.</sub>
150
156
  </p>
151
157
 
152
158
  ## Built for the keyboard
@@ -164,10 +170,10 @@ number of running sessions. Agentbox keeps one order and uses it for both.
164
170
  | `L` | Later: put a task off until a time you pick |
165
171
  | `S` | Show or hide the summary, in an open task |
166
172
  | `Z` | Undo |
167
- | `⌘K` | Everything else |
173
+ | `⌘K` or `Ctrl+K` | Everything else |
168
174
 
169
175
  Fifteen skins, each in light and dark, from plain slate to woodblock and riso
170
- prints. Press `⌘K` and type "theme".
176
+ prints. Press `⌘K` or `Ctrl+K` and type "theme". `Ctrl+K` is the same palette on Linux, where there is no Command key.
171
177
 
172
178
  ## Runs on the plan you already have
173
179
 
@@ -191,8 +197,10 @@ nothing new to pay for.
191
197
  default works unset and they all live in `main/config.mjs`. The ones that
192
198
  matter:
193
199
 
194
- - `storeRoot`: where the store lives. Defaults to a folder named after the app
195
- in your home directory.
200
+ - `storeRoot`: where the store lives. On macOS this defaults to a folder
201
+ named after the app in your home directory. On Linux it defaults to
202
+ `$XDG_DATA_HOME/agentbox`, which is `~/.local/share/agentbox` when that
203
+ variable is unset.
196
204
  - `claudeBin`: the binary to run. The app finds `claude` itself if you leave
197
205
  this alone.
198
206
  - `maxConcurrentSessions`: how many agents run at once. The queue absorbs the
@@ -311,8 +319,8 @@ picks which coding agents the fixture Mac has, where nothing is one agent,
311
319
  or `?engines=codex` give two.
312
320
 
313
321
  To open the app as somebody who has never seen it, with its own throwaway home
314
- and nothing of yours inside it, press `⌘K` and choose "Open Agentbox as a new
315
- user". `npm run fresh` does the same from a terminal.
322
+ and nothing of yours inside it, press `⌘K` or `Ctrl+K` and choose "Open Agentbox as a new
323
+ user". `npm run fresh` does the same from a terminal. `ZERO_NO_SUPERVISOR=1` applies to `npm start` and to `npx agentbox-app`.
316
324
 
317
325
  The app is named in exactly one place, `shared/product-name.mjs`. Change it
318
326
  there and run `node scripts/product-name.mjs --write`, and everything else
package/bin/agentbox.mjs CHANGED
@@ -12,11 +12,11 @@
12
12
 
13
13
  import path from 'node:path';
14
14
  import fs from 'node:fs';
15
- import os from 'node:os';
16
15
  import { spawn } from 'node:child_process';
17
16
  import { fileURLToPath } from 'node:url';
18
17
 
19
18
  import { bootHeadless, createServer, newToken } from '../main/serve.mjs';
19
+ import { appHome } from '../main/store/home.mjs';
20
20
 
21
21
  const here = path.dirname(fileURLToPath(import.meta.url));
22
22
  const appDir = path.dirname(here);
@@ -43,9 +43,13 @@ if (args.includes('--help') || args.includes('-h')) {
43
43
 
44
44
  // WHERE THE STORE LIVES. A copy run out of npm has no folder of its own to
45
45
  // write into, and writing inside node_modules would be thrown away by the next
46
- // install. `~/.agentbox` is the plain answer and `--dir` is the escape hatch.
46
+ // install. The default is appHome(): a dot-folder already on disk, else the
47
+ // XDG data directory on Linux and `~/.agentbox` on macOS, where this command
48
+ // has always kept it. Not the desktop config's default, which on macOS is
49
+ // `~/Agentbox` and would open every existing Mac user on an empty inbox.
50
+ // `--dir` and AGENTBOX_HOME are the escape hatches.
47
51
  const dataDir = path.resolve(
48
- flag('dir', process.env.AGENTBOX_HOME ?? path.join(os.homedir(), '.agentbox')),
52
+ flag('dir', process.env.AGENTBOX_HOME ?? appHome()),
49
53
  );
50
54
  fs.mkdirSync(dataDir, { recursive: true });
51
55
 
@@ -56,12 +60,17 @@ if (!fs.existsSync(path.join(dist, 'index.html'))) {
56
60
  }
57
61
 
58
62
  const booted = await bootHeadless({ dataDir, appDir, userDir: dataDir });
59
- // Whoever was signed in to the team last time is signed in again.
60
- booted.team?.start().then(() => booted.supervisor.wake?.()).catch((err) => console.warn(`team: ${err.message}`));
61
- // AND THE AGENTS' CLOCK, as the desktop starts it (main/main.mjs). Without it a
62
- // tab only moved work when something woke the supervisor: a queued thread sat
63
- // queued and a scheduled one never fired (found 2026-10-01 on a test copy).
64
- booted.supervisor.start();
63
+ // ZERO_NO_SUPERVISOR: the desktop already honors this (main/main.mjs). This
64
+ // entry is the same inbox, so a launch that asks for no agents must not start
65
+ // them here either.
66
+ if (!process.env.ZERO_NO_SUPERVISOR) {
67
+ // Whoever was signed in to the team last time is signed in again.
68
+ booted.team?.start().then(() => booted.supervisor.wake?.()).catch((err) => console.warn(`team: ${err.message}`));
69
+ // AND THE AGENTS' CLOCK, as the desktop starts it (main/main.mjs). Without it a
70
+ // tab only moved work when something woke the supervisor: a queued thread sat
71
+ // queued and a scheduled one never fired (found 2026-10-01 on a test copy).
72
+ booted.supervisor.start();
73
+ }
65
74
  const token = newToken();
66
75
  const server = createServer({
67
76
  channels: booted.channels,
@@ -59,10 +59,21 @@ opens beside the message by itself. Not the source files you edited, which are
59
59
  no use to them.
60
60
 
61
61
  A PICTURE YOU SHOW IS SAVED IN THE PRODUCT'S DOCS FOLDER, the one your brief
62
- names, in designs/ under this task's id, and named in the message by its full
63
- path, one per line. The app draws it where you name it. Not in your code
64
- checkout, which is deleted once the work ships, and never in /tmp, which the
65
- app will not draw at all.
62
+ names, in designs/ under this task's id, and named by its full path. Not in
63
+ your code checkout, which is deleted once the work ships, and never in /tmp,
64
+ which the app will not draw at all.
65
+
66
+ WHAT THE APP CAN SHOW, so you can lay out what you made the way it reads best:
67
+ - A picture's path, anywhere in the message, is drawn right there at full
68
+ width, and a click opens it. It is drawn as a block, so inside a sentence or
69
+ a bullet it splits the line.
70
+ - A folder's path on a line of its own draws its newest six pictures as a grid.
71
+ - An html page in designs/ opens beside the message by itself, so one page can
72
+ set several options side by side with notes on each.
73
+ - A sound or a film plays where it is named; a localhost address opens beside
74
+ the message. Headings, lists, tables and bold all draw.
75
+ The arrangement is yours. The 200 words are for your report, not for the words
76
+ that help them read what you show.
66
77
 
67
78
  Steps they must take are a numbered list, five at most, the first one doable now.
68
79
 
@@ -198,7 +198,8 @@ function list(names) {
198
198
  const identityCache = new Map();
199
199
 
200
200
  export function accountIdentity(dir, { home = os.homedir() } = {}) {
201
- const file = path.join(dir || defaultClaudeHome(home), '.claude.json');
201
+ let file = path.join(dir || defaultClaudeHome(home), '.claude.json');
202
+ if (!dir && !fs.existsSync(file)) file = path.join(home, '.claude.json');
202
203
  // Cached on the file's own mtime rather than on a timer. That file is ~120 KB
203
204
  // on this machine and the settings page re-reads on every poll, so parsing it
204
205
  // each time would be real work for an answer that changes about twice a
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import {execFile} from 'node:child_process';
7
7
  import {promisify} from 'node:util';
8
8
  import {TaskTerminals,terminalEnv} from './task-terminals.mjs';
9
+ import {codexLaunchEnv} from './codex-launch-env.mjs';
9
10
  const exec=promisify(execFile);
10
11
  const valid=v=>typeof v==='string'&&/^\d+\.\d+\.\d+$/.test(v);
11
12
  export function newerVersion(installed,latest){
@@ -34,7 +35,8 @@ export async function installedAgent(engine,config){
34
35
  if(!bin)throw Error('This agent is not installed.');
35
36
  const home=config.home||os.homedir();
36
37
  const settings=engine==='claude'?json(path.join(process.env.CLAUDE_CONFIG_DIR||path.join(home,'.claude'),'settings.json')):{};
37
- const env=terminalEnv();
38
+ const cleanEnv=terminalEnv();
39
+ const env=engine==='codex'?codexLaunchEnv(bin,cleanEnv):cleanEnv;
38
40
  // Version inspection must not itself auto-update anything.
39
41
  const {stdout}=await exec(bin,['--version'],{timeout:8000,maxBuffer:16384,env:{...env,DISABLE_AUTOUPDATER:'1'}});
40
42
  const version=stdout.match(/\b\d+\.\d+\.\d+(?:-[\w.-]+)?\b/)?.[0];
@@ -387,12 +387,20 @@ const REQUEST_SUFFIX = '.request.json';
387
387
  * FORWARD to now. A worker can therefore make its own request look younger than
388
388
  * it is, which costs it the front of the stack, and can never make it look
389
389
  * older. Nanoseconds because APFS keeps them and two files written back to back
390
- * differ by about 170 microseconds; equal stamps keep `readdir` order, which is
391
- * as good as anything when two things really did happen at once.
390
+ * differ by about 170 microseconds.
391
+ *
392
+ * A coarse clock stamps both files the same. tmpfs does this: two writes in
393
+ * one poll share a ctime, and `readdir` then comes back in name order, which
394
+ * is the forgery the paragraph above is about. The inode breaks that tie.
395
+ * The kernel assigns it, a later create in a fresh directory gets a higher
396
+ * one, and a worker cannot move it backwards. Equal inode and equal ctime
397
+ * keep the order we already had.
392
398
  */
393
399
  function bornAt(dir, name) {
394
- try { return fs.lstatSync(path.join(dir, name), { bigint: true }).ctimeNs; }
395
- catch { return 2n ** 63n; } // gone or unreadable: never in front of something real
400
+ try {
401
+ const st = fs.lstatSync(path.join(dir, name), { bigint: true });
402
+ return { ctime: st.ctimeNs, ino: st.ino };
403
+ } catch { return { ctime: 2n ** 63n, ino: 2n ** 63n }; } // gone or unreadable: never in front of something real
396
404
  }
397
405
 
398
406
  export function listPending(storeRoot, { now = Date.now } = {}) {
@@ -409,7 +417,8 @@ export function listPending(storeRoot, { now = Date.now } = {}) {
409
417
  for (const { f } of names
410
418
  .filter((f) => !seenAt.has(prefix + f.slice(0, -REQUEST_SUFFIX.length)))
411
419
  .map((f) => ({ f, born: bornAt(dir, f) }))
412
- .sort((a, b) => (a.born < b.born ? -1 : a.born > b.born ? 1 : 0))) {
420
+ .sort((a, b) => (a.born.ctime < b.born.ctime ? -1 : a.born.ctime > b.born.ctime ? 1
421
+ : a.born.ino < b.born.ino ? -1 : a.born.ino > b.born.ino ? 1 : 0))) {
413
422
  seenAt.set(prefix + f.slice(0, -REQUEST_SUFFIX.length), { at, seq: sightings += 1 });
414
423
  }
415
424
  const present = new Set();
@@ -71,6 +71,7 @@ export function candidatePaths(home = os.homedir()) {
71
71
  path.join(home, '.npm-packages/bin/claude'), // actually write in a guide
72
72
  path.join(home, '.yarn/bin/claude'), // yarn global
73
73
  path.join(home, 'Library/pnpm/claude'), // pnpm's own bin on macOS
74
+ path.join(home, '.local/share/pnpm/claude'), // pnpm's own bin on Linux
74
75
  path.join(home, '.deno/bin/claude'), // deno
75
76
  path.join(home, 'bin/claude'), // a hand-rolled ~/bin on PATH
76
77
  '/opt/local/bin/claude', // MacPorts
@@ -95,13 +95,25 @@ export function isMaxTier(raw) {
95
95
  * as it was". */
96
96
  export function readPlan(profile, { home = os.homedir(), file = null } = {}) {
97
97
  const at = file ?? planFile(profile, home);
98
- const none = { known: false, max: false, label: null, tier: null, from: null, file: at };
99
98
  let account = null;
100
99
  try {
101
100
  account = JSON.parse(fs.readFileSync(at, 'utf8'))?.oauthAccount ?? null;
102
101
  } catch {
103
- return none;
102
+ return planOfAccount(null, at);
104
103
  }
104
+ return planOfAccount(account, at);
105
+ }
106
+
107
+ /**
108
+ * THE SAME ANSWER OFF AN `oauthAccount` SOMEBODY ALREADY HAS IN HAND.
109
+ *
110
+ * Lifted out of `readPlan` so a caller that has read the file for its own
111
+ * reasons (main/runs-on.mjs, which also wants to know whether there is a login
112
+ * in there at all) does not carry a second copy of the tier ordering. One copy
113
+ * of that rule, and the file read stays in one place per caller.
114
+ */
115
+ export function planOfAccount(account, at = null) {
116
+ const none = { known: false, max: false, label: null, tier: null, from: null, file: at };
105
117
  if (!account || typeof account !== 'object') return none;
106
118
  // THE ORDER IS MOST SPECIFIC FIRST. A rate limit tier names the plan AND its
107
119
  // size (`max_20x` against `max_5x`), which is the thing worth showing; the
@@ -114,6 +114,7 @@ export function candidatePaths(home = os.homedir()) {
114
114
  path.join(home, '.npm-packages/bin/codex'), // actually write in a guide
115
115
  path.join(home, '.yarn/bin/codex'),
116
116
  path.join(home, 'Library/pnpm/codex'), // pnpm's own bin on macOS
117
+ path.join(home, '.local/share/pnpm/codex'), // pnpm's own bin on Linux
117
118
  path.join(home, '.deno/bin/codex'),
118
119
  path.join(home, 'bin/codex'), // a hand-rolled ~/bin on PATH
119
120
  '/opt/local/bin/codex', // MacPorts
@@ -0,0 +1,31 @@
1
+ // An npm Codex executable starts with `#!/usr/bin/env node`. Finding that
2
+ // executable does not put its Node on a desktop app's launchd PATH.
3
+ // Resolve from disk, without running shell profiles or changing the app's env.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { candidatePaths, versionedPaths } from './codex-bin.mjs';
8
+
9
+ function executable(file) {
10
+ try {
11
+ fs.accessSync(file, fs.constants.X_OK);
12
+ return fs.statSync(file).isFile();
13
+ } catch { return false; }
14
+ }
15
+
16
+ export function codexLaunchEnv(bin, source = process.env, {
17
+ home = source.HOME || os.homedir(), exists = executable, readdir,
18
+ } = {}) {
19
+ if (!bin) return { ...source };
20
+ const binDir = path.dirname(bin);
21
+ const inherited = (source.PATH || '').split(path.delimiter).filter(Boolean);
22
+ // Prefer the Node beside this Codex, then the user's existing PATH. A custom
23
+ // npm prefix can hold Codex while its Node lives under nvm, fnm, or asdf.
24
+ const directories = [...new Set([
25
+ binDir, ...inherited,
26
+ ...candidatePaths(home).map(path.dirname),
27
+ ...versionedPaths(home, readdir).map(path.dirname),
28
+ ])];
29
+ const nodeDir = directories.find(dir => exists(path.join(dir, 'node')));
30
+ return { ...source, PATH: [...new Set([binDir, ...(nodeDir ? [nodeDir] : []), ...inherited])].join(path.delimiter) };
31
+ }
package/main/config.mjs CHANGED
@@ -11,7 +11,7 @@ import os from 'node:os';
11
11
  import crypto from 'node:crypto';
12
12
  import { resolveClaudeBin, candidatePaths } from './claude-bin.mjs';
13
13
  import { resolveCodexBin } from './codex-bin.mjs';
14
- import { Name } from '../shared/product-name.mjs';
14
+ import { defaultStoreRoot } from './store/home.mjs';
15
15
  import { readPlans, slotsForPlans } from './claude-plan.mjs';
16
16
  import { machineSlots } from './machine.mjs';
17
17
  import { effectiveProfiles } from './account-discovery.mjs';
@@ -34,14 +34,6 @@ const CONFIG_NAME = 'zero.config.json';
34
34
  export const DEFAULT_SESSIONS_AT_ONCE = 3;
35
35
 
36
36
  const DEFAULTS = {
37
- // Where the inbox's own files live. WHICH FOLDER THIS SHOULD BE ON A
38
- // STRANGER'S MAC IS NOT DECIDED HERE: it is the open question on.
39
- //
40
- // IT IS THE APP'S OWN NAME SINCE 2026-09-22, where it used to be an older
41
- // product's. A real store is always named in the config, so this default is
42
- // only ever the answer for an install carrying none, and renaming it moved
43
- // no files: the folder it used to point at is untouched and still on disk.
44
- storeRoot: path.join(os.homedir(), Name),
45
37
  // ONE ACCOUNT FOLDER PER INSTALL, AND NOBODY ELSE'S.
46
38
  //
47
39
  // This was a literal id for most of the app's life, which meant every copy
@@ -160,6 +152,19 @@ export function loadConfig(appDir, { home = os.homedir() } = {}) {
160
152
  else console.warn(`zero: could not read ${file}: ${err.message}`);
161
153
  }
162
154
  const config = { ...DEFAULTS, ...overrides, freshInstall: fresh };
155
+ // Where the inbox's own files live, when the file names no store. A path in
156
+ // the file always wins, including on Linux. The default is computed here, not
157
+ // at import: on Linux it is the XDG data directory, and on macOS a folder of
158
+ // the app's name. An import-time path cannot see the `home` a test passes,
159
+ // and it cannot see a blanked XDG_DATA_HOME.
160
+ //
161
+ // A stand-in home does not inherit this machine's XDG_DATA_HOME. That
162
+ // variable is an absolute path in the real home, and a throwaway asked with
163
+ // `{ home }` would otherwise open the real store.
164
+ if (typeof overrides.storeRoot !== 'string' || !overrides.storeRoot.trim()) {
165
+ const real = path.resolve(home) === path.resolve(os.homedir());
166
+ config.storeRoot = defaultStoreRoot({ home, env: real ? process.env : {} });
167
+ }
163
168
  // An older alias for `storeRoot` was read here until 2026-09-22. A config
164
169
  // still carrying only the old key falls through to the default now, which is
165
170
  // why the key was renamed in zero.config.json in the same step and not after
package/main/demo.mjs CHANGED
@@ -34,9 +34,8 @@ import path from 'node:path';
34
34
  import { spawn } from 'node:child_process';
35
35
  import { DEMO_DOCS, DEMO_ENV, DEMO_PRODUCTS, demoLedgerLines } from '../shared/demo-world.mjs';
36
36
  import { prepareHome } from '../shared/fresh-user-home.mjs';
37
- import { Name } from '../shared/product-name.mjs';
38
37
  import { relaunchCommand } from './fresh-user.mjs';
39
- import { encodeProjectPath, storeRootEnv } from './store/home.mjs';
38
+ import { defaultStoreRoot, encodeProjectPath, storeRootEnv } from './store/home.mjs';
40
39
  import { NAME, nameSlug } from '../shared/product-name.mjs';
41
40
 
42
41
  /**
@@ -87,22 +86,21 @@ export function clearDemoHomes() {
87
86
  /**
88
87
  * Where the demo copy will keep its store, and where it will keep its own
89
88
  * records. Both are derived from the throwaway home rather than configured,
90
- * because the copy derives them the same way from the same home: `storeRoot`
91
- * defaults to `<home>/<Name>` (main/config.mjs) and the app's home defaults
92
- * to `<home>/.agentbox` (main/store/home.mjs).
89
+ * because the copy derives them the same way from the same home. Both are
90
+ * `defaultStoreRoot` for that home with no inherited data directory: on
91
+ * macOS a folder of the app's name, on Linux the XDG path under the
92
+ * throwaway. Passing the machine's XDG_DATA_HOME would seed the demo into
93
+ * the real store.
93
94
  *
94
- * THE FOLDER IS READ FROM THE SAME CONSTANT AS THAT DEFAULT, not spelled out
95
- * again. It was the literal 'the app' mirroring what config.mjs then said,
96
- * and when that default was renamed on 2026-09-22 this line went on pointing
97
- * at the old folder: the demo copy would have written its store to one place
98
- * and looked for it in another. Two literals that have to agree is the defect;
99
- * one import is the fix. */
95
+ * THE FOLDER IS THAT FUNCTION, not a second spelling of it. A literal next
96
+ * to the default is how the demo once wrote its store in one place and
97
+ * looked for it in another. */
100
98
  export function demoPaths(home) {
101
- const storeRoot = path.join(home, Name);
99
+ const storeRoot = defaultStoreRoot({ home, env: {} });
102
100
  return {
103
101
  storeRoot,
104
102
  accountRoot: path.join(storeRoot, 'accounts', DEMO_ACCOUNT),
105
- appHome: path.join(home, `.${nameSlug}`),
103
+ appHome: storeRoot,
106
104
  };
107
105
  }
108
106
 
@@ -192,7 +190,12 @@ export function seedDemoStore(home, { now = Date.now() } = {}) {
192
190
  * standing in an invented store. */
193
191
  export function demoEnv(home, env = process.env) {
194
192
  const { appHome } = demoPaths(home);
195
- return { ...env, HOME: home, CFFIXED_USER_HOME: home, ...storeRootEnv(appHome), [DEMO_ENV]: '1' };
193
+ const next = { ...env, HOME: home, CFFIXED_USER_HOME: home, ...storeRootEnv(appHome), [DEMO_ENV]: '1' };
194
+ // The copy's store is defaultStoreRoot of ITS home. The parent's
195
+ // XDG_DATA_HOME is the real data directory, and leaving it set points the
196
+ // demo at that directory. The other base directories go for the same reason.
197
+ for (const key of ['XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME']) delete next[key];
198
+ return next;
196
199
  }
197
200
 
198
201
  /**
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { spawn as nodeSpawn } from 'node:child_process';
25
25
  import { terminalEnv } from './task-terminals.mjs';
26
+ import { codexLaunchEnv } from './codex-launch-env.mjs';
26
27
 
27
28
  const INSTALLERS = {
28
29
  claude: { shell: '/bin/bash', url: 'https://claude.ai/install.sh', pipe: 'bash' },
@@ -57,7 +58,8 @@ function realSpawn(file, args, { env, onData, keepStdin = false } = {}) {
57
58
  let child;
58
59
  try {
59
60
  child = nodeSpawn(file, args, { env, stdio: [keepStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
60
- } catch {
61
+ } catch (error) {
62
+ onData?.(`${error.message}\n`);
61
63
  return { exit: Promise.resolve(127), kill() {} };
62
64
  }
63
65
  const feed = (d) => onData?.(String(d));
@@ -65,7 +67,7 @@ function realSpawn(file, args, { env, onData, keepStdin = false } = {}) {
65
67
  child.stderr?.on('data', feed);
66
68
  const exit = new Promise((resolve) => {
67
69
  child.on('exit', (code) => resolve(code ?? 1));
68
- child.on('error', () => resolve(127));
70
+ child.on('error', (error) => { onData?.(`${error.message}\n`); resolve(127); });
69
71
  });
70
72
  return { exit, kill: () => { try { child.kill('SIGTERM'); } catch { /* already gone */ } } };
71
73
  }
@@ -82,6 +84,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
82
84
  */
83
85
  export function createEngineSetup({ find, spawn = realSpawn, wait = sleep, env = process.env, poll = POLL, onSignIn = () => {} } = {}) {
84
86
  const runEnv = terminalEnv(env);
87
+ const toolEnv = (engine, bin) => engine === 'codex' ? codexLaunchEnv(bin, runEnv) : runEnv;
85
88
  const jobs = new Map();
86
89
 
87
90
  const fresh = (engine) => ({ engine, phase: 'idle', error: null, log: '', running: null, login: null, run: 0 });
@@ -91,20 +94,26 @@ export function createEngineSetup({ find, spawn = realSpawn, wait = sleep, env =
91
94
 
92
95
  async function signedIn(j, bin) {
93
96
  const { file, args } = statusCommand(j.engine, bin);
94
- const p = spawn(file, args, { env: runEnv });
97
+ let output = '';
98
+ const p = spawn(file, args, { env: toolEnv(j.engine, bin), onData: (d) => { output = (output + String(d).replace(ANSI, '')).slice(-LOG_LIMIT); } });
95
99
  const TIMED_OUT = Symbol('no answer');
96
100
  const code = await Promise.race([p.exit, wait(STATUS_TIMEOUT).then(() => { p.kill(); return TIMED_OUT; })]);
97
101
  // TOLD TO THE APP, which routes on it (w-d5d632e503): a Claude Code that
98
102
  // is signed out beside a Codex that is signed in is a Mac that runs on
99
103
  // Codex. Only a real yes or no; a check that never answered says nothing.
100
104
  if (code === 0 || code === 1) onSignIn(j.engine, code === 0);
101
- return code === 0;
105
+ if (code === 0 || code === 1) return code === 0;
106
+ if (output) say(j, output);
107
+ const name = j.engine === 'codex' ? 'Codex' : 'Claude Code';
108
+ throw new Error(code === TIMED_OUT
109
+ ? `${name} did not answer the sign-in check. Try again.`
110
+ : `${name} could not check sign-in: ${lastLine(output) || `exit ${code}`}`);
102
111
  }
103
112
 
104
113
  function startLogin(j, bin) {
105
114
  j.login?.kill();
106
115
  const { file, args } = signInCommand(j.engine, bin);
107
- const login = spawn(file, args, { env: runEnv, keepStdin: true, onData: (d) => say(j, d) });
116
+ const login = spawn(file, args, { env: toolEnv(j.engine, bin), keepStdin: true, onData: (d) => say(j, d) });
108
117
  j.login = login;
109
118
  return login;
110
119
  }
@@ -130,18 +139,19 @@ export function createEngineSetup({ find, spawn = realSpawn, wait = sleep, env =
130
139
  if (!live()) return;
131
140
  j.phase = 'signing-in';
132
141
  let login = startLogin(j, hit.path);
133
- let gaveUp = false;
134
- login.exit.then((code) => { if (login === j.login && code !== 0) gaveUp = true; });
142
+ let ended = false;
143
+ const watchLogin = (loginProcess) => loginProcess.exit.then(() => { if (loginProcess === j.login) ended = true; });
144
+ watchLogin(login);
135
145
  for (;;) {
136
146
  await wait(poll);
137
147
  if (!live()) return;
138
148
  if (await signedIn(j, hit.path)) { if (live()) ready(j); return; }
139
149
  if (j.login !== login) {
140
150
  login = j.login;
141
- gaveUp = false;
142
- login.exit.then((code) => { if (login === j.login && code !== 0) gaveUp = true; });
151
+ ended = false;
152
+ watchLogin(login);
143
153
  }
144
- if (gaveUp) return fail(j, 'The sign-in was closed before it finished.');
154
+ if (ended) return fail(j, `The sign-in was closed before it finished.${lastLine(j.log) ? ` ${lastLine(j.log)}` : ''}`);
145
155
  }
146
156
  }
147
157
 
@@ -182,12 +192,13 @@ export function createEngineSetup({ find, spawn = realSpawn, wait = sleep, env =
182
192
  async readiness(engine) {
183
193
  const hit = find(engine);
184
194
  if (!hit?.found) return { engine, found: false, signedIn: false };
185
- return { engine, found: true, signedIn: await signedIn({ engine }, hit.path) };
195
+ try { return { engine, found: true, signedIn: await signedIn({ engine, log: '' }, hit.path) }; }
196
+ catch (error) { return { engine, found: true, signedIn: false, error: error.message }; }
186
197
  },
187
198
  /** The same question against a path already found, so asking it at
188
199
  * launch does not search the Mac again. */
189
200
  signedInNow(engine, bin) {
190
- return signedIn({ engine }, bin);
201
+ return signedIn({ engine, log: '' }, bin).catch(() => false);
191
202
  },
192
203
  };
193
204
  }
@@ -96,7 +96,7 @@ export function openFreshUser({
96
96
  const notes = [];
97
97
  if (withTools && !prepared.claudeBin) notes.push('Claude Code is not at ~/.local/bin/claude, so agents will not run in it.');
98
98
  if (!prepared.keychain) notes.push('There is no ~/Library/Keychains here, so Claude Code will say it is not logged in.');
99
- if (withAgents && !prepared.agents) notes.push('There is no ~/.claude on this Mac, so it will have no agents to offer.');
99
+ if (withAgents && !prepared.agents) notes.push('There is no ~/.claude on this computer, so it will have no agents to offer.');
100
100
 
101
101
  return { ok: true, home: prepared.home, agents: prepared.agents, notes, pid: child?.pid ?? null };
102
102
  }
@@ -42,7 +42,10 @@ const MAX_ROWS_PER_FILE = 20_000;
42
42
  const NOT_CODE = /(^|\/)(node_modules|\.git)\//;
43
43
 
44
44
  function git(cwd, args, { maxBuffer = 64 * 1024 * 1024 } = {}) {
45
- return execFileSync('git', ['--no-pager', ...args], {
45
+ // A user's `diff.mnemonicPrefix` rewrites a/ and b/ into c/ i/ o/ w/.
46
+ // The reader below only knows one prefix. Turn the setting off for this
47
+ // process so a checkout reads the same on every machine.
48
+ return execFileSync('git', ['--no-pager', '-c', 'diff.mnemonicPrefix=false', ...args], {
46
49
  cwd,
47
50
  encoding: 'utf8',
48
51
  maxBuffer,
@@ -189,7 +192,10 @@ export function filesFromPatch(patch) {
189
192
  const nameOn = (line, side) => {
190
193
  const name = line.slice(4).trim();
191
194
  if (name === '/dev/null') return null;
192
- return name.replace(side === 'old' ? /^a\// : /^b\//, '');
195
+ // a/ and b/ are the ordinary prefixes. c/ i/ o/ w/ are mnemonicPrefix,
196
+ // and 1/ 2/ are `git diff --no-index` under that same setting. One
197
+ // prefix comes off; a file that is itself named `b/foo` stays `b/foo`.
198
+ return name.replace(/^[abiowc12]\//, '');
193
199
  };
194
200
  for (const line of String(patch ?? '').split('\n')) {
195
201
  if (line.startsWith('diff --git ')) { closeHunk(); file = null; oldName = null; continue; }