agent-dag 1.33.80 → 1.33.82
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/bin/agent-dag.js +57 -16
- package/dist/web/assets/{index-5_r9cSQl.js → index-CK4AFqll.js} +8 -8
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/server/index.mjs +124 -11
- package/src/server/npx.mjs +133 -0
- package/src/server/self-update.mjs +95 -3
package/dist/web/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>agents-deck</title>
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CK4AFqll.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-dwRx-2y0.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.33.
|
|
3
|
+
"version": "1.33.82",
|
|
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": {
|
package/src/server/index.mjs
CHANGED
|
@@ -757,8 +757,12 @@ function maybeResolveCodex(payload) {
|
|
|
757
757
|
// transcript enrichment (which needs transcript_path / hook events) but still
|
|
758
758
|
// persists + broadcasts them exactly like a hook event. This path is entirely
|
|
759
759
|
// additive — the Claude hook flow is untouched.
|
|
760
|
-
const codexFileState = new Map(); // path -> { offset, sid, cwd, skip }
|
|
760
|
+
const codexFileState = new Map(); // path -> { offset, sid, cwd, skip, seenAt }
|
|
761
761
|
const codexSessionModel = new Map(); // sid -> last model string
|
|
762
|
+
// How long a rollout's tail cursor is kept after it stops showing up in the
|
|
763
|
+
// listing. The listing covers two day-directories, so anything missing from it
|
|
764
|
+
// is at least a day old and will never be appended to again.
|
|
765
|
+
const CODEX_STATE_TTL_MS = 10 * 60 * 1000;
|
|
762
766
|
let codexScanRunning = false;
|
|
763
767
|
let codexWatchTimer = null;
|
|
764
768
|
let codexWorkspace = "";
|
|
@@ -902,11 +906,13 @@ async function codexScanOnce(firstRun) {
|
|
|
902
906
|
if (codexScanRunning) return;
|
|
903
907
|
codexScanRunning = true;
|
|
904
908
|
try {
|
|
909
|
+
const now = Date.now();
|
|
905
910
|
const files = await listRecentCodexRollouts();
|
|
906
911
|
for (const path of files) {
|
|
907
912
|
let st;
|
|
908
913
|
try { st = await stat(path); } catch { continue; }
|
|
909
914
|
let state = codexFileState.get(path);
|
|
915
|
+
if (state) state.seenAt = now;
|
|
910
916
|
|
|
911
917
|
if (!state) {
|
|
912
918
|
// New file — read the header for sid + cwd, then decide whether to
|
|
@@ -914,10 +920,10 @@ async function codexScanOnce(firstRun) {
|
|
|
914
920
|
const header = await readCodexHeader(path);
|
|
915
921
|
if (!header || !header.sid) continue; // not ready yet — retry next tick
|
|
916
922
|
if (!codexCwdInWorkspace(header.cwd)) {
|
|
917
|
-
codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false });
|
|
923
|
+
codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false, seenAt: now });
|
|
918
924
|
continue;
|
|
919
925
|
}
|
|
920
|
-
state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false };
|
|
926
|
+
state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false, seenAt: now };
|
|
921
927
|
codexFileState.set(path, state);
|
|
922
928
|
if (firstRun) {
|
|
923
929
|
// On startup, skip a pre-existing session's history entirely — no
|
|
@@ -955,6 +961,15 @@ async function codexScanOnce(firstRun) {
|
|
|
955
961
|
}
|
|
956
962
|
}
|
|
957
963
|
}
|
|
964
|
+
|
|
965
|
+
// Rollout files fall out of the newest-2-days listing and never come back,
|
|
966
|
+
// but their tail cursors used to live as long as the process did. Expire by
|
|
967
|
+
// "not seen for a while" rather than "absent from this listing": a single
|
|
968
|
+
// unreadable directory mid-scan would otherwise drop a live file's cursor,
|
|
969
|
+
// and re-adding it at offset 0 replays that entire rollout as fresh events.
|
|
970
|
+
for (const [p, s] of codexFileState) {
|
|
971
|
+
if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
|
|
972
|
+
}
|
|
958
973
|
} catch {
|
|
959
974
|
/* swallow — watcher must never crash the server */
|
|
960
975
|
} finally {
|
|
@@ -988,6 +1003,79 @@ export function eventsSince(seq) {
|
|
|
988
1003
|
return events.filter(e => e.seq > after);
|
|
989
1004
|
}
|
|
990
1005
|
|
|
1006
|
+
// ─── Per-session cache expiry ────────────────────────────────────────────
|
|
1007
|
+
// Every enrichment cache above is keyed by session id and nothing ever
|
|
1008
|
+
// removed an entry: a deck left up for weeks — the 24/7 use this thing is
|
|
1009
|
+
// built for — kept a model string, a subagent signature and four read-throttle
|
|
1010
|
+
// stamps for every session it had ever seen, plus the Codex rollout path and
|
|
1011
|
+
// model of each. SessionEnd is not a usable eviction signal (a killed CLI
|
|
1012
|
+
// never sends one, and Codex has no such hook at all), so entries expire by
|
|
1013
|
+
// least-recent use against a cap instead — the same shape pruneTranscriptScans
|
|
1014
|
+
// already uses for its per-path state. The cap sits far above any plausible
|
|
1015
|
+
// number of concurrent sessions, so a live session is never evicted; and an
|
|
1016
|
+
// evicted one that speaks again simply re-reads its transcript.
|
|
1017
|
+
const sessionTouchedAt = new Map(); // sid -> ms of the last event seen
|
|
1018
|
+
const MAX_TRACKED_SESSIONS = 256;
|
|
1019
|
+
|
|
1020
|
+
function forgetSession(sid) {
|
|
1021
|
+
modelBySession.delete(sid);
|
|
1022
|
+
modelLastReadAt.delete(sid);
|
|
1023
|
+
lastUsageReadAt.delete(sid);
|
|
1024
|
+
lastContextReadAt.delete(sid);
|
|
1025
|
+
codexRolloutPathBySid.delete(sid);
|
|
1026
|
+
lastCodexUsageReadAt.delete(sid);
|
|
1027
|
+
codexSessionModel.delete(sid);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function touchSession(sid) {
|
|
1031
|
+
if (!sid || typeof sid !== "string") return;
|
|
1032
|
+
// Re-insert so the Map's own insertion order *is* the LRU order and eviction
|
|
1033
|
+
// below is one key read rather than a scan of every session ever seen.
|
|
1034
|
+
sessionTouchedAt.delete(sid);
|
|
1035
|
+
sessionTouchedAt.set(sid, Date.now());
|
|
1036
|
+
while (sessionTouchedAt.size > MAX_TRACKED_SESSIONS) {
|
|
1037
|
+
const oldest = sessionTouchedAt.keys().next().value;
|
|
1038
|
+
sessionTouchedAt.delete(oldest);
|
|
1039
|
+
forgetSession(oldest);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ─── SSE backpressure ────────────────────────────────────────────────────
|
|
1044
|
+
// A client that stops reading without closing its socket — a frozen tab, a
|
|
1045
|
+
// suspended machine, a stalled `ssh -L` tunnel — never fires 'close' and never
|
|
1046
|
+
// makes write() throw, so the old `try { res.write(line) } catch {}` had no
|
|
1047
|
+
// way to notice it. On loopback nothing times the connection out either, so
|
|
1048
|
+
// every event (tool responses run to megabytes) queued in that socket's write
|
|
1049
|
+
// buffer for as long as the process lived.
|
|
1050
|
+
//
|
|
1051
|
+
// We drop the client rather than the events. EventSource reconnects after the
|
|
1052
|
+
// `retry: 1500` we send on connect and resumes from Last-Event-ID, so the ring
|
|
1053
|
+
// buffer replays whatever it missed. Dropping individual events instead would
|
|
1054
|
+
// leave a hole the resume path cannot even see, the client's last id having
|
|
1055
|
+
// moved past it.
|
|
1056
|
+
const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
1057
|
+
|
|
1058
|
+
/** Bytes queued for a client: what the response has not handed to the socket
|
|
1059
|
+
* yet, plus what the socket has not handed to the kernel. */
|
|
1060
|
+
function queuedBytes(res) {
|
|
1061
|
+
const own = typeof res.writableLength === "number" ? res.writableLength : 0;
|
|
1062
|
+
const sock = res.socket && typeof res.socket.writableLength === "number" ? res.socket.writableLength : 0;
|
|
1063
|
+
return own + sock;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Write one SSE frame, hanging up on a client too far behind to keep. */
|
|
1067
|
+
function writeSse(res, frame) {
|
|
1068
|
+
try {
|
|
1069
|
+
res.write(frame);
|
|
1070
|
+
if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) return;
|
|
1071
|
+
} catch { /* already dead — drop it below */ }
|
|
1072
|
+
sseClients.delete(res);
|
|
1073
|
+
// Destroying the socket is what makes the request emit 'close', which is
|
|
1074
|
+
// where the ping interval is cleared.
|
|
1075
|
+
try { res.destroy(); } catch {}
|
|
1076
|
+
try { res.socket?.destroy(); } catch {}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
991
1079
|
function pushEvent(raw, source, opts = {}) {
|
|
992
1080
|
// Synchronous enrichment: if we already know this session's model, stamp
|
|
993
1081
|
// it on the payload so the client's recursive scanner picks it up.
|
|
@@ -1007,18 +1095,42 @@ function pushEvent(raw, source, opts = {}) {
|
|
|
1007
1095
|
events.push(evt);
|
|
1008
1096
|
if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
|
|
1009
1097
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1098
|
+
// Does this event reach the log at all? Not on a replay (it came from
|
|
1099
|
+
// there), not when the hook told us another deck owns this session's log,
|
|
1100
|
+
// and not when we have no log. Decided before serializing because it is half
|
|
1101
|
+
// of the answer to whether serializing is worth doing.
|
|
1102
|
+
const persisting = persistPath && !opts.replay && opts.persist !== false && writesLogFor(raw);
|
|
1103
|
+
|
|
1104
|
+
// One serialization, shared by both consumers — and skipped entirely when
|
|
1105
|
+
// neither wants it. This used to stringify the whole envelope twice on the
|
|
1106
|
+
// hottest path in the process (once for the SSE frame, once for the persist
|
|
1107
|
+
// line), and built the frame even with nobody subscribed: a headless deck
|
|
1108
|
+
// paid a full stringify per event for a string no one read, and boot replay
|
|
1109
|
+
// — which runs before the listener exists and never broadcasts — paid one
|
|
1110
|
+
// for every line of a log that rotates at 50MB. An event this deck is not
|
|
1111
|
+
// logging is still broadcast, so a subscriber alone is reason enough.
|
|
1112
|
+
const json = (sseClients.size > 0 || persisting) ? JSON.stringify(evt) : null;
|
|
1113
|
+
|
|
1114
|
+
if (sseClients.size > 0) {
|
|
1115
|
+
const line = `id: ${seq}\nevent: hook\ndata: ${json}\n\n`;
|
|
1116
|
+
// writeSse may drop a client mid-loop; deleting from a Set while iterating
|
|
1117
|
+
// it is well defined and skips only the entry removed.
|
|
1118
|
+
for (const res of sseClients) writeSse(res, line);
|
|
1013
1119
|
}
|
|
1014
1120
|
|
|
1015
|
-
if (
|
|
1121
|
+
if (persisting) {
|
|
1016
1122
|
// Fire-and-forget append. JSONL = newline-delimited JSON.
|
|
1017
|
-
appendFile(persistPath,
|
|
1123
|
+
appendFile(persistPath, json + "\n", "utf8").catch(() => {});
|
|
1018
1124
|
// Cheap throttled check (every 30s) — only rotates if file > 50MB.
|
|
1019
1125
|
maybeRotatePersistFile();
|
|
1020
1126
|
}
|
|
1021
1127
|
|
|
1128
|
+
// Note the session so the caches the scanners below fill can expire by
|
|
1129
|
+
// least-recent use. Replays are excluded: they fill nothing, and a boot
|
|
1130
|
+
// replay of a log spanning weeks would otherwise churn the whole LRU through
|
|
1131
|
+
// dead session ids before the first live event even arrives.
|
|
1132
|
+
if (!opts.replay && raw && typeof raw === "object") touchSession(raw.session_id);
|
|
1133
|
+
|
|
1022
1134
|
// Kick off async transcript scans. Model arrives as a one-shot
|
|
1023
1135
|
// ModelObserved; usage is re-read periodically (throttled to 2.5s per
|
|
1024
1136
|
// session) so the cost columns track running totals as the session
|
|
@@ -1161,9 +1273,10 @@ function handleSse(req, res) {
|
|
|
1161
1273
|
res.write(`event: replay-end\ndata: {}\n\n`);
|
|
1162
1274
|
|
|
1163
1275
|
sseClients.add(res);
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1276
|
+
// Through writeSse like every other frame: on a client that has stopped
|
|
1277
|
+
// reading, the ping is the one thing still being written between events, and
|
|
1278
|
+
// it is what eventually reveals the socket as unrecoverable.
|
|
1279
|
+
const ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
|
|
1167
1280
|
|
|
1168
1281
|
req.on("close", () => {
|
|
1169
1282
|
clearInterval(ping);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// How to run npx from a supervisor that cannot afford npx to be broken.
|
|
2
|
+
//
|
|
3
|
+
// The supervisor's upgrade path is `npx -y <spec>@latest`, and it used to
|
|
4
|
+
// resolve npx by bare name — `npx.cmd` on Windows — letting the OS find it on
|
|
5
|
+
// PATH. That trusts a batch shim to be sitting in a real npm install root,
|
|
6
|
+
// because the shim computes everything else from its own directory:
|
|
7
|
+
//
|
|
8
|
+
// SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
|
|
9
|
+
// SET "NPM_PREFIX_JS=%~dp0\node_modules\npm\bin\npm-prefix.js"
|
|
10
|
+
//
|
|
11
|
+
// Reported from a Windows machine on Node 24 where `%~dp0` was the user's home
|
|
12
|
+
// directory and no `node_modules\npm` existed under it — the usual cause is an
|
|
13
|
+
// npm global prefix pointed somewhere the shim did not follow. Clicking
|
|
14
|
+
// "Update & restart" printed a raw MODULE_NOT_FOUND stack trace and came back
|
|
15
|
+
// on the same version.
|
|
16
|
+
//
|
|
17
|
+
// Node ships npm, so npx's real entry point can be reached from
|
|
18
|
+
// `process.execPath` with no PATH lookup, no batch file, and no shim-relative
|
|
19
|
+
// arithmetic. That is what this prefers. The shim stays as the fallback for
|
|
20
|
+
// installs whose npm lives somewhere else entirely, and it still goes through
|
|
21
|
+
// spawnSpec — the cmd.exe quoting there is load-bearing and unchanged.
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { spawnSpec } from "./exec.mjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where npm's own `npx-cli.js` sits relative to the running Node binary, most
|
|
27
|
+
* likely first. Pure: the platform and the executable path are parameters, so
|
|
28
|
+
* the Windows layout can be checked from any OS.
|
|
29
|
+
*
|
|
30
|
+
* Windows keeps npm beside node.exe (`C:\Program Files\nodejs\node_modules\npm`,
|
|
31
|
+
* and the same shape under nvm-windows). POSIX puts it in a `lib` next to the
|
|
32
|
+
* `bin` — usually the parent (`/usr/local/bin/node` →
|
|
33
|
+
* `/usr/local/lib/node_modules/npm`, as for nvm, fnm and Volta), but not
|
|
34
|
+
* always: Homebrew's node lives in `/usr/local/Cellar/node/<v>/bin` while its
|
|
35
|
+
* npm stays at the prefix, four levels up. So the `lib` spelling is tried at
|
|
36
|
+
* each of the first four ancestors rather than only at the parent. Every
|
|
37
|
+
* candidate is confirmed by the exact file existing, so a wider search cannot
|
|
38
|
+
* pick something that is not npm.
|
|
39
|
+
*
|
|
40
|
+
* The path arithmetic is done on the string rather than through `node:path`,
|
|
41
|
+
* for the same reason npxRoot in self-update.mjs does: `path` here is the
|
|
42
|
+
* platform running the SUITE, so a Windows layout checked from macOS would come
|
|
43
|
+
* back with forward slashes and a `..` nothing resolves.
|
|
44
|
+
*/
|
|
45
|
+
export function npxCliCandidates(execPath = process.execPath, platform = process.platform) {
|
|
46
|
+
if (typeof execPath !== "string" || !execPath) return [];
|
|
47
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
48
|
+
const dir = execPath.split(/[\\/]/);
|
|
49
|
+
dir.pop(); // the binary itself
|
|
50
|
+
if (!dir.length) return [];
|
|
51
|
+
const beside = [...dir, "node_modules", "npm", "bin", "npx-cli.js"].join(sep);
|
|
52
|
+
const above = [];
|
|
53
|
+
for (let up = 1; up <= 4 && dir.length - up > 0; up++) {
|
|
54
|
+
above.push([...dir.slice(0, -up), "lib", "node_modules", "npm", "bin", "npx-cli.js"].join(sep));
|
|
55
|
+
}
|
|
56
|
+
return platform === "win32" ? [beside, ...above] : [...above, beside];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What to hand `spawn` to run `npx <args>`.
|
|
61
|
+
*
|
|
62
|
+
* Returns `{ file, args, opts, via }`, where `via` is "node" for the bundled
|
|
63
|
+
* CLI and "shim" for the PATH lookup. `exists` is injected so the choice can be
|
|
64
|
+
* tested without a matching Node install on the machine running the suite.
|
|
65
|
+
*/
|
|
66
|
+
export function npxLaunch(args, {
|
|
67
|
+
platform = process.platform,
|
|
68
|
+
execPath = process.execPath,
|
|
69
|
+
exists = existsSync,
|
|
70
|
+
} = {}) {
|
|
71
|
+
for (const cli of npxCliCandidates(execPath, platform)) {
|
|
72
|
+
let found = false;
|
|
73
|
+
try { found = exists(cli); } catch { found = false; }
|
|
74
|
+
// Same node, same npm, spawned directly: nothing here reads PATH and
|
|
75
|
+
// nothing resolves a path relative to a shim's own directory.
|
|
76
|
+
if (found) return { file: execPath, args: [cli, ...args], opts: {}, via: "node", cli };
|
|
77
|
+
}
|
|
78
|
+
const shim = platform === "win32" ? "npx.cmd" : "npx";
|
|
79
|
+
return { ...spawnSpec(shim, args, platform), via: "shim", cli: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Lines that are structure rather than content: a Node crash prints a stack,
|
|
83
|
+
// the source line it threw on, a caret under it, then the error object's own
|
|
84
|
+
// fields and the runtime version. None of that tells a user of a DAG dashboard
|
|
85
|
+
// what went wrong.
|
|
86
|
+
const NOISE = [
|
|
87
|
+
/^\s*at\s/,
|
|
88
|
+
/^\s*\^+\s*$/,
|
|
89
|
+
/^node:internal\//,
|
|
90
|
+
/^\s*throw\s/,
|
|
91
|
+
/^Node\.js v/,
|
|
92
|
+
/^\s*[{}]\s*$/,
|
|
93
|
+
/^\s*(code|errno|syscall|path|requireStack|stack):/,
|
|
94
|
+
/^A complete log/,
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// The line worth quoting, when there is one. npm's failures and Node's both
|
|
98
|
+
// lead with the sentence a person can act on; everything after it is detail.
|
|
99
|
+
const SIGNAL = /(Error:|error code|ERR_|No matching version|ENOENT|EACCES|EPERM|not found|permission denied)/i;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One line summarising why an npx run failed, or null when its output said
|
|
103
|
+
* nothing. Pure, and separate from the printing, because "do not dump a stack
|
|
104
|
+
* trace at the user" is the rule and a rule worth a bug is worth a test.
|
|
105
|
+
*/
|
|
106
|
+
export function npxFailureSummary(output) {
|
|
107
|
+
const lines = String(output ?? "")
|
|
108
|
+
.split(/\r?\n/)
|
|
109
|
+
// A warning is by definition not the reason this failed, and npm emits one
|
|
110
|
+
// for every ordinary exec ("the following package will be installed") whose
|
|
111
|
+
// wording would otherwise outrank the real error below it.
|
|
112
|
+
.filter(l => !/^npm (WARN|warn)\b/.test(l))
|
|
113
|
+
.map(l => l.replace(/^npm (ERR!|error)\s*/i, "").trimEnd())
|
|
114
|
+
.filter(l => l.trim() && !NOISE.some(re => re.test(l)));
|
|
115
|
+
if (!lines.length) return null;
|
|
116
|
+
const pick = lines.find(l => SIGNAL.test(l)) ?? lines[lines.length - 1];
|
|
117
|
+
return pick.trim().slice(0, 300);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The actionable half, for the one failure shape that is a broken environment
|
|
122
|
+
* rather than a broken network: npx exists, runs, and cannot find the npm it is
|
|
123
|
+
* supposed to load. `npm config get prefix` is the thing to check — a prefix
|
|
124
|
+
* pointing at a directory with no `node_modules/npm` under it leaves exactly
|
|
125
|
+
* this trace.
|
|
126
|
+
*/
|
|
127
|
+
export function npxFailureHint(output) {
|
|
128
|
+
const s = String(output ?? "");
|
|
129
|
+
if (/MODULE_NOT_FOUND/.test(s) && /(npx-cli\.js|npm-cli\.js|npm-prefix\.js)/.test(s)) {
|
|
130
|
+
return "the npx on PATH is a shim whose npm install is missing — check `npm config get prefix`";
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// it by name and only where it can actually work: a global install, on a
|
|
22
22
|
// directory we can write, outside a git checkout and outside an npx cache.
|
|
23
23
|
// Everywhere else this stays what it has always been — a printed command.
|
|
24
|
-
import { accessSync, constants as FS, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { accessSync, constants as FS, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { spawn } from "node:child_process";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -267,6 +267,12 @@ async function isPublished(name, version) {
|
|
|
267
267
|
* cannot produce a path the OS refuses. An unusable name falls back to the
|
|
268
268
|
* default rather than to the shared file this fix exists to get rid of. */
|
|
269
269
|
export function markerFileName(name = "agents-deck") {
|
|
270
|
+
return `.self-update-check-${safeNamePart(name)}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The sanitised half, shared with the restart-failure note below so the two
|
|
274
|
+
* files agree on what a package name becomes on disk. */
|
|
275
|
+
function safeNamePart(name) {
|
|
270
276
|
const raw = typeof name === "string" ? name.trim().toLowerCase() : "";
|
|
271
277
|
const safe = raw
|
|
272
278
|
.replace(/^@/, "")
|
|
@@ -276,7 +282,7 @@ export function markerFileName(name = "agents-deck") {
|
|
|
276
282
|
// trailing dot from a file name, so a name that ends in one would write to
|
|
277
283
|
// a path that is not the path we would later read.
|
|
278
284
|
.replace(/^[-.]+|[-.]+$/g, "");
|
|
279
|
-
return
|
|
285
|
+
return safe || "agents-deck";
|
|
280
286
|
}
|
|
281
287
|
|
|
282
288
|
function markerPath(name) {
|
|
@@ -514,6 +520,84 @@ export function upgradeBlock(pkgRoot) {
|
|
|
514
520
|
});
|
|
515
521
|
}
|
|
516
522
|
|
|
523
|
+
// ── the note a failed npx relaunch leaves behind ─────────────────────────────
|
|
524
|
+
//
|
|
525
|
+
// The npx upgrade is the one path whose failure the server cannot see. It runs
|
|
526
|
+
// in the SUPERVISOR, after this process has already exited: the worker asks to
|
|
527
|
+
// come back through `npx -y <spec>@latest`, npx fails, and the supervisor
|
|
528
|
+
// relaunches the copy on disk. The new worker boots knowing nothing, so
|
|
529
|
+
// /api/version kept answering `upgrade: {state:"idle"}` — the banner still
|
|
530
|
+
// offered "Update & restart", the tab said nothing at all, and a user who
|
|
531
|
+
// clicked the button without watching the terminal saw the deck blink and come
|
|
532
|
+
// back unchanged. Every click then repeated the whole cycle identically.
|
|
533
|
+
//
|
|
534
|
+
// A file is the only channel between the two processes: the supervisor writes
|
|
535
|
+
// one when the relaunch fails, and the worker it starts instead reads it here.
|
|
536
|
+
// Same directory and same per-package naming as the update markers, for the
|
|
537
|
+
// same reason — several decks share a home directory and must not answer for
|
|
538
|
+
// each other.
|
|
539
|
+
|
|
540
|
+
export function restartFailureFileName(name = "agents-deck") {
|
|
541
|
+
return `.restart-failed-${safeNamePart(name)}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function restartFailurePath(name) {
|
|
545
|
+
return join(MARKER_DIR, restartFailureFileName(name));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Called by the supervisor, in the moment between "npx failed" and "relaunch
|
|
549
|
+
* the old copy". Best-effort: a read-only home costs the report, not the deck. */
|
|
550
|
+
export function recordRestartFailure({ name = "agents-deck", command = null, error = null, version = null, at = Date.now() } = {}) {
|
|
551
|
+
const path = restartFailurePath(name);
|
|
552
|
+
try {
|
|
553
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
554
|
+
writeFileSync(path, JSON.stringify({
|
|
555
|
+
command,
|
|
556
|
+
error: error ? String(error).slice(0, 300) : null,
|
|
557
|
+
// The version that failed to leave — see restartFailureNotice.
|
|
558
|
+
version,
|
|
559
|
+
at,
|
|
560
|
+
}));
|
|
561
|
+
} catch { /* ignore */ }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Called before each attempt, so a retry is answered by its own outcome rather
|
|
565
|
+
* than by the last one's. */
|
|
566
|
+
export function clearRestartFailure(name = "agents-deck") {
|
|
567
|
+
try { rmSync(restartFailurePath(name), { force: true }); } catch { /* ignore */ }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function readRestartFailure(name = "agents-deck") {
|
|
571
|
+
try {
|
|
572
|
+
const m = JSON.parse(readFileSync(restartFailurePath(name), "utf8"));
|
|
573
|
+
return m && typeof m === "object" ? m : null;
|
|
574
|
+
} catch {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* The note as the version report should carry it, or null when it no longer
|
|
581
|
+
* describes this deck. Pure, because the staleness rule is the whole subtlety.
|
|
582
|
+
*
|
|
583
|
+
* The note names the version that was running when the upgrade failed. While
|
|
584
|
+
* that is still the version on disk, the failure is current: the deck really is
|
|
585
|
+
* stuck where it was. Once the files are a different version the upgrade
|
|
586
|
+
* happened some other way — a `npm i -g`, a fixed npm prefix, a manual npx —
|
|
587
|
+
* and a note about a deck that no longer exists must not keep claiming the
|
|
588
|
+
* update is broken.
|
|
589
|
+
*/
|
|
590
|
+
export function restartFailureNotice(record, installed = null) {
|
|
591
|
+
if (!record || typeof record.error !== "string" || !record.error) return null;
|
|
592
|
+
if (record.version && installed && record.version !== installed) return null;
|
|
593
|
+
return {
|
|
594
|
+
state: "failed",
|
|
595
|
+
command: typeof record.command === "string" ? record.command : null,
|
|
596
|
+
error: record.error,
|
|
597
|
+
at: typeof record.at === "number" ? record.at : 0,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
517
601
|
// One install at a time, per process. State is deliberately coarse: the UI only
|
|
518
602
|
// needs to know whether to show a spinner, a version, or an error.
|
|
519
603
|
let _upgrade = { state: "idle", command: null, error: null, at: 0 };
|
|
@@ -642,6 +726,14 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
|
|
|
642
726
|
const latest = skipRegistry ? null : await latestOnNpm(target, now, force);
|
|
643
727
|
const marker = skipRegistry ? null : readMarker(target);
|
|
644
728
|
const blocked = upgradeBlock(pkgRoot);
|
|
729
|
+
// An install started in THIS process outranks the note on disk: it is newer
|
|
730
|
+
// by construction, and a running one must not be reported as a past failure.
|
|
731
|
+
// The note is read whatever the registry is doing — it is a local event, not
|
|
732
|
+
// a lookup — so an offline deck still explains why its update did nothing.
|
|
733
|
+
const live = upgradeStatus();
|
|
734
|
+
const upgrade = live.state === "idle"
|
|
735
|
+
? (restartFailureNotice(readRestartFailure(target), installed) ?? live)
|
|
736
|
+
: live;
|
|
645
737
|
return {
|
|
646
738
|
name: target,
|
|
647
739
|
running: running ?? null,
|
|
@@ -669,6 +761,6 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
|
|
|
669
761
|
// of the install, not of the update: upgradeMode says so.
|
|
670
762
|
upgradeBlocked: blocked,
|
|
671
763
|
upgradeMode: upgradeMode(blocked),
|
|
672
|
-
upgrade
|
|
764
|
+
upgrade,
|
|
673
765
|
};
|
|
674
766
|
}
|