agent-dag 3.18.3 → 3.20.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/README.md CHANGED
@@ -165,11 +165,22 @@ ccdeck [options]
165
165
 
166
166
  -p, --port <number> Preferred port (default: 4317; fallback: random 4318–4400)
167
167
  --no-open Don't open the browser automatically
168
+ --foreground Hold the terminal, the way every version before 3.20
169
+ did (Ctrl+C stops the deck again)
170
+ --new Start a second deck even if one is already running
171
+ --stop Stop the deck a bare `ccdeck` would open
172
+ (--port <n> stops that one; --all stops every deck)
173
+ --status What is running on this machine, and on which ports
174
+ --logs What a backgrounded deck wrote where a terminal would
175
+ have shown it
168
176
  --workspace <path> Only capture sessions whose cwd is inside <path>
169
177
  --scope Restrict to the current working directory
170
178
  --all Capture every session on this machine (default)
179
+ (beside --stop: every deck, not every session)
171
180
  --history <path> Override the events log file
172
- (default: ~/.claude/agent-dag/events.jsonl)
181
+ (default: this platform's log directory —
182
+ ~/Library/Logs/ccdeck on macOS, %LOCALAPPDATA%\ccdeck\Log
183
+ on Windows, $XDG_STATE_HOME/ccdeck on Linux)
173
184
  --no-persist RAM-only mode — don't write or replay the log
174
185
  --codex Force Codex capture even if ~/.codex/ is missing
175
186
  --no-codex Skip Codex capture (Claude only)
@@ -185,6 +196,57 @@ Anything else on the command line is reported as an unknown option and then
185
196
  ignored — the deck still starts, so a typo costs you a warning rather than a
186
197
  dashboard.
187
198
 
199
+ `--foreground` is there for anything that was relying on the old behaviour — a
200
+ wrapper script, a CI step, a supervisor of your own that starts the deck and
201
+ waits on it. On the first run after upgrading you may also see a line about a
202
+ deck from an older version still running: those publish nothing this one can
203
+ check, so it starts beside them rather than attaching to something it cannot
204
+ identify, and `ccdeck --stop --all` clears them.
205
+
206
+ `ccdeck` runs in the background. The boot report prints in your terminal exactly
207
+ as it always has — the hooks, the port, the URL — and then the prompt comes back
208
+ and the deck stays up: closing the window does not take it with you any more,
209
+ because it is in its own process group and the terminal's hangup never reaches
210
+ it. `ccdeck --stop` ends it, `ccdeck --status` says what is running, and
211
+ `ccdeck --logs` shows what it wrote after you stopped watching. Ctrl+C while the
212
+ boot is still printing cancels the start, which is the one thing it still means.
213
+
214
+ One Windows exception, and it is `ssh`'s rather than the deck's: OpenSSH puts a
215
+ session's processes in a job object that it kills when the session ends, and
216
+ nothing started from inside one survives it. A deck started from an ordinary
217
+ Windows terminal, or by the login task below, keeps running — measured on a real
218
+ machine, from a second SSH session, still serving.
219
+
220
+ It also starts when you log in, so a reboot does not cost you the morning's
221
+ events. That is set up once, on the first run, and said out loud when it happens;
222
+ `ccdeck --uninstall-service` undoes it and `ccdeck --uninstall` takes it with the
223
+ hooks. A launchd agent on macOS, a `systemd --user` unit on Linux, a Task
224
+ Scheduler logon task on Windows — none of them carrying a restart policy of its
225
+ own, because the one that decides when a crashed deck stops coming back lives in
226
+ ccdeck and two policies over one process is how a stop becomes a suggestion. An
227
+ `npx` run never installs one: it would name a path inside npm's cache, which npm
228
+ deletes without warning. On Linux, `systemd --user` is torn down at logout unless
229
+ `loginctl enable-linger` is on for your account — the install says so rather than
230
+ changing that for you.
231
+
232
+ If the deck falls over on its own, it comes back — five times in ten minutes,
233
+ with the wait doubling each time, and then it stops and says so in the log rather
234
+ than spinning on a machine nobody is watching. A clean `--stop` is never answered
235
+ with a restart, and neither is a deck that failed to start in the first place:
236
+ retrying a port the OS will not give you just prints the same refusal six times.
237
+
238
+ Typing plain `ccdeck` beside a deck that is already running opens **that deck's
239
+ tab** rather than building a second one. It used to build the second one: port
240
+ 4317 was busy, so the new deck took a random port out of 4318–4400 and stood
241
+ there beside a perfectly healthy first one, and neither mentioned the other.
242
+ The port fallback is still there — 4317 is also the standard OTLP collector
243
+ port, and Windows reserves whole TCP ranges for Hyper-V, WSL2 and Docker — but
244
+ it now runs only when the thing holding the port is *not* one of your decks. The
245
+ deck on the port has to prove it is yours, with the same token handshake the
246
+ hooks use, before its tab is opened. Any flag that changes what the deck **is**
247
+ — a port, a workspace, a log, either `--codex` or `--claude` pair — still starts
248
+ its own, and `--new` starts one unconditionally.
249
+
188
250
  ccdeck looks for each CLI before it does anything on that CLI's behalf. Claude
189
251
  Code counts as present when its binary is on `PATH` (or in one of the places its
190
252
  installers put it), or when its config dir carries traces of having been used;
package/bin/agent-dag.js CHANGED
@@ -37,13 +37,19 @@ import { dirname, join } from "node:path";
37
37
  import { fileURLToPath } from "node:url";
38
38
  import { killTree } from "../src/server/exec.mjs";
39
39
  import { invokedAs } from "../src/server/invoked-as.mjs";
40
+ import { isOneShot, parseArgs } from "../src/server/args.mjs";
40
41
  import { npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
41
42
  import {
42
43
  bareSpecName, claimRestartFailureKey, clearRestartFailure, installedName, installedVersion,
43
- lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure, successorRoot,
44
+ isNpxInstall, lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure,
45
+ successorRoot,
44
46
  } from "../src/server/self-update.mjs";
45
- import { dieOfSignal, dieWithParent, replacedNote, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
46
- import { colorProfile, glyphs, palette, unicodeOK } from "../src/server/term.mjs";
47
+ import {
48
+ CRASH_CEILING, CRASH_WINDOW_MS, crashPolicy, dieOfSignal, dieWithParent, isCrash, replacedNote,
49
+ upgradeAttempt, upgradeRefusalText, workerExitAction,
50
+ } from "../src/server/supervisor.mjs";
51
+ import { colorProfile, glyphs, palette, termColumns, unicodeOK } from "../src/server/term.mjs";
52
+ import { DETACHED_ENV, detachAndWatch, stopCommand } from "../src/server/detach.mjs";
47
53
  import { PRODUCT } from "../src/server/brand.mjs";
48
54
 
49
55
  const BIN_DIR = dirname(fileURLToPath(import.meta.url));
@@ -77,6 +83,72 @@ const G = glyphs(unicodeOK());
77
83
  // update reported someone else's failed npx as its own. Our pid is unique among
78
84
  // the decks alive on the machine, and putting it in our own environment is what
79
85
  // carries it to the worker: launch() spawns with a copy of it.
86
+ // ── the terminal stops being the deck's leash ────────────────────────────────
87
+ //
88
+ // Everything about why is in src/server/detach.mjs. Here is only the decision,
89
+ // and it has exactly two ways out:
90
+ //
91
+ // ALREADY DETACHED — we ARE the background copy. Carry on as this file always
92
+ // has: spawn the worker, supervise it, never come back here.
93
+ //
94
+ // A ONE-SHOT — `--version`, `--stop`, `--status`, `--help`, `--uninstall`.
95
+ // Those answer and leave, and a one-shot that detached would print its answer
96
+ // into a log file and hand the terminal back empty. They run in the
97
+ // foreground exactly as they always have.
98
+ //
99
+ // Anything else is a start, and a start goes to the background.
100
+ const DETACHED = process.env[DETACHED_ENV] === "1";
101
+ // A parent already holding our lifecycle. `process.send` exists only when
102
+ // somebody spawned us with an IPC channel, and that somebody has armed
103
+ // dieWithParent below and is waiting on our exit code — running away from them
104
+ // into our own process group is precisely the wrong answer to being supervised.
105
+ // The suite's spawnSupervised is the caller that does this today.
106
+ const LEASHED = typeof process.send === "function";
107
+ // And the way to ask for the old behaviour out loud.
108
+ //
109
+ // Every version before this one held the terminal, and something out there
110
+ // depends on that: a wrapper script, a CI step, a supervisor of somebody else's
111
+ // that starts `ccdeck` and waits on it, a `ccdeck && open …`. Handing all of
112
+ // those an immediate exit and no way to say otherwise would be a breaking change
113
+ // with no escape hatch — and the marker above is an internal one, not something
114
+ // to tell a user to export.
115
+ const FLAGS = parseArgs(process.argv.slice(2));
116
+ if (!DETACHED && !LEASHED && FLAGS.foreground !== true && !isOneShot(FLAGS)) {
117
+ const { deckLogDir } = await import("../src/server/deck-home.mjs");
118
+ const { registeredDecks } = await import("../src/server/running-deck.mjs");
119
+ const isTTY = Boolean(process.stdout.isTTY);
120
+ const profile = colorProfile({ isTTY });
121
+ const tone = palette(profile);
122
+ const npx = isNpxInstall(PKG_ROOT);
123
+ const stop = stopCommand({ npx, invokedAs: INVOKED_AS, product: PRODUCT });
124
+ // THE ONE THING AN NPX RUN CANNOT HAVE, said where it is missing. A login item
125
+ // must name a path that will still be there tomorrow, and npx runs out of a
126
+ // cache npm deletes whenever it likes — so an npx deck runs in the background
127
+ // and cannot come back after a reboot. One line, no disk written, offered
128
+ // rather than done: `npx` means "run without installing", and a tool that
129
+ // installs itself anyway is the one people uninstall.
130
+ const offer = npx
131
+ ? ` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --install\` also starts it at login${tone.reset}\n`
132
+ : "";
133
+ const outcome = await detachAndWatch({
134
+ file: fileURLToPath(import.meta.url),
135
+ argv: process.argv.slice(2),
136
+ logDir: deckLogDir(),
137
+ // Only the count, and only to decide whether deck.log is anybody's — see
138
+ // logMode. No handshake: this is a directory listing and a signal-0 each.
139
+ liveCount: (await registeredDecks().catch(() => [])).length,
140
+ isTTY,
141
+ profile,
142
+ columns: termColumns(process.stdout),
143
+ backgroundLine: ` ${tone.muted}${G.dash} running in the background ${G.bullet} \`${stop}\` ends it${tone.reset}\n${offer}\n`,
144
+ });
145
+ // detachAndWatch never returns on the paths that worked. Reaching this line
146
+ // means the log could not be opened at all — a read-only home, a full disk —
147
+ // and a deck that will not start over a LOG is a worse answer than one that
148
+ // stays in the terminal, which is what every version before this did anyway.
149
+ console.error(`${PRODUCT}: could not open ${PRODUCT}'s log (${outcome.reason}) ${G.dash} staying in the foreground.`);
150
+ }
151
+
80
152
  claimRestartFailureKey();
81
153
 
82
154
  // The port the worker actually bound, which is not necessarily the one it was
@@ -100,6 +172,11 @@ let attempting = null;
100
172
  // worker that was already exiting 75 or 76 when the signal landed reads as a
101
173
  // restart request — both resurrect the deck the user just stopped.
102
174
  let stopping = false;
175
+ // When this supervisor has put a crashed worker back, pruned to the window the
176
+ // ceiling is counted in. See crashPolicy: a deck that dies once an hour comes
177
+ // back every time; one that dies five times in ten minutes is broken, and the
178
+ // answer to broken is to stop and say so rather than to spin.
179
+ let crashes = [];
103
180
 
104
181
  function launch(respawn) {
105
182
  // Asked on every spawn rather than once at boot: the deletion this catches
@@ -146,6 +223,21 @@ function launch(respawn) {
146
223
  worker.on("message", (m) => {
147
224
  if (!m || typeof m !== "object") return;
148
225
  if (m.type === "listening" && typeof m.port === "number") boundPort = m.port;
226
+ // The worker saying its boot is finished — every row printed, the browser
227
+ // launched. Forwarded to whoever detached us, who has been tailing the log
228
+ // into the user's terminal and is waiting for exactly this to stop.
229
+ //
230
+ // GUARDED BY `process.connected`, AND GIVEN A CALLBACK, and it took a real
231
+ // crash restart to find out why: the launcher disconnects the moment the
232
+ // first boot finishes, so every LATER boot forwards this down a dead
233
+ // channel — and `process.send` on a closed channel does not throw where the
234
+ // call is, it emits 'error' on `process` a tick later. Unhandled, that ends
235
+ // the supervisor. So a try/catch here was decoration: the guard is the
236
+ // check, and the callback is what turns the remaining race (disconnect
237
+ // between the check and the send) into a value nobody has to catch.
238
+ else if (m.type === "booted" && process.connected) {
239
+ try { process.send({ type: "booted" }, () => {}); } catch { /* the launcher left */ }
240
+ }
149
241
  // The worker asking to be replaced, while it is still serving. Answered by
150
242
  // prefetchUpgrade, which is the whole of this file's new shape: the fetch
151
243
  // happens here, and only then does that worker exit — see UPGRADE_CODE.
@@ -173,6 +265,32 @@ function launch(respawn) {
173
265
  launchNpx();
174
266
  return;
175
267
  }
268
+ // THE DECK FELL OVER, and nobody is watching it any more.
269
+ //
270
+ // Before this ran in the background a crash was self-reporting: the terminal
271
+ // came back with the stack on it. Detached, the first sign is noticing hours
272
+ // later that a day of work was never recorded — so it goes back up. The
273
+ // whole of which crashes qualify is in isCrash, and the ceiling that stops
274
+ // this becoming a spin loop is in crashPolicy.
275
+ if (isCrash({ code, signal, served: boundPort != null, stopping })) {
276
+ const verdict = crashPolicy(crashes);
277
+ if (verdict.restart) {
278
+ crashes = verdict.history;
279
+ const how = signal ? `killed by ${signal}` : `exit ${code}`;
280
+ console.error(`${PRODUCT}: the deck stopped on its own (${how}) ${G.dash} starting it again in ${Math.round(verdict.delayMs / 1000)}s (${verdict.recent}/${CRASH_CEILING}).`);
281
+ restarts++;
282
+ // Unref'd would be wrong here: this timer IS the supervisor's reason to
283
+ // stay alive, and without it the event loop empties and the process
284
+ // exits before the deck it promised to bring back.
285
+ setTimeout(() => { if (!stopping) launch(true); }, verdict.delayMs);
286
+ return;
287
+ }
288
+ // Said once, with the two numbers that make it actionable, and then this
289
+ // process really does end — a supervisor that keeps trying forever is the
290
+ // failure the ceiling exists to prevent.
291
+ console.error(`${PRODUCT}: the deck has stopped ${CRASH_CEILING} times in ${Math.round(CRASH_WINDOW_MS / 60000)} minutes ${G.dash} not starting it again. Run \`${INVOKED_AS ?? PRODUCT}\` when you have looked at the log above.`);
292
+ }
293
+
176
294
  // Anything else is the worker's own verdict and belongs to whoever started
177
295
  // us — including the ccdeck wrapper, which exits with our code in turn. A
178
296
  // worker killed by a signal is reported by dying of the same one; doing
@@ -492,7 +610,10 @@ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
492
610
  // worker's exit is not read as a request to bring it back; the kill is the
493
611
  // signal on POSIX and, through killTree, `taskkill /T /F` on Windows, which has
494
612
  // neither SIGTERM nor a process group to aim at.
495
- dieWithParent(() => {
613
+ // NOT IN THE DETACHED COPY. There the parent is the launcher, which disconnects
614
+ // and exits the moment the deck is up — by design — and arming this would have
615
+ // the deck kill itself a second after every successful start.
616
+ if (!DETACHED) dieWithParent(() => {
496
617
  stopping = true;
497
618
  if (fetching) { killTree(fetching, "SIGTERM"); fetching = null; }
498
619
  if (child) killTree(child, "SIGTERM");