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
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
|
-
|
|
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
|
|
56
|
-
|
|
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.
|
|
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.
|
|
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 ??
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
booted.supervisor.
|
|
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,
|
package/briefs/message-rules.md
CHANGED
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
package/main/account-tooling.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/main/agent-updates.mjs
CHANGED
|
@@ -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
|
|
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];
|
package/main/approvals.mjs
CHANGED
|
@@ -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
|
|
391
|
-
*
|
|
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 {
|
|
395
|
-
|
|
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
|
|
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();
|
package/main/claude-bin.mjs
CHANGED
|
@@ -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
|
package/main/claude-plan.mjs
CHANGED
|
@@ -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
|
|
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
|
package/main/codex-bin.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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 =
|
|
99
|
+
const storeRoot = defaultStoreRoot({ home, env: {} });
|
|
102
100
|
return {
|
|
103
101
|
storeRoot,
|
|
104
102
|
accountRoot: path.join(storeRoot, 'accounts', DEMO_ACCOUNT),
|
|
105
|
-
appHome:
|
|
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
|
-
|
|
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
|
/**
|
package/main/engine-setup.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
142
|
-
|
|
151
|
+
ended = false;
|
|
152
|
+
watchLogin(login);
|
|
143
153
|
}
|
|
144
|
-
if (
|
|
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
|
}
|
package/main/fresh-user.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|
package/main/git-change.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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; }
|