agent-dag 1.34.6 → 1.35.1
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 +22 -1
- package/bin/deck.js +74 -10
- package/dist/web/assets/index-BWurW9Rz.js +70 -0
- package/dist/web/index.html +1 -1
- package/hook/hook.js +64 -21
- package/package.json +1 -1
- package/src/server/claude-dir.mjs +191 -7
- package/src/server/index.mjs +52 -2
- package/src/server/log-writer.mjs +34 -19
- package/src/server/quota.mjs +7 -39
- package/dist/web/assets/index-G1Xt6iC6.js +0 -70
package/dist/web/index.html
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-BWurW9Rz.js"></script>
|
|
44
44
|
<link rel="stylesheet" crossorigin href="/assets/index-CRPobBZf.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
package/hook/hook.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// agent-dag hook forwarder. Invoked by Claude Code or Codex CLI as a command
|
|
3
3
|
// hook. Reads stdin (event JSON), tags it with the provider passed via
|
|
4
|
-
// `--provider <name>`, finds
|
|
5
|
-
// discovery files in <claude config dir>/agent-dag
|
|
6
|
-
// is the deck
|
|
7
|
-
// cleaned up.
|
|
4
|
+
// `--provider <name>`, finds every agent-dag server whose workspace contains the
|
|
5
|
+
// session — via the discovery files in <claude config dir>/agent-dag/ — makes
|
|
6
|
+
// each one prove it is the deck its file describes, and POSTs the payload. Dead
|
|
7
|
+
// instances are cleaned up.
|
|
8
8
|
"use strict";
|
|
9
9
|
|
|
10
10
|
const fs = require("fs");
|
|
@@ -86,6 +86,36 @@ function cwdInWorkspace(cwd, workspace, platform = process.platform) {
|
|
|
86
86
|
return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Does a deck scoped to `workspace` capture a session running in `cwd`? This is
|
|
91
|
+
* the whole of what `--workspace` means, and it is a question about ONE deck: it
|
|
92
|
+
* asks nothing about the others that may also be up, so a deck's answer never
|
|
93
|
+
* depends on who else is running.
|
|
94
|
+
*
|
|
95
|
+
* An empty workspace is the default — machine-wide — and captures everything.
|
|
96
|
+
* It is answered before cwdInWorkspace rather than passed to it because
|
|
97
|
+
* p.resolve("") is the resolving process's own cwd, which here is the agent's,
|
|
98
|
+
* so an unscoped deck would be silently scoped to whatever directory the user
|
|
99
|
+
* happened to run their agent in.
|
|
100
|
+
*
|
|
101
|
+
* A session that never said where it runs is inside no workspace, so only an
|
|
102
|
+
* unscoped deck sees it. Unreachable from main(), which exits before this on a
|
|
103
|
+
* payload with no cwd — it is here because the rule has to be stated the same
|
|
104
|
+
* way on both sides to be pinned against the other one.
|
|
105
|
+
*
|
|
106
|
+
* src/server/log-writer.mjs answers this same question, for the sessions the
|
|
107
|
+
* server builds itself out of Codex's rollout files, under the name
|
|
108
|
+
* codexCwdInWorkspace — this script is copied out of the package and run
|
|
109
|
+
* standalone, so it cannot import that copy. A test walks one table of paths
|
|
110
|
+
* through both: a disagreement between them is `--workspace` meaning two
|
|
111
|
+
* different things depending on which CLI produced the session.
|
|
112
|
+
*/
|
|
113
|
+
function capturesSession(cwd, workspace, platform = process.platform) {
|
|
114
|
+
if (!workspace || typeof workspace !== "string") return true;
|
|
115
|
+
if (!cwd || typeof cwd !== "string") return false;
|
|
116
|
+
return cwdInWorkspace(cwd, workspace, platform);
|
|
117
|
+
}
|
|
118
|
+
|
|
89
119
|
function isAlive(pid) {
|
|
90
120
|
try { process.kill(pid, 0); return true; }
|
|
91
121
|
catch (e) { return e && e.code === "EPERM"; }
|
|
@@ -315,7 +345,24 @@ function main() {
|
|
|
315
345
|
} catch { return process.exit(0); }
|
|
316
346
|
if (!files.length) return process.exit(0);
|
|
317
347
|
|
|
318
|
-
|
|
348
|
+
// Every deck whose workspace contains this cwd, and nothing else decides it.
|
|
349
|
+
//
|
|
350
|
+
// This used to sort the matches by how long each deck's workspace path was
|
|
351
|
+
// and deliver only to the longest — so a deck scoped to /Users/x/proj TOOK
|
|
352
|
+
// that tree's sessions away from a machine-wide deck, which then sat there
|
|
353
|
+
// showing nothing while `--all` promised it captured every session on this
|
|
354
|
+
// machine. Nothing documented that, and the server's own Codex capture never
|
|
355
|
+
// did it: each deck tails the rollout files itself and evaluates its own
|
|
356
|
+
// workspace, so a Codex session inside a scoped tree appeared on both decks
|
|
357
|
+
// while the Claude session beside it appeared on one. One flag, one path,
|
|
358
|
+
// two answers.
|
|
359
|
+
//
|
|
360
|
+
// The fan-out is the documented meaning and the one kept: `--workspace` says
|
|
361
|
+
// which sessions a deck captures, not which sessions it takes from the decks
|
|
362
|
+
// around it. It is also what electWriters below already assumes — several
|
|
363
|
+
// decks drawing one event is the case it exists to keep from being written
|
|
364
|
+
// to one log several times.
|
|
365
|
+
const targets = [];
|
|
319
366
|
for (const file of files) {
|
|
320
367
|
let d;
|
|
321
368
|
try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
|
|
@@ -329,29 +376,25 @@ function main() {
|
|
|
329
376
|
continue;
|
|
330
377
|
}
|
|
331
378
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
379
|
+
// "" is machine-wide and must never reach normPath: resolving it would
|
|
380
|
+
// produce this hook's own cwd — the agent's — and scope a deck that asked
|
|
381
|
+
// for no scope at all. Any other spelling is canonicalized here, which is
|
|
382
|
+
// now a second pass over a path bin/deck.js already canonicalized before
|
|
383
|
+
// publishing it — kept because a deck old enough to have published a
|
|
384
|
+
// relative one is still entitled to its events.
|
|
385
|
+
const ws = d.workspace === "" ? "" : normPath(d.workspace);
|
|
386
|
+
if (capturesSession(resolvedCwd, ws)) targets.push(d);
|
|
340
387
|
}
|
|
341
388
|
|
|
342
|
-
if (!
|
|
343
|
-
|
|
344
|
-
matches.sort((a, b) => b.wsLen - a.wsLen);
|
|
345
|
-
const bestLen = matches[0].wsLen;
|
|
346
|
-
const targets = matches.filter(m => m.wsLen === bestLen);
|
|
389
|
+
if (!targets.length) return process.exit(0);
|
|
347
390
|
|
|
348
391
|
// One deck per events log records this event; the others only draw it.
|
|
349
|
-
const writers = electWriters(targets
|
|
392
|
+
const writers = electWriters(targets);
|
|
350
393
|
|
|
351
394
|
let pending = targets.length;
|
|
352
395
|
const done = () => { if (--pending <= 0) process.exit(0); };
|
|
353
396
|
|
|
354
|
-
for (const
|
|
397
|
+
for (const d of targets) deliver(d, taggedInput, writers.has(d), done);
|
|
355
398
|
});
|
|
356
399
|
}
|
|
357
400
|
|
|
@@ -360,5 +403,5 @@ function main() {
|
|
|
360
403
|
// require() it exports the rules it decides by — matching, election, the
|
|
361
404
|
// handshake — and starts nothing, which is what lets them be tested without a
|
|
362
405
|
// 1.5s exit timer in the test runner.
|
|
363
|
-
module.exports = { cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
|
|
406
|
+
module.exports = { capturesSession, cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
|
|
364
407
|
if (require.main === module) main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.1",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Where Claude Code
|
|
2
|
-
//
|
|
1
|
+
// Where Claude Code lives on this machine: its configuration directory, its
|
|
2
|
+
// binary, and the question of whether it is here at all.
|
|
3
3
|
//
|
|
4
4
|
// CLAUDE_CONFIG_DIR relocates that directory wholesale; it is a replacement for
|
|
5
5
|
// ~/.claude, not an overlay, so on a machine where it is set there is nothing in
|
|
@@ -12,11 +12,195 @@
|
|
|
12
12
|
// reads another. hook/hook.js repeats the rule inline rather than importing it:
|
|
13
13
|
// it is copied out of the package and run standalone by the host CLI, so it has
|
|
14
14
|
// no way back to this module, but it has to resolve the same directory.
|
|
15
|
+
//
|
|
16
|
+
// The binary list and the presence test moved in beside it for the same reason.
|
|
17
|
+
// The list used to sit in quota.mjs under the name quotaClaudeCandidates, where
|
|
18
|
+
// it read as a detail of one poller; it is nothing of the kind. It is the answer
|
|
19
|
+
// to "where does the `claude` command live", and the deck now asks that question
|
|
20
|
+
// at boot, before any quota exists, to decide whether this is a Claude machine
|
|
21
|
+
// at all. One list, one caller-visible name, no second spelling to drift.
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
15
23
|
import { homedir } from "node:os";
|
|
16
|
-
import { join, resolve } from "node:path";
|
|
24
|
+
import { join, resolve, posix as posixPath, win32 as winPath } from "node:path";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Absolute path of the Claude Code config dir: $CLAUDE_CONFIG_DIR or ~/.claude.
|
|
28
|
+
*
|
|
29
|
+
* The environment and the home directory are parameters purely so callers that
|
|
30
|
+
* are already working from an injected environment can stay consistent with it;
|
|
31
|
+
* every caller in the deck passes nothing and gets the real machine's answer.
|
|
32
|
+
*/
|
|
33
|
+
export function claudeConfigDir(env = process.env, home = homedir()) {
|
|
34
|
+
const override = env.CLAUDE_CONFIG_DIR?.trim();
|
|
35
|
+
return override ? resolve(override) : join(home, ".claude");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every place the `claude` CLI is known to live, in the order to try them.
|
|
40
|
+
*
|
|
41
|
+
* Pure, and the platform, environment and home directory are parameters, so the
|
|
42
|
+
* Windows list can be checked from a Mac — which is the only way this list stays
|
|
43
|
+
* right, since it exists entirely for machines the author is not sitting at.
|
|
44
|
+
*/
|
|
45
|
+
export function claudeCliCandidates(platform = process.platform, env = process.env, home = homedir()) {
|
|
46
|
+
// The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
|
|
47
|
+
// would emit forward slashes when the Windows list is built on a Mac.
|
|
48
|
+
const { join } = platform === "win32" ? winPath : posixPath;
|
|
49
|
+
if (platform !== "win32") {
|
|
50
|
+
return [
|
|
51
|
+
"claude",
|
|
52
|
+
join(home, ".local", "bin", "claude"),
|
|
53
|
+
"/usr/local/bin/claude",
|
|
54
|
+
"/opt/homebrew/bin/claude",
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
return [
|
|
58
|
+
// The native installer, which ships a bare claude.exe and NO .cmd shim. It
|
|
59
|
+
// was the one install this branch could not reach: the npm path below does
|
|
60
|
+
// not exist on such a machine, and the bare-name fallback used to be spelled
|
|
61
|
+
// `claude.cmd`, which cmd.exe cannot resolve to an .exe — PATHEXT supplies a
|
|
62
|
+
// missing extension, it never substitutes one that is already there.
|
|
63
|
+
join(home, ".local", "bin", "claude.exe"),
|
|
64
|
+
// `npm i -g @anthropic-ai/claude-code`. npm's global prefix is %APPDATA%\npm
|
|
65
|
+
// — Roaming rather than Local, deliberately, since it follows the user
|
|
66
|
+
// between machines — and APPDATA is read from the environment because a
|
|
67
|
+
// roaming profile puts it on a network share, not under the home directory.
|
|
68
|
+
join(env.APPDATA || join(home, "AppData", "Roaming"), "npm", "claude.cmd"),
|
|
69
|
+
// Last resort: the bare name, which cmd.exe resolves through PATH + PATHEXT
|
|
70
|
+
// and so finds claude.exe and claude.cmd alike.
|
|
71
|
+
"claude",
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The names Claude Code itself writes inside its config dir, and the deck never
|
|
77
|
+
* does. Presence of any one of them is proof Claude Code has actually run here.
|
|
78
|
+
*
|
|
79
|
+
* The exclusions are the entire point. `settings.json` and `agent-dag/` are OURS
|
|
80
|
+
* — installHooks writes the first and keepDiscovery creates the second — so a
|
|
81
|
+
* test that accepted them would answer "Claude is installed" on any machine this
|
|
82
|
+
* deck had ever been started on, including the Codex-only one this whole check
|
|
83
|
+
* exists to recognise. That is not a hypothetical: every Codex-only machine that
|
|
84
|
+
* ran an earlier ccdeck already has both sitting in a ~/.claude the deck created
|
|
85
|
+
* for itself.
|
|
86
|
+
*
|
|
87
|
+
* `.credentials.json` is in the list but cannot carry it alone: macOS keeps the
|
|
88
|
+
* OAuth token in the Keychain and writes no such file, so on the platform where
|
|
89
|
+
* a credentials test is most tempting it is always negative (see #360). It earns
|
|
90
|
+
* its place as one more way to say yes on Linux and Windows, never as the test.
|
|
91
|
+
*/
|
|
92
|
+
const CLAUDE_USE_MARKERS = [
|
|
93
|
+
"projects", // one directory per cwd, written from the first session on
|
|
94
|
+
"history.jsonl", // the prompt history, appended to on the first prompt
|
|
95
|
+
"statsig", // written at first launch, before any session completes
|
|
96
|
+
"todos",
|
|
97
|
+
"shell-snapshots",
|
|
98
|
+
"ide",
|
|
99
|
+
"plugins",
|
|
100
|
+
".credentials.json", // Linux + Windows OAuth store; absent on macOS by design
|
|
101
|
+
".claude.json", // Claude Code's own state file, when the config dir moved
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether the `claude` binary is on this machine — on PATH, or in one of the
|
|
106
|
+
* places its installers are known to put it.
|
|
107
|
+
*
|
|
108
|
+
* `run()` in exec.mjs can afford to hand a bare name to spawn and let the OS
|
|
109
|
+
* resolve it. This cannot: it has to answer without launching anything. Boot is
|
|
110
|
+
* the wrong place to spend a `claude --version` child process (quota.mjs
|
|
111
|
+
* measured those at ~3.0s each), and a spawn probe would also have to decide
|
|
112
|
+
* what a non-zero exit means, which is a question with no good answer. So the
|
|
113
|
+
* PATH walk is done by hand, with PATHEXT applied on Windows exactly as cmd.exe
|
|
114
|
+
* would — a `claude.cmd` shim from npm and a bare `claude.exe` from the native
|
|
115
|
+
* installer both have to count, and neither is spelled `claude`.
|
|
116
|
+
*/
|
|
117
|
+
export function claudeCliOnDisk({
|
|
118
|
+
platform = process.platform,
|
|
119
|
+
env = process.env,
|
|
120
|
+
home = homedir(),
|
|
121
|
+
exists = existsSync,
|
|
122
|
+
} = {}) {
|
|
123
|
+
const path = platform === "win32" ? winPath : posixPath;
|
|
124
|
+
const bare = [];
|
|
125
|
+
for (const candidate of claudeCliCandidates(platform, env, home)) {
|
|
126
|
+
// A full path is worth a single stat; a bare name means "ask PATH", which
|
|
127
|
+
// is the walk below. Same split quotaClaudeBin makes, for the same reason.
|
|
128
|
+
if (candidate.includes(path.sep)) { if (exists(candidate)) return true; }
|
|
129
|
+
else bare.push(candidate);
|
|
130
|
+
}
|
|
131
|
+
if (bare.length === 0) return false;
|
|
132
|
+
|
|
133
|
+
// process.env is case-insensitive on Windows, but an injected plain object in
|
|
134
|
+
// a test is not, and %Path% is how the variable is actually spelled there.
|
|
135
|
+
const rawPath = env.PATH ?? env.Path ?? env.path ?? "";
|
|
136
|
+
// The empty extension stays in the list: a Git-Bash or WSL-style shim on
|
|
137
|
+
// Windows can be an extensionless file, and on POSIX it is the only entry.
|
|
138
|
+
const exts = platform === "win32"
|
|
139
|
+
? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map(e => e.trim()).filter(Boolean)]
|
|
140
|
+
: [""];
|
|
141
|
+
|
|
142
|
+
for (const entry of String(rawPath).split(path.delimiter)) {
|
|
143
|
+
// Windows PATH entries are routinely quoted, and an empty entry means the
|
|
144
|
+
// current directory — which is not a place to go looking for a CLI.
|
|
145
|
+
const dir = entry.trim().replace(/^"+|"+$/g, "");
|
|
146
|
+
if (dir === "") continue;
|
|
147
|
+
for (const name of bare) {
|
|
148
|
+
for (const ext of exts) if (exists(path.join(dir, name + ext))) return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
17
153
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Whether this machine has Claude Code at all — the mirror of hasCodexInstalled().
|
|
156
|
+
*
|
|
157
|
+
* README offers "Claude Code CLI or OpenAI Codex CLI (or both)", and until now
|
|
158
|
+
* nothing anywhere asked which. A Codex-only machine got a Python
|
|
159
|
+
* account-switcher installed for a CLI it does not have, an accounts panel open
|
|
160
|
+
* on first run, and a banner telling it to sign into that CLI (#402).
|
|
161
|
+
*
|
|
162
|
+
* WHY TWO KINDS OF EVIDENCE, OR'd. Neither half is sufficient on its own:
|
|
163
|
+
*
|
|
164
|
+
* - The binary alone misses the user whose `claude` lives somewhere no list
|
|
165
|
+
* knows (nvm, mise, volta, a corporate wrapper) and whose deck was launched
|
|
166
|
+
* from a desktop shortcut with a PATH that never sourced their shell rc.
|
|
167
|
+
* That user has run Claude Code for months; the config dir proves it.
|
|
168
|
+
* - The config dir alone misses the machine where Claude Code is installed and
|
|
169
|
+
* has never been launched, which is exactly the moment somebody installs
|
|
170
|
+
* both CLIs and starts the deck first. It also cannot be the test on its own
|
|
171
|
+
* for the opposite reason: THE DECK CREATES THAT DIRECTORY ITSELF. Hence
|
|
172
|
+
* CLAUDE_USE_MARKERS above, which is a list of things only Claude Code puts
|
|
173
|
+
* there.
|
|
174
|
+
*
|
|
175
|
+
* WHY NOT CREDENTIALS. A credentials file is not a presence test on macOS at
|
|
176
|
+
* all — the token is in the Keychain and no file exists (#360) — so a machine
|
|
177
|
+
* with Claude Code, signed in and in daily use, would read as Codex-only.
|
|
178
|
+
*
|
|
179
|
+
* WHY NOT "a Claude session was seen". It is the most direct evidence there is,
|
|
180
|
+
* and it arrives far too late: the decision this answers is made at boot, before
|
|
181
|
+
* the server is listening, and a machine whose first session has not started yet
|
|
182
|
+
* would install nothing and show no panel until a restart.
|
|
183
|
+
*
|
|
184
|
+
* The bias is deliberate and one-way. A false yes leaves a Claude-only surface
|
|
185
|
+
* on a Codex machine — today's bug, visible, and the user can pass --no-claude.
|
|
186
|
+
* A false no takes the hooks away from somebody who has Claude Code, which is a
|
|
187
|
+
* deck that stays empty forever. So every check here is generous, the banner
|
|
188
|
+
* says out loud which way it went, and --claude overrides it.
|
|
189
|
+
*/
|
|
190
|
+
export function hasClaudeInstalled({
|
|
191
|
+
platform = process.platform,
|
|
192
|
+
env = process.env,
|
|
193
|
+
home = homedir(),
|
|
194
|
+
configDir = claudeConfigDir(env, home),
|
|
195
|
+
exists = existsSync,
|
|
196
|
+
} = {}) {
|
|
197
|
+
if (claudeCliOnDisk({ platform, env, home, exists })) return true;
|
|
198
|
+
const path = platform === "win32" ? winPath : posixPath;
|
|
199
|
+
for (const marker of CLAUDE_USE_MARKERS) {
|
|
200
|
+
if (exists(path.join(configDir, marker))) return true;
|
|
201
|
+
}
|
|
202
|
+
// ~/.claude.json is Claude Code's own state file on every install that never
|
|
203
|
+
// moved the config dir, and it sits BESIDE the home directory rather than
|
|
204
|
+
// inside ~/.claude — so the loop above cannot reach it there.
|
|
205
|
+
return exists(path.join(home, ".claude.json"));
|
|
22
206
|
}
|
package/src/server/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Single-file pure Node HTTP server, zero deps.
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { readFile, stat, mkdir, appendFile, open, truncate, readdir, unlink } from "node:fs/promises";
|
|
5
|
-
import { createReadStream, existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { createReadStream, existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { extname, join, resolve, dirname as pdirname } from "node:path";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -1918,6 +1918,52 @@ async function handleSoundHookSet(req, res) {
|
|
|
1918
1918
|
// can read before a single event has arrived.
|
|
1919
1919
|
let _workspace = "";
|
|
1920
1920
|
|
|
1921
|
+
// Which CLIs this deck is actually watching. Decided in bin/deck.js — from
|
|
1922
|
+
// whether each one is on the machine, and from --claude/--no-claude and
|
|
1923
|
+
// --codex/--no-codex — and passed in here, because the browser had no way to
|
|
1924
|
+
// learn it and so drew both sides of the UI on every machine.
|
|
1925
|
+
//
|
|
1926
|
+
// That is the whole of #402 and its mirror. A Codex-only machine got the
|
|
1927
|
+
// accounts panel open on first run, telling it to sign into a CLI it does not
|
|
1928
|
+
// have; a Claude-only machine permanently carried "Quota unavailable. / Run
|
|
1929
|
+
// codex login to authenticate." Same missing fact, two directions.
|
|
1930
|
+
//
|
|
1931
|
+
// Defaults are both true, which is what an older deck effectively reported by
|
|
1932
|
+
// saying nothing — and the browser reads a missing field as "could not say" and
|
|
1933
|
+
// shows both, so the two agree.
|
|
1934
|
+
let _providers = { claude: true, codex: true };
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* The one spelling of `--workspace` everything downstream compares against.
|
|
1938
|
+
* Empty — including a value that is nothing but spaces — stays empty, which is
|
|
1939
|
+
* how every reader of it says "machine-wide".
|
|
1940
|
+
*
|
|
1941
|
+
* Called once, in bin/deck.js, where the flag arrives: that process's cwd is the
|
|
1942
|
+
* shell the user typed the command in, and it is the only process on either
|
|
1943
|
+
* capture path whose cwd is the one a relative `--workspace ./sub` was written
|
|
1944
|
+
* against. The raw string used to go straight into the discovery file, and
|
|
1945
|
+
* hook.js resolved it inside its own process — which the host CLI runs with the
|
|
1946
|
+
* AGENT's cwd — so `--workspace ./sub` scoped Claude sessions to a `sub`
|
|
1947
|
+
* directory under whatever the agent happened to be working in, a different
|
|
1948
|
+
* directory per agent and none of them the one asked for. The Codex path
|
|
1949
|
+
* resolved the same string in the server process and was right. Resolving here
|
|
1950
|
+
* makes both of them right, and for the same reason bin/deck.js already resolves
|
|
1951
|
+
* the events log before publishing it: what goes in the discovery file is read
|
|
1952
|
+
* by other processes that cannot reconstruct the context it was written in.
|
|
1953
|
+
*
|
|
1954
|
+
* Symlinks are resolved too, because a process's cwd — which is what both
|
|
1955
|
+
* providers report — comes from getcwd() and has none left in it. Without this,
|
|
1956
|
+
* `--workspace /tmp/proj` on a Mac is scoped to /tmp/proj while every session
|
|
1957
|
+
* inside it reports /private/tmp/proj, and the deck stays empty. A path that
|
|
1958
|
+
* does not exist yet keeps its resolved form rather than failing: scoping a deck
|
|
1959
|
+
* to a directory you are about to create is not an error.
|
|
1960
|
+
*/
|
|
1961
|
+
export function canonicalWorkspace(raw) {
|
|
1962
|
+
if (typeof raw !== "string" || raw.trim() === "") return "";
|
|
1963
|
+
const abs = resolve(raw);
|
|
1964
|
+
try { return realpathSync(abs); } catch { return abs; }
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1921
1967
|
function handleHealth(_req, res) {
|
|
1922
1968
|
send(res, 200, {
|
|
1923
1969
|
ok: true,
|
|
@@ -1926,6 +1972,7 @@ function handleHealth(_req, res) {
|
|
|
1926
1972
|
clients: sseClients.size,
|
|
1927
1973
|
uptimeMs: Math.round(process.uptime() * 1000),
|
|
1928
1974
|
workspace: _workspace,
|
|
1975
|
+
providers: _providers,
|
|
1929
1976
|
});
|
|
1930
1977
|
}
|
|
1931
1978
|
|
|
@@ -2312,10 +2359,13 @@ let _onRestart = null;
|
|
|
2312
2359
|
// ask; the second ask must not re-enter the shutdown.
|
|
2313
2360
|
let _restarting = false;
|
|
2314
2361
|
|
|
2315
|
-
export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, onRestart = null } = {}) {
|
|
2362
|
+
export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null } = {}) {
|
|
2316
2363
|
_onRestart = typeof onRestart === "function" ? onRestart : null;
|
|
2317
2364
|
_canRestart = _onRestart != null && persist != null;
|
|
2318
2365
|
_workspace = typeof workspace === "string" ? workspace : "";
|
|
2366
|
+
// `!== false` rather than a cast: a caller that omits the field means "yes",
|
|
2367
|
+
// which is how every embedder that predates this option keeps working.
|
|
2368
|
+
_providers = { claude: claude !== false, codex: codex !== false };
|
|
2319
2369
|
const removed = await sweepStaleDiscovery();
|
|
2320
2370
|
if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
|
|
2321
2371
|
if (persist) {
|
|
@@ -73,17 +73,29 @@ export function electWriters(decks, platform = process.platform) {
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
|
|
76
|
-
* empty workspace is unscoped and captures every session
|
|
76
|
+
* empty workspace is unscoped and captures every session; a rollout that never
|
|
77
|
+
* said where it runs is inside no workspace, so only an unscoped deck draws it.
|
|
77
78
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
79
|
+
* This answers two questions with one function — whether THIS deck tails a
|
|
80
|
+
* rollout, and whether another deck tails it too — and that is only sound while
|
|
81
|
+
* the rule below is the rule every deck actually runs. Model another deck's
|
|
82
|
+
* capture with anything else and the election covers the wrong set: a deck that
|
|
83
|
+
* writes without being elected, or an elected deck that never opened the file.
|
|
84
|
+
*
|
|
85
|
+
* It is also the rule hook/hook.js runs for the sessions it delivers, under the
|
|
86
|
+
* name capturesSession — that script is copied out of the package and run
|
|
87
|
+
* standalone, so the two are written twice and pinned equal by a test walking
|
|
88
|
+
* one table of paths through both. They were not equal: case was folded here on
|
|
89
|
+
* every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
|
|
90
|
+
* from /srv/Proj and Claude sessions from neither. Those are two real
|
|
91
|
+
* directories there, and the hook's own comment says what folding them together
|
|
92
|
+
* costs — a deck handed the events of a tree it was not scoped to. So the fold
|
|
93
|
+
* is per-platform on both sides now, and `--workspace` means one thing.
|
|
94
|
+
*
|
|
95
|
+
* (The narrow window that opens: two decks on Linux whose workspaces differ only
|
|
96
|
+
* in case, one of them old enough to still fold, both containing one rollout's
|
|
97
|
+
* cwd. Each models the other as tailing the file; one of them is wrong, and the
|
|
98
|
+
* cost is a single log line written twice.)
|
|
87
99
|
*
|
|
88
100
|
* The platform is a parameter, following the hook's cwdInWorkspace and
|
|
89
101
|
* spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
|
|
@@ -93,8 +105,9 @@ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform)
|
|
|
93
105
|
if (!workspace || typeof workspace !== "string") return true;
|
|
94
106
|
if (!cwd || typeof cwd !== "string") return false;
|
|
95
107
|
const p = platform === "win32" ? win32 : posix;
|
|
96
|
-
const
|
|
97
|
-
const
|
|
108
|
+
const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
|
|
109
|
+
const a = fold(p.resolve(cwd));
|
|
110
|
+
const b = fold(p.resolve(workspace));
|
|
98
111
|
if (a === b) return true;
|
|
99
112
|
// A root ("C:\", "/") already ends in the separator; appending a second one
|
|
100
113
|
// would match nothing.
|
|
@@ -106,13 +119,15 @@ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform)
|
|
|
106
119
|
* it? `decks` is every deck registered right now, `pid` identifies this one
|
|
107
120
|
* among them, and `cwd` is the workspace the rollout is running in.
|
|
108
121
|
*
|
|
109
|
-
* The group is every deck that tails this same rollout
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
122
|
+
* The group is every deck that tails this same rollout: each deck decides for
|
|
123
|
+
* itself, so all of them whose workspace contains the cwd read the file and all
|
|
124
|
+
* of them would write it. The hook builds the same group the same way for the
|
|
125
|
+
* events it delivers — it used to narrow them to the longest workspace match
|
|
126
|
+
* first, which is the asymmetry the predicate above describes the end of. A deck
|
|
127
|
+
* started with `--no-codex` tails nothing and is left out; electing it would
|
|
128
|
+
* mean the rollout's events reach no log at all. A deck too old to say either
|
|
129
|
+
* way is assumed to be tailing, which is what it was doing before this field
|
|
130
|
+
* existed.
|
|
116
131
|
*/
|
|
117
132
|
export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
|
|
118
133
|
const live = Array.isArray(decks) ? decks : [];
|
package/src/server/quota.mjs
CHANGED
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
// 2 and 3 are rate-floored (SELF_POLL_MS) and gated behind the same 429
|
|
29
29
|
// cooldown; 1 is not, because it is a local file read.
|
|
30
30
|
import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
|
|
31
|
-
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
31
|
+
import { claudeCliCandidates, claudeConfigDir } from "./claude-dir.mjs";
|
|
32
32
|
import { run } from "./exec.mjs";
|
|
33
33
|
import { existsSync } from "node:fs";
|
|
34
34
|
import { readFile } from "node:fs/promises";
|
|
35
|
-
import { join
|
|
35
|
+
import { join } from "node:path";
|
|
36
36
|
import { homedir } from "node:os";
|
|
37
37
|
import { PRODUCT } from "./brand.mjs";
|
|
38
38
|
|
|
@@ -334,42 +334,10 @@ function parseUsageText(raw) {
|
|
|
334
334
|
return Object.keys(result).length > 0 ? result : null;
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
* Windows list can be checked from a Mac — which is the only way this list stays
|
|
342
|
-
* right, since it exists entirely for machines the author is not sitting at.
|
|
343
|
-
*/
|
|
344
|
-
export function quotaClaudeCandidates(platform = process.platform, env = process.env, home = homedir()) {
|
|
345
|
-
// The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
|
|
346
|
-
// would emit forward slashes when the Windows list is built on a Mac.
|
|
347
|
-
const { join } = platform === "win32" ? winPath : posixPath;
|
|
348
|
-
if (platform !== "win32") {
|
|
349
|
-
return [
|
|
350
|
-
"claude",
|
|
351
|
-
join(home, ".local", "bin", "claude"),
|
|
352
|
-
"/usr/local/bin/claude",
|
|
353
|
-
"/opt/homebrew/bin/claude",
|
|
354
|
-
];
|
|
355
|
-
}
|
|
356
|
-
return [
|
|
357
|
-
// The native installer, which ships a bare claude.exe and NO .cmd shim. It
|
|
358
|
-
// was the one install this branch could not reach: the npm path below does
|
|
359
|
-
// not exist on such a machine, and the bare-name fallback used to be spelled
|
|
360
|
-
// `claude.cmd`, which cmd.exe cannot resolve to an .exe — PATHEXT supplies a
|
|
361
|
-
// missing extension, it never substitutes one that is already there.
|
|
362
|
-
join(home, ".local", "bin", "claude.exe"),
|
|
363
|
-
// `npm i -g @anthropic-ai/claude-code`. npm's global prefix is %APPDATA%\npm
|
|
364
|
-
// — Roaming rather than Local, deliberately, since it follows the user
|
|
365
|
-
// between machines — and APPDATA is read from the environment because a
|
|
366
|
-
// roaming profile puts it on a network share, not under the home directory.
|
|
367
|
-
join(env.APPDATA || join(home, "AppData", "Roaming"), "npm", "claude.cmd"),
|
|
368
|
-
// Last resort: the bare name, which cmd.exe resolves through PATH + PATHEXT
|
|
369
|
-
// and so finds claude.exe and claude.cmd alike.
|
|
370
|
-
"claude",
|
|
371
|
-
];
|
|
372
|
-
}
|
|
337
|
+
// Where the `claude` CLI can be. The list moved to claude-dir.mjs, which is the
|
|
338
|
+
// module that owns every "where does Claude Code live" answer the deck has —
|
|
339
|
+
// the config dir was already there, and the boot-time presence check that reads
|
|
340
|
+
// this same list had no business importing a quota poller to get at it.
|
|
373
341
|
|
|
374
342
|
/** Which `claude` to run for `--print /usage`: the first candidate that exists.
|
|
375
343
|
*
|
|
@@ -396,7 +364,7 @@ export function quotaClaudeBin(platform = process.platform, env = process.env,
|
|
|
396
364
|
// A bare name is left to spawn's own PATH lookup (and, on Windows, to the
|
|
397
365
|
// PATHEXT walk exec.mjs does by hand); a full path is only worth naming when
|
|
398
366
|
// it is actually there.
|
|
399
|
-
return
|
|
367
|
+
return claudeCliCandidates(platform, env, home)
|
|
400
368
|
.find(c => !c.includes(sep) || exists(c)) ?? "claude";
|
|
401
369
|
}
|
|
402
370
|
|