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.
- package/dist/cli/charts/bar-chart.js +104 -0
- package/dist/cli/charts/bar-chart.js.map +1 -0
- package/dist/cli/charts/sparkline.js +18 -0
- package/dist/cli/charts/sparkline.js.map +1 -0
- package/dist/cli/charts/stacked-bar.js +59 -0
- package/dist/cli/charts/stacked-bar.js.map +1 -0
- package/dist/cli/commands/done.js +35 -0
- package/dist/cli/commands/done.js.map +1 -0
- package/dist/cli/commands/models.js +4 -4
- package/dist/cli/commands/models.js.map +1 -1
- package/dist/cli/commands/open.js +218 -57
- package/dist/cli/commands/open.js.map +1 -1
- package/dist/cli/commands/ps.js +1 -35
- package/dist/cli/commands/ps.js.map +1 -1
- package/dist/cli/commands/run.js +4 -3
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/send.js +2 -0
- package/dist/cli/commands/send.js.map +1 -1
- package/dist/cli/commands/setup.js +604 -13
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/usage.js +171 -16
- package/dist/cli/commands/usage.js.map +1 -1
- package/dist/cli/commands/web.js +307 -0
- package/dist/cli/commands/web.js.map +1 -0
- package/dist/cli/index.js +5 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/ui.js +34 -0
- package/dist/cli/ui.js.map +1 -1
- package/dist/cli/usage/dashboard.js +330 -0
- package/dist/cli/usage/dashboard.js.map +1 -0
- package/dist/cli/usage/snapshot.js +112 -0
- package/dist/cli/usage/snapshot.js.map +1 -0
- package/dist/config/config.js +287 -5
- package/dist/config/config.js.map +1 -1
- package/dist/config/paths.js +20 -9
- package/dist/config/paths.js.map +1 -1
- package/dist/core/autocompact.js +77 -0
- package/dist/core/autocompact.js.map +1 -0
- package/dist/core/driver.js.map +1 -1
- package/dist/core/events.js.map +1 -1
- package/dist/core/models.js +166 -3
- package/dist/core/models.js.map +1 -1
- package/dist/core/pricing.js +18 -0
- package/dist/core/pricing.js.map +1 -1
- package/dist/core/session.js +14 -1
- package/dist/core/session.js.map +1 -1
- package/dist/daemon/daemon.js +483 -96
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/drivers/antigravity/driver.js +172 -0
- package/dist/drivers/antigravity/driver.js.map +1 -0
- package/dist/drivers/antigravity/parser.js +168 -0
- package/dist/drivers/antigravity/parser.js.map +1 -0
- package/dist/drivers/claude/driver.js +13 -3
- package/dist/drivers/claude/driver.js.map +1 -1
- package/dist/drivers/registry.js +2 -0
- package/dist/drivers/registry.js.map +1 -1
- package/dist/drivers/session-driver.js +6 -4
- package/dist/drivers/session-driver.js.map +1 -1
- package/dist/drivers/session-runtime.js +1 -1
- package/dist/drivers/session-runtime.js.map +1 -1
- package/dist/open/contract.js +16 -4
- package/dist/open/contract.js.map +1 -1
- package/dist/open/injection.js +1 -0
- package/dist/open/injection.js.map +1 -1
- package/dist/open/launchers/claude.js +4 -1
- package/dist/open/launchers/claude.js.map +1 -1
- package/dist/open/launchers/opencode.js +37 -2
- package/dist/open/launchers/opencode.js.map +1 -1
- package/dist/open/runtime.js +43 -8
- package/dist/open/runtime.js.map +1 -1
- package/dist/plugin/agents/orchestrator-edit.md +1 -3
- package/dist/plugin/agents/orchestrator-read.md +1 -3
- package/dist/plugin/agents/orchestrator.md +2 -4
- package/dist/store/database.js +40 -15
- package/dist/store/database.js.map +1 -1
- package/dist/store/sessions.js +225 -3
- package/dist/store/sessions.js.map +1 -1
- package/dist/web/canvas-page.js +1069 -0
- package/dist/web/canvas-page.js.map +1 -0
- package/package.json +1 -1
package/dist/daemon/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
351
|
-
if (!
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
}
|