ccdeck 3.22.8 → 3.22.10
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 +26 -26
- package/bin/agent-dag.js +8 -2
- package/bin/deck.js +90 -62
- package/dist/web/assets/index-CEo8A2T-.js +274 -0
- package/dist/web/assets/index-DIsEfUjc.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -3
- package/release-notes.json +10 -0
- package/src/server/boot-lock.mjs +137 -0
- package/src/server/installer.mjs +2 -2
- package/src/server/invoked-as.mjs +4 -2
- package/src/server/lan-sync.mjs +21 -1
- package/src/server/login-service.mjs +24 -0
- package/src/server/running-deck.mjs +110 -101
- package/src/server/self-update.mjs +70 -36
- package/dist/web/assets/index-BJnVANQi.css +0 -1
- package/dist/web/assets/index-BjQA8le6.js +0 -274
|
@@ -314,6 +314,30 @@ export function lingerState({ platform = process.platform, run = spawnSync, user
|
|
|
314
314
|
} catch { return "unknown"; }
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/**
|
|
318
|
+
* The variables that decide WHICH deck a start is, for the login item's job.
|
|
319
|
+
*
|
|
320
|
+
* A login item runs in the environment the service manager builds, not the
|
|
321
|
+
* shell's. Somebody who exports CLAUDE_CONFIG_DIR, CCDECK_HOME or CODEX_HOME got
|
|
322
|
+
* a deck keyed to those directories from the terminal and one keyed to the
|
|
323
|
+
* defaults at login: two registries that cannot see each other, so two decks
|
|
324
|
+
* and two LAN keys on one machine — the one-deck rule in running-deck.mjs holds
|
|
325
|
+
* per registry and cannot reach across. Carried into the job so both starts are
|
|
326
|
+
* the same deck. Only the ones that are set: an unset variable written into the
|
|
327
|
+
* job as "" would be a different setting, not the same absent one.
|
|
328
|
+
*
|
|
329
|
+
* Windows is not given these. A Task Scheduler job has no environment block in
|
|
330
|
+
* its XML; it inherits the user's persistent variables, which is where a
|
|
331
|
+
* Windows user sets them (`setx`) for them to outlive the shell anyway.
|
|
332
|
+
*/
|
|
333
|
+
export const SCOPE_VARS = Object.freeze(["CLAUDE_CONFIG_DIR", "CCDECK_HOME", "CODEX_HOME"]);
|
|
334
|
+
|
|
335
|
+
export function scopeEnv(env = process.env) {
|
|
336
|
+
return Object.fromEntries(
|
|
337
|
+
SCOPE_VARS.filter(k => typeof env?.[k] === "string" && env[k].trim() !== "").map(k => [k, env[k]]),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
317
341
|
/**
|
|
318
342
|
* Write the login item and register it.
|
|
319
343
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Why a second `ccdeck` must
|
|
1
|
+
// Why a second `ccdeck` must never leave a second deck behind it.
|
|
2
2
|
//
|
|
3
3
|
// THE DEFECT. `startServer` is handed `portRange: [4318, 4400]` and answers a
|
|
4
4
|
// refused bind on 4317 by taking a random port out of it. That fallback is
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
//
|
|
16
16
|
// THE QUESTION IS NOT "IS THE PORT FREE". It is "is one of MY decks already
|
|
17
17
|
// running", and the registry answers that without touching a port: every deck
|
|
18
|
-
// writes
|
|
18
|
+
// writes <config>/agent-dag/<pid>.json with its pid, port, token and shape.
|
|
19
19
|
//
|
|
20
20
|
// A PID IS NOT EVIDENCE, and that is #695's whole lesson. A record left behind
|
|
21
21
|
// by a deck that is gone — SIGKILL, an OOM kill, a power cut, a console window
|
|
@@ -28,12 +28,26 @@
|
|
|
28
28
|
// seen. A collector, a container, a stranger on a recycled port cannot answer.
|
|
29
29
|
// The deck that wrote the file can.
|
|
30
30
|
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
31
|
+
// ONE DECK, WHATEVER THE SHAPE. The first answer to "a deck is up" attached
|
|
32
|
+
// only to a deck that was exactly the one this start would have built, and
|
|
33
|
+
// built a second one beside anything else. So a flag, an older version, or an
|
|
34
|
+
// environment that decided `codex` or the log path differently — a login item
|
|
35
|
+
// runs under the service manager's environment, not the shell's — each left
|
|
36
|
+
// two decks running on one machine, with two LAN keys and two rows on every
|
|
37
|
+
// colleague's panel. It was reported from the field exactly that way. Now a
|
|
38
|
+
// start keeps at most one:
|
|
39
|
+
//
|
|
40
|
+
// the deck found serves what this start asked for, and is not older → attach
|
|
41
|
+
// anything else → stop it, start
|
|
42
|
+
//
|
|
43
|
+
// The newest start wins, because it is the one somebody just asked for. The
|
|
44
|
+
// rule is secondStart, a function of what is running and what was asked, so it
|
|
45
|
+
// can be pinned without starting a process.
|
|
46
|
+
//
|
|
47
|
+
// PER CLAUDE CONFIG DIRECTORY, which is where the registry lives. For nearly
|
|
48
|
+
// everybody that is one per user; somebody who points CLAUDE_CONFIG_DIR at a
|
|
49
|
+
// second directory has a second registry, and has been getting a second deck
|
|
50
|
+
// with its own identity on purpose since deck-home.mjs was written.
|
|
37
51
|
//
|
|
38
52
|
// WHAT THIS FILE DOES NOT IMPORT: src/server/index.mjs. That module is the whole
|
|
39
53
|
// server, and it arms its timers the moment it is loaded — but this one is read
|
|
@@ -63,67 +77,86 @@ export function deckRegistryDir(env = process.env, home = undefined) {
|
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/**
|
|
66
|
-
*
|
|
67
|
-
* deck".
|
|
68
|
-
*
|
|
69
|
-
* Every one of them changes what the deck IS — which sessions it captures,
|
|
70
|
-
* which log it appends to, which of the two CLIs it serves, which port it
|
|
71
|
-
* binds. A command line carrying any of them is a request that an existing deck
|
|
72
|
-
* cannot satisfy, so it is never answered by attaching to one.
|
|
80
|
+
* Would this record's deck serve what we were about to build?
|
|
73
81
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
82
|
+
* Compared field for field rather than by a version or a heuristic. `claude`
|
|
83
|
+
* and `codex` are strict identity against a boolean, so a record written before
|
|
84
|
+
* either field existed carries `undefined` and fails — and a deck that old is
|
|
85
|
+
* replaced rather than attached to, which is what a start does with any deck
|
|
86
|
+
* it cannot vouch for.
|
|
77
87
|
*/
|
|
78
|
-
export
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
export function sameShape(record, want = {}) {
|
|
89
|
+
if (!record) return false;
|
|
90
|
+
return (record.workspace ?? "") === (want.workspace ?? "")
|
|
91
|
+
&& (record.persist ?? null) === (want.persist ?? null)
|
|
92
|
+
&& record.codex === (want.codex !== false)
|
|
93
|
+
&& record.claude === (want.claude !== false);
|
|
94
|
+
}
|
|
82
95
|
|
|
83
96
|
/**
|
|
84
|
-
* Is
|
|
97
|
+
* Is `running` a version older than `ours`?
|
|
85
98
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
* Numeric, field by field, because "3.9.0" sorts after "3.22.0" as a string. A
|
|
100
|
+
* running deck that publishes no version at all predates the field, which makes
|
|
101
|
+
* it older than anything that does. Our own version unreadable is the one case
|
|
102
|
+
* with nothing to compare, and it answers no: a start that cannot say what it
|
|
103
|
+
* is has no grounds to replace anything.
|
|
104
|
+
*/
|
|
105
|
+
export function olderVersion(running, ours) {
|
|
106
|
+
const parse = (v) => {
|
|
107
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v ?? ""));
|
|
108
|
+
return m ? m.slice(1).map(Number) : null;
|
|
109
|
+
};
|
|
110
|
+
const o = parse(ours);
|
|
111
|
+
if (!o) return false;
|
|
112
|
+
const r = parse(running);
|
|
113
|
+
if (!r) return true;
|
|
114
|
+
for (let i = 0; i < 3; i++) if (r[i] !== o[i]) return r[i] < o[i];
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Does this running deck already serve what this start asked for?
|
|
96
120
|
*
|
|
97
|
-
* The
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* on a random port behind it.
|
|
121
|
+
* The shape, the port when one was named, and a version no older than ours.
|
|
122
|
+
* `port` is the one `--port` asked for, or null when none was; 0 is "any free
|
|
123
|
+
* port", which every running deck satisfies.
|
|
101
124
|
*/
|
|
102
|
-
export function
|
|
103
|
-
|
|
104
|
-
|
|
125
|
+
export function serves(record, { want = {}, port = null, ours = "" } = {}) {
|
|
126
|
+
return sameShape(record, want)
|
|
127
|
+
&& (port == null || port === 0 || record.port === port)
|
|
128
|
+
&& !olderVersion(record.version, ours);
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
/**
|
|
108
|
-
*
|
|
132
|
+
* What a start does about the decks already running.
|
|
109
133
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
134
|
+
* { act: "start", stop: [] } nothing is running
|
|
135
|
+
* { act: "attach", deck, stop } open `deck`; stop the rest — leftovers
|
|
136
|
+
* from before this rule
|
|
137
|
+
* { act: "replace", stop } stop every one of them, then start
|
|
138
|
+
* { act: "yield", deck, stop: [] } a respawn found somebody else's deck
|
|
139
|
+
*
|
|
140
|
+
* `fresh` is `--new`: replace even a deck that would have served. `respawn` is
|
|
141
|
+
* the supervisor bringing a crashed or restarted worker back — that is THIS
|
|
142
|
+
* deck returning, so anything already running got there in the gap, was asked
|
|
143
|
+
* for more recently, and keeps its place.
|
|
144
|
+
*
|
|
145
|
+
* The deck kept on an attach is the first that serves, in the registry's port
|
|
146
|
+
* order, so the answer is the same every time it is asked.
|
|
115
147
|
*/
|
|
116
|
-
export function
|
|
117
|
-
if (!
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
148
|
+
export function secondStart({ live = [], want = {}, port = null, ours = "", fresh = false, respawn = false } = {}) {
|
|
149
|
+
if (!live.length) return { act: "start", stop: [] };
|
|
150
|
+
if (respawn) return { act: "yield", deck: live[0], stop: [] };
|
|
151
|
+
const keep = fresh ? null : live.find(d => serves(d, { want, port, ours })) ?? null;
|
|
152
|
+
if (keep) return { act: "attach", deck: keep, stop: live.filter(d => d !== keep) };
|
|
153
|
+
return { act: "replace", stop: [...live] };
|
|
122
154
|
}
|
|
123
155
|
|
|
124
156
|
/** A record complete enough to be worth a challenge. A missing token is a deck
|
|
125
|
-
* older than the handshake: it cannot prove anything, so it
|
|
126
|
-
* to, and it keeps the behaviour it has always
|
|
157
|
+
* older than the handshake: it cannot prove anything, so it can be neither
|
|
158
|
+
* attached to nor stopped by token, and it keeps the behaviour it has always
|
|
159
|
+
* had. */
|
|
127
160
|
function usable(d) {
|
|
128
161
|
return Boolean(d)
|
|
129
162
|
&& Number.isInteger(d.pid)
|
|
@@ -134,13 +167,13 @@ function usable(d) {
|
|
|
134
167
|
/**
|
|
135
168
|
* Every registered deck whose pid is still there, ordered.
|
|
136
169
|
*
|
|
137
|
-
* NOT PROVED — this is the cheap half, a directory listing and a signal-0 each
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* electWriters uses for the log and is here for the same reason: several
|
|
142
|
-
* can qualify, and the answer has to be the same one every time it is
|
|
143
|
-
* rather than whatever `readdir` happened to return first.
|
|
170
|
+
* NOT PROVED — this is the cheap half, a directory listing and a signal-0 each.
|
|
171
|
+
* `detach.mjs` wants only the count, to decide whether deck.log belongs to a
|
|
172
|
+
* deck that is still running or to nobody; a round trip per record to answer
|
|
173
|
+
* that would be absurd. Ordered by port with pid breaking the tie, which is the
|
|
174
|
+
* rule electWriters uses for the log and is here for the same reason: several
|
|
175
|
+
* decks can qualify, and the answer has to be the same one every time it is
|
|
176
|
+
* asked rather than whatever `readdir` happened to return first.
|
|
144
177
|
*
|
|
145
178
|
* A failure to read the directory is "no decks", not an error. This runs on the
|
|
146
179
|
* boot path of a program whose job is to start, and there is no reading of that
|
|
@@ -167,39 +200,16 @@ export async function registeredDecks({
|
|
|
167
200
|
return out.sort((a, b) => a.port - b.port || a.pid - b.pid);
|
|
168
201
|
}
|
|
169
202
|
|
|
170
|
-
/**
|
|
171
|
-
* The deck already serving what this process was about to serve, or null.
|
|
172
|
-
*
|
|
173
|
-
* Only records that already match the shape are challenged, and the walk stops
|
|
174
|
-
* at the first that proves itself. On the ordinary machine that is one loopback
|
|
175
|
-
* round trip; on a machine with no deck running it is a directory listing and
|
|
176
|
-
* nothing else. That is why this is not `liveDecks().find(…)`: the boot path
|
|
177
|
-
* must not pay a round trip per deck to answer a question the first one settles.
|
|
178
|
-
*/
|
|
179
|
-
export async function runningDeck({
|
|
180
|
-
want = {},
|
|
181
|
-
dir = deckRegistryDir(),
|
|
182
|
-
fs = { readdir, readFile },
|
|
183
|
-
self = process.pid,
|
|
184
|
-
alive = isProcessAlive,
|
|
185
|
-
prove = challengeDeck,
|
|
186
|
-
} = {}) {
|
|
187
|
-
for (const d of await registeredDecks({ dir, fs, self, alive })) {
|
|
188
|
-
if (!sameShape(d, want)) continue;
|
|
189
|
-
if (await prove(d.port, d.token)) return d;
|
|
190
|
-
}
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
203
|
/**
|
|
195
204
|
* Every deck on this machine that answered a challenge, in port order.
|
|
196
205
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
206
|
+
* What a start decides by, the list `--status` prints and the list `--stop`
|
|
207
|
+
* ends. Everything is challenged: a start that is about to stop a deck must
|
|
208
|
+
* know it is one, and a list that quietly omitted a deck it could not be
|
|
209
|
+
* bothered to ask about would be worse than no list, because the whole reason
|
|
210
|
+
* to run `--status` is to find the process you did not know was there. The
|
|
211
|
+
* round trips go out together — one deadline for the lot, not one after
|
|
212
|
+
* another — since they are independent and each is bounded at 400ms.
|
|
203
213
|
*/
|
|
204
214
|
export async function liveDecks({
|
|
205
215
|
dir = deckRegistryDir(),
|
|
@@ -216,19 +226,18 @@ export async function liveDecks({
|
|
|
216
226
|
/**
|
|
217
227
|
* What to say about the version of the deck we attached to.
|
|
218
228
|
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* they are attached and TOLD, with the one sentence that gets them the version
|
|
224
|
-
* they asked for.
|
|
229
|
+
* An attach only ever keeps a deck at least as new as the one launched — an
|
|
230
|
+
* older one is replaced — so a difference here is somebody running an older
|
|
231
|
+
* copy, an npx cache or a second install, beside a newer deck. That deck is
|
|
232
|
+
* kept and they are told, because the newer one is what they would have wanted.
|
|
225
233
|
*
|
|
226
|
-
* Empty when the versions agree, and empty when
|
|
227
|
-
* report one: "unknown" beside a number is noise
|
|
228
|
-
* to do about it either way.
|
|
234
|
+
* Empty when the versions agree, and empty when either side is too old to
|
|
235
|
+
* report one: "unknown" beside a number is noise.
|
|
229
236
|
*/
|
|
230
237
|
export function versionNote(running, ours) {
|
|
231
238
|
if (typeof running !== "string" || running === "" || typeof ours !== "string" || ours === "") return "";
|
|
232
239
|
if (running === ours) return "";
|
|
233
|
-
return
|
|
240
|
+
return olderVersion(running, ours)
|
|
241
|
+
? `running v${running}, older than the v${ours} you launched`
|
|
242
|
+
: `running v${running}, newer than the v${ours} you launched — kept it`;
|
|
234
243
|
}
|
|
@@ -274,7 +274,7 @@ export function bareSpecName(spec) {
|
|
|
274
274
|
* the answer is null instead, which is what invoked-as.mjs needs: it asks which
|
|
275
275
|
* name the user typed, and there the package name is not a lesser answer, it is
|
|
276
276
|
* a wrong one. */
|
|
277
|
-
export function npxSpecFromMeta(meta, fallback =
|
|
277
|
+
export function npxSpecFromMeta(meta, fallback = PUBLISHED_NAME) {
|
|
278
278
|
const list = meta && meta._npx && Array.isArray(meta._npx.packages) ? meta._npx.packages : [];
|
|
279
279
|
for (const entry of list) {
|
|
280
280
|
const name = bareSpecName(entry);
|
|
@@ -286,7 +286,7 @@ export function npxSpecFromMeta(meta, fallback = "agents-deck") {
|
|
|
286
286
|
/** The same, answered against the filesystem. Null when this is not an npx run,
|
|
287
287
|
* and — for a caller that passed no fallback — when the metadata cannot be
|
|
288
288
|
* read. */
|
|
289
|
-
export function npxRestartSpec(pkgRoot, name =
|
|
289
|
+
export function npxRestartSpec(pkgRoot, name = PUBLISHED_NAME) {
|
|
290
290
|
const root = npxRoot(pkgRoot);
|
|
291
291
|
if (!root) return null;
|
|
292
292
|
let meta = null;
|
|
@@ -331,18 +331,32 @@ export function npxRestartSpec(pkgRoot, name = "agents-deck") {
|
|
|
331
331
|
// other. What both of them do carry is their own package.json, which npm's
|
|
332
332
|
// rename made authoritative: see installedName.
|
|
333
333
|
|
|
334
|
-
/** Every name this deck
|
|
334
|
+
/** Every name an install of this deck can have been REACHED under.
|
|
335
335
|
*
|
|
336
|
-
* `agents-deck` and `agent-dag`
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
336
|
+
* Only `ccdeck` is published now. `agents-deck` and `agent-dag` were this
|
|
337
|
+
* tarball published twice more, then (from 3.22.3) small packages that
|
|
338
|
+
* depended on ccdeck, and both stay on the registry at their last version — so
|
|
339
|
+
* a deck can still be running inside one of them, or out of an npx run of one
|
|
340
|
+
* of them, and this list is what lets it recognise that. It is not the list of
|
|
341
|
+
* names to ask npm about: see PUBLISHED_NAME. The same three strings as
|
|
340
342
|
* invoked-as.mjs's COMMANDS, and deliberately not that list: this is the set
|
|
341
|
-
* of npm PACKAGES
|
|
342
|
-
*
|
|
343
|
-
*
|
|
343
|
+
* of npm PACKAGES an install may belong to, that is the set of commands a user
|
|
344
|
+
* may have typed. A test pins them against each other rather than either side
|
|
345
|
+
* assuming it. */
|
|
344
346
|
export const ALIAS_PACKAGES = ["agents-deck", "agent-dag", "ccdeck"];
|
|
345
347
|
|
|
348
|
+
/** The one name the deck is published under, and so the only package whose
|
|
349
|
+
* dist-tag still moves. */
|
|
350
|
+
export const PUBLISHED_NAME = "ccdeck";
|
|
351
|
+
|
|
352
|
+
/** The names it is no longer published under. */
|
|
353
|
+
export const RETIRED_NAMES = ["agents-deck", "agent-dag"];
|
|
354
|
+
|
|
355
|
+
/** The published name in place of a retired one; any other name unchanged. */
|
|
356
|
+
export function currentName(name) {
|
|
357
|
+
return RETIRED_NAMES.includes(name) ? PUBLISHED_NAME : name;
|
|
358
|
+
}
|
|
359
|
+
|
|
346
360
|
/** Which of those three THIS build was published as, out of its own manifest.
|
|
347
361
|
*
|
|
348
362
|
* The other half of the same bug, and the easier half. `agent-dag` is not a
|
|
@@ -373,7 +387,7 @@ export const ALIAS_PACKAGES = ["agents-deck", "agent-dag", "ccdeck"];
|
|
|
373
387
|
* `fallback` is what to answer when the manifest is missing, unreadable, or
|
|
374
388
|
* names something that is not one of ours — the caller's own `name`, so every
|
|
375
389
|
* shape that cannot prove which alias it is keeps the behaviour it had. */
|
|
376
|
-
export function installedName(pkgRoot, fallback =
|
|
390
|
+
export function installedName(pkgRoot, fallback = PUBLISHED_NAME) {
|
|
377
391
|
const self = readManifest(pkgRoot)?.name;
|
|
378
392
|
return typeof self === "string" && ALIAS_PACKAGES.includes(self) ? self : fallback;
|
|
379
393
|
}
|
|
@@ -405,7 +419,7 @@ export function hostRoot(pkgRoot) {
|
|
|
405
419
|
* `npm i -g their-app@latest`: a package it has no business installing, and on
|
|
406
420
|
* a private name one that does not exist. Confining the answer to the three
|
|
407
421
|
* published names leaves every such install with the fallback it has today. */
|
|
408
|
-
export function hostNameFromMeta(meta, name =
|
|
422
|
+
export function hostNameFromMeta(meta, name = PUBLISHED_NAME) {
|
|
409
423
|
const host = typeof meta?.name === "string" ? meta.name : null;
|
|
410
424
|
if (!host || !ALIAS_PACKAGES.includes(host)) return null;
|
|
411
425
|
return typeof meta?.dependencies?.[name] === "string" ? host : null;
|
|
@@ -413,7 +427,7 @@ export function hostNameFromMeta(meta, name = "agents-deck") {
|
|
|
413
427
|
|
|
414
428
|
/** The same, answered against the filesystem: `{ root, name }` for the alias
|
|
415
429
|
* package this copy was installed as a dependency of, or null. */
|
|
416
|
-
export function hostPackage(pkgRoot, name =
|
|
430
|
+
export function hostPackage(pkgRoot, name = PUBLISHED_NAME) {
|
|
417
431
|
const root = hostRoot(pkgRoot);
|
|
418
432
|
if (!root) return null;
|
|
419
433
|
const host = hostNameFromMeta(readManifest(root), name);
|
|
@@ -457,8 +471,8 @@ export function successorRoot(pkgRoot) {
|
|
|
457
471
|
return typeof name === "string" && ALIAS_PACKAGES.includes(name) ? root : null;
|
|
458
472
|
}
|
|
459
473
|
|
|
460
|
-
/** The package an upgrade would actually install here —
|
|
461
|
-
* asking npm about.
|
|
474
|
+
/** The package an upgrade would actually install here — and, but for a retired
|
|
475
|
+
* name, the one worth asking npm about (see registryName).
|
|
462
476
|
*
|
|
463
477
|
* The check used to ask about `agents-deck` no matter what the upgrade
|
|
464
478
|
* command installed, so a deck started with `npx ccdeck` compared its version
|
|
@@ -475,7 +489,7 @@ export function successorRoot(pkgRoot) {
|
|
|
475
489
|
* shape carries that answer somewhere different — npx in the spec it recorded,
|
|
476
490
|
* a stub install in the layout npm built, a plain global install in the
|
|
477
491
|
* manifest of the package it is. All three are read; none is assumed. */
|
|
478
|
-
export function upgradeName(pkgRoot, name =
|
|
492
|
+
export function upgradeName(pkgRoot, name = PUBLISHED_NAME) {
|
|
479
493
|
// The published name this build actually carries, which outranks `name` in
|
|
480
494
|
// every branch below because `name` is a default at every call site in the
|
|
481
495
|
// deck and the manifest on disk is not a guess. It replaces the parameter
|
|
@@ -483,17 +497,20 @@ export function upgradeName(pkgRoot, name = "agents-deck") {
|
|
|
483
497
|
// registry-shaped answers meet — the dist-tag that is fetched, the marker it
|
|
484
498
|
// is cached in, the command the user is shown, and the argv npm is spawned
|
|
485
499
|
// with — so resolving the name once here is what keeps those four naming one
|
|
486
|
-
// package
|
|
500
|
+
// package. registryName is the one exception, for a retired name, and it
|
|
501
|
+
// starts from this answer.
|
|
487
502
|
const self = installedName(pkgRoot, name);
|
|
488
503
|
// A checkout installs nothing at all, so the published name is the only
|
|
489
504
|
// sensible subject for the version question — and the only one whose
|
|
490
505
|
// dist-tag says anything about the branch the maintainer is sitting on.
|
|
491
506
|
if (isGitCheckout(pkgRoot)) return self;
|
|
492
507
|
// npx recorded the spec the user typed, which is a better answer than the
|
|
493
|
-
// manifest
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
508
|
+
// manifest — except that a retired name answers `ccdeck`. npx reinstalls only
|
|
509
|
+
// when a spec resolves to a different version, and `agents-deck@latest`
|
|
510
|
+
// resolves to the same last-published package forever, so re-running it
|
|
511
|
+
// would reuse the cached copy, and the ccdeck inside it, and never move. The
|
|
512
|
+
// manifest is what is left when the record cannot be read.
|
|
513
|
+
if (isNpxInstall(pkgRoot)) return currentName(bareSpecName(npxRestartSpec(pkgRoot, self)) ?? self);
|
|
497
514
|
// Left: a global install, where the answer is whichever package owns the
|
|
498
515
|
// directory npm would rewrite. That is the stub for `npm i -g ccdeck`, where
|
|
499
516
|
// the deck is nested one level down inside a package it is not named after,
|
|
@@ -517,7 +534,7 @@ export function upgradeName(pkgRoot, name = "agents-deck") {
|
|
|
517
534
|
}
|
|
518
535
|
|
|
519
536
|
/** The exact line the user can paste, for the way THIS copy was installed. */
|
|
520
|
-
export function upgradeCommand(pkgRoot, name =
|
|
537
|
+
export function upgradeCommand(pkgRoot, name = PUBLISHED_NAME) {
|
|
521
538
|
// A checkout is updated by pulling, and the bundle is built, not shipped —
|
|
522
539
|
// so `npm run build` is part of the answer rather than an afterthought.
|
|
523
540
|
if (isGitCheckout(pkgRoot)) return "git pull && npm run build";
|
|
@@ -529,6 +546,21 @@ export function upgradeCommand(pkgRoot, name = "agents-deck") {
|
|
|
529
546
|
return `npm i -g ${upgradeName(pkgRoot, name)}@latest`;
|
|
530
547
|
}
|
|
531
548
|
|
|
549
|
+
/** The package to ask npm about: the one an upgrade installs, except that a
|
|
550
|
+
* retired name is asked about as `ccdeck`.
|
|
551
|
+
*
|
|
552
|
+
* One install reaches that exception. A deck sitting inside the old
|
|
553
|
+
* `agents-deck` or `agent-dag` package upgrades by reinstalling THAT package —
|
|
554
|
+
* installing `ccdeck` would write a tree this process never reads, #358 again —
|
|
555
|
+
* but that package's dist-tag stopped moving when it stopped being published.
|
|
556
|
+
* The reinstall is still the right act: the old package depends on
|
|
557
|
+
* `ccdeck@^3`, and npm resolves that to the newest ccdeck on every reinstall,
|
|
558
|
+
* even of the same version. So the version asked about is ccdeck's and the
|
|
559
|
+
* command names the old package — the one place the two differ, on purpose. */
|
|
560
|
+
export function registryName(pkgRoot, name = PUBLISHED_NAME) {
|
|
561
|
+
return currentName(upgradeName(pkgRoot, name));
|
|
562
|
+
}
|
|
563
|
+
|
|
532
564
|
// ── what npm has ─────────────────────────────────────────────────────────────
|
|
533
565
|
|
|
534
566
|
// The dist-tags endpoint answers with ~20 bytes ({"latest":"1.30.7"}); the full
|
|
@@ -597,7 +629,7 @@ async function isPublished(name, version) {
|
|
|
597
629
|
* common case reads plainly, and the tail is trimmed so a pathological name
|
|
598
630
|
* cannot produce a path the OS refuses. An unusable name falls back to the
|
|
599
631
|
* default rather than to the shared file this fix exists to get rid of. */
|
|
600
|
-
export function markerFileName(name =
|
|
632
|
+
export function markerFileName(name = PUBLISHED_NAME) {
|
|
601
633
|
return `.self-update-check-${safeNamePart(name)}`;
|
|
602
634
|
}
|
|
603
635
|
|
|
@@ -613,7 +645,7 @@ function safeNamePart(name) {
|
|
|
613
645
|
// trailing dot from a file name, so a name that ends in one would write to
|
|
614
646
|
// a path that is not the path we would later read.
|
|
615
647
|
.replace(/^[-.]+|[-.]+$/g, "");
|
|
616
|
-
return safe ||
|
|
648
|
+
return safe || PUBLISHED_NAME;
|
|
617
649
|
}
|
|
618
650
|
|
|
619
651
|
function markerPath(name) {
|
|
@@ -664,7 +696,7 @@ function readMarker(name) {
|
|
|
664
696
|
* between a click and a fetch, and the reason the fetch is about to fail may
|
|
665
697
|
* well be that there is no network — and it has to be the same number the
|
|
666
698
|
* banner offered, which is precisely what the marker holds. */
|
|
667
|
-
export function lastKnownLatest(name =
|
|
699
|
+
export function lastKnownLatest(name = PUBLISHED_NAME) {
|
|
668
700
|
const v = readMarker(name)?.version;
|
|
669
701
|
return typeof v === "string" && v ? v : null;
|
|
670
702
|
}
|
|
@@ -1026,7 +1058,7 @@ export function claimRestartFailureKey(env = process.env, pid = process.pid) {
|
|
|
1026
1058
|
|
|
1027
1059
|
/** `ccdeck` under supervisor 4821 → `.restart-failed-ccdeck-4821`, or null when
|
|
1028
1060
|
* there is no supervisor, which is not a deck any note can be about. */
|
|
1029
|
-
export function restartFailureFileName(name =
|
|
1061
|
+
export function restartFailureFileName(name = PUBLISHED_NAME, key = restartFailureKey()) {
|
|
1030
1062
|
const owner = safeOwner(key);
|
|
1031
1063
|
return owner ? `${NOTE_PREFIX}${safeNamePart(name)}-${owner}` : null;
|
|
1032
1064
|
}
|
|
@@ -1041,7 +1073,7 @@ function restartFailurePath(name, key) {
|
|
|
1041
1073
|
* already failed here and is not being tried again. Best-effort: a read-only
|
|
1042
1074
|
* home costs the report, not the deck. */
|
|
1043
1075
|
export function recordRestartFailure({
|
|
1044
|
-
name =
|
|
1076
|
+
name = PUBLISHED_NAME, command = null, error = null, version = null,
|
|
1045
1077
|
target = null, attempts = 1, at = Date.now(), failedAt = at, key = restartFailureKey(),
|
|
1046
1078
|
} = {}) {
|
|
1047
1079
|
const file = restartFailureFileName(name, key);
|
|
@@ -1072,13 +1104,13 @@ export function recordRestartFailure({
|
|
|
1072
1104
|
|
|
1073
1105
|
/** Called before each attempt, so a retry is answered by its own outcome rather
|
|
1074
1106
|
* than by the last one's. */
|
|
1075
|
-
export function clearRestartFailure(name =
|
|
1107
|
+
export function clearRestartFailure(name = PUBLISHED_NAME, key = restartFailureKey()) {
|
|
1076
1108
|
const path = restartFailurePath(name, key);
|
|
1077
1109
|
if (!path) return;
|
|
1078
1110
|
try { rmSync(path, { force: true }); } catch { /* ignore */ }
|
|
1079
1111
|
}
|
|
1080
1112
|
|
|
1081
|
-
export function readRestartFailure(name =
|
|
1113
|
+
export function readRestartFailure(name = PUBLISHED_NAME, key = restartFailureKey()) {
|
|
1082
1114
|
const path = restartFailurePath(name, key);
|
|
1083
1115
|
if (!path) return null;
|
|
1084
1116
|
try {
|
|
@@ -1162,7 +1194,7 @@ export function upgradeStatus() {
|
|
|
1162
1194
|
* assembled from request input: the only thing that varies is which alias, and
|
|
1163
1195
|
* that is read off the install on disk and confined to ALIAS_PACKAGES.
|
|
1164
1196
|
*/
|
|
1165
|
-
export function startUpgrade({ pkgRoot, name =
|
|
1197
|
+
export function startUpgrade({ pkgRoot, name = PUBLISHED_NAME }) {
|
|
1166
1198
|
if (_upgrade.state === "running") return { ok: true, already: true, command: _upgrade.command };
|
|
1167
1199
|
const blocked = upgradeBlock(pkgRoot);
|
|
1168
1200
|
if (blocked) return { ok: false, reason: blocked, command: upgradeCommand(pkgRoot, name) };
|
|
@@ -1314,13 +1346,15 @@ export function lastMeaningfulLine(text) {
|
|
|
1314
1346
|
* yet — the installability probe (see runCheck), each carrying its own
|
|
1315
1347
|
* AbortSignal timeout. That second request costs its timeout at most once per
|
|
1316
1348
|
* release; a deck sitting on the current release stays inside one. */
|
|
1317
|
-
export async function versionReport({ running, pkgRoot, name =
|
|
1349
|
+
export async function versionReport({ running, pkgRoot, name = PUBLISHED_NAME, now = Date.now(), force = false }) {
|
|
1318
1350
|
const installed = installedVersion(pkgRoot);
|
|
1319
1351
|
// Asked about the package the command installs, not about the one this build
|
|
1320
|
-
// happens to be named after — see upgradeName
|
|
1321
|
-
//
|
|
1322
|
-
// the
|
|
1352
|
+
// happens to be named after — see upgradeName — except that a retired name
|
|
1353
|
+
// is asked about as ccdeck, the only dist-tag that still moves (registryName).
|
|
1354
|
+
// `target` is what the command installs and what the failure note is filed
|
|
1355
|
+
// under; `asked` is whose version this reports and the marker it is cached in.
|
|
1323
1356
|
const target = upgradeName(pkgRoot, name);
|
|
1357
|
+
const asked = registryName(pkgRoot, name);
|
|
1324
1358
|
// Only an explicit opt-out silences the registry.
|
|
1325
1359
|
//
|
|
1326
1360
|
// A checkout used to be excluded here too, on the reasoning that its version
|
|
@@ -1334,8 +1368,8 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
|
|
|
1334
1368
|
const skipRegistry =
|
|
1335
1369
|
process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
|
|
1336
1370
|
process.env.AGENTS_DECK_NO_INSTALL === "1";
|
|
1337
|
-
const latest = skipRegistry ? null : await latestOnNpm(
|
|
1338
|
-
const marker = skipRegistry ? null : readMarker(
|
|
1371
|
+
const latest = skipRegistry ? null : await latestOnNpm(asked, now, force);
|
|
1372
|
+
const marker = skipRegistry ? null : readMarker(asked);
|
|
1339
1373
|
const blocked = upgradeBlock(pkgRoot);
|
|
1340
1374
|
// An install started in THIS process outranks the note on disk: it is newer
|
|
1341
1375
|
// by construction, and a running one must not be reported as a past failure.
|