@agent-deck/cli 1.9.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/backend-runtime.d.ts +13 -0
  2. package/dist/backend-runtime.d.ts.map +1 -1
  3. package/dist/backend-runtime.js +20 -0
  4. package/dist/backend-runtime.js.map +1 -1
  5. package/dist/cli-integration-harness.d.ts +93 -0
  6. package/dist/cli-integration-harness.d.ts.map +1 -0
  7. package/dist/cli-integration-harness.js +309 -0
  8. package/dist/cli-integration-harness.js.map +1 -0
  9. package/dist/daemon-logs.d.ts +7 -0
  10. package/dist/daemon-logs.d.ts.map +1 -1
  11. package/dist/daemon-logs.js +55 -0
  12. package/dist/daemon-logs.js.map +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/mcp-bridge.d.ts +182 -0
  17. package/dist/mcp-bridge.d.ts.map +1 -0
  18. package/dist/mcp-bridge.js +856 -0
  19. package/dist/mcp-bridge.js.map +1 -0
  20. package/dist/mcp-launcher.d.ts +9 -0
  21. package/dist/mcp-launcher.d.ts.map +1 -1
  22. package/dist/mcp-launcher.js +47 -0
  23. package/dist/mcp-launcher.js.map +1 -1
  24. package/dist/menubar.d.ts +13 -0
  25. package/dist/menubar.d.ts.map +1 -1
  26. package/dist/menubar.js +32 -1
  27. package/dist/menubar.js.map +1 -1
  28. package/dist/node-runtime.d.ts +14 -2
  29. package/dist/node-runtime.d.ts.map +1 -1
  30. package/dist/node-runtime.js +37 -18
  31. package/dist/node-runtime.js.map +1 -1
  32. package/dist/ports.d.ts +38 -0
  33. package/dist/ports.d.ts.map +1 -1
  34. package/dist/ports.js +63 -0
  35. package/dist/ports.js.map +1 -1
  36. package/dist/setup.js +1 -1
  37. package/dist/setup.js.map +1 -1
  38. package/dist/shutdown-reason.d.ts +105 -0
  39. package/dist/shutdown-reason.d.ts.map +1 -0
  40. package/dist/shutdown-reason.js +247 -0
  41. package/dist/shutdown-reason.js.map +1 -0
  42. package/dist/start.d.ts.map +1 -1
  43. package/dist/start.js +361 -37
  44. package/dist/start.js.map +1 -1
  45. package/dist/status.d.ts.map +1 -1
  46. package/dist/status.js +30 -0
  47. package/dist/status.js.map +1 -1
  48. package/dist/stop.d.ts +14 -1
  49. package/dist/stop.d.ts.map +1 -1
  50. package/dist/stop.js +72 -1
  51. package/dist/stop.js.map +1 -1
  52. package/dist/store.d.ts +9 -0
  53. package/dist/store.d.ts.map +1 -1
  54. package/dist/store.js +41 -0
  55. package/dist/store.js.map +1 -1
  56. package/package.json +3 -3
package/dist/start.js CHANGED
@@ -15,20 +15,40 @@ const version_1 = require("./version");
15
15
  const defaults_1 = require("./defaults");
16
16
  const managed_1 = require("./managed");
17
17
  const daemon_logs_1 = require("./daemon-logs");
18
+ const shutdown_reason_1 = require("./shutdown-reason");
18
19
  const dashboard_open_1 = require("./dashboard-open");
20
+ const backend_runtime_1 = require("./backend-runtime");
21
+ const store_1 = require("./store");
19
22
  function formatStartVersionLine(version = (0, version_1.getAgentDeckVersion)()) {
20
23
  return ` Version ${version}`;
21
24
  }
22
25
  function formatClaudeMcpAddCommand(host, mcpPort) {
23
26
  return `claude mcp add --scope user agent-deck -e AGENT_DECK_MCP_PORT=${mcpPort} -e AGENT_DECK_HOST=${host} -- agent-deck mcp-launch`;
24
27
  }
28
+ const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
25
29
  const children = [];
26
30
  let shuttingDown = false;
31
+ /** Only the process that wrote run.json may clear it — an aborted start must not. */
32
+ let ownsRunState = false;
33
+ /** Non-null until the deck is up: names the phase a stop interrupted. */
34
+ let startupPhase = null;
27
35
  function isSupervisorMode(options) {
28
36
  return options.supervisor === true || process.env.AGENT_DECK_SUPERVISOR === '1';
29
37
  }
30
- async function waitForHealth(url, attempts = 60) {
38
+ /**
39
+ * Set once `runStart` knows what it is. The `--_supervisor` flag alone (no env
40
+ * var) still means stderr *is* supervisor.log, and printing there would write
41
+ * every line twice.
42
+ */
43
+ let supervisorProcess = false;
44
+ function isSupervisorProcess() {
45
+ return supervisorProcess || isSupervisorMode({});
46
+ }
47
+ async function waitForHealth(url, attempts = 60, giveUp) {
31
48
  for (let i = 0; i < attempts; i += 1) {
49
+ if (giveUp?.()) {
50
+ return false;
51
+ }
32
52
  try {
33
53
  const response = await fetch(url);
34
54
  if (response.ok) {
@@ -49,11 +69,59 @@ function resolveServiceStdio(label, ioMode) {
49
69
  const fd = (0, daemon_logs_1.openDaemonLogFd)(label);
50
70
  return ['ignore', fd, fd];
51
71
  }
72
+ /**
73
+ * Copy the child's own last words into supervisor.log. The child already logs
74
+ * why it died, but the operator reading "backend exited (code 1)" is looking at
75
+ * a different file — so bring the reason to them.
76
+ */
77
+ function surfaceChildLogTail(label, ioMode) {
78
+ if (ioMode !== 'file') {
79
+ // Inherit mode already printed the child's output to this terminal.
80
+ return;
81
+ }
82
+ for (const line of (0, daemon_logs_1.formatChildLogTail)(label, (0, daemon_logs_1.readDaemonLogTail)(label, 20))) {
83
+ (0, daemon_logs_1.appendDaemonLogLine)('supervisor', `${new Date().toISOString()} ${line}`);
84
+ }
85
+ }
86
+ /**
87
+ * supervisor.log always, the terminal as well unless stderr already *is* that
88
+ * log. Used by the paths that run outside the ioMode-aware body of `runStart`.
89
+ */
90
+ function reportSupervisor(lines) {
91
+ const stamp = new Date().toISOString();
92
+ for (const line of lines) {
93
+ try {
94
+ (0, daemon_logs_1.appendDaemonLogLine)('supervisor', `${stamp} ${line}`);
95
+ }
96
+ catch {
97
+ // A diagnostic never fails on its own bookkeeping.
98
+ }
99
+ if (!isSupervisorProcess()) {
100
+ console.error(line);
101
+ }
102
+ }
103
+ }
52
104
  function spawnNodeService(label, entry, env, ioMode) {
53
105
  const child = (0, node_child_process_1.spawn)(process.execPath, [entry], {
54
106
  env: { ...process.env, ...env },
55
107
  stdio: resolveServiceStdio(label, ioMode),
56
108
  });
109
+ // A spawn that never starts emits 'error', not 'exit' — and an unhandled
110
+ // 'error' event would take the supervisor down with Node's default trace and
111
+ // no record at all, which is the failure mode this ticket is about.
112
+ child.on('error', (error) => {
113
+ if (shuttingDown) {
114
+ return;
115
+ }
116
+ const { reason, detail } = (0, shutdown_reason_1.describeCrash)(error);
117
+ reportSupervisor([
118
+ `[agent-deck] ${label} failed to spawn: ${reason}`,
119
+ ...detail.map((frame) => `[agent-deck] ${label} spawn| ${frame}`),
120
+ ]);
121
+ if (label === 'backend') {
122
+ void shutdown(1, `backend failed to spawn: ${reason}`);
123
+ }
124
+ });
57
125
  child.on('exit', (code, signal) => {
58
126
  if (shuttingDown) {
59
127
  return;
@@ -66,8 +134,11 @@ function spawnNodeService(label, entry, env, ioMode) {
66
134
  else {
67
135
  console.error(message);
68
136
  }
137
+ if (signal || (code ?? 1) !== 0) {
138
+ surfaceChildLogTail(label, ioMode);
139
+ }
69
140
  if (label === 'backend') {
70
- void shutdown(code ?? 1);
141
+ void shutdown(code ?? 1, `backend exited (${detail})`);
71
142
  return;
72
143
  }
73
144
  const mcpWarn = '[agent-deck] MCP stopped; dashboard API remains available. Run `agent-deck stop && agent-deck start` to recover MCP.';
@@ -81,15 +152,45 @@ function spawnNodeService(label, entry, env, ioMode) {
81
152
  children.push(child);
82
153
  return child;
83
154
  }
84
- async function shutdown(exitCode = 0) {
155
+ /**
156
+ * @param origin what this supervisor observed (a signal, a child exit, a failed
157
+ * start). Enriched with the requester's note when one was left behind, so the
158
+ * log names *who* stopped the deck and not just that it stopped.
159
+ */
160
+ async function shutdown(exitCode = 0, origin = 'origin not recorded') {
85
161
  if (shuttingDown) {
86
162
  return;
87
163
  }
88
164
  shuttingDown = true;
89
- if (isSupervisorMode({})) {
90
- (0, daemon_logs_1.appendDaemonLogLine)('supervisor', `${new Date().toISOString()} [agent-deck] supervisor shutting down (exit ${exitCode})`);
165
+ const request = (0, shutdown_reason_1.consumeStopRequest)({ supervisorPid: process.pid });
166
+ const reason = (0, shutdown_reason_1.composeShutdownReason)(origin, request);
167
+ // Only a process that actually ran the deck may answer "why did it stop?".
168
+ // A `start` that found one already running, or died in preflight, would
169
+ // otherwise record a stop for a deck that is still up and serving.
170
+ const supervised = ownsRunState || children.length > 0;
171
+ if (supervised) {
172
+ // supervisor.log answers "why did the deck stop?" for every run, daemon or
173
+ // not — a Ctrl-C in inherit mode must not be the one stop with no record.
174
+ reportSupervisor([(0, shutdown_reason_1.formatSupervisorShutdownLine)(exitCode, reason)]);
175
+ (0, shutdown_reason_1.writeLastStop)({
176
+ at: new Date().toISOString(),
177
+ exitCode,
178
+ reason,
179
+ supervisorPid: process.pid,
180
+ });
181
+ }
182
+ else if (startupPhase !== null) {
183
+ // Nothing was ever supervised, so this is a start that did not finish —
184
+ // "why won't it start?", which keeps its own record.
185
+ (0, shutdown_reason_1.recordStartFailure)({ reason, exitCode });
186
+ if (!isSupervisorProcess()) {
187
+ console.error((0, shutdown_reason_1.formatStartFailureLine)(reason));
188
+ }
189
+ }
190
+ else {
191
+ reportSupervisor([(0, shutdown_reason_1.formatCommandExitLine)(exitCode, reason)]);
91
192
  }
92
- (0, runtime_state_1.clearRunState)();
193
+ clearOwnRunState();
93
194
  for (const child of children) {
94
195
  if (!child.killed) {
95
196
  child.kill('SIGTERM');
@@ -98,6 +199,117 @@ async function shutdown(exitCode = 0) {
98
199
  await new Promise((resolve) => setTimeout(resolve, 300));
99
200
  process.exit(exitCode);
100
201
  }
202
+ /**
203
+ * A start aborted before `writeRunState` never owned run.json, and deleting the
204
+ * running instance's state would leave `status` and `stop` blind. A record whose
205
+ * supervisor is gone is nobody's, though — that one is ours to clean up.
206
+ */
207
+ function clearOwnRunState() {
208
+ if (ownsRunState) {
209
+ (0, runtime_state_1.clearRunState)();
210
+ return;
211
+ }
212
+ const state = (0, runtime_state_1.readRunState)();
213
+ if (state && (state.cliPid === process.pid || !(0, runtime_state_1.isProcessAlive)(state.cliPid))) {
214
+ (0, runtime_state_1.clearRunState)();
215
+ }
216
+ }
217
+ /** A signal that lands mid-startup names the phase it interrupted. */
218
+ function signalShutdownOrigin(signal) {
219
+ const origin = (0, shutdown_reason_1.describeSignalOrigin)(signal);
220
+ return startupPhase ? `${origin} while starting (phase: ${startupPhase})` : origin;
221
+ }
222
+ /**
223
+ * Every way this process can end, routed through one reporting path. Installed
224
+ * before any startup work — preflight, upgrade checks and port probes all await,
225
+ * and a stop arriving during them used to take Node's default exit path: no
226
+ * shutdown line, no last-stop record, no origin.
227
+ */
228
+ function installSupervisorExitHandlers() {
229
+ for (const signal of SHUTDOWN_SIGNALS) {
230
+ process.on(signal, () => void shutdown(0, signalShutdownOrigin(signal)));
231
+ }
232
+ // The stop with the worst record of all: Node's default handler prints a
233
+ // trace to wherever stderr points and exits, leaving no shutdown line and no
234
+ // last-stop record. Route it through the same reporting as every other stop.
235
+ for (const [event, kind] of [
236
+ ['uncaughtException', 'uncaught exception'],
237
+ ['unhandledRejection', 'unhandled promise rejection'],
238
+ ]) {
239
+ process.on(event, (error) => {
240
+ const { reason, detail } = (0, shutdown_reason_1.describeCrash)(error);
241
+ reportSupervisor(detail.map((frame) => `[agent-deck] supervisor stack| ${frame}`));
242
+ void shutdown(1, `supervisor ${kind}: ${reason}`);
243
+ });
244
+ }
245
+ }
246
+ /**
247
+ * `start --daemon` launcher: it owns no run state and no children, so it records
248
+ * the interrupted start and leaves any supervisor it already spawned running.
249
+ * Once the deck is up (`startupPhase === null`) this process is only printing —
250
+ * interrupting it is not a failed start and must not be recorded as one.
251
+ */
252
+ function installLauncherExitHandlers(getSupervisorPid) {
253
+ for (const signal of SHUTDOWN_SIGNALS) {
254
+ process.on(signal, () => {
255
+ if (startupPhase === null) {
256
+ process.exit(0);
257
+ }
258
+ const supervisorPid = getSupervisorPid();
259
+ (0, shutdown_reason_1.recordStartFailure)({
260
+ reason: `${(0, shutdown_reason_1.describeSignalOrigin)(signal)} while starting in background (phase: ${startupPhase})`,
261
+ detail: survivingSupervisorNote(supervisorPid),
262
+ });
263
+ process.exit(1);
264
+ });
265
+ }
266
+ // Same reasoning as the supervisor's crash handlers: the launcher is the only
267
+ // process watching a background start, so its own throw has to be recorded.
268
+ for (const [event, kind] of [
269
+ ['uncaughtException', 'uncaught exception'],
270
+ ['unhandledRejection', 'unhandled promise rejection'],
271
+ ]) {
272
+ process.on(event, (error) => {
273
+ const { reason, detail } = (0, shutdown_reason_1.describeCrash)(error);
274
+ const frames = detail.map((frame) => `[agent-deck] launcher stack| ${frame}`);
275
+ if (startupPhase === null) {
276
+ // The deck is up and this process was only printing — not a failed
277
+ // start, so it must not be recorded as one.
278
+ reportSupervisor([
279
+ `[agent-deck] start --daemon ${kind} after the deck was up: ${reason}`,
280
+ ...frames,
281
+ ]);
282
+ process.exit(1);
283
+ }
284
+ console.error(`[agent-deck] start --daemon ${kind}: ${reason}`);
285
+ (0, shutdown_reason_1.recordStartFailure)({
286
+ reason: `agent-deck start --daemon ${kind} (phase: ${startupPhase}): ${reason}`,
287
+ detail: [...frames, ...survivingSupervisorNote(getSupervisorPid())],
288
+ });
289
+ process.exit(1);
290
+ });
291
+ }
292
+ }
293
+ /** A launcher that gives up has usually left a working supervisor behind. */
294
+ function survivingSupervisorNote(supervisorPid) {
295
+ return supervisorPid > 0
296
+ ? [
297
+ `[agent-deck] background supervisor (pid ${supervisorPid}) was already spawned and keeps running — check agent-deck status`,
298
+ ]
299
+ : [];
300
+ }
301
+ /**
302
+ * Print (unless stderr already *is* supervisor.log) and persist, so the reason
303
+ * survives in supervisor.log and `agent-deck status`.
304
+ */
305
+ function failStart(failure, supervisor) {
306
+ if (!supervisor) {
307
+ for (const line of failure.lines) {
308
+ console.error(line);
309
+ }
310
+ }
311
+ return (0, shutdown_reason_1.recordStartFailure)({ reason: failure.reason, detail: failure.lines });
312
+ }
101
313
  async function printRunningEndpoints(host, backendPort, mcpPort, backendUrl) {
102
314
  console.log('');
103
315
  console.log('Agent Deck is running');
@@ -136,25 +348,66 @@ function buildSupervisorArgs(options) {
136
348
  }
137
349
  return args;
138
350
  }
139
- async function runDaemonLauncher(options) {
351
+ async function runDaemonLauncher(options, onSupervisorSpawned) {
140
352
  const backendPort = options.backendPort ?? (0, defaults_1.readCliBackendPort)();
141
353
  const mcpPort = options.mcpPort ?? (0, defaults_1.parseCliMcpPort)(process.env.AGENT_DECK_MCP_PORT);
142
354
  const host = process.env.AGENT_DECK_HOST ?? '127.0.0.1';
143
355
  const backendUrl = `http://${host}:${backendPort}`;
144
356
  const supervisorLogFd = (0, daemon_logs_1.openDaemonLogFd)('supervisor');
145
357
  const cliEntry = (0, daemon_logs_1.resolveCliEntry)();
358
+ const launchedAt = Date.now();
146
359
  const child = (0, node_child_process_1.spawn)(process.execPath, [cliEntry, ...buildSupervisorArgs(options)], {
147
360
  detached: true,
148
361
  stdio: ['ignore', supervisorLogFd, supervisorLogFd],
149
362
  env: { ...process.env, AGENT_DECK_SUPERVISOR: '1' },
150
363
  });
364
+ onSupervisorSpawned(child.pid ?? 0);
365
+ // Object, not a `let`: the end arrives from a callback, and the reads below
366
+ // are all after an await. Holds a phrase, not a code, so a supervisor that
367
+ // never started reads as plainly as one that exited.
368
+ const supervisor = { ended: null };
369
+ child.on('exit', (code, signal) => {
370
+ supervisor.ended = signal ? `exited (signal ${signal})` : `exited (code ${code ?? 1})`;
371
+ });
372
+ // Without this handler a failed spawn throws an unhandled 'error' event and
373
+ // kills the launcher before it can write any diagnostic.
374
+ child.on('error', (error) => {
375
+ supervisor.ended = `failed to spawn (${(0, shutdown_reason_1.describeCrash)(error).reason})`;
376
+ });
151
377
  child.unref();
152
- const healthy = await waitForHealth(`${backendUrl}/health`);
378
+ // Waiting out the full health budget after the supervisor is already gone
379
+ // only delays the diagnostic it just wrote.
380
+ const healthy = await waitForHealth(`${backendUrl}/health`, 60, () => supervisor.ended !== null);
153
381
  if (!healthy) {
154
- console.error('[agent-deck] Daemon supervisor failed health check.');
382
+ const reason = supervisor.ended
383
+ ? `daemon supervisor ${supervisor.ended} before the API became healthy`
384
+ : 'daemon supervisor never passed its API health check';
385
+ console.error(`[agent-deck] ${reason}`);
386
+ // The supervisor log already holds the reason (and the child log tail it
387
+ // copied in) — show it here rather than sending the operator hunting.
388
+ for (const line of (0, daemon_logs_1.formatChildLogTail)('supervisor', (0, daemon_logs_1.readDaemonLogTail)('supervisor', 20))) {
389
+ console.error(line);
390
+ }
155
391
  console.error(`[agent-deck] See ${(0, daemon_logs_1.resolveDaemonLogPath)('supervisor')}`);
156
- return 1;
157
- }
392
+ // A supervisor that is merely slow is still running and still holding the
393
+ // ports; saying only "start failed" sends the operator into a port conflict
394
+ // on their next attempt.
395
+ const stillRunning = supervisor.ended === null ? survivingSupervisorNote(child.pid ?? 0) : [];
396
+ for (const line of stillRunning) {
397
+ console.error(line);
398
+ }
399
+ // The supervisor child knows more than "it exited"; keep its record if it
400
+ // got far enough to write one for this launch.
401
+ const recorded = (0, shutdown_reason_1.readLastStartFailure)();
402
+ const supervisorRecorded = recorded !== null && Date.parse(recorded.at) >= launchedAt;
403
+ return (0, shutdown_reason_1.recordStartFailure)({
404
+ reason,
405
+ detail: stillRunning,
406
+ keepExistingRecord: supervisorRecorded,
407
+ });
408
+ }
409
+ // The deck is up; everything past here is reporting, not starting.
410
+ startupPhase = null;
158
411
  const mcpHealthy = await waitForHealth(`http://${host}:${mcpPort}/health`, 20);
159
412
  if (!mcpHealthy) {
160
413
  console.warn('[agent-deck] MCP not healthy yet — dashboard API is up.');
@@ -176,34 +429,51 @@ async function runDaemonLauncher(options) {
176
429
  }
177
430
  return 0;
178
431
  }
432
+ function portConflictFailure(port, label, host) {
433
+ return {
434
+ reason: `port ${port} (${label}) is held by another program on ${host}`,
435
+ lines: (0, ports_1.formatPortConflict)(port, label, host, false)
436
+ .split('\n')
437
+ .map((line) => `[agent-deck] ${line}`),
438
+ };
439
+ }
179
440
  async function ensurePortsAvailable(host, backendPort, mcpPort, probe) {
180
441
  const [backendBusy, mcpBusy] = await Promise.all([
181
442
  (0, ports_1.isTcpPortOpen)(host, backendPort),
182
443
  (0, ports_1.isTcpPortOpen)(host, mcpPort),
183
444
  ]);
184
445
  if (backendBusy && !probe.backendUp) {
185
- console.error(`[agent-deck] ${(0, ports_1.formatPortConflict)(backendPort, 'API/dashboard', host, false)}`);
186
- return 1;
446
+ return portConflictFailure(backendPort, 'API/dashboard', host);
187
447
  }
188
448
  if (mcpBusy && !probe.mcpUp) {
189
- console.error(`[agent-deck] ${(0, ports_1.formatPortConflict)(mcpPort, 'MCP', host, false)}`);
190
- return 1;
449
+ return portConflictFailure(mcpPort, 'MCP', host);
191
450
  }
192
451
  return null;
193
452
  }
194
453
  async function runStart(options = {}) {
195
- const nodeError = (0, node_runtime_1.assertSupportedNodeVersion)();
196
- if (nodeError !== null) {
197
- return nodeError;
454
+ const supervisor = isSupervisorMode(options);
455
+ const launcher = options.daemon === true && !supervisor;
456
+ supervisorProcess = supervisor;
457
+ // Before the first await: preflight, upgrade checks and port probes all take
458
+ // time, and a stop landing in that window used to kill this process through
459
+ // Node's default signal path — no shutdown line, no origin, no record.
460
+ startupPhase = 'preflight';
461
+ let daemonSupervisorPid = 0;
462
+ if (launcher) {
463
+ installLauncherExitHandlers(() => daemonSupervisorPid);
464
+ }
465
+ else {
466
+ installSupervisorExitHandlers();
198
467
  }
199
- const sqliteError = (0, node_runtime_1.assertSqliteNative)();
200
- if (sqliteError !== null) {
201
- return sqliteError;
468
+ const preflight = (0, node_runtime_1.checkStartPreflight)();
469
+ if (preflight !== null) {
470
+ return failStart({ reason: preflight.reason, lines: preflight.message.split('\n') }, supervisor);
202
471
  }
472
+ startupPhase = 'update check';
203
473
  await (0, upgrade_1.maybeAutoUpgradeOnStart)();
204
474
  await (0, upgrade_1.notifyIfUpdateAvailable)();
205
- const supervisor = isSupervisorMode(options);
206
- if (options.daemon && !supervisor) {
475
+ if (launcher) {
476
+ startupPhase = 'probing for a running instance';
207
477
  const backendPort = options.backendPort ?? (0, defaults_1.readCliBackendPort)();
208
478
  const mcpPort = options.mcpPort ?? (0, defaults_1.parseCliMcpPort)(process.env.AGENT_DECK_MCP_PORT);
209
479
  const host = process.env.AGENT_DECK_HOST ?? '127.0.0.1';
@@ -211,22 +481,29 @@ async function runStart(options = {}) {
211
481
  if (probe.backendUp && probe.mcpUp) {
212
482
  if (options.force) {
213
483
  console.log('[agent-deck] Restarting existing instance (--force) ...');
214
- await (0, stop_1.runStop)();
484
+ await (0, stop_1.runStop)({ source: 'agent-deck start --force', detail: 'restarting existing instance' });
215
485
  await new Promise((resolve) => setTimeout(resolve, 500));
216
486
  }
217
487
  else {
488
+ // Nothing is starting any more — an interrupt from here on is a stopped
489
+ // printout, not a failed start, and must not be recorded as one.
490
+ startupPhase = null;
218
491
  await printRunningEndpoints(host, backendPort, mcpPort, `http://${host}:${backendPort}`);
219
492
  console.log('Already running. Use `agent-deck stop` or `agent-deck start --daemon --force` to restart.');
220
493
  await maybeOpenDashboard(`http://${host}:${backendPort}`, options.openBrowser);
221
494
  return 0;
222
495
  }
223
496
  }
497
+ startupPhase = 'checking ports';
224
498
  const refreshedProbe = options.force ? await (0, ports_1.probeAgentDeck)(host, backendPort, mcpPort) : probe;
225
499
  const portError = await ensurePortsAvailable(host, backendPort, mcpPort, refreshedProbe);
226
500
  if (portError !== null) {
227
- return portError;
501
+ return failStart(portError, supervisor);
228
502
  }
229
- return runDaemonLauncher(options);
503
+ startupPhase = 'launching background supervisor';
504
+ return runDaemonLauncher(options, (pid) => {
505
+ daemonSupervisorPid = pid;
506
+ });
230
507
  }
231
508
  const ioMode = supervisor ? 'file' : 'inherit';
232
509
  const backendPort = options.backendPort ?? (0, defaults_1.readCliBackendPort)();
@@ -234,24 +511,29 @@ async function runStart(options = {}) {
234
511
  const host = process.env.AGENT_DECK_HOST ?? '127.0.0.1';
235
512
  const backendUrl = `http://${host}:${backendPort}`;
236
513
  const uiDist = options.skipUi ? undefined : (0, paths_1.resolveUiDist)();
514
+ startupPhase = 'probing for a running instance';
237
515
  const probe = await (0, ports_1.probeAgentDeck)(host, backendPort, mcpPort);
238
516
  if (probe.backendUp && probe.mcpUp) {
239
517
  if (options.force) {
240
518
  console.log('[agent-deck] Restarting existing instance (--force) ...');
241
- await (0, stop_1.runStop)();
519
+ await (0, stop_1.runStop)({ source: 'agent-deck start --force', detail: 'restarting existing instance' });
242
520
  await new Promise((resolve) => setTimeout(resolve, 500));
243
521
  }
244
522
  else {
523
+ // As above: past this point this process is only reporting on a deck it
524
+ // did not start, so it owns neither a stop nor a failed start.
525
+ startupPhase = null;
245
526
  await printRunningEndpoints(host, backendPort, mcpPort, backendUrl);
246
527
  console.log('Already running. Use `agent-deck stop` or `agent-deck start --force` to restart.');
247
528
  await maybeOpenDashboard(backendUrl, options.openBrowser);
248
529
  return 0;
249
530
  }
250
531
  }
532
+ startupPhase = 'checking ports';
251
533
  const refreshedProbe = options.force ? await (0, ports_1.probeAgentDeck)(host, backendPort, mcpPort) : probe;
252
534
  const portError = await ensurePortsAvailable(host, backendPort, mcpPort, refreshedProbe);
253
535
  if (portError !== null) {
254
- return portError;
536
+ return failStart(portError, supervisor);
255
537
  }
256
538
  if (!options.skipUi && !uiDist) {
257
539
  const warnUi = '[agent-deck] Dashboard UI bundle not found (static-ui). API and MCP will still start.';
@@ -265,8 +547,17 @@ async function runStart(options = {}) {
265
547
  console.warn(warnDist);
266
548
  }
267
549
  }
268
- const backendEntry = (0, paths_1.resolveBackendEntry)('index');
269
- const mcpEntry = (0, paths_1.resolveBackendEntry)('mcp-index');
550
+ startupPhase = 'resolving the backend build';
551
+ let backendEntry;
552
+ let mcpEntry;
553
+ try {
554
+ backendEntry = (0, paths_1.resolveBackendEntry)('index');
555
+ mcpEntry = (0, paths_1.resolveBackendEntry)('mcp-index');
556
+ }
557
+ catch (error) {
558
+ const message = error instanceof Error ? error.message : String(error);
559
+ return failStart({ reason: message, lines: [`[agent-deck] ${message}`] }, supervisor);
560
+ }
270
561
  const logStart = (line) => {
271
562
  if (ioMode === 'file') {
272
563
  (0, daemon_logs_1.appendDaemonLogLine)('supervisor', `${new Date().toISOString()} ${line}`);
@@ -275,6 +566,7 @@ async function runStart(options = {}) {
275
566
  console.log(line);
276
567
  }
277
568
  };
569
+ startupPhase = 'starting backend';
278
570
  logStart('[agent-deck] Starting backend ...');
279
571
  const backendChild = spawnNodeService('backend', backendEntry, {
280
572
  PORT: String(backendPort),
@@ -284,19 +576,33 @@ async function runStart(options = {}) {
284
576
  AGENT_DECK_MCP_PORT: String(mcpPort),
285
577
  ...(uiDist ? { AGENT_DECK_UI_DIST: uiDist } : {}),
286
578
  }, ioMode);
287
- const healthy = await waitForHealth(`${backendUrl}/health`);
579
+ // A backend that exits takes the shutdown path immediately; polling out the
580
+ // remaining health budget would only postpone this process's own exit.
581
+ const healthy = await waitForHealth(`${backendUrl}/health`, 60, () => shuttingDown);
288
582
  if (!healthy) {
583
+ if (shuttingDown) {
584
+ // Something else (the backend's own exit, or a stop) already logged the
585
+ // cause and is shutting down with its own exit code. Returning here would
586
+ // race that exit and could report a failure for a requested stop; repeating
587
+ // the tail would only double it in supervisor.log.
588
+ await new Promise(() => {
589
+ // shutdown() exits this process.
590
+ });
591
+ }
289
592
  const failMsg = '[agent-deck] Backend failed health check (port conflict or crash).';
290
593
  if (ioMode === 'file') {
291
- (0, daemon_logs_1.appendDaemonLogLine)('supervisor', failMsg);
594
+ (0, daemon_logs_1.appendDaemonLogLine)('supervisor', `${new Date().toISOString()} ${failMsg}`);
292
595
  }
293
596
  else {
294
597
  console.error(failMsg);
598
+ console.error(`[agent-deck] See ${(0, daemon_logs_1.resolveDaemonLogPath)('backend')}`);
295
599
  console.error('[agent-deck] Run: agent-deck status');
296
600
  }
297
- await shutdown(1);
601
+ surfaceChildLogTail('backend', ioMode);
602
+ await shutdown(1, 'backend failed health check during start');
298
603
  return 1;
299
604
  }
605
+ startupPhase = 'starting MCP server';
300
606
  logStart('[agent-deck] Starting MCP server ...');
301
607
  let mcpPid = 0;
302
608
  if (refreshedProbe.mcpUp) {
@@ -315,12 +621,14 @@ async function runStart(options = {}) {
315
621
  if (!mcpProbe.mcpUp) {
316
622
  const mcpFail = '[agent-deck] MCP server failed to start (port conflict or crash). Dashboard API is still running.';
317
623
  if (ioMode === 'file') {
318
- (0, daemon_logs_1.appendDaemonLogLine)('supervisor', mcpFail);
624
+ (0, daemon_logs_1.appendDaemonLogLine)('supervisor', `${new Date().toISOString()} ${mcpFail}`);
319
625
  }
320
626
  else {
321
627
  console.error(mcpFail);
628
+ console.error(`[agent-deck] See ${(0, daemon_logs_1.resolveDaemonLogPath)('mcp')}`);
322
629
  console.error('[agent-deck] Run: agent-deck stop && agent-deck start');
323
630
  }
631
+ surfaceChildLogTail('mcp', ioMode);
324
632
  }
325
633
  else {
326
634
  mcpPid = mcpChild.pid ?? 0;
@@ -335,6 +643,9 @@ async function runStart(options = {}) {
335
643
  cliPid: process.pid,
336
644
  startedAt: new Date().toISOString(),
337
645
  });
646
+ ownsRunState = true;
647
+ startupPhase = null;
648
+ (0, shutdown_reason_1.clearLastStartFailure)();
338
649
  const dashboardLine = uiDist
339
650
  ? (0, dashboard_open_1.formatDashboardStatusLine)()
340
651
  : 'Dashboard (UI bundle missing — use npm run dev:all for dev UI)';
@@ -369,8 +680,6 @@ async function runStart(options = {}) {
369
680
  console.warn(`[agent-deck] ${result.message ?? 'Failed to open dashboard'}`);
370
681
  }
371
682
  }
372
- process.on('SIGINT', () => void shutdown(0));
373
- process.on('SIGTERM', () => void shutdown(0));
374
683
  await new Promise(() => {
375
684
  // keep alive until signal
376
685
  });
@@ -388,7 +697,7 @@ async function runDoctor() {
388
697
  console.error('FAIL: Node.js 20+ required (24 recommended — current OS default)');
389
698
  ok = false;
390
699
  }
391
- else if (![20, 22, 23, 24, 25, 26].includes(nodeMajor)) {
700
+ else if (!(0, node_runtime_1.isSupportedNodeMajor)(nodeMajor)) {
392
701
  console.error('FAIL: Unsupported Node.js major for better-sqlite3 prebuilds');
393
702
  console.error(' Use Node 20+; Node 24 is the default target');
394
703
  ok = false;
@@ -447,6 +756,21 @@ async function runDoctor() {
447
756
  else {
448
757
  console.warn('WARN: Agent Deck is not running (agent-deck start)');
449
758
  }
759
+ // A store→sqlite import that failed leaves a healthy-looking backend serving a
760
+ // stale snapshot, so doctor has to fail on it (NOT-123).
761
+ const lastReindex = (0, backend_runtime_1.readLastReindex)();
762
+ const [reindexHeadline, ...reindexDetail] = (0, store_1.formatLastReindex)(lastReindex);
763
+ if (reindexHeadline) {
764
+ if (lastReindex?.ok) {
765
+ console.log(`OK: ${reindexHeadline}`);
766
+ reindexDetail.forEach((line) => console.log(` ${line}`));
767
+ }
768
+ else {
769
+ console.error(`FAIL: ${reindexHeadline}`);
770
+ reindexDetail.forEach((line) => console.error(` ${line}`));
771
+ ok = false;
772
+ }
773
+ }
450
774
  return ok ? 0 : 1;
451
775
  }
452
776
  //# sourceMappingURL=start.js.map