codedeck 0.7.0 → 0.7.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 (121) hide show
  1. package/README.md +49 -34
  2. package/dist/cli/commands/claims.js +3 -2
  3. package/dist/cli/commands/claims.js.map +1 -1
  4. package/dist/cli/commands/doctor.js +8 -40
  5. package/dist/cli/commands/doctor.js.map +1 -1
  6. package/dist/cli/commands/logs.js +2 -2
  7. package/dist/cli/commands/logs.js.map +1 -1
  8. package/dist/cli/commands/open.js +8 -9
  9. package/dist/cli/commands/open.js.map +1 -1
  10. package/dist/cli/commands/ps.js +6 -6
  11. package/dist/cli/commands/ps.js.map +1 -1
  12. package/dist/cli/commands/review.js +36 -58
  13. package/dist/cli/commands/review.js.map +1 -1
  14. package/dist/cli/commands/run.js +27 -27
  15. package/dist/cli/commands/run.js.map +1 -1
  16. package/dist/cli/commands/setup.js +96 -314
  17. package/dist/cli/commands/setup.js.map +1 -1
  18. package/dist/cli/commands/show.js +2 -1
  19. package/dist/cli/commands/show.js.map +1 -1
  20. package/dist/cli/commands/ui.js +61 -0
  21. package/dist/cli/commands/ui.js.map +1 -0
  22. package/dist/cli/commands/usage.js +65 -42
  23. package/dist/cli/commands/usage.js.map +1 -1
  24. package/dist/cli/commands/wait.js +3 -2
  25. package/dist/cli/commands/wait.js.map +1 -1
  26. package/dist/cli/index.js +55 -39
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/cli/web-launch.js +126 -0
  29. package/dist/cli/web-launch.js.map +1 -0
  30. package/dist/config/config.js +0 -70
  31. package/dist/config/config.js.map +1 -1
  32. package/dist/config/paths.js +1 -0
  33. package/dist/config/paths.js.map +1 -1
  34. package/dist/config/setup.js +248 -0
  35. package/dist/config/setup.js.map +1 -0
  36. package/dist/config/web-host.js +31 -0
  37. package/dist/config/web-host.js.map +1 -0
  38. package/dist/config/web-port.js +24 -0
  39. package/dist/config/web-port.js.map +1 -0
  40. package/dist/core/claude-transcript.js +87 -0
  41. package/dist/core/claude-transcript.js.map +1 -1
  42. package/dist/core/errors.js +17 -0
  43. package/dist/core/errors.js.map +1 -1
  44. package/dist/core/models.js +4 -1
  45. package/dist/core/models.js.map +1 -1
  46. package/dist/core/pricing.js +73 -50
  47. package/dist/core/pricing.js.map +1 -1
  48. package/dist/core/roles.js +6 -5
  49. package/dist/core/roles.js.map +1 -1
  50. package/dist/core/run-skills.js +114 -0
  51. package/dist/core/run-skills.js.map +1 -0
  52. package/dist/core/run-usage.js +4 -1
  53. package/dist/core/run-usage.js.map +1 -1
  54. package/dist/core/session.js +6 -16
  55. package/dist/core/session.js.map +1 -1
  56. package/dist/core/usage-query.js +41 -0
  57. package/dist/core/usage-query.js.map +1 -0
  58. package/dist/daemon/build-id.js +30 -0
  59. package/dist/daemon/build-id.js.map +1 -0
  60. package/dist/daemon/daemon.js +1116 -767
  61. package/dist/daemon/daemon.js.map +1 -1
  62. package/dist/daemon/instance-lock.js +48 -0
  63. package/dist/daemon/instance-lock.js.map +1 -0
  64. package/dist/daemon/web-supervisor.js +251 -0
  65. package/dist/daemon/web-supervisor.js.map +1 -0
  66. package/dist/drivers/claude/driver.js +1 -0
  67. package/dist/drivers/claude/driver.js.map +1 -1
  68. package/dist/drivers/codex/driver.js +14 -3
  69. package/dist/drivers/codex/driver.js.map +1 -1
  70. package/dist/drivers/codex/host-skills.js +57 -0
  71. package/dist/drivers/codex/host-skills.js.map +1 -0
  72. package/dist/drivers/opencode/driver.js +6 -2
  73. package/dist/drivers/opencode/driver.js.map +1 -1
  74. package/dist/drivers/session-runtime.js +2 -0
  75. package/dist/drivers/session-runtime.js.map +1 -1
  76. package/dist/drivers/terminal.js +15 -0
  77. package/dist/drivers/terminal.js.map +1 -1
  78. package/dist/git/diff.js +29 -4
  79. package/dist/git/diff.js.map +1 -1
  80. package/dist/open/contract.js.map +1 -1
  81. package/dist/open/pty.js +330 -5
  82. package/dist/open/pty.js.map +1 -1
  83. package/dist/plugin/agents/general.md +33 -6
  84. package/dist/plugin/hooks/pane-ticker.ts +102 -0
  85. package/dist/plugin/hooks/register.tsx +33 -8
  86. package/dist/plugin/mods/agents/pane.ts +259 -136
  87. package/dist/plugin/mods/agents/types.ts +19 -3
  88. package/dist/plugin/statusline.sh +22 -34
  89. package/dist/store/database.js +5 -1
  90. package/dist/store/database.js.map +1 -1
  91. package/dist/store/sessions.js +55 -12
  92. package/dist/store/sessions.js.map +1 -1
  93. package/dist/utils/process.js +2 -16
  94. package/dist/utils/process.js.map +1 -1
  95. package/dist/web/brand.js +59 -0
  96. package/dist/web/brand.js.map +1 -0
  97. package/dist/web/child.js +67 -0
  98. package/dist/web/child.js.map +1 -0
  99. package/dist/web/home-page.js +48 -0
  100. package/dist/web/home-page.js.map +1 -0
  101. package/dist/web/review-page.js +12 -5
  102. package/dist/web/review-page.js.map +1 -1
  103. package/dist/web/security.js +170 -0
  104. package/dist/web/security.js.map +1 -0
  105. package/dist/web/server.js +198 -0
  106. package/dist/web/server.js.map +1 -0
  107. package/dist/web/setup-icons.js +37 -0
  108. package/dist/web/setup-icons.js.map +1 -0
  109. package/dist/web/setup-page.js +1795 -0
  110. package/dist/web/setup-page.js.map +1 -0
  111. package/dist/web/setup-routes.js +475 -0
  112. package/dist/web/setup-routes.js.map +1 -0
  113. package/dist/web/usage-page.js +609 -0
  114. package/dist/web/usage-page.js.map +1 -0
  115. package/dist/web/usage-routes.js +88 -0
  116. package/dist/web/usage-routes.js.map +1 -0
  117. package/dist/web/web-token.js +56 -0
  118. package/dist/web/web-token.js.map +1 -0
  119. package/package.json +1 -1
  120. package/dist/plugin/prompts/_partials/commit.md +0 -145
  121. package/dist/plugin/prompts/_partials/pr-writer.md +0 -186
@@ -1,30 +1,34 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { spawn } from "node:child_process";
5
4
  import { Database } from "../store/database.js";
6
- import { SessionStore } from "../store/sessions.js";
5
+ import { AmbiguousSessionIdError, SessionStore } from "../store/sessions.js";
7
6
  import { EventStore } from "../store/events.js";
8
7
  import { ClaimsStore } from "../store/claims.js";
9
8
  import { getPaths, ensureDirs } from "../config/paths.js";
10
9
  import { createIpcServer } from "./ipc.js";
10
+ import { acquireInstanceLock } from "./instance-lock.js";
11
+ import { WebEnsureError, WebSupervisor } from "./web-supervisor.js";
11
12
  import { getRegistry } from "../drivers/registry.js";
12
- import { isTerminalStatus, liveStatus, normalizeAgentId } from "../core/session.js";
13
+ import { isActiveStatus, isTerminalStatus, liveStatus, normalizeAgentId } from "../core/session.js";
13
14
  import { parseSandbox } from "../core/driver.js";
14
15
  import { generateSessionId } from "../core/session.js";
15
16
  import { getGitInfo } from "../git/repository.js";
16
17
  import { createWorktree } from "../git/worktree.js";
17
18
  import { getDiff } from "../git/diff.js";
18
- import { killTree, processAlive, processStartTime, resolveInhibitBin, sleep } from "../utils/process.js";
19
+ import { killTree, processAlive, processStartTime, sleep } from "../utils/process.js";
19
20
  import { readSessionProcessMetadata } from "../drivers/session-runtime.js";
20
21
  import { loadConfig, resolveDefaultSandbox } from "../config/config.js";
21
- import { classifyFailure, RunAgentError } from "../core/errors.js";
22
+ import { invalidWebHostMessage, resolveWebHost } from "../config/web-host.js";
23
+ import { invalidWebPortMessage, resolveWebPort } from "../config/web-port.js";
24
+ import { classifyFailure, isStoreBusy, RunAgentError } from "../core/errors.js";
25
+ import { parseRole } from "../core/roles.js";
22
26
  import { getCachedOrDiscoverModels } from "../core/models.js";
23
27
  import { aggregateRunUsage } from "../core/run-usage.js";
24
28
  import { UsageLedger } from "../store/usage-ledger.js";
25
29
  import { openSourceKey, workerSourceKey } from "../core/usage-source.js";
26
30
  import { NativeLinkStore } from "../store/native-links.js";
27
- import { findTranscript, readTranscriptUsage } from "../core/claude-transcript.js";
31
+ import { consumeTranscriptChunk, findTranscript, readTranscriptUsage, TRANSCRIPT_CHUNK_LIMIT_BYTES, } from "../core/claude-transcript.js";
28
32
  // Daemon's view of power readiness for the doctor IPC result (field names
29
33
  // fixed by cross-worker contract; the CLI falls back to local detection
30
34
  // when the daemon is unreachable).
@@ -74,6 +78,16 @@ function livePidIdentity(s) {
74
78
  processAlive(s.pid) &&
75
79
  processStartTime(s.pid) === s.pidStartTime);
76
80
  }
81
+ // Backoff for a busy store in the event loop; capped, never given up while
82
+ // the daemon runs (see persistDriverEvent).
83
+ const STORE_BUSY_RETRY_BASE_MS = 25;
84
+ const STORE_BUSY_RETRY_MAX_MS = 2000;
85
+ function appendDaemonLog(line) {
86
+ try {
87
+ fs.appendFileSync(getPaths().daemonLog, `[${new Date().toISOString()}] ${line}\n`);
88
+ }
89
+ catch { }
90
+ }
77
91
  class Daemon {
78
92
  db;
79
93
  sessions;
@@ -87,16 +101,174 @@ class Daemon {
87
101
  startTime = Date.now();
88
102
  startupReconcilePromise = Promise.resolve();
89
103
  sessionLocks = new Set();
104
+ reservedSessionIds = new Set();
105
+ // Sessions recover() revived from a store-busy failure: the sequence the
106
+ // drain starts after, and the log mtime when the harness is already dead.
107
+ healing = new Map();
90
108
  // Power-shutdown state. `shuttingDown` is set synchronously by the signal
91
109
  // handler so concurrent handleRequest calls are refused during the drain.
92
110
  shuttingDown = false;
93
111
  // Exactly-once drain: concurrent handleShutdown callers share one promise.
94
112
  shutdownPromise = null;
95
- // Best-effort delay-lock child (systemd-inhibit), alive for the daemon's
96
- // whole life when the binary exists; killed after TRUNCATE in the drain.
97
- inhibitChild = null;
98
- inhibitExitHookInstalled = false;
113
+ // Supervisor of the web console child, created on the first web.ensure.
114
+ web;
115
+ spawnWebChild;
99
116
  inFlightModels = new Map();
117
+ inFlightOpenUsageReconciliations = new Map();
118
+ liveTranscriptCursors = new Map();
119
+ liveTranscriptQueues = new Map();
120
+ async withLiveTranscriptLock(nativeId, operation) {
121
+ const previous = this.liveTranscriptQueues.get(nativeId) ?? Promise.resolve();
122
+ let unlock;
123
+ const current = new Promise((resolve) => { unlock = resolve; });
124
+ this.liveTranscriptQueues.set(nativeId, current);
125
+ await previous;
126
+ try {
127
+ return await operation();
128
+ }
129
+ finally {
130
+ unlock();
131
+ if (this.liveTranscriptQueues.get(nativeId) === current) {
132
+ this.liveTranscriptQueues.delete(nativeId);
133
+ }
134
+ }
135
+ }
136
+ async ingestLiveTranscript(sessionId, nativeId, suppliedPath) {
137
+ return this.withLiveTranscriptLock(nativeId, async () => {
138
+ const isActiveClaudeOpenRow = () => {
139
+ const session = this.sessions.get(sessionId);
140
+ return session?.origin === "open" && session.agent === "claude" && isActiveStatus(session.status);
141
+ };
142
+ if (!isActiveClaudeOpenRow())
143
+ return [];
144
+ let resolvedPath;
145
+ let stat;
146
+ try {
147
+ const projectsRoot = await fs.promises.realpath(path.join(os.homedir(), ".claude", "projects"));
148
+ resolvedPath = await fs.promises.realpath(suppliedPath);
149
+ const relativePath = path.relative(projectsRoot, resolvedPath);
150
+ if (relativePath === "" ||
151
+ relativePath === ".." ||
152
+ relativePath.startsWith(`..${path.sep}`) ||
153
+ path.isAbsolute(relativePath) ||
154
+ path.basename(resolvedPath) !== `${nativeId}.jsonl`)
155
+ return [];
156
+ stat = await fs.promises.stat(resolvedPath);
157
+ if (!stat.isFile())
158
+ return [];
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ let file;
164
+ try {
165
+ file = await fs.promises.open(resolvedPath, "r");
166
+ }
167
+ catch {
168
+ return [];
169
+ }
170
+ try {
171
+ if (!isActiveClaudeOpenRow())
172
+ return [];
173
+ let previous = [];
174
+ try {
175
+ previous = this.nativeLinks.link(sessionId, nativeId).previous;
176
+ const saved = this.liveTranscriptCursors.get(nativeId);
177
+ const reset = saved !== undefined &&
178
+ (saved.device !== stat.dev || saved.inode !== stat.ino || stat.size < saved.byteOffset);
179
+ const state = {
180
+ pendingLine: reset ? new Uint8Array() : saved?.pendingLine ?? new Uint8Array(),
181
+ discardUntilNewline: reset ? false : saved?.discardUntilNewline ?? false,
182
+ inputTokens: saved?.inputTokens ?? 0,
183
+ outputTokens: saved?.outputTokens ?? 0,
184
+ cachedTokens: saved?.cachedTokens ?? 0,
185
+ seenMessageKeys: saved?.seenMessageKeys ?? new Set(),
186
+ };
187
+ const byteOffset = reset ? 0 : saved?.byteOffset ?? 0;
188
+ const bytesToRead = Math.min(TRANSCRIPT_CHUNK_LIMIT_BYTES, Math.max(0, stat.size - byteOffset));
189
+ const buffer = Buffer.alloc(bytesToRead);
190
+ const { bytesRead } = bytesToRead === 0
191
+ ? { bytesRead: 0 }
192
+ : await file.read(buffer, 0, bytesToRead, byteOffset);
193
+ const nextState = consumeTranscriptChunk(state, buffer.subarray(0, bytesRead));
194
+ if (!isActiveClaudeOpenRow())
195
+ return previous;
196
+ const db = this.db.getHandle();
197
+ db.exec("BEGIN");
198
+ try {
199
+ this.usageLedger.observe(sessionId, openSourceKey(nativeId), {
200
+ inputTokens: nextState.inputTokens,
201
+ outputTokens: nextState.outputTokens,
202
+ cachedTokens: nextState.cachedTokens,
203
+ });
204
+ db.exec("COMMIT");
205
+ }
206
+ catch {
207
+ try {
208
+ db.exec("ROLLBACK");
209
+ }
210
+ catch { }
211
+ return previous;
212
+ }
213
+ this.liveTranscriptCursors.set(nativeId, {
214
+ ...nextState,
215
+ byteOffset: byteOffset + bytesRead,
216
+ device: stat.dev,
217
+ inode: stat.ino,
218
+ });
219
+ return previous;
220
+ }
221
+ catch {
222
+ return previous;
223
+ }
224
+ }
225
+ finally {
226
+ await file.close().catch(() => { });
227
+ }
228
+ });
229
+ }
230
+ queueOpenUsageReconciliation(sessionId, nativeIds) {
231
+ const uniqueIds = [...new Set(nativeIds)];
232
+ const keyFor = (nativeId) => JSON.stringify([sessionId, nativeId]);
233
+ const pendingIds = uniqueIds.filter((nativeId) => !this.inFlightOpenUsageReconciliations.has(keyFor(nativeId)));
234
+ if (pendingIds.length === 0)
235
+ return;
236
+ const keys = pendingIds.map(keyFor);
237
+ const reconciliation = Promise.resolve().then(() => this.reconcileOpenUsageSafely(sessionId, pendingIds));
238
+ for (const key of keys)
239
+ this.inFlightOpenUsageReconciliations.set(key, reconciliation);
240
+ const clearInFlight = () => {
241
+ for (const key of keys) {
242
+ if (this.inFlightOpenUsageReconciliations.get(key) === reconciliation) {
243
+ this.inFlightOpenUsageReconciliations.delete(key);
244
+ }
245
+ }
246
+ };
247
+ void reconciliation.then(clearInFlight, clearInFlight);
248
+ }
249
+ async cleanupReleasedTranscriptCursors(sessionId) {
250
+ const nativeIds = new Set(this.nativeLinks.linksFor([sessionId]).map((link) => link.nativeId));
251
+ await Promise.all([...nativeIds].map((nativeId) => this.withLiveTranscriptLock(nativeId, async () => {
252
+ const activeClaudeOpenIds = this.sessions.listActive()
253
+ .filter((session) => session.origin === "open" && session.agent === "claude")
254
+ .map((session) => session.id);
255
+ const stillActive = this.nativeLinks.linksFor(activeClaudeOpenIds)
256
+ .some((link) => link.nativeId === nativeId);
257
+ if (!stillActive)
258
+ this.liveTranscriptCursors.delete(nativeId);
259
+ })));
260
+ }
261
+ queueReleasedTranscriptCursorCleanup(sessionId) {
262
+ queueMicrotask(() => {
263
+ void this.cleanupReleasedTranscriptCursors(sessionId).catch((error) => {
264
+ const detail = error instanceof Error ? error.message : String(error);
265
+ try {
266
+ fs.appendFileSync(getPaths().daemonLog, `[${new Date().toISOString()}] usage cursor cleanup failed session=${sessionId}: ${detail}\n`);
267
+ }
268
+ catch { }
269
+ });
270
+ });
271
+ }
100
272
  async fetchModels(agent, refresh) {
101
273
  const key = `${agent || "all"}:${Boolean(refresh)}`;
102
274
  const existing = this.inFlightModels.get(key);
@@ -108,7 +280,9 @@ class Daemon {
108
280
  this.inFlightModels.set(key, promise);
109
281
  return promise;
110
282
  }
111
- constructor() {
283
+ constructor(options = {}) {
284
+ this.web = options.webSupervisor;
285
+ this.spawnWebChild = options.spawnWebChild;
112
286
  ensureDirs();
113
287
  this.db = new Database();
114
288
  const handle = this.db.getHandle();
@@ -158,14 +332,16 @@ class Daemon {
158
332
  }
159
333
  catch { }
160
334
  // Graceful power shutdown: exactly-once drain on power signals. SIGHUP
161
- // covers lid-close/logout via logind. Keep listeners installed so a
335
+ // covers logout/session teardown. Keep listeners installed so a
162
336
  // repeated signal reaches the synchronous shuttingDown guard.
337
+ //
338
+ // No systemd-inhibit delay lock: one only helps a holder that reacts to
339
+ // logind's PrepareForShutdown and then releases it. The drain starts on
340
+ // SIGTERM, which arrives after the delay expires, so the lock stalled
341
+ // every poweroff and suspend for the full InhibitDelayMaxSec for nothing.
163
342
  process.on("SIGTERM", () => this.onSignal("SIGTERM"));
164
343
  process.on("SIGINT", () => this.onSignal("SIGINT"));
165
344
  process.on("SIGHUP", () => this.onSignal("SIGHUP"));
166
- // Best-effort delay lock so the system waits for the drain. Silent
167
- // no-op when systemd-inhibit is absent (containers, macOS, CI).
168
- this.maybeSpawnInhibit();
169
345
  console.log(`[daemon] listening on ${paths.daemonSock} pid=${process.pid}`);
170
346
  // Keep alive
171
347
  // Write log
@@ -180,6 +356,7 @@ class Daemon {
180
356
  // streaming; a dead one is drained and classified from its log tail.
181
357
  async recover() {
182
358
  const paths = getPaths();
359
+ this.reviveStoreBusyFailures(paths.logsDir);
183
360
  const actives = this.sessions.listActive();
184
361
  for (const s of actives) {
185
362
  // Power-shutdown rows are terminal: never reattach, never flip. A
@@ -248,7 +425,10 @@ class Daemon {
248
425
  // Never attach to or signal a PID whose identity changed while the
249
426
  // daemon was away. The original harness is gone; the replacement is
250
427
  // somebody else's process.
251
- if (pidReused) {
428
+ // A revived row died long ago: its PID may be anyone's by now, but its
429
+ // log is still its own, so drain it as dead instead of failing it.
430
+ const healing = this.healing.has(s.id);
431
+ if (pidReused && !healing) {
252
432
  const failure = {
253
433
  code: "HARNESS_CRASH",
254
434
  blame: "harness",
@@ -269,13 +449,14 @@ class Daemon {
269
449
  this.sessions.setStatus(s.id, "failed", { lastEvent: detail, failure });
270
450
  continue;
271
451
  }
272
- const alive = processPresent;
452
+ // Only a healing row reaches here with a reused PID; it counts as dead.
453
+ const alive = processPresent && !pidReused;
273
454
  const stdoutPath = path.join(paths.logsDir, `${s.id}.ndjson`);
274
455
  const stderrPath = path.join(paths.logsDir, `${s.id}.stderr.log`);
275
456
  const hasDetachedLogs = fs.existsSync(stdoutPath) || fs.existsSync(stderrPath);
276
457
  // A live PID without a recorded identity is unsafe to attach: it may be
277
458
  // a recycled process. A dead PID is safe to drain from its own log.
278
- const identityVerified = !processPresent || (recordedStart !== undefined && currentStart === recordedStart);
459
+ const identityVerified = !alive || (recordedStart !== undefined && currentStart === recordedStart);
279
460
  if (typeof driver.attach === "function" && pid != null && hasDetachedLogs && identityVerified) {
280
461
  try {
281
462
  await driver.attach({
@@ -321,6 +502,61 @@ class Daemon {
321
502
  }
322
503
  }
323
504
  }
505
+ // A session the old event loop failed on "database is locked" was never
506
+ // over: the harness kept writing its log past the persisted offset. Flip
507
+ // such rows back to working so the reattach loop below drains the log
508
+ // (dead harness) or keeps tailing it (live one), exactly as after a restart.
509
+ reviveStoreBusyFailures(logsDir) {
510
+ for (const s of this.sessions.listStoreBusyFailures()) {
511
+ if (s.origin === "open")
512
+ continue;
513
+ const logs = [path.join(logsDir, `${s.id}.ndjson`), path.join(logsDir, `${s.id}.stderr.log`)]
514
+ .filter((file) => fs.existsSync(file));
515
+ const metadata = readSessionProcessMetadata(s.id);
516
+ const pid = metadata?.pid ?? s.pid;
517
+ if (logs.length === 0 || pid == null)
518
+ continue;
519
+ const recordedStart = metadata?.pidStartTime ?? s.pidStartTime;
520
+ const alive = processAlive(pid) && (recordedStart === undefined || processStartTime(pid) === recordedStart);
521
+ // Parsers stamp events with read time. For a dead harness the last log
522
+ // write is the best bound on when the backlog really happened.
523
+ const endedAt = alive
524
+ ? undefined
525
+ : new Date(Math.max(...logs.map((file) => fs.statSync(file).mtimeMs)));
526
+ const last = this.db.getHandle().prepare(`SELECT COALESCE(MAX(sequence), 0) AS seq FROM events WHERE session_id = ?`).get(s.id);
527
+ this.healing.set(s.id, { fromSequence: last.seq, endedAt });
528
+ this.sessions.setStatus(s.id, "working", {
529
+ failure: null,
530
+ completedAt: null,
531
+ lastEvent: "healing after a store-busy failure",
532
+ });
533
+ appendDaemonLog(`healing ${s.id} from store-busy failure (${alive ? "live" : "dead"} harness)`);
534
+ }
535
+ }
536
+ // Once a revived session's stream ends, pin the drained backlog and the
537
+ // terminal time to the log mtime instead of the replay time.
538
+ finishHeal(sessionId) {
539
+ const heal = this.healing.get(sessionId);
540
+ if (!heal)
541
+ return;
542
+ this.healing.delete(sessionId);
543
+ if (!heal.endedAt)
544
+ return;
545
+ const endedAt = heal.endedAt.toISOString();
546
+ try {
547
+ this.db.getHandle().prepare(`
548
+ UPDATE events
549
+ SET timestamp = ?, normalized_payload = json_set(normalized_payload, '$.timestamp', ?)
550
+ WHERE session_id = ? AND sequence > ? AND timestamp > ?
551
+ `).run(endedAt, endedAt, sessionId, heal.fromSequence, endedAt);
552
+ const s = this.sessions.get(sessionId);
553
+ if (s && isTerminalStatus(s.status))
554
+ this.sessions.update(sessionId, { completedAt: heal.endedAt });
555
+ }
556
+ catch (error) {
557
+ appendDaemonLog(`heal timestamps for ${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`);
558
+ }
559
+ }
324
560
  async reconcileOpenUsage(sessionId, nativeIds) {
325
561
  const session = this.sessions.get(sessionId);
326
562
  if (!session || session.origin !== "open" || session.agent !== "claude")
@@ -366,6 +602,7 @@ class Daemon {
366
602
  async reconcileOpenUsageSafely(sessionId, nativeIds) {
367
603
  try {
368
604
  await this.reconcileOpenUsage(sessionId, nativeIds);
605
+ return true;
369
606
  }
370
607
  catch (error) {
371
608
  const detail = error instanceof Error ? error.message : String(error);
@@ -373,7 +610,21 @@ class Daemon {
373
610
  fs.appendFileSync(getPaths().daemonLog, `[${new Date().toISOString()}] usage reconcile failed session=${sessionId}: ${detail}\n`);
374
611
  }
375
612
  catch { }
613
+ return false;
614
+ }
615
+ }
616
+ nextSessionId() {
617
+ for (let attempt = 0; attempt < 100; attempt++) {
618
+ const sessionId = generateSessionId();
619
+ if (!this.sessions.getExact(sessionId) && !this.reservedSessionIds.has(sessionId)) {
620
+ this.reservedSessionIds.add(sessionId);
621
+ return sessionId;
622
+ }
376
623
  }
624
+ throw new Error("Unable to generate a unique session ID after 100 attempts");
625
+ }
626
+ releaseSessionId(sessionId) {
627
+ this.reservedSessionIds.delete(sessionId);
377
628
  }
378
629
  async handleRequest(req, socket) {
379
630
  const { id, method, params } = req;
@@ -390,739 +641,783 @@ class Daemon {
390
641
  send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
391
642
  return;
392
643
  }
393
- switch (method) {
394
- case "session.create": {
395
- const p = params;
396
- const prompt = p.prompt;
397
- if (!prompt) {
398
- send({ error: { code: "INVALID", message: "prompt required" } });
399
- return;
400
- }
401
- const cwdIn = p.cwd || process.cwd();
402
- const cfg = loadConfig();
403
- const rawAgent = p.agent || cfg.defaultAgent || "claude";
404
- let agent = (normalizeAgentId(rawAgent) ?? rawAgent);
405
- if (!this.registry.has(agent)) {
406
- send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${agent}` } });
407
- return;
408
- }
409
- const requestSandbox = resolveRequestSandbox(p.sandbox);
410
- const configuredSandbox = resolveDefaultSandbox(cfg);
411
- const sandbox = agent === "codex" ? requestSandbox ?? configuredSandbox : undefined;
412
- const sessionId = generateSessionId();
413
- let cwd = path.resolve(cwdIn);
414
- let worktree;
415
- let branch;
416
- let repository;
417
- let baseCommit;
418
- const gitInfo = await getGitInfo(cwd);
419
- if (this.shuttingDown) {
420
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
421
- return;
422
- }
423
- if (gitInfo)
424
- repository = gitInfo.root;
425
- const wantsWorktree = p.worktree === true || (p.worktree === undefined && cfg.worktree === true);
426
- const wantsNoWorktree = p.noWorktree === true || p.worktree === false;
427
- if (wantsWorktree && gitInfo) {
644
+ try {
645
+ switch (method) {
646
+ case "session.create": {
647
+ const p = params;
648
+ const prompt = p.prompt;
649
+ if (!prompt) {
650
+ send({ error: { code: "INVALID", message: "prompt required" } });
651
+ return;
652
+ }
653
+ const cwdIn = p.cwd || process.cwd();
654
+ const cfg = loadConfig();
655
+ const rawAgent = p.agent || cfg.defaultAgent || "claude";
656
+ let agent = (normalizeAgentId(rawAgent) ?? rawAgent);
657
+ if (!this.registry.has(agent)) {
658
+ send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${agent}` } });
659
+ return;
660
+ }
661
+ const requestSandbox = resolveRequestSandbox(p.sandbox);
662
+ const configuredSandbox = resolveDefaultSandbox(cfg);
663
+ const sandbox = agent === "codex" ? requestSandbox ?? configuredSandbox : undefined;
664
+ let cwd = path.resolve(cwdIn);
665
+ // Only record an edge to a session the store knows: a stale or foreign
666
+ // CODEDECK_SESSION_ID must not invent a parent the tree cannot draw.
667
+ const parent = typeof p.parentId === "string" && p.parentId.length > 0
668
+ ? this.sessions.getExact(p.parentId)
669
+ : null;
670
+ const sessionId = this.nextSessionId();
671
+ let worktree;
672
+ let branch;
673
+ let repository;
674
+ let baseCommit;
675
+ let gitInfo;
428
676
  try {
429
- const wt = await createWorktree({ repoRoot: gitInfo.root, sessionId, prompt, name: p.name });
430
- if (this.shuttingDown) {
431
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
677
+ gitInfo = await getGitInfo(cwd);
678
+ }
679
+ catch (error) {
680
+ this.releaseSessionId(sessionId);
681
+ throw error;
682
+ }
683
+ if (this.shuttingDown) {
684
+ this.releaseSessionId(sessionId);
685
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
686
+ return;
687
+ }
688
+ if (gitInfo)
689
+ repository = gitInfo.root;
690
+ const wantsWorktree = p.worktree === true || (p.worktree === undefined && cfg.worktree === true);
691
+ const wantsNoWorktree = p.noWorktree === true || p.worktree === false;
692
+ if (wantsWorktree && gitInfo) {
693
+ try {
694
+ const wt = await createWorktree({ repoRoot: gitInfo.root, sessionId, prompt, name: p.name });
695
+ if (this.shuttingDown) {
696
+ this.releaseSessionId(sessionId);
697
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
698
+ return;
699
+ }
700
+ worktree = wt.path;
701
+ branch = wt.branch;
702
+ baseCommit = wt.baseCommit;
703
+ cwd = worktree;
704
+ }
705
+ catch (e) {
706
+ this.releaseSessionId(sessionId);
707
+ send({ error: { code: "WORKTREE_FAILED", message: e instanceof Error ? e.message : String(e) } });
432
708
  return;
433
709
  }
434
- worktree = wt.path;
435
- branch = wt.branch;
436
- baseCommit = wt.baseCommit;
437
- cwd = worktree;
438
710
  }
439
- catch (e) {
440
- send({ error: { code: "WORKTREE_FAILED", message: e instanceof Error ? e.message : String(e) } });
441
- return;
711
+ else if (gitInfo) {
712
+ baseCommit = gitInfo.head;
442
713
  }
443
- }
444
- else if (gitInfo) {
445
- baseCommit = gitInfo.head;
446
- }
447
- const now = new Date();
448
- const session = {
449
- id: sessionId,
450
- runId: typeof p.runId === "string" ? p.runId : undefined,
451
- name: p.name,
452
- agent,
453
- model: p.model,
454
- effort: p.effort,
455
- fast: !!p.fast,
456
- sandbox,
457
- dangerouslyBypassApprovalsAndSandbox: !!p.dangerouslyBypassApprovalsAndSandbox,
458
- status: "starting",
459
- repository,
460
- cwd,
461
- worktree,
462
- branch,
463
- baseCommit: baseCommit || undefined,
464
- pid: undefined,
465
- createdAt: now,
466
- updatedAt: now,
467
- };
468
- this.sessions.create(session);
469
- // Start driver async (don't block response too long)
470
- // But we need to start before returning? Return quickly then start
471
- send({ result: { session } });
472
- // Now start driver in background
473
- this.startDriverForSession(sessionId, prompt, p.model).catch((e) => {
474
- if (this.shuttingDown)
475
- return;
476
- const msg = e instanceof Error ? e.message : String(e);
477
- const failure = classifyFailure(msg);
478
- const failEv = {
479
- type: "session.failed",
480
- sessionId,
481
- timestamp: new Date().toISOString(),
482
- error: msg,
483
- failure,
484
- raw: e,
714
+ const now = new Date();
715
+ const session = {
716
+ id: sessionId,
717
+ runId: typeof p.runId === "string" ? p.runId : parent?.runId ?? undefined,
718
+ parentId: parent?.id,
719
+ role: typeof p.role === "string" ? parseRole(p.role) : undefined,
720
+ name: p.name,
721
+ agent,
722
+ model: p.model,
723
+ effort: p.effort,
724
+ fast: !!p.fast,
725
+ sandbox,
726
+ dangerouslyBypassApprovalsAndSandbox: !!p.dangerouslyBypassApprovalsAndSandbox,
727
+ status: "starting",
728
+ repository,
729
+ cwd,
730
+ worktree,
731
+ branch,
732
+ baseCommit: baseCommit || undefined,
733
+ pid: undefined,
734
+ createdAt: now,
735
+ updatedAt: now,
485
736
  };
486
737
  try {
487
- this.sessions.setStatus(sessionId, "failed", { lastEvent: msg.slice(0, 200), failure });
738
+ this.sessions.create(session);
488
739
  }
489
- catch { }
490
- this.broadcast(sessionId, failEv);
491
- });
492
- break;
493
- }
494
- case "session.adopt": {
495
- const p = params;
496
- const cwdIn = p.cwd || process.cwd();
497
- const rawAgent = p.agent || "claude";
498
- const agent = (normalizeAgentId(rawAgent) ?? rawAgent);
499
- if (!this.registry.has(agent)) {
500
- send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${agent}` } });
501
- return;
502
- }
503
- const cwd = path.resolve(cwdIn);
504
- let repository;
505
- let baseCommit = p.baseCommit;
506
- const gitInfo = await getGitInfo(cwd);
507
- if (this.shuttingDown) {
508
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
509
- return;
510
- }
511
- const matchingSession = typeof p.resume === "string" && p.resume.length > 0
512
- ? this.sessions.listOpenByNativeId(p.resume).find((session) => session.agent === agent)
513
- : undefined;
514
- if (matchingSession &&
515
- (isTerminalStatus(matchingSession.status) || !livePidIdentity(matchingSession))) {
516
- const now = new Date();
517
- this.sessions.setStatus(matchingSession.id, "working", {
518
- ...(p.model !== undefined ? { model: p.model } : {}),
519
- ...(p.effort !== undefined ? { effort: p.effort } : {}),
520
- ...(p.name !== undefined ? { name: p.name } : {}),
521
- ...(p.cwd !== undefined ? { cwd } : {}),
522
- pid: null,
523
- pidStartTime: null,
524
- completedAt: null,
525
- lastEvent: null,
526
- failure: null,
527
- updatedAt: now,
740
+ finally {
741
+ this.releaseSessionId(sessionId);
742
+ }
743
+ // Start driver async (don't block response too long)
744
+ // But we need to start before returning? Return quickly then start
745
+ send({ result: { session } });
746
+ // Now start driver in background
747
+ this.startDriverForSession(sessionId, prompt, p.model).catch((e) => {
748
+ if (this.shuttingDown)
749
+ return;
750
+ const msg = e instanceof Error ? e.message : String(e);
751
+ const failure = classifyFailure(msg);
752
+ const failEv = {
753
+ type: "session.failed",
754
+ sessionId,
755
+ timestamp: new Date().toISOString(),
756
+ error: msg,
757
+ failure,
758
+ raw: e,
759
+ };
760
+ try {
761
+ this.sessions.setStatus(sessionId, "failed", { lastEvent: msg.slice(0, 200), failure });
762
+ }
763
+ catch { }
764
+ this.broadcast(sessionId, failEv);
528
765
  });
529
- const revived = this.sessions.get(matchingSession.id);
530
- const event = {
531
- type: "session.started",
532
- sessionId: revived.id,
533
- timestamp: now.toISOString(),
534
- agent,
535
- nativeSessionId: p.resume,
536
- };
537
- this.events.append(revived.id, event);
538
- this.broadcast(revived.id, event);
539
- send({ result: { session: revived } });
540
766
  break;
541
767
  }
542
- let sessionId = generateSessionId();
543
- while (this.sessions.get(sessionId)) {
544
- sessionId = generateSessionId();
545
- }
546
- if (gitInfo) {
547
- repository = gitInfo.root;
548
- if (!baseCommit)
549
- baseCommit = gitInfo.head;
550
- }
551
- const now = new Date();
552
- const session = {
553
- id: sessionId,
554
- runId: sessionId,
555
- origin: "open",
556
- name: p.name,
557
- agent,
558
- model: p.model,
559
- effort: p.effort,
560
- status: "working",
561
- repository,
562
- cwd,
563
- worktree: p.worktree,
564
- branch: p.branch,
565
- baseCommit: baseCommit || undefined,
566
- createdAt: now,
567
- updatedAt: now,
568
- };
569
- this.sessions.create(session);
570
- send({ result: { session } });
571
- break;
572
- }
573
- case "session.patch": {
574
- const p = params;
575
- const s = this.sessions.get(p.id);
576
- if (!s) {
577
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
578
- return;
579
- }
580
- let pidStartTime = p.pidStartTime;
581
- if (p.pid !== undefined && pidStartTime === undefined) {
582
- pidStartTime = processStartTime(p.pid);
583
- }
584
- const updates = {};
585
- if (p.pid !== undefined)
586
- updates.pid = p.pid;
587
- if (pidStartTime !== undefined)
588
- updates.pidStartTime = pidStartTime;
589
- if (p.worktree !== undefined)
590
- updates.worktree = p.worktree;
591
- if (p.branch !== undefined)
592
- updates.branch = p.branch;
593
- if (p.baseCommit !== undefined)
594
- updates.baseCommit = p.baseCommit;
595
- if (p.cwd !== undefined)
596
- updates.cwd = p.cwd;
597
- this.sessions.update(s.id, updates);
598
- const updated = this.sessions.get(s.id);
599
- send({ result: { session: updated } });
600
- break;
601
- }
602
- case "session.linkNative": {
603
- const p = (params || {});
604
- if (typeof p.id !== "string" || typeof p.nativeId !== "string" || p.nativeId.length === 0) {
605
- send({ error: { code: "INVALID", message: "id and nativeId required" } });
606
- return;
607
- }
608
- const session = this.sessions.get(p.id);
609
- if (!session) {
610
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
611
- return;
612
- }
613
- if (session.origin !== "open") {
614
- send({ result: { created: false } });
615
- return;
616
- }
617
- const { created, previous } = this.nativeLinks.link(session.id, p.nativeId);
618
- if (previous.length > 0)
619
- await this.reconcileOpenUsageSafely(session.id, previous);
620
- send({ result: { created } });
621
- break;
622
- }
623
- case "session.release": {
624
- const p = params;
625
- const s = this.sessions.get(p.id);
626
- if (!s) {
627
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
628
- return;
629
- }
630
- // Release owns the terminal outcome: a queued message must not
631
- // survive as a phantom "na fila" on every exit, including the
632
- // terminal early return below.
633
- this.clearPending(s.id);
634
- // Interrupted is an archival terminal: a power-shutdown row stays
635
- // actionable and release may finalize it to completed/failed — unless
636
- // its process is still alive under the same identity, which keeps
637
- // the send path's stop-first guard instead of orphaning it.
638
- if (s.status === "interrupted" && livePidIdentity(s)) {
639
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
640
- return;
641
- }
642
- if (isTerminalStatus(s.status) && s.status !== "interrupted") {
643
- send({ result: { session: this.sessions.get(s.id) } });
644
- return;
645
- }
646
- const targetStatus = p.status === "failed" ? "failed" : "completed";
647
- const extra = {};
648
- if (p.nativeSessionId !== undefined) {
649
- extra.nativeSessionId = p.nativeSessionId;
650
- }
651
- if (targetStatus === "failed") {
652
- extra.lastEvent = (p.error || "failed").slice(0, 200);
653
- }
654
- else {
655
- extra.lastEvent = "released";
656
- }
657
- this.sessions.setStatus(s.id, targetStatus, extra);
658
- if (s.origin === "open" && s.agent === "claude") {
659
- if (typeof p.nativeSessionId === "string" && p.nativeSessionId.length > 0) {
660
- this.nativeLinks.link(s.id, p.nativeSessionId);
768
+ case "session.adopt": {
769
+ const p = params;
770
+ const cwdIn = p.cwd || process.cwd();
771
+ const rawAgent = p.agent || "claude";
772
+ const agent = (normalizeAgentId(rawAgent) ?? rawAgent);
773
+ if (!this.registry.has(agent)) {
774
+ send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${agent}` } });
775
+ return;
661
776
  }
662
- await this.reconcileOpenUsageSafely(s.id);
663
- }
664
- const ev = targetStatus === "failed"
665
- ? {
666
- type: "session.failed",
667
- sessionId: s.id,
668
- timestamp: new Date().toISOString(),
669
- error: p.error || "session failed",
777
+ const cwd = path.resolve(cwdIn);
778
+ let repository;
779
+ let baseCommit = p.baseCommit;
780
+ const gitInfo = await getGitInfo(cwd);
781
+ if (this.shuttingDown) {
782
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
783
+ return;
670
784
  }
671
- : {
672
- type: "session.completed",
673
- sessionId: s.id,
674
- timestamp: new Date().toISOString(),
675
- reason: "released",
676
- };
677
- this.events.append(s.id, ev);
678
- this.broadcast(s.id, ev);
679
- const updated = this.sessions.get(s.id);
680
- send({ result: { session: updated } });
681
- break;
682
- }
683
- case "session.list": {
684
- const p = params;
685
- const all = p?.all;
686
- const list = this.sessions.list(100, all).map(withLiveStatus);
687
- const hidden = all ? 0 : this.sessions.countHiddenByWindow();
688
- // Enrich with last event?
689
- send({ result: { sessions: list, hidden } });
690
- break;
691
- }
692
- case "session.get": {
693
- const p = params;
694
- const stored = this.sessions.get(p.id);
695
- if (!stored) {
696
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
697
- return;
698
- }
699
- const s = withLiveStatus(stored);
700
- const evCount = this.events.count(s.id);
701
- const recent = this.events.list(s.id, 10);
702
- send({ result: { session: s, events: recent, eventCount: evCount } });
703
- break;
704
- }
705
- case "session.rename": {
706
- const p = params;
707
- const s = this.sessions.get(p.id);
708
- if (!s) {
709
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
710
- return;
711
- }
712
- if (!p.name || !p.name.trim()) {
713
- send({ error: { code: "INVALID", message: "name required" } });
714
- return;
715
- }
716
- this.sessions.update(s.id, { name: p.name });
717
- send({ result: { ok: true } });
718
- break;
719
- }
720
- case "session.send": {
721
- const p = params;
722
- const validated = validateSendMessage(p.message);
723
- if ("code" in validated) {
724
- send({ error: { code: validated.code, message: validated.error } });
725
- return;
726
- }
727
- const message = validated.message;
728
- const s = this.sessions.get(p.id);
729
- if (!s) {
730
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
731
- return;
732
- }
733
- if (s.origin === "open") {
734
- send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} is an interactive head session and does not support headless prompt turns` } });
735
- return;
736
- }
737
- const driver = this.registry.get(s.agent);
738
- if (!driver.capabilities().resume) {
739
- send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
740
- return;
741
- }
742
- if (s.status === "interrupted" && !s.nativeSessionId) {
743
- send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} cannot resume (no native session id or agent ${s.agent} does not support resume)` } });
744
- return;
745
- }
746
- // Power-interrupted sessions with a live process keep the old rule:
747
- // stop first, never queue behind a running harness.
748
- if (s.status === "interrupted" && livePidIdentity(s)) {
749
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
750
- return;
751
- }
752
- // A lifecycle operation owns the session: retry instead of queueing,
753
- // so a send racing stop cannot strand a phantom slot with no stream
754
- // tail left to consume it (stop cancels the queue on exit).
755
- if (this.sessionLocks.has(s.id)) {
756
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
757
- return;
758
- }
759
- // Do not start a second harness while the current one is still live:
760
- // both runtimes would tail the same per-session file and duplicate
761
- // every event from the follow-up turn. Queue behind a live turn only.
762
- const busyHandle = driver.getHandle?.(s.id);
763
- const busyState = busyHandle && typeof busyHandle === "object"
764
- ? busyHandle
765
- : undefined;
766
- const runtimeBusy = busyHandle !== undefined && (busyState?.done !== true || busyState?.drained !== true);
767
- const busy = s.status === "starting" || runtimeBusy || livePidIdentity(s);
768
- if (busy) {
769
- // One-slot queue, last-wins: persist without holding the lifecycle
770
- // lock, then converge — the turn may have ended between the busy
771
- // read and this persist, in which case tryDispatch starts now.
772
- const pendingAt = new Date().toISOString();
773
- try {
774
- this.sessions.update(s.id, { pendingMessage: message, pendingAt });
785
+ const matchingSession = typeof p.resume === "string" && p.resume.length > 0
786
+ ? this.sessions.listOpenByNativeId(p.resume).find((session) => session.agent === agent)
787
+ : undefined;
788
+ if (matchingSession &&
789
+ (isTerminalStatus(matchingSession.status) || !livePidIdentity(matchingSession))) {
790
+ const now = new Date();
791
+ this.sessions.setStatus(matchingSession.id, "working", {
792
+ ...(p.model !== undefined ? { model: p.model } : {}),
793
+ ...(p.effort !== undefined ? { effort: p.effort } : {}),
794
+ ...(p.name !== undefined ? { name: p.name } : {}),
795
+ ...(p.cwd !== undefined ? { cwd } : {}),
796
+ pid: null,
797
+ pidStartTime: null,
798
+ completedAt: null,
799
+ lastEvent: null,
800
+ failure: null,
801
+ updatedAt: now,
802
+ });
803
+ const revived = this.sessions.get(matchingSession.id);
804
+ const event = {
805
+ type: "session.started",
806
+ sessionId: revived.id,
807
+ timestamp: now.toISOString(),
808
+ agent,
809
+ nativeSessionId: p.resume,
810
+ };
811
+ this.events.append(revived.id, event);
812
+ this.broadcast(revived.id, event);
813
+ send({ result: { session: revived } });
814
+ break;
775
815
  }
776
- catch (e) {
777
- send({ error: { code: "SEND_FAILED", message: e instanceof Error ? e.message : String(e) } });
778
- return;
816
+ const sessionId = this.nextSessionId();
817
+ if (gitInfo) {
818
+ repository = gitInfo.root;
819
+ if (!baseCommit)
820
+ baseCommit = gitInfo.head;
779
821
  }
780
- const queuedEvent = {
781
- type: "message.queued",
782
- sessionId: s.id,
783
- timestamp: pendingAt,
784
- prompt: message,
785
- pendingAt,
822
+ const now = new Date();
823
+ const session = {
824
+ id: sessionId,
825
+ runId: sessionId,
826
+ origin: "open",
827
+ role: typeof p.role === "string" ? parseRole(p.role) : undefined,
828
+ name: p.name,
829
+ agent,
830
+ model: p.model,
831
+ effort: p.effort,
832
+ status: "working",
833
+ repository,
834
+ cwd,
835
+ worktree: p.worktree,
836
+ branch: p.branch,
837
+ baseCommit: baseCommit || undefined,
838
+ createdAt: now,
839
+ updatedAt: now,
786
840
  };
787
841
  try {
788
- this.events.append(s.id, queuedEvent);
842
+ this.sessions.create(session);
789
843
  }
790
- catch { }
791
- this.broadcast(s.id, queuedEvent);
792
- try {
793
- this.sessions.update(s.id, { lastEvent: `queued: ${message.slice(0, 80)}` });
844
+ finally {
845
+ this.releaseSessionId(sessionId);
794
846
  }
795
- catch { }
796
- void this.tryDispatch(s.id);
797
- send({ result: { ok: true, queued: true } });
798
- return;
847
+ send({ result: { session } });
848
+ break;
799
849
  }
800
- // Resumable now (terminal completed/failed/stopped/orphaned, or
801
- // interrupted without a live process): a stale queued slot loses to
802
- // the new message, which starts immediately.
803
- this.sessionLocks.add(s.id);
804
- try {
805
- try {
806
- this.sessions.update(s.id, { pendingMessage: null, pendingAt: null });
850
+ case "session.patch": {
851
+ const p = params;
852
+ const s = this.sessions.get(p.id);
853
+ if (!s) {
854
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
855
+ return;
807
856
  }
808
- catch { }
809
- await this.runResumeTurn(s, message);
810
- send({ result: { ok: true, queued: false } });
857
+ let pidStartTime = p.pidStartTime;
858
+ if (p.pid !== undefined && pidStartTime === undefined) {
859
+ pidStartTime = processStartTime(p.pid);
860
+ }
861
+ const updates = {};
862
+ if (p.pid !== undefined)
863
+ updates.pid = p.pid;
864
+ if (pidStartTime !== undefined)
865
+ updates.pidStartTime = pidStartTime;
866
+ if (p.worktree !== undefined)
867
+ updates.worktree = p.worktree;
868
+ if (p.branch !== undefined)
869
+ updates.branch = p.branch;
870
+ if (p.baseCommit !== undefined)
871
+ updates.baseCommit = p.baseCommit;
872
+ if (p.cwd !== undefined)
873
+ updates.cwd = p.cwd;
874
+ this.sessions.update(s.id, updates);
875
+ const updated = this.sessions.get(s.id);
876
+ send({ result: { session: updated } });
877
+ break;
811
878
  }
812
- catch (e) {
813
- const errText = e instanceof Error ? e.message : String(e);
814
- const code = e instanceof Error && e.code === "SERVICE_UNAVAILABLE"
815
- ? "SERVICE_UNAVAILABLE"
816
- : this.shuttingDown ? "SERVICE_UNAVAILABLE" : "SEND_FAILED";
817
- if (code === "SERVICE_UNAVAILABLE") {
818
- send({ error: { code, message: errText } });
879
+ case "session.linkNative": {
880
+ const p = (params || {});
881
+ if (typeof p.id !== "string" || typeof p.nativeId !== "string" || p.nativeId.length === 0) {
882
+ send({ error: { code: "INVALID", message: "id and nativeId required" } });
819
883
  return;
820
884
  }
821
- try {
822
- this.sessions.setStatus(s.id, "failed");
885
+ const session = this.sessions.get(p.id);
886
+ if (!session) {
887
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
888
+ return;
823
889
  }
824
- catch { }
825
- send({ error: { code: "SEND_FAILED", message: errText } });
826
- }
827
- finally {
828
- if (!this.shuttingDown)
829
- this.sessionLocks.delete(s.id);
830
- }
831
- break;
832
- }
833
- case "session.stop": {
834
- const p = params;
835
- const s = this.sessions.get(p.id);
836
- if (!s) {
837
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
838
- return;
839
- }
840
- // Stop cancels a queued message on every exit (explicit cancel),
841
- // including the BUSY/NOT_RUNNING/UNSAFE early returns below.
842
- this.clearPending(s.id);
843
- const driver = this.registry.get(s.agent);
844
- if (this.sessionLocks.has(s.id)) {
845
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
846
- return;
847
- }
848
- const handle = driver.getHandle?.(s.id);
849
- const hasRuntime = handle !== undefined;
850
- const liveIdentity = s.pid != null &&
851
- s.pidStartTime != null &&
852
- processAlive(s.pid) &&
853
- processStartTime(s.pid) === s.pidStartTime;
854
- if (isTerminalStatus(s.status) && !hasRuntime && !liveIdentity) {
855
- send({ error: { code: "SESSION_NOT_RUNNING", message: `Session ${s.id} is ${s.status}` } });
856
- return;
857
- }
858
- if (s.pid != null && !hasRuntime && !s.pidStartTime) {
859
- send({ error: { code: "STOP_UNSAFE", message: `Cannot safely stop session ${s.id}: PID identity is unavailable` } });
860
- return;
890
+ if (session.origin !== "open") {
891
+ send({ result: { created: false } });
892
+ return;
893
+ }
894
+ const { created, previous } = this.nativeLinks.link(session.id, p.nativeId);
895
+ if (previous.length > 0)
896
+ await this.reconcileOpenUsageSafely(session.id, previous);
897
+ send({ result: { created } });
898
+ break;
861
899
  }
862
- this.sessionLocks.add(s.id);
863
- const previousStatus = s.status;
864
- const hadTerminalStatus = isTerminalStatus(s.status);
865
- const eventsBeforeStop = this.events.count(s.id);
866
- try {
867
- // Mark active sessions first so an attached event loop that ends
868
- // during stop cannot synthesize a failure or overwrite the outcome.
869
- if (!hadTerminalStatus)
870
- this.sessions.setStatus(s.id, "stopped");
871
- await driver.stop({
872
- id: s.id,
873
- nativeSessionId: s.nativeSessionId,
874
- cwd: s.worktree || s.cwd,
875
- pid: s.pid,
876
- pidStartTime: s.pidStartTime,
877
- });
878
- if (this.shuttingDown) {
879
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
900
+ case "session.release": {
901
+ const p = params;
902
+ const s = this.sessions.get(p.id);
903
+ if (!s) {
904
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
880
905
  return;
881
906
  }
882
- const last = this.events.last(s.id);
883
- const terminalArrivedDuringStop = this.events.count(s.id) > eventsBeforeStop &&
884
- (last?.type === "session.completed" || last?.type === "session.failed");
885
- if (terminalArrivedDuringStop) {
886
- if (last.type === "session.completed") {
887
- this.sessions.setStatus(s.id, "completed", { lastEvent: last.reason || "completed" });
888
- }
889
- else {
890
- this.sessions.setStatus(s.id, "failed", { lastEvent: last.error.slice(0, 200), failure: last.failure });
907
+ // Release owns the terminal outcome: a queued message must not
908
+ // survive as a phantom "na fila" on every exit, including the
909
+ // terminal early return below.
910
+ this.clearPending(s.id);
911
+ // Interrupted is an archival terminal: a power-shutdown row stays
912
+ // actionable and release may finalize it to completed/failed — unless
913
+ // its process is still alive under the same identity, which keeps
914
+ // the send path's stop-first guard instead of orphaning it.
915
+ if (s.status === "interrupted" && livePidIdentity(s)) {
916
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
917
+ return;
918
+ }
919
+ if (isTerminalStatus(s.status) && s.status !== "interrupted") {
920
+ send({ result: { session: this.sessions.get(s.id) } });
921
+ return;
922
+ }
923
+ const targetStatus = p.status === "failed" ? "failed" : "completed";
924
+ const extra = {};
925
+ if (p.nativeSessionId !== undefined) {
926
+ extra.nativeSessionId = p.nativeSessionId;
927
+ }
928
+ if (targetStatus === "failed") {
929
+ extra.lastEvent = (p.error || "failed").slice(0, 200);
930
+ }
931
+ else {
932
+ extra.lastEvent = "released";
933
+ }
934
+ this.sessions.setStatus(s.id, targetStatus, extra);
935
+ if (s.origin === "open" && s.agent === "claude") {
936
+ if (typeof p.nativeSessionId === "string" && p.nativeSessionId.length > 0) {
937
+ this.nativeLinks.link(s.id, p.nativeSessionId);
891
938
  }
939
+ const reconciled = await this.reconcileOpenUsageSafely(s.id);
940
+ if (reconciled)
941
+ this.queueReleasedTranscriptCursorCleanup(s.id);
892
942
  }
893
- else if (!hadTerminalStatus) {
894
- const ev = {
943
+ const ev = targetStatus === "failed"
944
+ ? {
945
+ type: "session.failed",
946
+ sessionId: s.id,
947
+ timestamp: new Date().toISOString(),
948
+ error: p.error || "session failed",
949
+ }
950
+ : {
895
951
  type: "session.completed",
896
952
  sessionId: s.id,
897
953
  timestamp: new Date().toISOString(),
898
- reason: "stopped",
899
- exitCode: 130,
954
+ reason: "released",
900
955
  };
901
- this.events.append(s.id, ev);
902
- this.broadcast(s.id, ev);
956
+ this.events.append(s.id, ev);
957
+ this.broadcast(s.id, ev);
958
+ const updated = this.sessions.get(s.id);
959
+ send({ result: { session: updated } });
960
+ break;
961
+ }
962
+ case "session.list": {
963
+ const p = params;
964
+ const all = p?.all;
965
+ const list = this.sessions.list(100, all).map(withLiveStatus);
966
+ const hidden = all ? 0 : this.sessions.countHiddenByWindow();
967
+ // Enrich with last event?
968
+ send({ result: { sessions: list, hidden } });
969
+ break;
970
+ }
971
+ case "session.get": {
972
+ const p = params;
973
+ const stored = this.sessions.get(p.id);
974
+ if (!stored) {
975
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
976
+ return;
903
977
  }
904
- // A send may have persisted a slot after the entry clear (it saw a
905
- // live turn before this stop took the lock): stop wins, clear again.
906
- this.clearPending(s.id);
907
- send({ result: { ok: true } });
978
+ const s = withLiveStatus(stored);
979
+ const evCount = this.events.count(s.id);
980
+ const recent = this.events.list(s.id, 10);
981
+ send({ result: { session: s, events: recent, eventCount: evCount } });
982
+ break;
908
983
  }
909
- catch (e) {
910
- if (this.shuttingDown) {
911
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
984
+ case "session.rename": {
985
+ const p = params;
986
+ const s = this.sessions.get(p.id);
987
+ if (!s) {
988
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
912
989
  return;
913
990
  }
914
- try {
915
- this.sessions.setStatus(s.id, previousStatus);
991
+ if (!p.name || !p.name.trim()) {
992
+ send({ error: { code: "INVALID", message: "name required" } });
993
+ return;
916
994
  }
917
- catch { }
918
- send({ error: { code: "STOP_FAILED", message: e instanceof Error ? e.message : String(e) } });
919
- }
920
- finally {
921
- if (!this.shuttingDown)
922
- this.sessionLocks.delete(s.id);
923
- }
924
- break;
925
- }
926
- case "session.logs": {
927
- const p = params;
928
- const s = this.sessions.get(p.id);
929
- if (!s) {
930
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
931
- return;
932
- }
933
- if (p.follow) {
934
- // For follow, we upgrade to subscribe behavior? But spec says logs --follow streams
935
- // We'll handle by keeping socket open and streaming
936
- // Send existing events as result then keep streaming live
937
- const all = this.events.list(s.id, 1000);
938
- send({ result: { session: s, events: all } });
939
- // Then subscribe
940
- this.addSubscriber(s.id, socket);
941
- // Don't close socket
942
- return;
943
- }
944
- else {
945
- const all = this.events.list(s.id, 1000);
946
- send({ result: { session: s, events: all } });
947
- }
948
- break;
949
- }
950
- case "session.subscribe": {
951
- const p = params;
952
- const s = this.sessions.get(p.id);
953
- if (!s) {
954
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
955
- return;
995
+ this.sessions.update(s.id, { name: p.name });
996
+ send({ result: { ok: true } });
997
+ break;
956
998
  }
957
- // A late subscriber must not wait forever for a broadcast that already
958
- // happened. Replay the terminal event when available, then close the
959
- // stream just like a live terminal broadcast.
960
- if (isTerminalStatus(s.status)) {
961
- const last = this.events.last(s.id);
962
- if (last?.type === "session.completed" || last?.type === "session.failed") {
999
+ case "session.send": {
1000
+ const p = params;
1001
+ const validated = validateSendMessage(p.message);
1002
+ if ("code" in validated) {
1003
+ send({ error: { code: validated.code, message: validated.error } });
1004
+ return;
1005
+ }
1006
+ const message = validated.message;
1007
+ const s = this.sessions.get(p.id);
1008
+ if (!s) {
1009
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
1010
+ return;
1011
+ }
1012
+ if (s.origin === "open") {
1013
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} is an interactive head session and does not support headless prompt turns` } });
1014
+ return;
1015
+ }
1016
+ const driver = this.registry.get(s.agent);
1017
+ if (!driver.capabilities().resume) {
1018
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
1019
+ return;
1020
+ }
1021
+ if (s.status === "interrupted" && !s.nativeSessionId) {
1022
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} cannot resume (no native session id or agent ${s.agent} does not support resume)` } });
1023
+ return;
1024
+ }
1025
+ // Power-interrupted sessions with a live process keep the old rule:
1026
+ // stop first, never queue behind a running harness.
1027
+ if (s.status === "interrupted" && livePidIdentity(s)) {
1028
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
1029
+ return;
1030
+ }
1031
+ // A lifecycle operation owns the session: retry instead of queueing,
1032
+ // so a send racing stop cannot strand a phantom slot with no stream
1033
+ // tail left to consume it (stop cancels the queue on exit).
1034
+ if (this.sessionLocks.has(s.id)) {
1035
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
1036
+ return;
1037
+ }
1038
+ // Do not start a second harness while the current one is still live:
1039
+ // both runtimes would tail the same per-session file and duplicate
1040
+ // every event from the follow-up turn. Queue behind a live turn only.
1041
+ const busyHandle = driver.getHandle?.(s.id);
1042
+ const busyState = busyHandle && typeof busyHandle === "object"
1043
+ ? busyHandle
1044
+ : undefined;
1045
+ const runtimeBusy = busyHandle !== undefined && (busyState?.done !== true || busyState?.drained !== true);
1046
+ const busy = s.status === "starting" || runtimeBusy || livePidIdentity(s);
1047
+ if (busy) {
1048
+ // One-slot queue, last-wins: persist without holding the lifecycle
1049
+ // lock, then converge — the turn may have ended between the busy
1050
+ // read and this persist, in which case tryDispatch starts now.
1051
+ const pendingAt = new Date().toISOString();
1052
+ try {
1053
+ this.sessions.update(s.id, { pendingMessage: message, pendingAt });
1054
+ }
1055
+ catch (e) {
1056
+ send({ error: { code: "SEND_FAILED", message: e instanceof Error ? e.message : String(e) } });
1057
+ return;
1058
+ }
1059
+ const queuedEvent = {
1060
+ type: "message.queued",
1061
+ sessionId: s.id,
1062
+ timestamp: pendingAt,
1063
+ prompt: message,
1064
+ pendingAt,
1065
+ };
1066
+ try {
1067
+ this.events.append(s.id, queuedEvent);
1068
+ }
1069
+ catch { }
1070
+ this.broadcast(s.id, queuedEvent);
963
1071
  try {
964
- socket.write(JSON.stringify({ type: "event", event: last, id: s.id }) + "\n");
1072
+ this.sessions.update(s.id, { lastEvent: `queued: ${message.slice(0, 80)}` });
965
1073
  }
966
1074
  catch { }
1075
+ void this.tryDispatch(s.id);
1076
+ send({ result: { ok: true, queued: true } });
1077
+ return;
967
1078
  }
1079
+ // Resumable now (terminal completed/failed/stopped/orphaned, or
1080
+ // interrupted without a live process): a stale queued slot loses to
1081
+ // the new message, which starts immediately.
1082
+ this.sessionLocks.add(s.id);
968
1083
  try {
969
- socket.write(JSON.stringify({ type: "done", id: s.id }) + "\n");
1084
+ try {
1085
+ this.sessions.update(s.id, { pendingMessage: null, pendingAt: null });
1086
+ }
1087
+ catch { }
1088
+ await this.runResumeTurn(s, message);
1089
+ send({ result: { ok: true, queued: false } });
970
1090
  }
971
- catch { }
972
- return;
973
- }
974
- // Send buffered events first?
975
- // Then keep streaming
976
- this.addSubscriber(s.id, socket);
977
- // Send ack? keep open
978
- // Optionally send done when session terminal? We'll handle via broadcast.
979
- // Don't send immediate response; just keep socket open for events
980
- // But we should send an initial ack to confirm subscription?
981
- // Use a separate message to indicate subscription started
982
- // Let's not send anything; client will just wait for events
983
- // Keep socket open
984
- return;
985
- }
986
- case "session.diff": {
987
- const p = params;
988
- const s = this.sessions.get(p.id);
989
- if (!s) {
990
- send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
991
- return;
992
- }
993
- const diff = await getDiff({ cwd: s.cwd, worktree: s.worktree, baseCommit: s.baseCommit, repository: s.repository });
994
- send({ result: diff });
995
- break;
996
- }
997
- case "claims.add": {
998
- const p = params;
999
- try {
1000
- const claim = this.claims.add(p.sessionId, p.pathGlob, p.reason);
1001
- send({ result: { claim } });
1091
+ catch (e) {
1092
+ const errText = e instanceof Error ? e.message : String(e);
1093
+ const code = e instanceof Error && e.code === "SERVICE_UNAVAILABLE"
1094
+ ? "SERVICE_UNAVAILABLE"
1095
+ : this.shuttingDown ? "SERVICE_UNAVAILABLE" : "SEND_FAILED";
1096
+ if (code === "SERVICE_UNAVAILABLE") {
1097
+ send({ error: { code, message: errText } });
1098
+ return;
1099
+ }
1100
+ try {
1101
+ this.sessions.setStatus(s.id, "failed");
1102
+ }
1103
+ catch { }
1104
+ send({ error: { code: "SEND_FAILED", message: errText } });
1105
+ }
1106
+ finally {
1107
+ if (!this.shuttingDown)
1108
+ this.sessionLocks.delete(s.id);
1109
+ }
1110
+ break;
1002
1111
  }
1003
- catch (error) {
1004
- if (error instanceof RunAgentError) {
1005
- send({ error: { code: error.code, message: error.message, details: error.details } });
1112
+ case "session.stop": {
1113
+ const p = params;
1114
+ const s = this.sessions.get(p.id);
1115
+ if (!s) {
1116
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
1006
1117
  return;
1007
1118
  }
1008
- throw error;
1009
- }
1010
- break;
1011
- }
1012
- case "claims.query": {
1013
- const p = params;
1014
- try {
1015
- const claims = this.claims.query(p.sessionId, p.path);
1016
- send({ result: { claims } });
1119
+ // Stop cancels a queued message on every exit (explicit cancel),
1120
+ // including the BUSY/NOT_RUNNING/UNSAFE early returns below.
1121
+ this.clearPending(s.id);
1122
+ const driver = this.registry.get(s.agent);
1123
+ if (this.sessionLocks.has(s.id)) {
1124
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
1125
+ return;
1126
+ }
1127
+ const handle = driver.getHandle?.(s.id);
1128
+ const hasRuntime = handle !== undefined;
1129
+ const liveIdentity = s.pid != null &&
1130
+ s.pidStartTime != null &&
1131
+ processAlive(s.pid) &&
1132
+ processStartTime(s.pid) === s.pidStartTime;
1133
+ if (isTerminalStatus(s.status) && !hasRuntime && !liveIdentity) {
1134
+ send({ error: { code: "SESSION_NOT_RUNNING", message: `Session ${s.id} is ${s.status}` } });
1135
+ return;
1136
+ }
1137
+ if (s.pid != null && !hasRuntime && !s.pidStartTime) {
1138
+ send({ error: { code: "STOP_UNSAFE", message: `Cannot safely stop session ${s.id}: PID identity is unavailable` } });
1139
+ return;
1140
+ }
1141
+ this.sessionLocks.add(s.id);
1142
+ const previousStatus = s.status;
1143
+ const hadTerminalStatus = isTerminalStatus(s.status);
1144
+ const eventsBeforeStop = this.events.count(s.id);
1145
+ try {
1146
+ // Mark active sessions first so an attached event loop that ends
1147
+ // during stop cannot synthesize a failure or overwrite the outcome.
1148
+ if (!hadTerminalStatus)
1149
+ this.sessions.setStatus(s.id, "stopped");
1150
+ await driver.stop({
1151
+ id: s.id,
1152
+ nativeSessionId: s.nativeSessionId,
1153
+ cwd: s.worktree || s.cwd,
1154
+ pid: s.pid,
1155
+ pidStartTime: s.pidStartTime,
1156
+ });
1157
+ if (this.shuttingDown) {
1158
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
1159
+ return;
1160
+ }
1161
+ const last = this.events.last(s.id);
1162
+ const terminalArrivedDuringStop = this.events.count(s.id) > eventsBeforeStop &&
1163
+ (last?.type === "session.completed" || last?.type === "session.failed");
1164
+ if (terminalArrivedDuringStop) {
1165
+ if (last.type === "session.completed") {
1166
+ this.sessions.setStatus(s.id, "completed", { lastEvent: last.reason || "completed" });
1167
+ }
1168
+ else {
1169
+ this.sessions.setStatus(s.id, "failed", { lastEvent: last.error.slice(0, 200), failure: last.failure });
1170
+ }
1171
+ }
1172
+ else if (!hadTerminalStatus) {
1173
+ const ev = {
1174
+ type: "session.completed",
1175
+ sessionId: s.id,
1176
+ timestamp: new Date().toISOString(),
1177
+ reason: "stopped",
1178
+ exitCode: 130,
1179
+ };
1180
+ this.events.append(s.id, ev);
1181
+ this.broadcast(s.id, ev);
1182
+ }
1183
+ // A send may have persisted a slot after the entry clear (it saw a
1184
+ // live turn before this stop took the lock): stop wins, clear again.
1185
+ this.clearPending(s.id);
1186
+ send({ result: { ok: true } });
1187
+ }
1188
+ catch (e) {
1189
+ if (this.shuttingDown) {
1190
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
1191
+ return;
1192
+ }
1193
+ try {
1194
+ this.sessions.setStatus(s.id, previousStatus);
1195
+ }
1196
+ catch { }
1197
+ send({ error: { code: "STOP_FAILED", message: e instanceof Error ? e.message : String(e) } });
1198
+ }
1199
+ finally {
1200
+ if (!this.shuttingDown)
1201
+ this.sessionLocks.delete(s.id);
1202
+ }
1203
+ break;
1017
1204
  }
1018
- catch (error) {
1019
- if (error instanceof RunAgentError) {
1020
- send({ error: { code: error.code, message: error.message, details: error.details } });
1205
+ case "session.logs": {
1206
+ const p = params;
1207
+ const s = this.sessions.get(p.id);
1208
+ if (!s) {
1209
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
1021
1210
  return;
1022
1211
  }
1023
- throw error;
1212
+ if (p.follow) {
1213
+ // For follow, we upgrade to subscribe behavior? But spec says logs --follow streams
1214
+ // We'll handle by keeping socket open and streaming
1215
+ // Send existing events as result then keep streaming live
1216
+ const all = this.events.list(s.id, 1000);
1217
+ send({ result: { session: s, events: all } });
1218
+ // Then subscribe
1219
+ this.addSubscriber(s.id, socket);
1220
+ // Don't close socket
1221
+ return;
1222
+ }
1223
+ else {
1224
+ const all = this.events.list(s.id, 1000);
1225
+ send({ result: { session: s, events: all } });
1226
+ }
1227
+ break;
1024
1228
  }
1025
- break;
1026
- }
1027
- case "claims.release": {
1028
- const p = params;
1029
- try {
1030
- const claim = this.claims.release(p.sessionId, p.claimId);
1031
- send({ result: { claim } });
1229
+ case "session.subscribe": {
1230
+ const p = params;
1231
+ const s = this.sessions.get(p.id);
1232
+ if (!s) {
1233
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
1234
+ return;
1235
+ }
1236
+ // A late subscriber must not wait forever for a broadcast that already
1237
+ // happened. Replay the terminal event when available, then close the
1238
+ // stream just like a live terminal broadcast.
1239
+ if (isTerminalStatus(s.status)) {
1240
+ const last = this.events.last(s.id);
1241
+ if (last?.type === "session.completed" || last?.type === "session.failed") {
1242
+ try {
1243
+ socket.write(JSON.stringify({ type: "event", event: last, id: s.id }) + "\n");
1244
+ }
1245
+ catch { }
1246
+ }
1247
+ try {
1248
+ socket.write(JSON.stringify({ type: "done", id: s.id }) + "\n");
1249
+ }
1250
+ catch { }
1251
+ return;
1252
+ }
1253
+ // Send buffered events first?
1254
+ // Then keep streaming
1255
+ this.addSubscriber(s.id, socket);
1256
+ // Send ack? keep open
1257
+ // Optionally send done when session terminal? We'll handle via broadcast.
1258
+ // Don't send immediate response; just keep socket open for events
1259
+ // But we should send an initial ack to confirm subscription?
1260
+ // Use a separate message to indicate subscription started
1261
+ // Let's not send anything; client will just wait for events
1262
+ // Keep socket open
1263
+ return;
1032
1264
  }
1033
- catch (error) {
1034
- if (error instanceof RunAgentError) {
1035
- send({ error: { code: error.code, message: error.message, details: error.details } });
1265
+ case "session.diff": {
1266
+ const p = params;
1267
+ const s = this.sessions.get(p.id);
1268
+ if (!s) {
1269
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
1036
1270
  return;
1037
1271
  }
1038
- throw error;
1272
+ const diff = await getDiff({ cwd: s.cwd, worktree: s.worktree, baseCommit: s.baseCommit, repository: s.repository });
1273
+ send({ result: diff });
1274
+ break;
1039
1275
  }
1040
- break;
1041
- }
1042
- case "daemon.status": {
1043
- const paths = getPaths();
1044
- let uptime = Date.now() - this.startTime;
1045
- send({ result: { running: true, pid: process.pid, uptime, db: paths.db } });
1046
- break;
1047
- }
1048
- case "doctor": {
1049
- const nodeVersion = process.version;
1050
- let gitInstalled = false;
1051
- let gitVersion;
1052
- try {
1053
- const { execFile } = await import("node:child_process");
1054
- const { promisify } = await import("node:util");
1055
- const execFileAsync = promisify(execFile);
1056
- const { stdout } = await execFileAsync("git", ["--version"], { timeout: 3000 });
1057
- gitInstalled = true;
1058
- gitVersion = stdout.trim();
1276
+ case "claims.add": {
1277
+ const p = params;
1278
+ try {
1279
+ const sessionId = this.sessions.get(p.sessionId)?.id ?? p.sessionId;
1280
+ const claim = this.claims.add(sessionId, p.pathGlob, p.reason);
1281
+ send({ result: { claim } });
1282
+ }
1283
+ catch (error) {
1284
+ if (error instanceof RunAgentError) {
1285
+ send({ error: { code: error.code, message: error.message, details: error.details } });
1286
+ return;
1287
+ }
1288
+ throw error;
1289
+ }
1290
+ break;
1059
1291
  }
1060
- catch {
1061
- gitInstalled = false;
1292
+ case "claims.query": {
1293
+ const p = params;
1294
+ try {
1295
+ const sessionId = this.sessions.get(p.sessionId)?.id ?? p.sessionId;
1296
+ const claims = this.claims.query(sessionId, p.path);
1297
+ send({ result: { claims } });
1298
+ }
1299
+ catch (error) {
1300
+ if (error instanceof RunAgentError) {
1301
+ send({ error: { code: error.code, message: error.message, details: error.details } });
1302
+ return;
1303
+ }
1304
+ throw error;
1305
+ }
1306
+ break;
1062
1307
  }
1063
- const agents = await this.registry.detectAll();
1064
- const caps = {};
1065
- for (const [k, drv] of this.registry.drivers.entries()) {
1066
- caps[k] = drv.capabilities();
1308
+ case "claims.release": {
1309
+ const p = params;
1310
+ try {
1311
+ const sessionId = this.sessions.get(p.sessionId)?.id ?? p.sessionId;
1312
+ const claim = this.claims.release(sessionId, p.claimId);
1313
+ send({ result: { claim } });
1314
+ }
1315
+ catch (error) {
1316
+ if (error instanceof RunAgentError) {
1317
+ send({ error: { code: error.code, message: error.message, details: error.details } });
1318
+ return;
1319
+ }
1320
+ throw error;
1321
+ }
1322
+ break;
1067
1323
  }
1068
- const agentsWithCaps = {};
1069
- for (const [k, v] of Object.entries(agents)) {
1070
- agentsWithCaps[k] = { ...v, capabilities: caps[k] };
1324
+ case "daemon.status": {
1325
+ const paths = getPaths();
1326
+ let uptime = Date.now() - this.startTime;
1327
+ send({ result: { running: true, pid: process.pid, uptime, db: paths.db } });
1328
+ break;
1071
1329
  }
1072
- const paths = getPaths();
1073
- const dbExists = fs.existsSync(paths.db);
1074
- send({
1075
- result: {
1076
- node: { version: nodeVersion },
1077
- git: { installed: gitInstalled, version: gitVersion },
1078
- agents: agentsWithCaps,
1079
- daemon: { running: true, pid: process.pid, uptime: Date.now() - this.startTime },
1080
- database: { path: paths.db, exists: dbExists },
1081
- // Power readiness from the daemon's view (field names fixed by
1082
- // cross-worker contract; CLI falls back to local detection).
1083
- power: {
1084
- serviceInstalled: powerServiceInstalled(),
1085
- inhibitAvailable: resolveInhibitBin() !== null,
1330
+ case "doctor": {
1331
+ const nodeVersion = process.version;
1332
+ let gitInstalled = false;
1333
+ let gitVersion;
1334
+ try {
1335
+ const { execFile } = await import("node:child_process");
1336
+ const { promisify } = await import("node:util");
1337
+ const execFileAsync = promisify(execFile);
1338
+ const { stdout } = await execFileAsync("git", ["--version"], { timeout: 3000 });
1339
+ gitInstalled = true;
1340
+ gitVersion = stdout.trim();
1341
+ }
1342
+ catch {
1343
+ gitInstalled = false;
1344
+ }
1345
+ const agents = await this.registry.detectAll();
1346
+ const caps = {};
1347
+ for (const [k, drv] of this.registry.drivers.entries()) {
1348
+ caps[k] = drv.capabilities();
1349
+ }
1350
+ const agentsWithCaps = {};
1351
+ for (const [k, v] of Object.entries(agents)) {
1352
+ agentsWithCaps[k] = { ...v, capabilities: caps[k] };
1353
+ }
1354
+ const paths = getPaths();
1355
+ const dbExists = fs.existsSync(paths.db);
1356
+ send({
1357
+ result: {
1358
+ node: { version: nodeVersion },
1359
+ git: { installed: gitInstalled, version: gitVersion },
1360
+ agents: agentsWithCaps,
1361
+ daemon: { running: true, pid: process.pid, uptime: Date.now() - this.startTime },
1362
+ database: { path: paths.db, exists: dbExists },
1363
+ // Power readiness from the daemon's view (field names fixed by
1364
+ // cross-worker contract; CLI falls back to local detection).
1365
+ power: {
1366
+ serviceInstalled: powerServiceInstalled(),
1367
+ },
1086
1368
  },
1087
- },
1088
- });
1089
- break;
1090
- }
1091
- case "models.list": {
1092
- const p = (params || {});
1093
- if (p.agent && !this.registry.has(p.agent)) {
1094
- send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${p.agent}` } });
1095
- return;
1369
+ });
1370
+ break;
1096
1371
  }
1097
- const agents = await this.fetchModels(p.agent, p.refresh);
1098
- send({ result: { agents } });
1099
- break;
1100
- }
1101
- case "usage.get": {
1102
- const p = (params || {});
1103
- if (typeof p.runId !== "string" || p.runId.length === 0) {
1104
- send({ error: { code: "INVALID", message: "runId required" } });
1105
- return;
1372
+ case "models.list": {
1373
+ const p = (params || {});
1374
+ if (p.agent && !this.registry.has(p.agent)) {
1375
+ send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${p.agent}` } });
1376
+ return;
1377
+ }
1378
+ const agents = await this.fetchModels(p.agent, p.refresh);
1379
+ send({ result: { agents } });
1380
+ break;
1106
1381
  }
1107
- if (p.observe !== undefined) {
1108
- const { nativeId, costUsd } = p.observe;
1109
- if (typeof nativeId !== "string" ||
1110
- nativeId.length === 0 ||
1111
- typeof costUsd !== "number" ||
1112
- !Number.isFinite(costUsd) ||
1113
- costUsd < 0) {
1114
- send({ error: { code: "INVALID", message: "observe requires nativeId and a finite non-negative costUsd" } });
1382
+ case "usage.get": {
1383
+ const p = (params || {});
1384
+ if (typeof p.runId !== "string" || p.runId.length === 0) {
1385
+ send({ error: { code: "INVALID", message: "runId required" } });
1115
1386
  return;
1116
1387
  }
1117
- const session = this.sessions.getByRunId(p.runId)
1118
- .find((candidate) => candidate.id === p.runId && candidate.origin === "open");
1119
- if (session) {
1388
+ const runId = p.runId;
1389
+ const resolvedRunId = this.sessions.getByRunId(runId).length > 0
1390
+ ? runId
1391
+ : this.sessions.get(runId)?.id ?? runId;
1392
+ let observation;
1393
+ if (p.observe !== undefined) {
1394
+ const raw = p.observe;
1395
+ const nativeId = typeof raw === "object" && raw !== null && !Array.isArray(raw)
1396
+ ? raw.nativeId
1397
+ : undefined;
1398
+ const costUsd = typeof raw === "object" && raw !== null && !Array.isArray(raw)
1399
+ ? raw.costUsd
1400
+ : undefined;
1401
+ if (typeof nativeId !== "string" ||
1402
+ nativeId.length === 0 ||
1403
+ typeof costUsd !== "number" ||
1404
+ !Number.isFinite(costUsd) ||
1405
+ costUsd < 0) {
1406
+ send({ error: { code: "INVALID", message: "observe requires nativeId and a finite non-negative costUsd" } });
1407
+ return;
1408
+ }
1409
+ observation = { nativeId, costUsd };
1410
+ }
1411
+ const session = this.sessions.getByRunId(resolvedRunId)
1412
+ .find((candidate) => candidate.id === resolvedRunId && candidate.origin === "open");
1413
+ const earlierNativeIds = new Set();
1414
+ if (session && observation) {
1120
1415
  const db = this.db.getHandle();
1121
1416
  db.exec("BEGIN");
1122
- let previous = [];
1123
1417
  try {
1124
- previous = this.nativeLinks.link(session.id, nativeId).previous;
1125
- this.usageLedger.observe(session.id, openSourceKey(nativeId), { cost: costUsd });
1418
+ const { previous } = this.nativeLinks.link(session.id, observation.nativeId);
1419
+ previous.forEach((nativeId) => earlierNativeIds.add(nativeId));
1420
+ this.usageLedger.observe(session.id, openSourceKey(observation.nativeId), { cost: observation.costUsd });
1126
1421
  db.exec("COMMIT");
1127
1422
  }
1128
1423
  catch (error) {
@@ -1132,35 +1427,74 @@ class Daemon {
1132
1427
  catch { }
1133
1428
  throw error;
1134
1429
  }
1135
- if (previous.length > 0)
1136
- await this.reconcileOpenUsageSafely(session.id, previous);
1137
1430
  }
1431
+ const rawTranscript = p.transcript;
1432
+ const transcript = typeof rawTranscript === "object" && rawTranscript !== null && !Array.isArray(rawTranscript)
1433
+ ? rawTranscript
1434
+ : undefined;
1435
+ if (session &&
1436
+ session.agent === "claude" &&
1437
+ isActiveStatus(session.status) &&
1438
+ transcript &&
1439
+ typeof transcript.nativeId === "string" &&
1440
+ transcript.nativeId.length > 0 &&
1441
+ typeof transcript.path === "string" &&
1442
+ transcript.path.length > 0 &&
1443
+ (!observation || observation.nativeId === transcript.nativeId)) {
1444
+ const previous = await this.ingestLiveTranscript(session.id, transcript.nativeId, transcript.path);
1445
+ previous.forEach((nativeId) => earlierNativeIds.add(nativeId));
1446
+ }
1447
+ const sessions = this.sessions.getByRunId(resolvedRunId);
1448
+ const sessionIds = sessions.map((session) => session.id);
1449
+ const attributions = this.usageLedger.attributionsFor(sessionIds);
1450
+ const linkStates = this.nativeLinks.linksFor(sessionIds).map(({ sessionId, state }) => ({ sessionId, state }));
1451
+ send({ result: aggregateRunUsage(resolvedRunId, sessions, attributions, linkStates) });
1452
+ if (session && earlierNativeIds.size > 0) {
1453
+ this.queueOpenUsageReconciliation(session.id, [...earlierNativeIds]);
1454
+ }
1455
+ break;
1138
1456
  }
1139
- const sessions = this.sessions.getByRunId(p.runId);
1140
- const sessionIds = sessions.map((session) => session.id);
1141
- const attributions = this.usageLedger.attributionsFor(sessionIds);
1142
- const linkStates = this.nativeLinks.linksFor(sessionIds).map(({ sessionId, state }) => ({ sessionId, state }));
1143
- send({ result: aggregateRunUsage(p.runId, sessions, attributions, linkStates) });
1144
- break;
1145
- }
1146
- case "usage.query": {
1147
- const p = (params || {});
1148
- try {
1149
- const result = this.sessions.queryUsage(p);
1150
- send({ result });
1457
+ case "usage.query": {
1458
+ const p = (params || {});
1459
+ try {
1460
+ const result = this.sessions.queryUsage(p);
1461
+ send({ result });
1462
+ }
1463
+ catch (error) {
1464
+ send({ error: { code: "USAGE_QUERY_ERROR", message: error instanceof Error ? error.message : String(error) } });
1465
+ }
1466
+ break;
1151
1467
  }
1152
- catch (error) {
1153
- send({ error: { code: "USAGE_QUERY_ERROR", message: error instanceof Error ? error.message : String(error) } });
1468
+ case "web.ensure": {
1469
+ const p = (params || {});
1470
+ try {
1471
+ send({ result: await this.webHost().ensure(p) });
1472
+ }
1473
+ catch (error) {
1474
+ if (error instanceof WebEnsureError) {
1475
+ send({ error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } });
1476
+ }
1477
+ else {
1478
+ send({ error: { code: "WEB_START_FAILED", message: error instanceof Error ? error.message : String(error) } });
1479
+ }
1480
+ }
1481
+ break;
1154
1482
  }
1155
- break;
1483
+ case "daemon.stop": {
1484
+ send({ result: { ok: true } });
1485
+ void this.handleShutdown("daemon.stop").finally(() => process.exit(0));
1486
+ break;
1487
+ }
1488
+ default:
1489
+ send({ error: { code: "UNKNOWN_METHOD", message: `Unknown method ${method}` } });
1156
1490
  }
1157
- case "daemon.stop": {
1158
- send({ result: { ok: true } });
1159
- void this.handleShutdown("daemon.stop").finally(() => process.exit(0));
1160
- break;
1491
+ }
1492
+ catch (error) {
1493
+ if (error instanceof AmbiguousSessionIdError) {
1494
+ send({ error: { code: error.code, message: error.message } });
1495
+ return;
1161
1496
  }
1162
- default:
1163
- send({ error: { code: "UNKNOWN_METHOD", message: `Unknown method ${method}` } });
1497
+ throw error;
1164
1498
  }
1165
1499
  }
1166
1500
  addSubscriber(sessionId, socket) {
@@ -1197,6 +1531,26 @@ class Daemon {
1197
1531
  }
1198
1532
  }
1199
1533
  }
1534
+ webHost() {
1535
+ this.web ??= new WebSupervisor({ log: appendDaemonLog, spawnChild: this.spawnWebChild });
1536
+ return this.web;
1537
+ }
1538
+ /**
1539
+ * Start the web console with the daemon so its address answers without a
1540
+ * prior web command. Not awaited: IPC never waits on the web stack.
1541
+ */
1542
+ autostartWeb() {
1543
+ const config = loadConfig();
1544
+ const { port, invalid } = resolveWebPort(config);
1545
+ const { host, invalid: invalidHost } = resolveWebHost(config);
1546
+ if (invalid !== undefined)
1547
+ appendDaemonLog(invalidWebPortMessage(invalid));
1548
+ if (invalidHost !== undefined)
1549
+ appendDaemonLog(invalidWebHostMessage(invalidHost));
1550
+ this.webHost()
1551
+ .ensure({ preferredPort: port, preferredHost: host })
1552
+ .catch((error) => appendDaemonLog(`web autostart failed: ${error instanceof Error ? error.message : String(error)}`));
1553
+ }
1200
1554
  async startDriverForSession(sessionId, prompt, model) {
1201
1555
  if (this.shuttingDown)
1202
1556
  return;
@@ -1438,19 +1792,21 @@ class Daemon {
1438
1792
  this.sessionLocks.delete(sessionId);
1439
1793
  }
1440
1794
  }
1441
- async attachDriverEvents(sessionId, driver, drvSession) {
1442
- try {
1443
- for await (const ev of driver.events(drvSession)) {
1444
- // A stop operation owns the terminal outcome. A terminal frame already
1445
- // buffered by the harness must not race it into the event log.
1446
- if (this.sessionLocks.has(sessionId) && (ev.type === "session.completed" || ev.type === "session.failed")) {
1447
- continue;
1448
- }
1449
- const db = this.db.getHandle();
1450
- db.exec("BEGIN");
1451
- let inserted = 0;
1795
+ // Commits one event with its cursor. A busy store is the daemon's problem,
1796
+ // not the session's: the harness keeps running and its output stays in the
1797
+ // log file, so retry the same event until the store frees up instead of
1798
+ // failing a live session. Returns undefined when shutdown interrupts the
1799
+ // wait (the drain owns the outcome); any other error propagates.
1800
+ async persistDriverEvent(sessionId, driver, ev) {
1801
+ const db = this.db.getHandle();
1802
+ for (let attempt = 0;; attempt += 1) {
1803
+ try {
1804
+ // IMMEDIATE takes the write lock up front, so busy_timeout applies.
1805
+ // A deferred BEGIN read first and then got SQLITE_BUSY_SNAPSHOT on
1806
+ // the upgrade, which no busy handler retries.
1807
+ db.exec("BEGIN IMMEDIATE");
1452
1808
  try {
1453
- inserted = this.events.append(sessionId, ev, ev.raw);
1809
+ const inserted = this.events.append(sessionId, ev, ev.raw);
1454
1810
  const offsets = driver.getOffsets?.(sessionId);
1455
1811
  if (offsets) {
1456
1812
  this.sessions.update(sessionId, { logOffset: offsets.log, stderrOffset: offsets.stderr });
@@ -1460,6 +1816,7 @@ class Daemon {
1460
1816
  this.updateSessionFromEvent(sessionId, ev, inserted);
1461
1817
  }
1462
1818
  db.exec("COMMIT");
1819
+ return inserted;
1463
1820
  }
1464
1821
  catch (error) {
1465
1822
  try {
@@ -1468,6 +1825,29 @@ class Daemon {
1468
1825
  catch { }
1469
1826
  throw error;
1470
1827
  }
1828
+ }
1829
+ catch (error) {
1830
+ if (!isStoreBusy(error))
1831
+ throw error;
1832
+ if (this.shuttingDown)
1833
+ return undefined;
1834
+ if (attempt === 0)
1835
+ appendDaemonLog(`store busy persisting ${ev.type} for ${sessionId}; retrying`);
1836
+ await sleep(Math.min(STORE_BUSY_RETRY_BASE_MS * 2 ** attempt, STORE_BUSY_RETRY_MAX_MS));
1837
+ }
1838
+ }
1839
+ }
1840
+ async attachDriverEvents(sessionId, driver, drvSession) {
1841
+ try {
1842
+ for await (const ev of driver.events(drvSession)) {
1843
+ // A stop operation owns the terminal outcome. A terminal frame already
1844
+ // buffered by the harness must not race it into the event log.
1845
+ if (this.sessionLocks.has(sessionId) && (ev.type === "session.completed" || ev.type === "session.failed")) {
1846
+ continue;
1847
+ }
1848
+ const inserted = await this.persistDriverEvent(sessionId, driver, ev);
1849
+ if (inserted === undefined)
1850
+ return;
1471
1851
  // Broadcast only after the durable event+cursor commit, and never
1472
1852
  // broadcast a replay that was already in the event store.
1473
1853
  if (inserted !== 0)
@@ -1527,6 +1907,7 @@ class Daemon {
1527
1907
  this.sessions.setStatus(sessionId, "failed", { lastEvent: error.slice(0, 200), failure });
1528
1908
  }
1529
1909
  }
1910
+ this.finishHeal(sessionId);
1530
1911
  // A message queued while the turn ran starts now as the next turn.
1531
1912
  // tryDispatch rechecks resumability under the lifecycle lock; a stale
1532
1913
  // or unresumable slot stays put for a manual send.
@@ -1640,52 +2021,6 @@ class Daemon {
1640
2021
  return;
1641
2022
  void this.handleShutdown(reason).finally(() => process.exit(0));
1642
2023
  }
1643
- // Best-effort delay lock: while this child lives, systemd/logind delays
1644
- // shutdown up to InhibitDelayMaxSec so the drain can finish. Silent no-op
1645
- // when the binary is absent. The binary resolves through fixed absolute
1646
- // paths only, never PATH (typescript:S4036). Public so tests can drive it
1647
- // without start() (no socket binding); binOverride is the test seam for
1648
- // a fake binary.
1649
- maybeSpawnInhibit(binOverride) {
1650
- if (this.inhibitChild)
1651
- return;
1652
- const bin = binOverride ?? resolveInhibitBin();
1653
- if (!bin)
1654
- return;
1655
- // Absent binary (removed between resolve and spawn, or a bogus test
1656
- // seam path) is a silent no-op: spawn(2) failure would only surface
1657
- // as an async error event, leaving a dead child behind.
1658
- try {
1659
- if (!fs.existsSync(bin))
1660
- return;
1661
- }
1662
- catch {
1663
- return;
1664
- }
1665
- try {
1666
- const child = spawn(bin, ["--what=shutdown:sleep", "--who=CodeDeck", "--why=flush sessions", "--mode=delay", "sleep", "infinity"], { stdio: "ignore", detached: true });
1667
- child.on("error", () => { });
1668
- child.unref();
1669
- this.inhibitChild = child;
1670
- if (!this.inhibitExitHookInstalled) {
1671
- this.inhibitExitHookInstalled = true;
1672
- process.once("exit", () => this.killInhibitChild());
1673
- }
1674
- }
1675
- catch {
1676
- this.inhibitChild = null;
1677
- }
1678
- }
1679
- killInhibitChild() {
1680
- const child = this.inhibitChild;
1681
- this.inhibitChild = null;
1682
- if (!child)
1683
- return;
1684
- try {
1685
- child.kill();
1686
- }
1687
- catch { }
1688
- }
1689
2024
  // Graceful power shutdown. Exposed as a method so tests can drive the
1690
2025
  // drain without signals/systemd: exactly-once (concurrent callers share
1691
2026
  // one drain), never throws, never exits — callers decide on exit.
@@ -1697,6 +2032,11 @@ class Daemon {
1697
2032
  return this.shutdownPromise;
1698
2033
  }
1699
2034
  async runShutdown(reason) {
2035
+ // SIGTERM only; the web child holds no state worth waiting for.
2036
+ try {
2037
+ this.web?.close();
2038
+ }
2039
+ catch { }
1700
2040
  const handle = this.db.getHandle();
1701
2041
  // Never close mid-transaction: roll back a stale BEGIN (best-effort)
1702
2042
  // BEFORE persisting, so the interrupted writes below stay durable.
@@ -1728,8 +2068,6 @@ class Daemon {
1728
2068
  handle.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1729
2069
  }
1730
2070
  catch { }
1731
- // The delay lock has served its purpose; release it before closing.
1732
- this.killInhibitChild();
1733
2071
  try {
1734
2072
  const paths = getPaths();
1735
2073
  try {
@@ -1791,7 +2129,7 @@ class Daemon {
1791
2129
  async markInterrupted(s, reason) {
1792
2130
  try {
1793
2131
  // Serialize against an in-flight send/stop owner (bounded: the drain
1794
- // must fit in InhibitDelayMaxSec), then hold the lock so attached
2132
+ // must fit before systemd's SIGKILL), then hold the lock so attached
1795
2133
  // event loops discard terminal frames instead of overwriting
1796
2134
  // `interrupted` (see attachDriverEvents).
1797
2135
  const deadline = Date.now() + 500;
@@ -1821,10 +2159,21 @@ class Daemon {
1821
2159
  catch { }
1822
2160
  }
1823
2161
  }
2162
+ // Held for the process lifetime (instance-lock keeps the connection alive
2163
+ // against GC); the kernel drops it on exit, so shutdown does not release it.
2164
+ let instanceLock = null;
1824
2165
  // Entry
1825
2166
  if (process.argv.includes("--daemon")) {
2167
+ // Take the lock before opening run-agent.db: a losing daemon must not
2168
+ // migrate, recover or bind the socket. Exit 0 so the spawning CLI keeps
2169
+ // polling and connects to the daemon that already runs.
2170
+ instanceLock = acquireInstanceLock(getPaths().daemonLock);
2171
+ if (!instanceLock) {
2172
+ appendDaemonLog(`pid ${process.pid}: another daemon holds ${getPaths().daemonLock}; exiting`);
2173
+ process.exit(0);
2174
+ }
1826
2175
  const d = new Daemon();
1827
- d.start().catch((e) => {
2176
+ d.start().then(() => d.autostartWeb(), (e) => {
1828
2177
  console.error("[daemon] failed to start", e);
1829
2178
  process.exit(1);
1830
2179
  });