agent-dag 3.19.0 → 3.21.0

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/deck.js CHANGED
@@ -12,11 +12,16 @@ import { isPortValue, parseArgs } from "../src/server/args.mjs";
12
12
  import {
13
13
  CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, oneLine,
14
14
  palette, pulseDot, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns,
15
- elapsedSuffix, unicodeOK, unregisteredDetail, visibleWidth, wordmark,
15
+ elapsedSuffix, sinceLabel, unicodeOK, unregisteredDetail, visibleWidth, wordmark,
16
16
  } from "../src/server/term.mjs";
17
17
  import { PRODUCT } from "../src/server/brand.mjs";
18
18
  import { invokedName, renameNotice } from "../src/server/invoked-as.mjs";
19
19
  import { budget, bootDeadlineMs } from "../src/server/boot-deadline.mjs";
20
+ // A leaf — fs, path and claude-dir.mjs, nothing else — so it is imported here
21
+ // with the rest rather than fetched later. Deliberately NOT the other way
22
+ // round: it takes the handshake as a parameter precisely so that it never has
23
+ // to import the server. See the note at the top of that file.
24
+ import { asksForOwnDeck, runningDeck, versionNote } from "../src/server/running-deck.mjs";
20
25
 
21
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
27
  const PKG_ROOT = resolve(__dirname, "..");
@@ -101,6 +106,32 @@ if (flags.uninstall) {
101
106
  // command's job. So is the other half: turning the sound on parked the user's
102
107
  // own afplay/PowerShell Stop hooks, and once the deck is uninstalled nothing
103
108
  // else on the machine knows where they went.
109
+ // The login item goes too, and this is the one place the uninstall's
110
+ // documented narrowness has to bend. "Hook entries only" is right for the
111
+ // event log and the port registry, which are data somebody may still want —
112
+ // but a login item left behind after an uninstall is not data, it is a
113
+ // machine that keeps starting a deck whose hooks were just removed.
114
+ {
115
+ const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
116
+ const { deckDataDir: dataDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
117
+ if (svc.readServiceRecord(dataDir()) !== null) {
118
+ const gone = svc.uninstallService();
119
+ svc.writeServiceRecord(dataDir(), { removed: new Date().toISOString(), version: PKG_VERSION });
120
+ // Silent when there was nothing on the machine: an uninstall that reports
121
+ // removing a login item this deck never had is the same lie the explicit
122
+ // command used to tell, in the place a reader is least able to check it.
123
+ if (!gone.ok) {
124
+ // `gDash`, not an em dash: this block runs at module top level, on a
125
+ // console that may be the legacy Windows one, and #797 is the rule that
126
+ // a printed string never carries punctuation the terminal may not have.
127
+ console.error(`${PRODUCT}: could NOT remove the login item ${gDash} ${gone.reason} (${gone.path})`);
128
+ } else if (gone.existed) {
129
+ console.log(`${PRODUCT}: no longer starts at login`);
130
+ }
131
+ if (!gone.ok) refused = true;
132
+ }
133
+ }
134
+
104
135
  const { retireSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/retire-sound-hook.mjs")).href);
105
136
  const sound = await retireSoundHook();
106
137
  if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
@@ -135,6 +166,284 @@ if (flags.uninstall) {
135
166
  process.exit(refused ? 1 : 0);
136
167
  }
137
168
 
169
+ // ── the two questions that are about another deck, not this one ──────────────
170
+ //
171
+ // One-shot commands in the shape `--uninstall` already established: do the
172
+ // thing, print, exit, and never start a server. They sit ABOVE the migration
173
+ // and well above the heavy imports, because a command that ends a deck has no
174
+ // business moving that deck's files on the way past, and because asking a
175
+ // server to stop should not require starting one.
176
+ //
177
+ // Their own glyphs and palette rather than `G`/`P`, for the reason the bad-port
178
+ // block below gives (#797): those are declared two hundred lines further down
179
+ // and this runs at module top level.
180
+ //
181
+ // WHICH DECK. The shape a bare `ccdeck` would have built — same `sameShape` the
182
+ // attach uses, so `ccdeck` and `ccdeck --stop` name the same deck and there is
183
+ // one model to hold. `--port <n>` names one directly; `--all` takes every deck
184
+ // on the machine. (`--all` is the legacy capture flag, a no-op since it became
185
+ // the default; beside `--stop` it can only mean this, and it is the word a
186
+ // person reaches for.)
187
+ if (flags.stop || flags.status || flags.logs || flags.install || flags.installService || flags.uninstallService) {
188
+ const { dash, ok: gOk, warn: gWarn, bullet, arrow, ellipsis: gEllipsis } = glyphs(unicodeOK());
189
+ const tone = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
190
+ const say = (line) => process.stdout.write(`${line}\n`);
191
+
192
+ const { deckDataDir, deckLogDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
193
+
194
+ // ── --install ─────────────────────────────────────────────────────────────
195
+ //
196
+ // The one command that turns an npx run into a deck that comes back after a
197
+ // reboot. `npx ccdeck` cannot start at login — a login item must name a path
198
+ // that will still be there tomorrow, and npx runs out of a cache npm deletes
199
+ // whenever it likes — so this puts the package on PATH and points the service
200
+ // at THAT.
201
+ //
202
+ // BEHIND A FLAG, because `npx` means "run without installing" and a tool that
203
+ // installs itself anyway is the tool people uninstall. Somebody typed this.
204
+ if (flags.install) {
205
+ const gi = await import(pathToFileURL(join(PKG_ROOT, "src/server/global-install.mjs")).href);
206
+ const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
207
+ const { installedName } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
208
+ const { run } = await import(pathToFileURL(join(PKG_ROOT, "src/server/exec.mjs")).href);
209
+ // The name they typed, not ours. Three packages publish this deck, and
210
+ // installing `ccdeck` for somebody who ran `npx agent-dag` hands them a
211
+ // command they did not ask for.
212
+ const pkg = installedName(PKG_ROOT, PRODUCT);
213
+
214
+ say(`\n ${tone.muted}${dash} installing ${pkg} globally${gEllipsis}${tone.reset}`);
215
+ const got = await run("npm", ["i", "-g", pkg], { timeout: gi.INSTALL_TIMEOUT_MS });
216
+ if (!got?.ok) {
217
+ say(` ${tone.err}${gWarn} ${gi.installFailure(got, { pkg })}${tone.reset}\n`);
218
+ process.exit(1);
219
+ }
220
+ say(` ${tone.ok}${gOk}${tone.reset} ${pkg} is on your PATH${tone.muted} ${bullet} type \`${pkg}\` to start it${tone.reset}`);
221
+
222
+ // WHERE npm PUT IT, asked rather than assumed: a prefix the user set
223
+ // themselves is common and nothing here can guess it.
224
+ const root = gi.readGlobalRoot(await run("npm", ["root", "-g"], { timeout: 30_000 }));
225
+ const script = gi.globalScript(root, pkg);
226
+ if (!script) {
227
+ say(` ${tone.warn}${gWarn} installed, but npm did not say where ${dash} run \`${pkg} --install-service\` to start it at login${tone.reset}\n`);
228
+ process.exit(0);
229
+ }
230
+ const out = svc.installService({ script, logPath: join(deckLogDir(), "deck.log"), product: PRODUCT });
231
+ if (!out.ok) {
232
+ say(` ${tone.warn}${gWarn} installed, but it will not start at login ${dash} ${out.reason}${tone.reset}\n`);
233
+ process.exit(0);
234
+ }
235
+ svc.writeServiceRecord(deckDataDir(), { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path });
236
+ say(` ${tone.ok}${gOk}${tone.reset} and starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
237
+ if (svc.lingerState() === "off") {
238
+ say(` ${tone.warn}${gWarn} systemd tears your session down at logout, so the deck goes with it.${tone.reset}`);
239
+ say(` ${tone.muted}\`sudo loginctl enable-linger $USER\` keeps it running when you are logged out.${tone.reset}`);
240
+ }
241
+ say(` ${tone.muted}\`${pkg} --uninstall-service\` undoes the login part${tone.reset}\n`);
242
+ process.exit(0);
243
+ }
244
+
245
+ // ── --install-service / --uninstall-service ───────────────────────────────
246
+ // Answered before the registry is read: neither one is about a deck that is
247
+ // running, and both are as meaningful on a machine with no deck up as on one
248
+ // with three.
249
+ if (flags.installService || flags.uninstallService) {
250
+ const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
251
+ const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
252
+ if (flags.uninstallService) {
253
+ const out = svc.uninstallService();
254
+ // Recorded either way. The record is what stops the next ordinary start
255
+ // putting back what was just taken away, and a tool that argues with its
256
+ // user about a login item is a tool that gets uninstalled entirely.
257
+ svc.writeServiceRecord(deckDataDir(), { removed: new Date().toISOString(), version: PKG_VERSION });
258
+ // `existed` rather than the record: the record says what THIS tool last
259
+ // did, and the machine is what actually has a login item on it. Somebody
260
+ // who removed the plist by hand should be told the truth about the
261
+ // machine, not about our bookkeeping.
262
+ say(!out.ok
263
+ ? `\n ${tone.err}${gWarn} could not remove it ${dash} ${out.reason}${tone.reset}\n`
264
+ : out.existed
265
+ ? `\n ${tone.ok}${gOk}${tone.reset} no longer starts at login${tone.muted} ${bullet} ${out.path}${tone.reset}\n`
266
+ : `\n ${tone.muted}${dash} it was not starting at login${tone.reset}\n`);
267
+ process.exit(out.ok ? 0 : 1);
268
+ }
269
+ if (isGitCheckout(PKG_ROOT)) {
270
+ // Not refused outright — somebody running from a checkout may genuinely
271
+ // want this — but not done silently either: the item would name a working
272
+ // tree, and that is worth knowing before it is written.
273
+ say(`\n ${tone.warn}${gWarn} this is a checkout, so the login item would name ${PKG_ROOT}${tone.reset}`);
274
+ say(` ${tone.muted}a renamed, moved or deleted working tree leaves a login item pointing at nothing${tone.reset}\n`);
275
+ }
276
+ if (isNpxInstall(PKG_ROOT)) {
277
+ // The item would name a path inside ~/.npm/_npx/<hash>/, which npm deletes
278
+ // whenever it feels like it — a login item pointing at nothing, forever,
279
+ // on a machine where nothing was ever installed.
280
+ say(`\n ${tone.warn}${gWarn} an npx run cannot start at login ${dash} its files live in npm's cache and are deleted without warning.${tone.reset}`);
281
+ say(` ${tone.muted}install it first: \`npm i -g ${INVOKED_AS ?? PRODUCT}\`${tone.reset}\n`);
282
+ process.exit(1);
283
+ }
284
+ const out = svc.installService({
285
+ script: join(PKG_ROOT, "bin", "agent-dag.js"),
286
+ logPath: join(deckLogDir(), "deck.log"),
287
+ product: PRODUCT,
288
+ });
289
+ if (!out.ok) {
290
+ say(`\n ${tone.err}${gWarn} could not set it up ${dash} ${out.reason}${tone.reset}\n`);
291
+ process.exit(1);
292
+ }
293
+ svc.writeServiceRecord(deckDataDir(), { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path });
294
+ say(`\n ${tone.ok}${gOk}${tone.reset} starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
295
+ if (out.how === "file-only") {
296
+ // The file is on disk and both launchd and systemd read their directories
297
+ // at the next login, so this works from then on. Said rather than hidden:
298
+ // "it will work tomorrow" is a different promise from "it works now".
299
+ say(` ${tone.warn}${gWarn} not started now ${dash} ${out.reason}. It will come up at your next login.${tone.reset}`);
300
+ }
301
+ // The one place the three platforms genuinely differ in what this buys you.
302
+ if (svc.lingerState() === "off") {
303
+ say(` ${tone.warn}${gWarn} systemd tears your session down at logout, so the deck goes with it.${tone.reset}`);
304
+ say(` ${tone.muted}\`sudo loginctl enable-linger $USER\` keeps it running when you are logged out.${tone.reset}`);
305
+ }
306
+ say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --uninstall-service\` undoes it${tone.reset}\n`);
307
+ process.exit(0);
308
+ }
309
+ const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-writer.mjs")).href);
310
+ const { hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
311
+ const { hasClaudeInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
312
+ const { liveDecks, sameShape } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
313
+
314
+ // The default shape, spelled the same way the boot below spells it. NOT the
315
+ // shape of the flags on THIS command line: `--stop --no-codex` is not a
316
+ // request to stop a Codex-less deck, it is a flag that means nothing here,
317
+ // and reading it as a selector would make `--stop` miss the deck it was
318
+ // pointed at and say "nothing is running".
319
+ const mine = {
320
+ workspace: "",
321
+ persist: canonicalLogPath(join(deckLogDir(), "events.jsonl")),
322
+ codex: hasCodexInstalled(),
323
+ claude: hasClaudeInstalled(),
324
+ };
325
+
326
+ // ── --logs ────────────────────────────────────────────────────────────────
327
+ // What the deck wrote where a terminal would have shown it. Printed RAW,
328
+ // escapes and all: the launcher passes its own colour tier down to a detached
329
+ // deck, so this file is a mirror of the terminal the deck was started from
330
+ // and re-rendering it is the whole point. The path goes last, because that is
331
+ // the line somebody copies into `tail -f`.
332
+ if (flags.logs) {
333
+ const { readFileSync: readLog, statSync: statLog } = await import("node:fs");
334
+ const logPath = join(deckLogDir(), "deck.log");
335
+ let text = null;
336
+ try { text = readLog(logPath, "utf8"); } catch { /* never started, or swept */ }
337
+ if (text === null) {
338
+ say(`\n ${tone.muted}${dash} nothing logged yet ${dash} ${logPath}${tone.reset}\n`);
339
+ process.exit(0);
340
+ }
341
+ // The tail, not the file. It is truncated at the first start on an idle
342
+ // machine, so it is normally small — but an attach appends to a running
343
+ // deck's log every time, and a long-lived deck's log is somebody's week.
344
+ const lines = text.split("\n");
345
+ const shown = lines.slice(Math.max(0, lines.length - 200));
346
+ if (shown.length < lines.length) {
347
+ say(` ${tone.muted}${dash} showing the last ${shown.length} of ${lines.length} lines${tone.reset}`);
348
+ }
349
+ process.stdout.write(shown.join("\n"));
350
+ if (!text.endsWith("\n")) say("");
351
+ const size = (() => { try { return statLog(logPath).size; } catch { return 0; } })();
352
+ say(` ${tone.muted}${dash} ${logPath} ${bullet} ${size} bytes${tone.reset}\n`);
353
+ process.exit(0);
354
+ }
355
+
356
+ const decks = await liveDecks().catch(() => []);
357
+ const age = (d) => sinceLabel(Date.now() - Date.parse(d.startedAt ?? ""));
358
+ const where = (d) => (d.workspace ? d.workspace : "(all)");
359
+ const url = (d) => `http://127.0.0.1:${d.port}`;
360
+
361
+ if (flags.status) {
362
+ if (!decks.length) {
363
+ say(`\n ${tone.muted}${dash} no deck is running ${dash} \`${INVOKED_AS ?? PRODUCT}\` starts one${tone.reset}\n`);
364
+ process.exit(0);
365
+ }
366
+ // The one a bare `ccdeck` would open is marked, because with two decks up
367
+ // that is the only question this command is really being asked.
368
+ const opens = decks.find(d => sameShape(d, mine)) ?? null;
369
+ say("");
370
+ for (const d of decks) {
371
+ // The version chunk is dropped rather than printed as "v?" for a deck too
372
+ // old to publish one — same rule as the attach line, and for the same
373
+ // reason: a question mark beside two real facts reads as a fault.
374
+ const head = [d.version ? `v${d.version}` : "", `pid ${d.pid}`, `up ${age(d)}`]
375
+ .filter(Boolean).join(` ${bullet} `);
376
+ const mark = d === opens ? `${tone.ok}${gOk}${tone.reset}` : `${tone.muted}${bullet}${tone.reset}`;
377
+ const tail = d === opens ? `${tone.muted} ${arrow} \`${INVOKED_AS ?? PRODUCT}\` opens this one${tone.reset}` : "";
378
+ say(` ${mark} ${tone.muted}${head}${tone.reset}${tail}`);
379
+ say(` ${tone.accent}${tone.bold}${url(d)}${tone.reset}`);
380
+ say(` ${tone.muted}${where(d)} ${bullet} ${d.persist ?? "no log (--no-persist)"}${tone.reset}`);
381
+ }
382
+ say("");
383
+ process.exit(0);
384
+ }
385
+
386
+ // ── --stop ────────────────────────────────────────────────────────────────
387
+ const { stopDeck } = await import(pathToFileURL(join(PKG_ROOT, "src/server/stop-deck.mjs")).href);
388
+ // `flags.port`, not the `rawPort` computed below: that folds in
389
+ // AGENT_DAG_PORT, which is how somebody RUNS a deck rather than which deck
390
+ // they mean to stop — and a deck started on a custom port still has the
391
+ // default SHAPE, so the matcher finds it without help. It is also declared
392
+ // below this block, which would make reading it here a ReferenceError (#797).
393
+ const named = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
394
+ const wanted = flags.all
395
+ ? decks
396
+ : named !== null
397
+ ? decks.filter(d => d.port === named)
398
+ : decks.filter(d => sameShape(d, mine)).slice(0, 1);
399
+
400
+ if (!wanted.length) {
401
+ // Three different silences, and saying the wrong one sends the reader
402
+ // looking in the wrong place. A machine with no deck at all is not a
403
+ // machine whose deck is scoped differently.
404
+ const why = !decks.length
405
+ ? `no deck is running`
406
+ : named !== null
407
+ ? `no deck is listening on ${named} ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists them`
408
+ : `no deck of this shape is running ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists the ${decks.length} that ${decks.length === 1 ? "is" : "are"}`;
409
+ say(`\n ${tone.muted}${dash} ${why}${tone.reset}\n`);
410
+ process.exit(0);
411
+ }
412
+
413
+ say("");
414
+ let refused = false;
415
+ for (const d of wanted) {
416
+ const was = age(d);
417
+ const out = await stopDeck(d);
418
+ if (!out.ok) {
419
+ refused = true;
420
+ say(` ${tone.err}${gWarn} could not stop pid ${d.pid} on ${d.port} ${dash} ${out.reason}${tone.reset}`);
421
+ continue;
422
+ }
423
+ // HOW it went out, not just that it did. "asked" means the deck closed its
424
+ // listener, unlinked its registration and left the LAN cleanly; anything
425
+ // else means none of that happened and the next boot has litter to sweep.
426
+ const how = out.how === "asked"
427
+ ? ""
428
+ : out.old
429
+ ? ` ${tone.muted}(${out.how} ${dash} that deck predates \`--stop\`)${tone.reset}`
430
+ : ` ${tone.muted}(${out.how} ${dash} it did not answer)${tone.reset}`;
431
+ say(` ${tone.ok}${gOk}${tone.reset} stopped${tone.muted} ${bullet} pid ${d.pid} ${bullet} port ${d.port} ${bullet} was up ${was}${tone.reset}${how}`);
432
+ }
433
+
434
+ // What is still up, named. A command that ends one of three decks and says
435
+ // only "stopped" leaves the reader believing the machine is clear.
436
+ const left = decks.filter(d => !wanted.includes(d));
437
+ if (left.length) {
438
+ say("");
439
+ say(` ${tone.muted}${dash} ${left.length} other deck${left.length === 1 ? "" : "s"} still running:${tone.reset}`);
440
+ for (const d of left) say(` ${tone.muted}pid ${d.pid} ${bullet} ${d.port} ${bullet} ${where(d)}${tone.reset}`);
441
+ say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --stop --port <n>\` ends one ${bullet} \`--stop --all\` ends every deck${tone.reset}`);
442
+ }
443
+ say("");
444
+ process.exit(refused ? 1 : 0);
445
+ }
446
+
138
447
  // The port, and the one piece of argv the deck really does refuse to boot over.
139
448
  //
140
449
  // It refused before too — `--port banana` and `--port --no-open` both became
@@ -173,6 +482,36 @@ const openBrowser = flags.noOpen !== true;
173
482
  // config dir rather than assuming ~/.claude — see src/server/claude-dir.mjs.
174
483
  const { claudeConfigDir, hasClaudeInstalled } =
175
484
  await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
485
+
486
+ // ── the deck's own files, brought to their own directory ────────────────────
487
+ //
488
+ // BEFORE ANYTHING READS THEM, which is why it is here and not in the server:
489
+ // index.mjs reads the preferences at module load, so a migration inside it
490
+ // would run after the file it is meant to find had already been looked for in
491
+ // the wrong place. deck-home.mjs owns the reasoning; the shape of it is that
492
+ // nothing is deleted and a destination that exists is never touched, so this is
493
+ // safe to run on every start and does nothing at all on all but the first.
494
+ //
495
+ // A failure here is reported and then ignored. A read-only home or a missing
496
+ // permission is not a reason for the deck not to start — the old paths are
497
+ // still there, and the worst case is a deck that keeps using them.
498
+ const { deckDataDir, deckLogDir, legacyDeckDir, migrateDeckFiles, sweepTempFiles } =
499
+ await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
500
+ {
501
+ const fsp = await import("node:fs/promises");
502
+ const legacy = legacyDeckDir();
503
+ const data = deckDataDir();
504
+ const log = deckLogDir();
505
+ const { moved } = await migrateDeckFiles({
506
+ from: legacy, data, log, fs: fsp,
507
+ onError: (name, err) => console.error(`${PRODUCT}: could not move ${name}:`, err?.message ?? err),
508
+ });
509
+ if (moved.length) console.error(`${PRODUCT}: moved ${moved.join(", ")} to ${data}`);
510
+ // The litter an atomic write leaves when its process is killed between the
511
+ // write and the rename. Nothing has ever swept it, because the code that
512
+ // makes it is not running any more when it is made.
513
+ await sweepTempFiles({ dirs: [legacy, data, log], fs: fsp }).catch(() => 0);
514
+ }
176
515
  // CANONICALISED here rather than left as typed: the discovery file publishes
177
516
  // this path so the hook can tell which decks share one log and elect a single
178
517
  // writer for it, and two spellings of one file would read as two files.
@@ -184,7 +523,12 @@ const { claudeConfigDir, hasClaudeInstalled } =
184
523
  const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-writer.mjs")).href);
185
524
  const persist = flags.noPersist
186
525
  ? null
187
- : canonicalLogPath(flags.history ?? join(claudeConfigDir(), "agent-dag", "events.jsonl"));
526
+ // The log follows the deck rather than Claude Code now — a hundred megabytes
527
+ // of rotating events was never configuration, and it was living in another
528
+ // application's configuration directory. `--history` still wins, and the
529
+ // canonical form is still what the discovery file publishes, so decks that
530
+ // share a path still elect one writer for it.
531
+ : canonicalLogPath(flags.history ?? join(deckLogDir(), "events.jsonl"));
188
532
 
189
533
  const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled } =
190
534
  await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
@@ -888,11 +1232,90 @@ dieWithParent(() => shutdown(0));
888
1232
  // whole report, and a bind that fails in there with nothing attached to it is an
889
1233
  // unhandledRejection — which Node answers by killing the process over a port it
890
1234
  // could have named.
1235
+ // ── is one of ours already up? ────────────────────────────────────────────────
1236
+ // A bare `ccdeck` typed beside a deck that is already running used to build a
1237
+ // second everything on a random port, and neither half mentioned the other.
1238
+ // src/server/running-deck.mjs carries the whole argument, the registry read and
1239
+ // the token handshake that makes the answer trustworthy; this is only where it
1240
+ // is asked.
1241
+ //
1242
+ // ASKED EXACTLY HERE, and the position is the point. Everything the answer
1243
+ // depends on is resolved above — the workspace, the canonical log path, which
1244
+ // of the two CLIs this deck would serve — and nothing below has run yet: no
1245
+ // port bound, no hooks installed, no tool probed, no banner painted, no
1246
+ // discovery file written. An attach therefore leaves the machine precisely as
1247
+ // it found it, which is what makes it safe to do without asking.
1248
+ //
1249
+ // A respawn is excluded on its own line rather than left to the flag check.
1250
+ // The supervisor relaunches us with `--port <bound>`, which is a shaping flag
1251
+ // and would be excluded anyway — but a restart is THIS deck coming back, and
1252
+ // having that read as "somebody typed ccdeck twice" is an accident waiting for
1253
+ // the day the supervisor stops passing the port.
1254
+ if (!RESPAWN && !asksForOwnDeck(flags)) {
1255
+ const live = await runningDeck({
1256
+ want: { workspace, persist, codex: wantCodex, claude: wantClaude },
1257
+ }).catch(() => null);
1258
+ if (live) {
1259
+ const liveUrl = `http://127.0.0.1:${live.port}`;
1260
+ const note = versionNote(live.version, PKG_VERSION);
1261
+ // Not the startup report's rows. That report has a label column because it
1262
+ // has twelve lines to align; this has two, and borrowing the column would
1263
+ // indent a three-line message behind a gutter sized for "Codex sessions".
1264
+ //
1265
+ // The version chunk is dropped rather than printed as "v?" when the running
1266
+ // deck is too old to report one — see versionNote, which says nothing in the
1267
+ // same case for the same reason.
1268
+ const ident = [live.version ? `v${live.version}` : "", `pid ${live.pid}`]
1269
+ .filter(Boolean).join(` ${G.bullet} `);
1270
+ write(`\n ${P.ok}${G.ok}${P.reset} deck already running${P.muted} ${G.bullet} ${ident}${P.reset}\n`);
1271
+ // Its own line, always. The URL is the one detail an ellipsis would destroy
1272
+ // — half an address is not a shorter address — and this message has no
1273
+ // report to hand it to. Same rule as statusLine's `keep`.
1274
+ write(` ${P.accent}${P.bold}${link(liveUrl, liveUrl, LINKS)}${P.reset}\n`);
1275
+ if (note) write(` ${P.warn}${G.warn} ${note}${P.reset}\n`);
1276
+ // THE TYPO'S WARNING, on the path that has no startup report to carry it.
1277
+ //
1278
+ // asksForOwnDeck used to answer `true` for these two so the report would
1279
+ // run and print them, and that is how `ccdeck --stpo` — a misspelling of
1280
+ // the flag that STOPS a deck — came to build a second one. The warning was
1281
+ // the requirement; the extra process never was. Printed here, in the same
1282
+ // rows the report uses, so nothing is lost and nothing is started.
1283
+ reportUnknownFlags(flags.unknown);
1284
+ reportIncompleteFlags(flags.incomplete);
1285
+ // The line that says a second deck was NOT started. Without it the command
1286
+ // looks like it did nothing at all, which is the other way to be confusing
1287
+ // about this — and it names the flag for the person who really did want two.
1288
+ write(`\n ${P.muted}${G.dash} no second deck was started ${G.dash} \`${INVOKED_AS ?? PRODUCT} --new\` starts one${P.reset}\n`);
1289
+ if (openBrowser) {
1290
+ write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
1291
+ try {
1292
+ const { openUrl, LAUNCH_GRACE_MS } = await import(pathToFileURL(join(PKG_ROOT, "src/server/open-url.mjs")).href);
1293
+ openUrl(liveUrl);
1294
+ // Held for exactly as long as openUrl needs to fall through to its next
1295
+ // launcher. Every child it spawns is unref'd, so an immediate exit ends
1296
+ // this process before a missing xdg-open has been answered by gio — and
1297
+ // then no browser opens and nothing says why. The boot path never had
1298
+ // to think about this because it stays alive forever.
1299
+ await sleep(LAUNCH_GRACE_MS);
1300
+ } catch { /* the URL is on screen; it can be clicked or pasted */ }
1301
+ } else {
1302
+ write("\n");
1303
+ }
1304
+ process.exit(0);
1305
+ }
1306
+ }
1307
+
891
1308
  const starting = startServer({
892
1309
  port, persist, workspace, codex: wantCodex, claude: wantClaude,
893
1310
  // Withheld when nothing is supervising us: without a parent, exiting is just
894
1311
  // exiting, and /api/restart answers 501 so the UI hides the control.
895
1312
  onRestart: SUPERVISED ? requestRestart : null,
1313
+ // NOT withheld, unlike the restart above. A restart needs a supervisor to
1314
+ // bring the replacement up on the same port; ending is something any deck can
1315
+ // do on its own, supervised or not, and `ccdeck --stop` must work on both.
1316
+ // shutdown() is a hoisted declaration precisely so it is callable from the
1317
+ // first instruction of this module — see the long note beside it (#448).
1318
+ onStop: () => shutdown(0),
896
1319
  }).then(s => ({ ok: true, s }), err => ({ ok: false, err }));
897
1320
 
898
1321
  // Once-per-session setup — hook install, tool probes, registry lookups, and the
@@ -975,6 +1398,17 @@ discovery = keepDiscovery({
975
1398
  token: hookToken(),
976
1399
  persist,
977
1400
  codex: wantCodex,
1401
+ // Both read by the next `ccdeck` on this machine, not by us: `claude` is half
1402
+ // of the shape it compares before it will attach to us, and `version` is what
1403
+ // it prints when the deck it found is not the one the user just launched. See
1404
+ // src/server/running-deck.mjs.
1405
+ claude: wantClaude,
1406
+ version: PKG_VERSION,
1407
+ // The supervisor above us, for `ccdeck --stop`'s fallback ladder: a worker
1408
+ // killed under a live supervisor is a worker the supervisor puts back, so the
1409
+ // parent has to go first and cannot be found without being told. Null when
1410
+ // nothing is supervising, which is the same question `onRestart` asks.
1411
+ parent: SUPERVISED ? process.ppid : null,
978
1412
  onState: (state) => {
979
1413
  const first = registered === null;
980
1414
  registered = state.ok;
@@ -1001,6 +1435,75 @@ if (openBrowser && !RESPAWN) {
1001
1435
  } catch {}
1002
1436
  }
1003
1437
 
1438
+ // ── the deck from before this version that is still running ──────────────────
1439
+ //
1440
+ // UPGRADE DAY, and without this it is a mystery. A deck older than the attach
1441
+ // publishes no `claude` and no `version` in its discovery record, so sameShape
1442
+ // cannot match it — deliberately, because a record that cannot be compared is
1443
+ // not one to attach to. The consequence is that the first `ccdeck` after an
1444
+ // upgrade starts a SECOND deck beside the one already running, opens a tab on
1445
+ // it, and says nothing; and `ccdeck --stop` will not find the old one either,
1446
+ // because it has the same shape problem.
1447
+ //
1448
+ // So it is named, once, with the command that actually clears it. Only for
1449
+ // decks with no version: a deck started with `--new`, or one scoped to another
1450
+ // workspace, is somebody's deliberate second deck and needs no explaining.
1451
+ if (!RESPAWN) {
1452
+ try {
1453
+ const { liveDecks } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
1454
+ const older = (await liveDecks()).filter(d => !d.version);
1455
+ if (older.length) {
1456
+ const where = older.map(d => d.port).join(", ");
1457
+ write(` ${P.warn}${G.warn}${P.reset} ${P.muted}${older.length === 1 ? "a deck" : `${older.length} decks`} from an older ${PRODUCT} ${older.length === 1 ? "is" : "are"} still running on ${where}${P.reset}\n`);
1458
+ write(` ${P.muted}too old to be recognised, so this one started beside ${older.length === 1 ? "it" : "them"} ${G.dash} \`${INVOKED_AS ?? PRODUCT} --stop --all\` clears the lot${P.reset}\n\n`);
1459
+ }
1460
+ } catch { /* a question about other decks is never a reason to fail a boot */ }
1461
+ }
1462
+
1463
+ // ── starting at login ─────────────────────────────────────────────────────────
1464
+ //
1465
+ // ONCE PER MACHINE, EVER. The record in the deck's own data directory is what
1466
+ // makes that true: without it, `--uninstall-service` would be undone by the next
1467
+ // start, which is not an uninstall — it is a tool arguing with its user.
1468
+ //
1469
+ // After the boot rather than during it, and deliberately: a deck that could not
1470
+ // come up has no business teaching the machine to start it at every login. By
1471
+ // here the port is bound, the hooks are registered and the browser is open.
1472
+ //
1473
+ // npx is excluded and AGENTS_DECK_NO_INSTALL is honoured — see
1474
+ // shouldOfferService, which owns both rules and says why. A failure is one line
1475
+ // and nothing else: the deck is already running, and the worst case is the
1476
+ // behaviour every version before this one had.
1477
+ if (!RESPAWN) {
1478
+ try {
1479
+ const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
1480
+ const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
1481
+ if (svc.shouldOfferService({
1482
+ record: svc.readServiceRecord(deckDataDir()),
1483
+ npx: isNpxInstall(PKG_ROOT),
1484
+ checkout: isGitCheckout(PKG_ROOT),
1485
+ })) {
1486
+ const out = svc.installService({
1487
+ script: join(PKG_ROOT, "bin", "agent-dag.js"),
1488
+ logPath: join(deckLogDir(), "deck.log"),
1489
+ product: PRODUCT,
1490
+ });
1491
+ svc.writeServiceRecord(deckDataDir(), out.ok
1492
+ ? { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path }
1493
+ : { failed: out.reason ?? "unknown", at: new Date().toISOString(), version: PKG_VERSION });
1494
+ // Said once, on the one run that does it, and never again. A tool that
1495
+ // adds itself to your login items and does not mention it is a tool you
1496
+ // find later, in a settings pane, and stop trusting.
1497
+ write(out.ok
1498
+ ? ` ${P.muted}${G.dash} ${PRODUCT} will now start when you log in ${G.dash} \`${INVOKED_AS ?? PRODUCT} --uninstall-service\` undoes it${P.reset}\n\n`
1499
+ : ` ${P.muted}${G.dash} could not set ${PRODUCT} to start at login (${out.reason}) ${G.dash} it still starts when you type it${P.reset}\n\n`);
1500
+ }
1501
+ } catch (err) {
1502
+ // Never fatal. The deck is up; this is a convenience that did not happen.
1503
+ console.error(`${PRODUCT}: could not check the login item:`, err?.message ?? err);
1504
+ }
1505
+ }
1506
+
1004
1507
  // ── Pulse indicator ───────────────────────────────────────────────────────────
1005
1508
  // The whole line is rewritten each beat rather than just the dot: anything else
1006
1509
  // on this deck that has something to say writes a newline first, and after that
@@ -1090,6 +1593,13 @@ if (MOTION) {
1090
1593
  // the window opened at the top of this file closes; see requestRestart.
1091
1594
  booted = true;
1092
1595
  markDeckReady();
1596
+ // And said out loud, one link up. A launcher that put this deck in the
1597
+ // background has been tailing its log into the user's terminal since the spawn
1598
+ // and is waiting for exactly this to stop and hand the prompt back — NOT for
1599
+ // `listening`, which is sent before the server-ready row, the log row and the
1600
+ // browser line are written, and would cut the last three lines off every boot.
1601
+ // Inert when nothing is supervising us.
1602
+ try { process.send?.({ type: "booted" }); } catch { /* no channel; nothing waiting */ }
1093
1603
  runHeldRestart();
1094
1604
 
1095
1605
  /**
@@ -1250,10 +1760,28 @@ Usage:
1250
1760
  Options:
1251
1761
  -p, --port <number> Preferred port (default: 4317; falls back to random 4318–4400)
1252
1762
  --no-open Don't open the browser automatically
1763
+ --foreground Hold the terminal, the way every version before 3.20
1764
+ did. Ctrl+C stops the deck again
1765
+ --new Start a second deck even if one is already running.
1766
+ Without it, a bare \`${PRODUCT}\` beside a deck that is
1767
+ already up opens that deck's tab instead of building
1768
+ a rival on another port
1769
+ --stop Stop the deck a bare \`${PRODUCT}\` would open.
1770
+ With --port <n>, stop that one; with --all, stop every
1771
+ deck on this machine
1772
+ --status What is running on this machine, and on which ports
1773
+ --logs What the deck wrote where a terminal would have shown
1774
+ it, and where that file is
1775
+ --install Put the deck on your PATH and start it at login.
1776
+ What an \`npx\` run needs to survive a reboot
1777
+ --install-service Start the deck when you log in. Set up on first run;
1778
+ this is only for putting it back
1779
+ --uninstall-service Stop starting at login. \`--uninstall\` does this too
1253
1780
  --workspace <path> Only capture sessions whose cwd is inside <path>
1254
1781
  --scope Restrict to current working directory
1255
- --all Capture every session (default)
1256
- --history <path> Override events log file (default: ~/.claude/agent-dag/events.jsonl)
1782
+ --all Capture every session (default). Beside --stop it
1783
+ means every deck rather than every session
1784
+ --history <path> Override events log file (default: this platform's log directory)
1257
1785
  --no-persist Don't write or replay events log (RAM-only)
1258
1786
  --codex Force-enable Codex capture even if ~/.codex/ missing
1259
1787
  --no-codex Skip Codex capture (Claude only)