@os-eco/overstory-cli 0.9.3 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/monitor.ts +2 -1
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +85 -1
- package/src/commands/sling.ts +153 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +39 -0
- package/src/worktree/tmux.ts +23 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
|
@@ -13,8 +13,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
13
13
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
+
import { createSessionStore, type SessionStore } from "../sessions/store.ts";
|
|
16
17
|
import { cleanupTempDir } from "../test-helpers.ts";
|
|
17
|
-
import type { EventStore } from "../types.ts";
|
|
18
|
+
import type { AgentSession, EventStore } from "../types.ts";
|
|
18
19
|
import { createEventStore } from "./store.ts";
|
|
19
20
|
import type { TailerHandle, TailerOptions } from "./tailer.ts";
|
|
20
21
|
import { findLatestStdoutLog, startEventTailer } from "./tailer.ts";
|
|
@@ -484,3 +485,235 @@ describe("daemon tailer integration", () => {
|
|
|
484
485
|
await cleanupTempDir(tmpDir);
|
|
485
486
|
});
|
|
486
487
|
});
|
|
488
|
+
|
|
489
|
+
// === session_id capture (overstory-7b8c Phase 1) ===
|
|
490
|
+
|
|
491
|
+
describe("startEventTailer session_id capture", () => {
|
|
492
|
+
let tmpDir: string;
|
|
493
|
+
let eventStore: EventStore;
|
|
494
|
+
let eventsDbPath: string;
|
|
495
|
+
let sessionStore: SessionStore;
|
|
496
|
+
let sessionsDbPath: string;
|
|
497
|
+
|
|
498
|
+
function makeSession(agentName: string): AgentSession {
|
|
499
|
+
const now = new Date().toISOString();
|
|
500
|
+
return {
|
|
501
|
+
id: `id-${agentName}`,
|
|
502
|
+
agentName,
|
|
503
|
+
capability: "builder",
|
|
504
|
+
worktreePath: "/tmp/wt",
|
|
505
|
+
branchName: "test-branch",
|
|
506
|
+
taskId: "task-1",
|
|
507
|
+
tmuxSession: "",
|
|
508
|
+
state: "working",
|
|
509
|
+
pid: 12345,
|
|
510
|
+
parentAgent: null,
|
|
511
|
+
depth: 0,
|
|
512
|
+
runId: null,
|
|
513
|
+
startedAt: now,
|
|
514
|
+
lastActivity: now,
|
|
515
|
+
escalationLevel: 0,
|
|
516
|
+
stalledSince: null,
|
|
517
|
+
transcriptPath: null,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
beforeEach(async () => {
|
|
522
|
+
tmpDir = await createTempDir();
|
|
523
|
+
eventsDbPath = join(tmpDir, "events.db");
|
|
524
|
+
eventStore = createEventStore(eventsDbPath);
|
|
525
|
+
sessionsDbPath = join(tmpDir, "sessions.db");
|
|
526
|
+
sessionStore = createSessionStore(sessionsDbPath);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
afterEach(async () => {
|
|
530
|
+
eventStore.close();
|
|
531
|
+
sessionStore.close();
|
|
532
|
+
await cleanupTempDir(tmpDir);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("parses system event session_id and calls updateClaudeSessionId once", async () => {
|
|
536
|
+
const agentName = "agent-sid-1";
|
|
537
|
+
sessionStore.upsert(makeSession(agentName));
|
|
538
|
+
const logPath = await createAgentLogDir(tmpDir, agentName);
|
|
539
|
+
|
|
540
|
+
const sysLine = JSON.stringify({
|
|
541
|
+
type: "system",
|
|
542
|
+
subtype: "init",
|
|
543
|
+
session_id: "sess-first-pin",
|
|
544
|
+
timestamp: new Date().toISOString(),
|
|
545
|
+
});
|
|
546
|
+
await writeFile(logPath, `${sysLine}\n`);
|
|
547
|
+
|
|
548
|
+
const handle = startEventTailer({
|
|
549
|
+
stdoutLogPath: logPath,
|
|
550
|
+
agentName,
|
|
551
|
+
runId: null,
|
|
552
|
+
eventsDbPath,
|
|
553
|
+
pollIntervalMs: 50,
|
|
554
|
+
_eventStore: eventStore,
|
|
555
|
+
_sessionStore: sessionStore,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
await waitFor(() => sessionStore.getByName(agentName)?.claudeSessionId === "sess-first-pin");
|
|
560
|
+
expect(sessionStore.getByName(agentName)?.claudeSessionId).toBe("sess-first-pin");
|
|
561
|
+
} finally {
|
|
562
|
+
handle.stop();
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("ignores subsequent system events with the same session_id (single-fire)", async () => {
|
|
567
|
+
const agentName = "agent-sid-2";
|
|
568
|
+
sessionStore.upsert(makeSession(agentName));
|
|
569
|
+
const logPath = await createAgentLogDir(tmpDir, agentName);
|
|
570
|
+
|
|
571
|
+
// Three system events all carrying the same session_id.
|
|
572
|
+
const lines = [
|
|
573
|
+
JSON.stringify({
|
|
574
|
+
type: "system",
|
|
575
|
+
subtype: "init",
|
|
576
|
+
session_id: "sess-stable",
|
|
577
|
+
timestamp: new Date().toISOString(),
|
|
578
|
+
}),
|
|
579
|
+
JSON.stringify({
|
|
580
|
+
type: "system",
|
|
581
|
+
subtype: "ping",
|
|
582
|
+
session_id: "sess-stable",
|
|
583
|
+
timestamp: new Date().toISOString(),
|
|
584
|
+
}),
|
|
585
|
+
JSON.stringify({
|
|
586
|
+
type: "system",
|
|
587
|
+
subtype: "ping",
|
|
588
|
+
session_id: "sess-stable",
|
|
589
|
+
timestamp: new Date().toISOString(),
|
|
590
|
+
}),
|
|
591
|
+
].join("\n");
|
|
592
|
+
await writeFile(logPath, `${lines}\n`);
|
|
593
|
+
|
|
594
|
+
// Wrap the SessionStore so we can count update calls without altering behaviour.
|
|
595
|
+
let updateCalls = 0;
|
|
596
|
+
const proxy: SessionStore = {
|
|
597
|
+
...sessionStore,
|
|
598
|
+
upsert: (s) => sessionStore.upsert(s),
|
|
599
|
+
getByName: (n) => sessionStore.getByName(n),
|
|
600
|
+
getActive: () => sessionStore.getActive(),
|
|
601
|
+
getAll: () => sessionStore.getAll(),
|
|
602
|
+
count: () => sessionStore.count(),
|
|
603
|
+
getByRun: (r) => sessionStore.getByRun(r),
|
|
604
|
+
updateState: (n, s) => sessionStore.updateState(n, s),
|
|
605
|
+
updateLastActivity: (n) => sessionStore.updateLastActivity(n),
|
|
606
|
+
updateEscalation: (n, l, s) => sessionStore.updateEscalation(n, l, s),
|
|
607
|
+
updateTranscriptPath: (n, p) => sessionStore.updateTranscriptPath(n, p),
|
|
608
|
+
updateClaudeSessionId: (n, s) => {
|
|
609
|
+
updateCalls++;
|
|
610
|
+
sessionStore.updateClaudeSessionId(n, s);
|
|
611
|
+
},
|
|
612
|
+
remove: (n) => sessionStore.remove(n),
|
|
613
|
+
purge: (o) => sessionStore.purge(o),
|
|
614
|
+
close: () => {
|
|
615
|
+
/* owned by outer test */
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const handle = startEventTailer({
|
|
620
|
+
stdoutLogPath: logPath,
|
|
621
|
+
agentName,
|
|
622
|
+
runId: null,
|
|
623
|
+
eventsDbPath,
|
|
624
|
+
pollIntervalMs: 50,
|
|
625
|
+
_eventStore: eventStore,
|
|
626
|
+
_sessionStore: proxy,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
try {
|
|
630
|
+
// Wait until events.db has all three lines processed.
|
|
631
|
+
await waitFor(() => eventStore.getByAgent(agentName).length >= 3);
|
|
632
|
+
// Allow extra poll cycles to confirm no late updates sneak in.
|
|
633
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
634
|
+
expect(updateCalls).toBe(1);
|
|
635
|
+
expect(sessionStore.getByName(agentName)?.claudeSessionId).toBe("sess-stable");
|
|
636
|
+
} finally {
|
|
637
|
+
handle.stop();
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("detects resume mismatch and invokes _onResumeMismatch DI hook (observed wins)", async () => {
|
|
642
|
+
const agentName = "agent-sid-3";
|
|
643
|
+
const session = makeSession(agentName);
|
|
644
|
+
session.claudeSessionId = "sess-requested-OLD";
|
|
645
|
+
sessionStore.upsert(session);
|
|
646
|
+
const logPath = await createAgentLogDir(tmpDir, agentName);
|
|
647
|
+
|
|
648
|
+
const sysLine = JSON.stringify({
|
|
649
|
+
type: "system",
|
|
650
|
+
subtype: "init",
|
|
651
|
+
session_id: "sess-observed-NEW",
|
|
652
|
+
timestamp: new Date().toISOString(),
|
|
653
|
+
});
|
|
654
|
+
await writeFile(logPath, `${sysLine}\n`);
|
|
655
|
+
|
|
656
|
+
const mismatches: Array<{ agent: string; requested: string; observed: string }> = [];
|
|
657
|
+
const handle = startEventTailer({
|
|
658
|
+
stdoutLogPath: logPath,
|
|
659
|
+
agentName,
|
|
660
|
+
runId: null,
|
|
661
|
+
eventsDbPath,
|
|
662
|
+
pollIntervalMs: 50,
|
|
663
|
+
_eventStore: eventStore,
|
|
664
|
+
_sessionStore: sessionStore,
|
|
665
|
+
_onResumeMismatch: (agent, requested, observed) =>
|
|
666
|
+
mismatches.push({ agent, requested, observed }),
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
await waitFor(
|
|
671
|
+
() => sessionStore.getByName(agentName)?.claudeSessionId === "sess-observed-NEW",
|
|
672
|
+
);
|
|
673
|
+
expect(mismatches).toHaveLength(1);
|
|
674
|
+
expect(mismatches[0]).toEqual({
|
|
675
|
+
agent: agentName,
|
|
676
|
+
requested: "sess-requested-OLD",
|
|
677
|
+
observed: "sess-observed-NEW",
|
|
678
|
+
});
|
|
679
|
+
// observed wins — SessionStore is overwritten with the new id.
|
|
680
|
+
expect(sessionStore.getByName(agentName)?.claudeSessionId).toBe("sess-observed-NEW");
|
|
681
|
+
} finally {
|
|
682
|
+
handle.stop();
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("backward compat: tailer with no sessionsDbPath performs no SessionStore writes", async () => {
|
|
687
|
+
const agentName = "agent-sid-4";
|
|
688
|
+
sessionStore.upsert(makeSession(agentName));
|
|
689
|
+
const logPath = await createAgentLogDir(tmpDir, agentName);
|
|
690
|
+
|
|
691
|
+
const sysLine = JSON.stringify({
|
|
692
|
+
type: "system",
|
|
693
|
+
subtype: "init",
|
|
694
|
+
session_id: "sess-should-not-pin",
|
|
695
|
+
timestamp: new Date().toISOString(),
|
|
696
|
+
});
|
|
697
|
+
await writeFile(logPath, `${sysLine}\n`);
|
|
698
|
+
|
|
699
|
+
// No sessionsDbPath, no _sessionStore — tailer must still process events.
|
|
700
|
+
const handle = startEventTailer({
|
|
701
|
+
stdoutLogPath: logPath,
|
|
702
|
+
agentName,
|
|
703
|
+
runId: null,
|
|
704
|
+
eventsDbPath,
|
|
705
|
+
pollIntervalMs: 50,
|
|
706
|
+
_eventStore: eventStore,
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
try {
|
|
710
|
+
await waitFor(() => eventStore.getByAgent(agentName).length >= 1);
|
|
711
|
+
// Give the tailer extra time to confirm no late writes occur.
|
|
712
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
713
|
+
// SessionStore must remain untouched.
|
|
714
|
+
expect(sessionStore.getByName(agentName)?.claudeSessionId ?? null).toBeNull();
|
|
715
|
+
} finally {
|
|
716
|
+
handle.stop();
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
});
|
package/src/events/tailer.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { readdir } from "node:fs/promises";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
+
import { createSessionStore, type SessionStore } from "../sessions/store.ts";
|
|
17
18
|
import type { EventStore, EventType } from "../types.ts";
|
|
18
19
|
import { createEventStore } from "./store.ts";
|
|
19
20
|
|
|
@@ -66,10 +67,26 @@ export interface TailerOptions {
|
|
|
66
67
|
runId: string | null;
|
|
67
68
|
/** Absolute path to events.db. The tailer opens its own connection. */
|
|
68
69
|
eventsDbPath: string;
|
|
70
|
+
/**
|
|
71
|
+
* Absolute path to sessions.db. When present and not equal to ":memory:",
|
|
72
|
+
* the tailer opens a dedicated SessionStore to persist the runtime-provided
|
|
73
|
+
* session_id (e.g. Claude stream-json `session_id`). Omit (or set to
|
|
74
|
+
* ":memory:") for tailers that should not write to SessionStore.
|
|
75
|
+
*/
|
|
76
|
+
sessionsDbPath?: string;
|
|
69
77
|
/** Poll interval in milliseconds (default: 500). */
|
|
70
78
|
pollIntervalMs?: number;
|
|
71
79
|
/** DI: injected EventStore for testing (overrides eventsDbPath). */
|
|
72
80
|
_eventStore?: EventStore;
|
|
81
|
+
/** DI: injected SessionStore for testing (overrides sessionsDbPath). */
|
|
82
|
+
_sessionStore?: SessionStore;
|
|
83
|
+
/**
|
|
84
|
+
* DI: invoked exactly once per tailer when an observed session_id differs
|
|
85
|
+
* from the prior claudeSessionId stored in SessionStore. Receives the agent
|
|
86
|
+
* name, the prior (requested) id, and the newly observed id. Production
|
|
87
|
+
* code logs a warning to stderr instead.
|
|
88
|
+
*/
|
|
89
|
+
_onResumeMismatch?: (agentName: string, requested: string, observed: string) => void;
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
/**
|
|
@@ -109,6 +126,28 @@ export function startEventTailer(opts: TailerOptions): TailerHandle {
|
|
|
109
126
|
}
|
|
110
127
|
}
|
|
111
128
|
|
|
129
|
+
// Open a dedicated SessionStore for this tailer's lifetime when a real
|
|
130
|
+
// sessionsDbPath is provided. Tailers that omit sessionsDbPath (or pass
|
|
131
|
+
// ":memory:") skip session_id persistence entirely — backward compat for
|
|
132
|
+
// callers that don't yet route through the watchdog wiring.
|
|
133
|
+
let sessionStore: SessionStore | null = opts._sessionStore ?? null;
|
|
134
|
+
let ownedSessionStore = false;
|
|
135
|
+
if (!sessionStore && opts.sessionsDbPath && opts.sessionsDbPath !== ":memory:") {
|
|
136
|
+
try {
|
|
137
|
+
sessionStore = createSessionStore(opts.sessionsDbPath);
|
|
138
|
+
ownedSessionStore = true;
|
|
139
|
+
} catch {
|
|
140
|
+
// SessionStore failure is non-fatal — events still flow.
|
|
141
|
+
sessionStore = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Single-fire guard for session_id pinning. Mirrors claude.ts:312
|
|
146
|
+
// `sessionIdPinned` so that updateClaudeSessionId is called at most once
|
|
147
|
+
// per tailer lifetime, even if many system events stream by.
|
|
148
|
+
let sessionIdPinned = false;
|
|
149
|
+
const onResumeMismatch = opts._onResumeMismatch;
|
|
150
|
+
|
|
112
151
|
let stopped = false;
|
|
113
152
|
let byteOffset = 0;
|
|
114
153
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -154,6 +193,48 @@ export function startEventTailer(opts: TailerOptions): TailerHandle {
|
|
|
154
193
|
|
|
155
194
|
const toolDurationMs = typeof event.duration_ms === "number" ? event.duration_ms : null;
|
|
156
195
|
|
|
196
|
+
// Extract session_id from stream-json system events (e.g. Claude Code
|
|
197
|
+
// emits `{type:"system", subtype:"init", session_id:"sess-..."}` on
|
|
198
|
+
// every spawn — including --resume spawns, which assign a fresh id).
|
|
199
|
+
// The "result" event also carries session_id; treat both as authoritative.
|
|
200
|
+
// Single-fire per tailer lifetime so we don't churn writes.
|
|
201
|
+
if (!sessionIdPinned && sessionStore !== null) {
|
|
202
|
+
const sid =
|
|
203
|
+
typeof event.session_id === "string" && event.session_id.length > 0
|
|
204
|
+
? event.session_id
|
|
205
|
+
: null;
|
|
206
|
+
if (sid !== null && (type === "system" || type === "result")) {
|
|
207
|
+
sessionIdPinned = true;
|
|
208
|
+
let prior: string | null = null;
|
|
209
|
+
try {
|
|
210
|
+
prior = sessionStore.getByName(agentName)?.claudeSessionId ?? null;
|
|
211
|
+
} catch {
|
|
212
|
+
prior = null;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
sessionStore.updateClaudeSessionId(agentName, sid);
|
|
216
|
+
} catch {
|
|
217
|
+
// Non-fatal: SessionStore write failure must not break tailing.
|
|
218
|
+
}
|
|
219
|
+
// Resume mismatch: requested != observed. The observed id wins
|
|
220
|
+
// (claude assigns fresh ids on --resume), but operators need to
|
|
221
|
+
// know — log a warning, and call DI hook for tests.
|
|
222
|
+
if (prior !== null && prior !== sid) {
|
|
223
|
+
if (onResumeMismatch) {
|
|
224
|
+
try {
|
|
225
|
+
onResumeMismatch(agentName, prior, sid);
|
|
226
|
+
} catch {
|
|
227
|
+
// DI hook errors must not crash the tailer.
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
process.stderr.write(
|
|
231
|
+
`[tailer] resume mismatch for ${agentName}: requested=${prior} observed=${sid}\n`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
157
238
|
try {
|
|
158
239
|
eventStore?.insert({
|
|
159
240
|
runId,
|
|
@@ -201,6 +282,15 @@ export function startEventTailer(opts: TailerOptions): TailerHandle {
|
|
|
201
282
|
}
|
|
202
283
|
eventStore = null;
|
|
203
284
|
}
|
|
285
|
+
// Close only the SessionStore this tailer owns.
|
|
286
|
+
if (ownedSessionStore && sessionStore) {
|
|
287
|
+
try {
|
|
288
|
+
sessionStore.close();
|
|
289
|
+
} catch {
|
|
290
|
+
// Non-fatal.
|
|
291
|
+
}
|
|
292
|
+
sessionStore = null;
|
|
293
|
+
}
|
|
204
294
|
},
|
|
205
295
|
};
|
|
206
296
|
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { createOrchestratorCommand } from "./commands/orchestrator.ts";
|
|
|
36
36
|
import { primeCommand } from "./commands/prime.ts";
|
|
37
37
|
import { createReplayCommand } from "./commands/replay.ts";
|
|
38
38
|
import { createRunCommand } from "./commands/run.ts";
|
|
39
|
+
import { createServeCommand } from "./commands/serve.ts";
|
|
39
40
|
import { slingCommand } from "./commands/sling.ts";
|
|
40
41
|
import { specWriteCommand } from "./commands/spec.ts";
|
|
41
42
|
import { createStatusCommand } from "./commands/status.ts";
|
|
@@ -51,7 +52,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
|
|
|
51
52
|
import { jsonError } from "./json.ts";
|
|
52
53
|
import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
|
|
53
54
|
|
|
54
|
-
export const VERSION = "0.
|
|
55
|
+
export const VERSION = "0.10.3";
|
|
55
56
|
|
|
56
57
|
const rawArgs = process.argv.slice(2);
|
|
57
58
|
|
|
@@ -103,6 +104,7 @@ const COMMANDS = [
|
|
|
103
104
|
"run",
|
|
104
105
|
"costs",
|
|
105
106
|
"metrics",
|
|
107
|
+
"serve",
|
|
106
108
|
"update",
|
|
107
109
|
"upgrade",
|
|
108
110
|
"completions",
|
|
@@ -204,9 +206,12 @@ program
|
|
|
204
206
|
},
|
|
205
207
|
});
|
|
206
208
|
|
|
207
|
-
// Apply global flags before any command action runs
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
// Apply global flags before any command action runs.
|
|
210
|
+
// `actionCommand` is the deepest command whose action is about to run (e.g.
|
|
211
|
+
// `coordinator start`); reading `optsWithGlobals()` on it walks up through
|
|
212
|
+
// every parent so subcommand-level `--project` flags are also seen.
|
|
213
|
+
program.hook("preAction", (_thisCmd, actionCommand) => {
|
|
214
|
+
const opts = actionCommand.optsWithGlobals();
|
|
210
215
|
if (opts.quiet) {
|
|
211
216
|
setQuiet(true);
|
|
212
217
|
}
|
|
@@ -225,8 +230,9 @@ program.hook("preAction", (thisCmd) => {
|
|
|
225
230
|
timingStart = performance.now();
|
|
226
231
|
}
|
|
227
232
|
});
|
|
228
|
-
program.hook("postAction", () => {
|
|
229
|
-
|
|
233
|
+
program.hook("postAction", (_thisCmd, actionCommand) => {
|
|
234
|
+
const opts = actionCommand.optsWithGlobals();
|
|
235
|
+
if (opts.timing && timingStart !== undefined) {
|
|
230
236
|
const elapsed = performance.now() - timingStart;
|
|
231
237
|
const formatted =
|
|
232
238
|
elapsed < 1000 ? `${Math.round(elapsed)}ms` : `${(elapsed / 1000).toFixed(2)}s`;
|
|
@@ -246,6 +252,7 @@ program.addCommand(createWorktreeCommand());
|
|
|
246
252
|
program.addCommand(createLogCommand());
|
|
247
253
|
program.addCommand(createWatchCommand());
|
|
248
254
|
program.addCommand(createGroupCommand());
|
|
255
|
+
program.addCommand(createServeCommand());
|
|
249
256
|
program.addCommand(createCompletionsCommand());
|
|
250
257
|
|
|
251
258
|
// Unmigrated commands — passthrough pattern
|
|
@@ -292,6 +299,14 @@ program
|
|
|
292
299
|
.option("--runtime <name>", "Runtime adapter (default: config or claude)")
|
|
293
300
|
.option("--base-branch <branch>", "Base branch for worktree creation (default: current HEAD)")
|
|
294
301
|
.option("--profile <name>", "Canopy profile to apply to agent overlay")
|
|
302
|
+
.option(
|
|
303
|
+
"--headless",
|
|
304
|
+
"Spawn through Bun.spawn (stream-json) instead of tmux. Requires runtime with buildDirectSpawn.",
|
|
305
|
+
)
|
|
306
|
+
.option(
|
|
307
|
+
"--recover",
|
|
308
|
+
"Allow dispatch against a task in any tracker status (e.g. closed). Use when a prior owner exited and the task needs a fresh agent.",
|
|
309
|
+
)
|
|
295
310
|
.option("--json", "Output result as JSON")
|
|
296
311
|
.action(async (taskId, opts) => {
|
|
297
312
|
await slingCommand(taskId, opts);
|
|
@@ -359,6 +374,7 @@ program
|
|
|
359
374
|
program
|
|
360
375
|
.command("mail")
|
|
361
376
|
.description("Mail system (send/check/list/read/reply)")
|
|
377
|
+
.helpOption(false)
|
|
362
378
|
.allowUnknownOption()
|
|
363
379
|
.allowExcessArguments()
|
|
364
380
|
.action(async (_opts, cmd) => {
|
|
@@ -422,6 +438,37 @@ program.addCommand(createUpdateCommand());
|
|
|
422
438
|
|
|
423
439
|
program.addCommand(createUpgradeCommand());
|
|
424
440
|
|
|
441
|
+
// Propagate root-level globals to every (sub)command so they can appear before
|
|
442
|
+
// or after the command name. With `enablePositionalOptions()`, options declared
|
|
443
|
+
// on the root program are not accepted after a subcommand name; copying them
|
|
444
|
+
// onto each command lets `ov status --project /path` work the same as
|
|
445
|
+
// `ov --project /path status`. Skips the delegated `mail`/`nudge`/`logs`/`trace`
|
|
446
|
+
// commands, which use `allowUnknownOption()` and forward args to an inner
|
|
447
|
+
// Commander parser. The preAction hook reads `actionCommand.optsWithGlobals()`,
|
|
448
|
+
// so it sees these regardless of which level they were parsed at.
|
|
449
|
+
const DELEGATED_COMMANDS = new Set(["mail", "nudge", "logs", "trace"]);
|
|
450
|
+
const PROPAGATED_GLOBALS: ReadonlyArray<readonly [string, string]> = [
|
|
451
|
+
["--project <path>", "Target project root (overrides auto-detection)"],
|
|
452
|
+
["-q, --quiet", "Suppress non-error output"],
|
|
453
|
+
["--timing", "Print command execution time to stderr"],
|
|
454
|
+
];
|
|
455
|
+
function propagateGlobalOptions(cmd: Command): void {
|
|
456
|
+
for (const sub of cmd.commands) {
|
|
457
|
+
if (sub === cmd) continue;
|
|
458
|
+
if (!DELEGATED_COMMANDS.has(sub.name())) {
|
|
459
|
+
for (const [flag, desc] of PROPAGATED_GLOBALS) {
|
|
460
|
+
const long = flag.split(/[\s,]+/).find((p) => p.startsWith("--"));
|
|
461
|
+
const alreadyDeclared = sub.options.some((o) => o.long === long);
|
|
462
|
+
if (!alreadyDeclared) {
|
|
463
|
+
sub.option(flag, desc);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
propagateGlobalOptions(sub);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
propagateGlobalOptions(program);
|
|
471
|
+
|
|
425
472
|
// Handle unknown commands with Levenshtein fuzzy-match suggestions
|
|
426
473
|
program.on("command:*", (operands) => {
|
|
427
474
|
const unknown = operands[0] ?? "";
|
package/src/json.ts
CHANGED
|
@@ -22,3 +22,32 @@ export function jsonOutput(command: string, data: Record<string, unknown>): void
|
|
|
22
22
|
export function jsonError(command: string, error: string): void {
|
|
23
23
|
process.stdout.write(`${JSON.stringify({ success: false, command, error })}\n`);
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a JSON success Response for HTTP API handlers.
|
|
28
|
+
* Envelope: { success: true, command: 'serve', data, nextCursor? }
|
|
29
|
+
*/
|
|
30
|
+
export function apiJson(
|
|
31
|
+
data: unknown,
|
|
32
|
+
init?: { status?: number; nextCursor?: string | null },
|
|
33
|
+
): Response {
|
|
34
|
+
const envelope: Record<string, unknown> = { success: true, command: "serve", data };
|
|
35
|
+
if (init?.nextCursor != null) {
|
|
36
|
+
envelope.nextCursor = init.nextCursor;
|
|
37
|
+
}
|
|
38
|
+
return new Response(JSON.stringify(envelope), {
|
|
39
|
+
status: init?.status ?? 200,
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a JSON error Response for HTTP API handlers.
|
|
46
|
+
* Envelope: { success: false, command: 'serve', error }
|
|
47
|
+
*/
|
|
48
|
+
export function apiError(message: string, status: number): Response {
|
|
49
|
+
return new Response(JSON.stringify({ success: false, command: "serve", error: message }), {
|
|
50
|
+
status,
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/mail/client.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { MailError } from "../errors.ts";
|
|
10
|
-
import type { MailMessage, MailPayloadMap, MailProtocolType } from "../types.ts";
|
|
10
|
+
import type { MailMessage, MailMessageType, MailPayloadMap, MailProtocolType } from "../types.ts";
|
|
11
11
|
import type { MailStore } from "./store.ts";
|
|
12
12
|
|
|
13
13
|
export interface MailClient {
|
|
@@ -42,7 +42,12 @@ export interface MailClient {
|
|
|
42
42
|
checkInject(agentName: string): string;
|
|
43
43
|
|
|
44
44
|
/** List messages with optional filters. */
|
|
45
|
-
list(filters?: {
|
|
45
|
+
list(filters?: {
|
|
46
|
+
from?: string;
|
|
47
|
+
to?: string;
|
|
48
|
+
unread?: boolean;
|
|
49
|
+
type?: MailMessageType;
|
|
50
|
+
}): MailMessage[];
|
|
46
51
|
|
|
47
52
|
/** Mark a message as read by ID. Returns whether the message was already read. */
|
|
48
53
|
markRead(id: string): { alreadyRead: boolean };
|
|
@@ -50,6 +55,9 @@ export interface MailClient {
|
|
|
50
55
|
/** Reply to a message. Returns the new message ID. */
|
|
51
56
|
reply(messageId: string, body: string, from: string): string;
|
|
52
57
|
|
|
58
|
+
/** Delete a single message by id. Returns true if a row was deleted. */
|
|
59
|
+
deleteById(id: string): boolean;
|
|
60
|
+
|
|
53
61
|
/** Close the underlying store. */
|
|
54
62
|
close(): void;
|
|
55
63
|
}
|
|
@@ -75,6 +83,7 @@ export function parsePayload<T extends MailProtocolType>(
|
|
|
75
83
|
/** Protocol types that represent structured coordination messages. */
|
|
76
84
|
const PROTOCOL_TYPES = new Set<string>([
|
|
77
85
|
"worker_done",
|
|
86
|
+
"worker_died",
|
|
78
87
|
"merge_ready",
|
|
79
88
|
"merged",
|
|
80
89
|
"merge_failed",
|
|
@@ -187,6 +196,10 @@ export function createMailClient(store: MailStore): MailClient {
|
|
|
187
196
|
return { alreadyRead: false };
|
|
188
197
|
},
|
|
189
198
|
|
|
199
|
+
deleteById(id): boolean {
|
|
200
|
+
return store.deleteById(id);
|
|
201
|
+
},
|
|
202
|
+
|
|
190
203
|
reply(messageId, body, from): string {
|
|
191
204
|
const original = store.getById(messageId);
|
|
192
205
|
if (!original) {
|
package/src/mail/store.test.ts
CHANGED
|
@@ -437,6 +437,88 @@ describe("createMailStore", () => {
|
|
|
437
437
|
expect(filtered).toHaveLength(1);
|
|
438
438
|
expect(filtered[0]?.subject).toBe("msg1");
|
|
439
439
|
});
|
|
440
|
+
|
|
441
|
+
test("filters by type", () => {
|
|
442
|
+
store.insert({
|
|
443
|
+
id: "",
|
|
444
|
+
from: "lead-a",
|
|
445
|
+
to: "coordinator",
|
|
446
|
+
subject: "merge_ready: t1",
|
|
447
|
+
body: "ready",
|
|
448
|
+
type: "merge_ready",
|
|
449
|
+
priority: "normal",
|
|
450
|
+
threadId: null,
|
|
451
|
+
});
|
|
452
|
+
store.insert({
|
|
453
|
+
id: "",
|
|
454
|
+
from: "builder-a",
|
|
455
|
+
to: "lead-a",
|
|
456
|
+
subject: "Worker done",
|
|
457
|
+
body: "done",
|
|
458
|
+
type: "worker_done",
|
|
459
|
+
priority: "normal",
|
|
460
|
+
threadId: null,
|
|
461
|
+
});
|
|
462
|
+
store.insert({
|
|
463
|
+
id: "",
|
|
464
|
+
from: "lead-a",
|
|
465
|
+
to: "coordinator",
|
|
466
|
+
subject: "status",
|
|
467
|
+
body: "still going",
|
|
468
|
+
type: "status",
|
|
469
|
+
priority: "normal",
|
|
470
|
+
threadId: null,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const mr = store.getAll({ type: "merge_ready" });
|
|
474
|
+
expect(mr).toHaveLength(1);
|
|
475
|
+
expect(mr[0]?.subject).toBe("merge_ready: t1");
|
|
476
|
+
|
|
477
|
+
const wd = store.getAll({ type: "worker_done" });
|
|
478
|
+
expect(wd).toHaveLength(1);
|
|
479
|
+
expect(wd[0]?.subject).toBe("Worker done");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("combines type with from filter", () => {
|
|
483
|
+
store.insert({
|
|
484
|
+
id: "",
|
|
485
|
+
from: "lead-a",
|
|
486
|
+
to: "coordinator",
|
|
487
|
+
subject: "merge_ready: t1",
|
|
488
|
+
body: "ready",
|
|
489
|
+
type: "merge_ready",
|
|
490
|
+
priority: "normal",
|
|
491
|
+
threadId: null,
|
|
492
|
+
});
|
|
493
|
+
store.insert({
|
|
494
|
+
id: "",
|
|
495
|
+
from: "lead-b",
|
|
496
|
+
to: "coordinator",
|
|
497
|
+
subject: "merge_ready: t2",
|
|
498
|
+
body: "ready",
|
|
499
|
+
type: "merge_ready",
|
|
500
|
+
priority: "normal",
|
|
501
|
+
threadId: null,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const mine = store.getAll({ from: "lead-a", type: "merge_ready" });
|
|
505
|
+
expect(mine).toHaveLength(1);
|
|
506
|
+
expect(mine[0]?.from).toBe("lead-a");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("returns empty array when no rows match the type filter", () => {
|
|
510
|
+
store.insert({
|
|
511
|
+
id: "",
|
|
512
|
+
from: "agent-a",
|
|
513
|
+
to: "orchestrator",
|
|
514
|
+
subject: "msg",
|
|
515
|
+
body: "body",
|
|
516
|
+
type: "status",
|
|
517
|
+
priority: "normal",
|
|
518
|
+
threadId: null,
|
|
519
|
+
});
|
|
520
|
+
expect(store.getAll({ type: "merge_ready" })).toHaveLength(0);
|
|
521
|
+
});
|
|
440
522
|
});
|
|
441
523
|
|
|
442
524
|
describe("getByThread", () => {
|