codedeck 0.3.0 → 0.4.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 (80) hide show
  1. package/dist/cli/charts/bar-chart.js +104 -0
  2. package/dist/cli/charts/bar-chart.js.map +1 -0
  3. package/dist/cli/charts/sparkline.js +18 -0
  4. package/dist/cli/charts/sparkline.js.map +1 -0
  5. package/dist/cli/charts/stacked-bar.js +59 -0
  6. package/dist/cli/charts/stacked-bar.js.map +1 -0
  7. package/dist/cli/commands/done.js +35 -0
  8. package/dist/cli/commands/done.js.map +1 -0
  9. package/dist/cli/commands/models.js +4 -4
  10. package/dist/cli/commands/models.js.map +1 -1
  11. package/dist/cli/commands/open.js +218 -57
  12. package/dist/cli/commands/open.js.map +1 -1
  13. package/dist/cli/commands/ps.js +1 -35
  14. package/dist/cli/commands/ps.js.map +1 -1
  15. package/dist/cli/commands/run.js +4 -3
  16. package/dist/cli/commands/run.js.map +1 -1
  17. package/dist/cli/commands/send.js +2 -0
  18. package/dist/cli/commands/send.js.map +1 -1
  19. package/dist/cli/commands/setup.js +604 -13
  20. package/dist/cli/commands/setup.js.map +1 -1
  21. package/dist/cli/commands/usage.js +171 -16
  22. package/dist/cli/commands/usage.js.map +1 -1
  23. package/dist/cli/commands/web.js +307 -0
  24. package/dist/cli/commands/web.js.map +1 -0
  25. package/dist/cli/index.js +5 -0
  26. package/dist/cli/index.js.map +1 -1
  27. package/dist/cli/ui.js +34 -0
  28. package/dist/cli/ui.js.map +1 -1
  29. package/dist/cli/usage/dashboard.js +330 -0
  30. package/dist/cli/usage/dashboard.js.map +1 -0
  31. package/dist/cli/usage/snapshot.js +112 -0
  32. package/dist/cli/usage/snapshot.js.map +1 -0
  33. package/dist/config/config.js +287 -5
  34. package/dist/config/config.js.map +1 -1
  35. package/dist/config/paths.js +20 -9
  36. package/dist/config/paths.js.map +1 -1
  37. package/dist/core/autocompact.js +77 -0
  38. package/dist/core/autocompact.js.map +1 -0
  39. package/dist/core/driver.js.map +1 -1
  40. package/dist/core/events.js.map +1 -1
  41. package/dist/core/models.js +166 -3
  42. package/dist/core/models.js.map +1 -1
  43. package/dist/core/pricing.js +18 -0
  44. package/dist/core/pricing.js.map +1 -1
  45. package/dist/core/session.js +14 -1
  46. package/dist/core/session.js.map +1 -1
  47. package/dist/daemon/daemon.js +483 -96
  48. package/dist/daemon/daemon.js.map +1 -1
  49. package/dist/drivers/antigravity/driver.js +172 -0
  50. package/dist/drivers/antigravity/driver.js.map +1 -0
  51. package/dist/drivers/antigravity/parser.js +168 -0
  52. package/dist/drivers/antigravity/parser.js.map +1 -0
  53. package/dist/drivers/claude/driver.js +13 -3
  54. package/dist/drivers/claude/driver.js.map +1 -1
  55. package/dist/drivers/registry.js +2 -0
  56. package/dist/drivers/registry.js.map +1 -1
  57. package/dist/drivers/session-driver.js +6 -4
  58. package/dist/drivers/session-driver.js.map +1 -1
  59. package/dist/drivers/session-runtime.js +1 -1
  60. package/dist/drivers/session-runtime.js.map +1 -1
  61. package/dist/open/contract.js +16 -4
  62. package/dist/open/contract.js.map +1 -1
  63. package/dist/open/injection.js +1 -0
  64. package/dist/open/injection.js.map +1 -1
  65. package/dist/open/launchers/claude.js +4 -1
  66. package/dist/open/launchers/claude.js.map +1 -1
  67. package/dist/open/launchers/opencode.js +37 -2
  68. package/dist/open/launchers/opencode.js.map +1 -1
  69. package/dist/open/runtime.js +43 -8
  70. package/dist/open/runtime.js.map +1 -1
  71. package/dist/plugin/agents/orchestrator-edit.md +1 -3
  72. package/dist/plugin/agents/orchestrator-read.md +1 -3
  73. package/dist/plugin/agents/orchestrator.md +2 -4
  74. package/dist/store/database.js +40 -15
  75. package/dist/store/database.js.map +1 -1
  76. package/dist/store/sessions.js +225 -3
  77. package/dist/store/sessions.js.map +1 -1
  78. package/dist/web/canvas-page.js +1069 -0
  79. package/dist/web/canvas-page.js.map +1 -0
  80. package/package.json +1 -1
@@ -9,7 +9,7 @@ import { ClaimsStore } from "../store/claims.js";
9
9
  import { getPaths, ensureDirs } from "../config/paths.js";
10
10
  import { createIpcServer } from "./ipc.js";
11
11
  import { getRegistry } from "../drivers/registry.js";
12
- import { isTerminalStatus } from "../core/session.js";
12
+ import { isTerminalStatus, liveStatus, normalizeAgentId } from "../core/session.js";
13
13
  import { parseSandbox } from "../core/driver.js";
14
14
  import { generateSessionId } from "../core/session.js";
15
15
  import { getGitInfo } from "../git/repository.js";
@@ -40,6 +40,36 @@ function resolveRequestSandbox(value) {
40
40
  return undefined;
41
41
  }
42
42
  }
43
+ // Read-boundary liveness: serve a corpse as "dead" (same rule `ps` shows)
44
+ // instead of the stale stored "working". Returns the same object when no
45
+ // correction applies, so callers can skip copies; never mutates the store.
46
+ function withLiveStatus(s) {
47
+ if (s.pid == null)
48
+ return s;
49
+ const status = liveStatus(s.status, s.pid, processAlive(s.pid));
50
+ return status === s.status ? s : { ...s, status: status };
51
+ }
52
+ // Send-queue cap: bytes, matching MAX_SEND_BODY_BYTES on the web route.
53
+ const MAX_SEND_MESSAGE_BYTES = 64 * 1024;
54
+ function validateSendMessage(raw) {
55
+ if (typeof raw !== "string")
56
+ return { code: "INVALID", error: "message required" };
57
+ const message = raw.trim();
58
+ if (!message)
59
+ return { code: "INVALID", error: "message required" };
60
+ if (Buffer.byteLength(message, "utf8") > MAX_SEND_MESSAGE_BYTES) {
61
+ return { code: "INVALID", error: "message too large" };
62
+ }
63
+ return { message };
64
+ }
65
+ // PID identity triple, same rule as the stop path: a recycled PID must
66
+ // neither block a legitimate resume nor permit killing a stranger.
67
+ function livePidIdentity(s) {
68
+ return (s.pid != null &&
69
+ s.pidStartTime != null &&
70
+ processAlive(s.pid) &&
71
+ processStartTime(s.pid) === s.pidStartTime);
72
+ }
43
73
  class Daemon {
44
74
  db;
45
75
  sessions;
@@ -135,6 +165,53 @@ class Daemon {
135
165
  // starting|working|needs_input|idle.
136
166
  if (s.status === "interrupted")
137
167
  continue;
168
+ if (s.origin === "open") {
169
+ if (s.pid == null) {
170
+ this.sessions.setStatus(s.id, "failed", { lastEvent: "open session terminated before patch" });
171
+ continue;
172
+ }
173
+ const currentStart = processStartTime(s.pid);
174
+ const processPresent = processAlive(s.pid);
175
+ const identityVerified = s.pidStartTime != null && currentStart === s.pidStartTime;
176
+ if (processPresent && identityVerified) {
177
+ // Head process still running interactively; remain working, no driver to attach
178
+ continue;
179
+ }
180
+ else if (!processPresent) {
181
+ // Closed normally while daemon was away
182
+ const ev = {
183
+ type: "session.completed",
184
+ sessionId: s.id,
185
+ timestamp: new Date().toISOString(),
186
+ reason: "closed while daemon was stopped",
187
+ };
188
+ this.events.append(s.id, ev);
189
+ this.sessions.setStatus(s.id, "completed", { lastEvent: "closed while daemon was stopped" });
190
+ continue;
191
+ }
192
+ else {
193
+ // PID recycled (processPresent is true but !identityVerified)
194
+ const failure = {
195
+ code: "HARNESS_CRASH",
196
+ blame: "harness",
197
+ retryable: false,
198
+ reason: "pid_reused",
199
+ detail: `PID ${s.pid} no longer identifies the head session`,
200
+ };
201
+ const detail = failure.detail ?? `PID ${s.pid} no longer identifies the head session`;
202
+ const event = {
203
+ type: "session.failed",
204
+ sessionId: s.id,
205
+ timestamp: new Date().toISOString(),
206
+ error: detail,
207
+ failure,
208
+ raw: { pid: s.pid, expectedStartTime: s.pidStartTime, currentStart },
209
+ };
210
+ this.events.append(s.id, event);
211
+ this.sessions.setStatus(s.id, "failed", { lastEvent: detail, failure });
212
+ continue;
213
+ }
214
+ }
138
215
  const driver = this.registry.get(s.agent);
139
216
  const metadata = readSessionProcessMetadata(s.id);
140
217
  const pid = metadata?.pid ?? s.pid;
@@ -246,7 +323,8 @@ class Daemon {
246
323
  }
247
324
  const cwdIn = p.cwd || process.cwd();
248
325
  const cfg = loadConfig();
249
- let agent = p.agent || cfg.defaultAgent || "claude";
326
+ const rawAgent = p.agent || cfg.defaultAgent || "claude";
327
+ let agent = (normalizeAgentId(rawAgent) ?? rawAgent);
250
328
  if (!this.registry.has(agent)) {
251
329
  send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${agent}` } });
252
330
  return;
@@ -336,10 +414,140 @@ class Daemon {
336
414
  });
337
415
  break;
338
416
  }
417
+ case "session.adopt": {
418
+ const p = params;
419
+ const cwdIn = p.cwd || process.cwd();
420
+ const rawAgent = p.agent || "claude";
421
+ const agent = (normalizeAgentId(rawAgent) ?? rawAgent);
422
+ if (!this.registry.has(agent)) {
423
+ send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${agent}` } });
424
+ return;
425
+ }
426
+ let sessionId = generateSessionId();
427
+ while (this.sessions.get(sessionId)) {
428
+ sessionId = generateSessionId();
429
+ }
430
+ const cwd = path.resolve(cwdIn);
431
+ let repository;
432
+ let baseCommit = p.baseCommit;
433
+ const gitInfo = await getGitInfo(cwd);
434
+ if (this.shuttingDown) {
435
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
436
+ return;
437
+ }
438
+ if (gitInfo) {
439
+ repository = gitInfo.root;
440
+ if (!baseCommit)
441
+ baseCommit = gitInfo.head;
442
+ }
443
+ const now = new Date();
444
+ const session = {
445
+ id: sessionId,
446
+ runId: sessionId,
447
+ origin: "open",
448
+ name: p.name,
449
+ agent,
450
+ model: p.model,
451
+ status: "working",
452
+ repository,
453
+ cwd,
454
+ worktree: p.worktree,
455
+ branch: p.branch,
456
+ baseCommit: baseCommit || undefined,
457
+ createdAt: now,
458
+ updatedAt: now,
459
+ };
460
+ this.sessions.create(session);
461
+ send({ result: { session } });
462
+ break;
463
+ }
464
+ case "session.patch": {
465
+ const p = params;
466
+ const s = this.sessions.get(p.id);
467
+ if (!s) {
468
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
469
+ return;
470
+ }
471
+ let pidStartTime = p.pidStartTime;
472
+ if (p.pid !== undefined && pidStartTime === undefined) {
473
+ pidStartTime = processStartTime(p.pid);
474
+ }
475
+ const updates = {};
476
+ if (p.pid !== undefined)
477
+ updates.pid = p.pid;
478
+ if (pidStartTime !== undefined)
479
+ updates.pidStartTime = pidStartTime;
480
+ if (p.worktree !== undefined)
481
+ updates.worktree = p.worktree;
482
+ if (p.branch !== undefined)
483
+ updates.branch = p.branch;
484
+ if (p.baseCommit !== undefined)
485
+ updates.baseCommit = p.baseCommit;
486
+ if (p.cwd !== undefined)
487
+ updates.cwd = p.cwd;
488
+ this.sessions.update(s.id, updates);
489
+ const updated = this.sessions.get(s.id);
490
+ send({ result: { session: updated } });
491
+ break;
492
+ }
493
+ case "session.release": {
494
+ const p = params;
495
+ const s = this.sessions.get(p.id);
496
+ if (!s) {
497
+ send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
498
+ return;
499
+ }
500
+ // Release owns the terminal outcome: a queued message must not
501
+ // survive as a phantom "na fila" on every exit, including the
502
+ // terminal early return below.
503
+ this.clearPending(s.id);
504
+ // Interrupted is an archival terminal: a power-shutdown row stays
505
+ // actionable and release may finalize it to completed/failed — unless
506
+ // its process is still alive under the same identity, which keeps
507
+ // the send path's stop-first guard instead of orphaning it.
508
+ if (s.status === "interrupted" && livePidIdentity(s)) {
509
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
510
+ return;
511
+ }
512
+ if (isTerminalStatus(s.status) && s.status !== "interrupted") {
513
+ send({ result: { session: this.sessions.get(s.id) } });
514
+ return;
515
+ }
516
+ const targetStatus = p.status === "failed" ? "failed" : "completed";
517
+ const extra = {};
518
+ if (p.nativeSessionId !== undefined) {
519
+ extra.nativeSessionId = p.nativeSessionId;
520
+ }
521
+ if (targetStatus === "failed") {
522
+ extra.lastEvent = (p.error || "failed").slice(0, 200);
523
+ }
524
+ else {
525
+ extra.lastEvent = "released";
526
+ }
527
+ this.sessions.setStatus(s.id, targetStatus, extra);
528
+ const ev = targetStatus === "failed"
529
+ ? {
530
+ type: "session.failed",
531
+ sessionId: s.id,
532
+ timestamp: new Date().toISOString(),
533
+ error: p.error || "session failed",
534
+ }
535
+ : {
536
+ type: "session.completed",
537
+ sessionId: s.id,
538
+ timestamp: new Date().toISOString(),
539
+ reason: "released",
540
+ };
541
+ this.events.append(s.id, ev);
542
+ this.broadcast(s.id, ev);
543
+ const updated = this.sessions.get(s.id);
544
+ send({ result: { session: updated } });
545
+ break;
546
+ }
339
547
  case "session.list": {
340
548
  const p = params;
341
549
  const all = p?.all;
342
- const list = this.sessions.list(100, all);
550
+ const list = this.sessions.list(100, all).map(withLiveStatus);
343
551
  const hidden = all ? 0 : this.sessions.countHiddenByWindow();
344
552
  // Enrich with last event?
345
553
  send({ result: { sessions: list, hidden } });
@@ -347,11 +555,12 @@ class Daemon {
347
555
  }
348
556
  case "session.get": {
349
557
  const p = params;
350
- const s = this.sessions.get(p.id);
351
- if (!s) {
558
+ const stored = this.sessions.get(p.id);
559
+ if (!stored) {
352
560
  send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
353
561
  return;
354
562
  }
563
+ const s = withLiveStatus(stored);
355
564
  const evCount = this.events.count(s.id);
356
565
  const recent = this.events.list(s.id, 10);
357
566
  send({ result: { session: s, events: recent, eventCount: evCount } });
@@ -374,124 +583,110 @@ class Daemon {
374
583
  }
375
584
  case "session.send": {
376
585
  const p = params;
586
+ const validated = validateSendMessage(p.message);
587
+ if ("code" in validated) {
588
+ send({ error: { code: validated.code, message: validated.error } });
589
+ return;
590
+ }
591
+ const message = validated.message;
377
592
  const s = this.sessions.get(p.id);
378
593
  if (!s) {
379
594
  send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
380
595
  return;
381
596
  }
597
+ if (s.origin === "open") {
598
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} is an interactive head session and does not support headless prompt turns` } });
599
+ return;
600
+ }
382
601
  const driver = this.registry.get(s.agent);
383
- // Do not start a second harness while the current one is still live:
384
- // both runtimes would tail the same per-session file and duplicate
385
- // every event from the follow-up turn.
602
+ if (!driver.capabilities().resume) {
603
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
604
+ return;
605
+ }
606
+ if (s.status === "interrupted" && !s.nativeSessionId) {
607
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} cannot resume (no native session id or agent ${s.agent} does not support resume)` } });
608
+ return;
609
+ }
610
+ // Power-interrupted sessions with a live process keep the old rule:
611
+ // stop first, never queue behind a running harness.
612
+ if (s.status === "interrupted" && livePidIdentity(s)) {
613
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
614
+ return;
615
+ }
616
+ // A lifecycle operation owns the session: retry instead of queueing,
617
+ // so a send racing stop cannot strand a phantom slot with no stream
618
+ // tail left to consume it (stop cancels the queue on exit).
386
619
  if (this.sessionLocks.has(s.id)) {
387
620
  send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
388
621
  return;
389
622
  }
390
- if (s.status === "interrupted") {
391
- // Resume-turn admission for power-interrupted sessions: capability
392
- // BEFORE liveness, and liveness by process identity (same rule as
393
- // stop). A recycled PID must not block a legitimate resume.
394
- if (!s.nativeSessionId || !driver.capabilities().resume) {
395
- send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} cannot resume (no native session id or agent ${s.agent} does not support resume)` } });
396
- return;
623
+ // Do not start a second harness while the current one is still live:
624
+ // both runtimes would tail the same per-session file and duplicate
625
+ // every event from the follow-up turn. Queue behind a live turn only.
626
+ const busyHandle = driver.getHandle?.(s.id);
627
+ const busyState = busyHandle && typeof busyHandle === "object"
628
+ ? busyHandle
629
+ : undefined;
630
+ const runtimeBusy = busyHandle !== undefined && (busyState?.done !== true || busyState?.drained !== true);
631
+ const busy = s.status === "starting" || runtimeBusy || livePidIdentity(s);
632
+ if (busy) {
633
+ // One-slot queue, last-wins: persist without holding the lifecycle
634
+ // lock, then converge — the turn may have ended between the busy
635
+ // read and this persist, in which case tryDispatch starts now.
636
+ const pendingAt = new Date().toISOString();
637
+ try {
638
+ this.sessions.update(s.id, { pendingMessage: message, pendingAt });
397
639
  }
398
- const liveIdentity = s.pid != null &&
399
- s.pidStartTime != null &&
400
- processAlive(s.pid) &&
401
- processStartTime(s.pid) === s.pidStartTime;
402
- if (liveIdentity) {
403
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
640
+ catch (e) {
641
+ send({ error: { code: "SEND_FAILED", message: e instanceof Error ? e.message : String(e) } });
404
642
  return;
405
643
  }
406
- }
407
- else {
408
- const handle = driver.getHandle?.(s.id);
409
- const runtimeState = handle && typeof handle === "object"
410
- ? handle
411
- : undefined;
412
- const runtimeDraining = handle !== undefined && (runtimeState?.done !== true || runtimeState?.drained !== true);
413
- if (s.status === "starting" || runtimeDraining || (s.status === "working" && s.pid != null && processAlive(s.pid))) {
414
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running` } });
415
- return;
644
+ const queuedEvent = {
645
+ type: "message.queued",
646
+ sessionId: s.id,
647
+ timestamp: pendingAt,
648
+ prompt: message,
649
+ pendingAt,
650
+ };
651
+ try {
652
+ this.events.append(s.id, queuedEvent);
416
653
  }
417
- if (!driver.capabilities().resume) {
418
- send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
419
- return;
654
+ catch { }
655
+ this.broadcast(s.id, queuedEvent);
656
+ try {
657
+ this.sessions.update(s.id, { lastEvent: `queued: ${message.slice(0, 80)}` });
420
658
  }
659
+ catch { }
660
+ void this.tryDispatch(s.id);
661
+ send({ result: { ok: true, queued: true } });
662
+ return;
421
663
  }
422
- const drvSession = {
423
- id: s.id,
424
- nativeSessionId: s.nativeSessionId,
425
- cwd: s.worktree || s.cwd,
426
- model: s.model,
427
- effort: s.effort,
428
- fast: s.fast,
429
- sandbox: s.sandbox,
430
- dangerouslyBypassApprovalsAndSandbox: s.dangerouslyBypassApprovalsAndSandbox,
431
- pid: s.pid,
432
- pidStartTime: s.pidStartTime,
433
- };
664
+ // Resumable now (terminal completed/failed/stopped/orphaned, or
665
+ // interrupted without a live process): a stale queued slot loses to
666
+ // the new message, which starts immediately.
434
667
  this.sessionLocks.add(s.id);
435
668
  try {
436
- if (this.shuttingDown) {
437
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
438
- return;
439
- }
440
- this.sessions.setStatus(s.id, "working", { lastEvent: `send: ${p.message.slice(0, 80)}` });
441
- const turnEvent = {
442
- type: "turn.started",
443
- sessionId: s.id,
444
- timestamp: new Date().toISOString(),
445
- prompt: p.message,
446
- };
447
- this.events.append(s.id, turnEvent);
448
- this.broadcast(s.id, turnEvent);
449
- await driver.send(drvSession, p.message);
450
- if (this.shuttingDown) {
451
- try {
452
- await driver.stop(drvSession);
453
- }
454
- catch { }
455
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
456
- return;
457
- }
458
- const handle = driver.getHandle?.(s.id);
459
- const handleNativeId = handle &&
460
- typeof handle === "object" &&
461
- "nativeSessionId" in handle &&
462
- typeof handle.nativeSessionId === "string"
463
- ? handle.nativeSessionId
464
- : undefined;
465
- const newNative = handleNativeId || drvSession.nativeSessionId;
466
- if (newNative && newNative !== s.nativeSessionId) {
467
- this.sessions.update(s.id, { nativeSessionId: newNative });
468
- }
469
- if (drvSession.pid) {
470
- this.sessions.update(s.id, {
471
- pid: drvSession.pid,
472
- pidStartTime: processStartTime(drvSession.pid),
473
- });
669
+ try {
670
+ this.sessions.update(s.id, { pendingMessage: null, pendingAt: null });
474
671
  }
475
- this.sessions.update(s.id, { status: "working" });
476
- // Attach event loop for new turn
477
- this.attachDriverEvents(s.id, driver, drvSession).catch(() => { });
478
- send({ result: { ok: true } });
672
+ catch { }
673
+ await this.runResumeTurn(s, message);
674
+ send({ result: { ok: true, queued: false } });
479
675
  }
480
676
  catch (e) {
481
- const message = e instanceof Error ? e.message : String(e);
482
- if (this.shuttingDown) {
483
- try {
484
- await driver.stop(drvSession);
485
- }
486
- catch { }
487
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
677
+ const errText = e instanceof Error ? e.message : String(e);
678
+ const code = e instanceof Error && e.code === "SERVICE_UNAVAILABLE"
679
+ ? "SERVICE_UNAVAILABLE"
680
+ : this.shuttingDown ? "SERVICE_UNAVAILABLE" : "SEND_FAILED";
681
+ if (code === "SERVICE_UNAVAILABLE") {
682
+ send({ error: { code, message: errText } });
488
683
  return;
489
684
  }
490
685
  try {
491
686
  this.sessions.setStatus(s.id, "failed");
492
687
  }
493
688
  catch { }
494
- send({ error: { code: "SEND_FAILED", message } });
689
+ send({ error: { code: "SEND_FAILED", message: errText } });
495
690
  }
496
691
  finally {
497
692
  if (!this.shuttingDown)
@@ -506,6 +701,9 @@ class Daemon {
506
701
  send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
507
702
  return;
508
703
  }
704
+ // Stop cancels a queued message on every exit (explicit cancel),
705
+ // including the BUSY/NOT_RUNNING/UNSAFE early returns below.
706
+ this.clearPending(s.id);
509
707
  const driver = this.registry.get(s.agent);
510
708
  if (this.sessionLocks.has(s.id)) {
511
709
  send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
@@ -567,6 +765,9 @@ class Daemon {
567
765
  this.events.append(s.id, ev);
568
766
  this.broadcast(s.id, ev);
569
767
  }
768
+ // A send may have persisted a slot after the entry clear (it saw a
769
+ // live turn before this stop took the lock): stop wins, clear again.
770
+ this.clearPending(s.id);
570
771
  send({ result: { ok: true } });
571
772
  }
572
773
  catch (e) {
@@ -771,6 +972,17 @@ class Daemon {
771
972
  send({ result: aggregateRunUsage(p.runId, sessions) });
772
973
  break;
773
974
  }
975
+ case "usage.query": {
976
+ const p = (params || {});
977
+ try {
978
+ const result = this.sessions.queryUsage(p);
979
+ send({ result });
980
+ }
981
+ catch (error) {
982
+ send({ error: { code: "USAGE_QUERY_ERROR", message: error instanceof Error ? error.message : String(error) } });
983
+ }
984
+ break;
985
+ }
774
986
  case "daemon.stop": {
775
987
  send({ result: { ok: true } });
776
988
  void this.handleShutdown("daemon.stop").finally(() => process.exit(0));
@@ -886,6 +1098,173 @@ class Daemon {
886
1098
  // Attach events
887
1099
  await this.attachDriverEvents(sessionId, driver, drvSession);
888
1100
  }
1101
+ /** Best-effort queue cancel; stop/release own the terminal outcome. */
1102
+ clearPending(sessionId) {
1103
+ try {
1104
+ this.sessions.update(sessionId, { pendingMessage: null, pendingAt: null });
1105
+ }
1106
+ catch { }
1107
+ }
1108
+ /**
1109
+ + * Starts a resumed turn; caller MUST hold sessionLocks for the session.
1110
+ + * Shared by session.send (immediate) and tryDispatch (queued) so both
1111
+ + * paths emit the same frames in the same order.
1112
+ + */
1113
+ async runResumeTurn(s, message) {
1114
+ const driver = this.registry.get(s.agent);
1115
+ if (this.shuttingDown) {
1116
+ const early = new Error("daemon is shutting down");
1117
+ early.code = "SERVICE_UNAVAILABLE";
1118
+ throw early;
1119
+ }
1120
+ this.sessions.setStatus(s.id, "working", { lastEvent: `send: ${message.slice(0, 80)}` });
1121
+ const turnEvent = {
1122
+ type: "turn.started",
1123
+ sessionId: s.id,
1124
+ timestamp: new Date().toISOString(),
1125
+ prompt: message,
1126
+ };
1127
+ this.events.append(s.id, turnEvent);
1128
+ this.broadcast(s.id, turnEvent);
1129
+ const drvSession = {
1130
+ id: s.id,
1131
+ nativeSessionId: s.nativeSessionId,
1132
+ cwd: s.worktree || s.cwd,
1133
+ model: s.model,
1134
+ effort: s.effort,
1135
+ fast: s.fast,
1136
+ sandbox: s.sandbox,
1137
+ dangerouslyBypassApprovalsAndSandbox: s.dangerouslyBypassApprovalsAndSandbox,
1138
+ pid: s.pid,
1139
+ pidStartTime: s.pidStartTime,
1140
+ };
1141
+ try {
1142
+ await driver.send(drvSession, message);
1143
+ }
1144
+ catch (e) {
1145
+ if (this.shuttingDown) {
1146
+ try {
1147
+ await driver.stop(drvSession);
1148
+ }
1149
+ catch { }
1150
+ }
1151
+ throw e;
1152
+ }
1153
+ if (this.shuttingDown) {
1154
+ try {
1155
+ await driver.stop(drvSession);
1156
+ }
1157
+ catch { }
1158
+ const late = new Error("daemon is shutting down");
1159
+ late.code = "SERVICE_UNAVAILABLE";
1160
+ throw late;
1161
+ }
1162
+ const handle = driver.getHandle?.(s.id);
1163
+ const handleNativeId = handle &&
1164
+ typeof handle === "object" &&
1165
+ "nativeSessionId" in handle &&
1166
+ typeof handle.nativeSessionId === "string"
1167
+ ? handle.nativeSessionId
1168
+ : undefined;
1169
+ const newNative = handleNativeId || drvSession.nativeSessionId;
1170
+ if (newNative && newNative !== s.nativeSessionId) {
1171
+ this.sessions.update(s.id, { nativeSessionId: newNative });
1172
+ }
1173
+ if (drvSession.pid) {
1174
+ this.sessions.update(s.id, {
1175
+ pid: drvSession.pid,
1176
+ pidStartTime: processStartTime(drvSession.pid),
1177
+ });
1178
+ }
1179
+ this.sessions.update(s.id, { status: "working" });
1180
+ // Attach event loop for new turn
1181
+ this.attachDriverEvents(s.id, driver, drvSession).catch(() => { });
1182
+ }
1183
+ /**
1184
+ + * Starts the queued message as a new turn when the session is resumable.
1185
+ + * Never blocks: returns false leaving the slot intact when busy,
1186
+ + * unresumable, or shutting down. The stream tail calls this, and the
1187
+ + * busy send path calls it to converge on races (enqueue landing as the
1188
+ + * turn ends starts immediately instead of stranding).
1189
+ + */
1190
+ async tryDispatch(sessionId) {
1191
+ if (this.shuttingDown || this.sessionLocks.has(sessionId))
1192
+ return false;
1193
+ const queued = this.sessions.get(sessionId);
1194
+ if (!queued || !queued.pendingMessage || queued.origin === "open")
1195
+ return false;
1196
+ this.sessionLocks.add(sessionId);
1197
+ let message;
1198
+ let pendingAt;
1199
+ try {
1200
+ const s = this.sessions.get(sessionId);
1201
+ if (!s || !s.pendingMessage || s.origin === "open")
1202
+ return false;
1203
+ const driver = this.registry.get(s.agent);
1204
+ if (!driver.capabilities().resume)
1205
+ return false;
1206
+ const handle = driver.getHandle?.(s.id);
1207
+ const hs = handle && typeof handle === "object"
1208
+ ? handle
1209
+ : undefined;
1210
+ // Same resolution as SessionDriver.send: without a native id the start
1211
+ // would only throw after emitting turn.started and flipping status, so
1212
+ // leave the slot quietly for a manual send instead.
1213
+ const nativeId = s.nativeSessionId
1214
+ || (typeof hs?.nativeSessionId === "string" ? hs.nativeSessionId : undefined);
1215
+ if (!nativeId)
1216
+ return false;
1217
+ if (s.status === "starting")
1218
+ return false;
1219
+ if (handle !== undefined && (hs?.done !== true || hs?.drained !== true))
1220
+ return false;
1221
+ if (livePidIdentity(s))
1222
+ return false;
1223
+ message = s.pendingMessage;
1224
+ pendingAt = s.pendingAt;
1225
+ // Clear first so a crash mid-start cannot double-send; restored below
1226
+ // when the start fails.
1227
+ this.sessions.update(s.id, { pendingMessage: null, pendingAt: null });
1228
+ await this.runResumeTurn(s, message);
1229
+ return true;
1230
+ }
1231
+ catch (e) {
1232
+ const errText = e instanceof Error ? e.message : String(e);
1233
+ const unavailable = e?.code === "SERVICE_UNAVAILABLE" || this.shuttingDown;
1234
+ if (!unavailable) {
1235
+ const failure = classifyFailure(errText);
1236
+ const errEv = {
1237
+ type: "session.failed",
1238
+ sessionId,
1239
+ timestamp: new Date().toISOString(),
1240
+ error: errText,
1241
+ failure,
1242
+ };
1243
+ try {
1244
+ this.events.append(sessionId, errEv);
1245
+ }
1246
+ catch { }
1247
+ this.broadcast(sessionId, errEv);
1248
+ try {
1249
+ this.sessions.setStatus(sessionId, "failed", { lastEvent: errText.slice(0, 200), failure });
1250
+ }
1251
+ catch { }
1252
+ }
1253
+ // Restore the slot so a failed dispatch never vanishes silently; the
1254
+ // next manual send (or a future tail) retries it.
1255
+ try {
1256
+ if (message !== undefined) {
1257
+ this.sessions.update(sessionId, { pendingMessage: message, pendingAt: pendingAt ?? new Date().toISOString() });
1258
+ }
1259
+ }
1260
+ catch { }
1261
+ return false;
1262
+ }
1263
+ finally {
1264
+ if (!this.shuttingDown)
1265
+ this.sessionLocks.delete(sessionId);
1266
+ }
1267
+ }
889
1268
  async attachDriverEvents(sessionId, driver, drvSession) {
890
1269
  try {
891
1270
  for await (const ev of driver.events(drvSession)) {
@@ -975,6 +1354,11 @@ class Daemon {
975
1354
  this.sessions.setStatus(sessionId, "failed", { lastEvent: error.slice(0, 200), failure });
976
1355
  }
977
1356
  }
1357
+ // A message queued while the turn ran starts now as the next turn.
1358
+ // tryDispatch rechecks resumability under the lifecycle lock; a stale
1359
+ // or unresumable slot stays put for a manual send.
1360
+ if (!this.shuttingDown)
1361
+ void this.tryDispatch(sessionId);
978
1362
  }
979
1363
  updateSessionFromEvent(sessionId, ev) {
980
1364
  try {
@@ -995,6 +1379,9 @@ class Daemon {
995
1379
  else if (ev.type === "tool.started") {
996
1380
  this.sessions.update(sessionId, { lastEvent: `tool: ${ev.tool.name}` });
997
1381
  }
1382
+ else if (ev.type === "message.queued") {
1383
+ this.sessions.update(sessionId, { lastEvent: `queued: ${ev.prompt.slice(0, 80)}` });
1384
+ }
998
1385
  else if (ev.type === "message") {
999
1386
  this.sessions.update(sessionId, { lastEvent: ev.content.slice(0, 80) });
1000
1387
  }