@os-eco/overstory-cli 0.7.9 → 0.8.2
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 +16 -7
- package/agents/coordinator.md +41 -0
- package/agents/orchestrator.md +239 -0
- package/package.json +1 -1
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/commands/coordinator.test.ts +334 -0
- package/src/commands/coordinator.ts +366 -0
- package/src/commands/dashboard.test.ts +86 -0
- package/src/commands/dashboard.ts +8 -4
- package/src/commands/feed.test.ts +8 -0
- package/src/commands/init.test.ts +2 -1
- package/src/commands/init.ts +2 -2
- package/src/commands/inspect.test.ts +156 -1
- package/src/commands/inspect.ts +19 -4
- package/src/commands/replay.test.ts +8 -0
- package/src/commands/sling.ts +218 -121
- package/src/commands/status.test.ts +77 -0
- package/src/commands/status.ts +6 -3
- package/src/commands/stop.test.ts +134 -0
- package/src/commands/stop.ts +41 -11
- package/src/commands/trace.test.ts +8 -0
- package/src/commands/update.test.ts +465 -0
- package/src/commands/update.ts +263 -0
- package/src/config.test.ts +65 -1
- package/src/config.ts +23 -0
- package/src/e2e/init-sling-lifecycle.test.ts +3 -2
- package/src/index.ts +21 -2
- package/src/logging/theme.ts +4 -0
- package/src/runtimes/connections.test.ts +74 -0
- package/src/runtimes/connections.ts +34 -0
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +2 -0
- package/src/runtimes/sapling.test.ts +1237 -0
- package/src/runtimes/sapling.ts +698 -0
- package/src/runtimes/types.ts +45 -0
- package/src/types.ts +5 -1
- package/src/watchdog/daemon.ts +34 -0
- package/src/watchdog/health.test.ts +102 -0
- package/src/watchdog/health.ts +140 -69
- package/src/worktree/process.test.ts +101 -0
- package/src/worktree/process.ts +111 -0
- package/src/worktree/tmux.ts +5 -0
|
@@ -18,7 +18,7 @@ import { createMetricsStore } from "../metrics/store.ts";
|
|
|
18
18
|
import { createSessionStore } from "../sessions/store.ts";
|
|
19
19
|
import { cleanupTempDir } from "../test-helpers.ts";
|
|
20
20
|
import type { InsertEvent, SessionMetrics } from "../types.ts";
|
|
21
|
-
import { gatherInspectData, inspectCommand } from "./inspect.ts";
|
|
21
|
+
import { gatherInspectData, inspectCommand, printInspectData } from "./inspect.ts";
|
|
22
22
|
|
|
23
23
|
/** Helper to create an InsertEvent with sensible defaults. */
|
|
24
24
|
function makeEvent(overrides: Partial<InsertEvent> = {}): InsertEvent {
|
|
@@ -565,6 +565,161 @@ describe("inspectCommand", () => {
|
|
|
565
565
|
});
|
|
566
566
|
});
|
|
567
567
|
|
|
568
|
+
// === Headless agent support ===
|
|
569
|
+
|
|
570
|
+
describe("headless agent support", () => {
|
|
571
|
+
test("gatherInspectData skips tmux capture for headless agents (empty tmuxSession)", async () => {
|
|
572
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
573
|
+
const sessionsDbPath = join(overstoryDir, "sessions.db");
|
|
574
|
+
const store = createSessionStore(sessionsDbPath);
|
|
575
|
+
store.upsert({
|
|
576
|
+
id: "sess-h1",
|
|
577
|
+
agentName: "headless-agent",
|
|
578
|
+
capability: "builder",
|
|
579
|
+
worktreePath: "/tmp/wt",
|
|
580
|
+
branchName: "overstory/headless/task-1",
|
|
581
|
+
taskId: "overstory-h01",
|
|
582
|
+
tmuxSession: "", // headless
|
|
583
|
+
state: "working",
|
|
584
|
+
pid: process.pid,
|
|
585
|
+
parentAgent: null,
|
|
586
|
+
depth: 0,
|
|
587
|
+
runId: null,
|
|
588
|
+
startedAt: new Date().toISOString(),
|
|
589
|
+
lastActivity: new Date().toISOString(),
|
|
590
|
+
escalationLevel: 0,
|
|
591
|
+
stalledSince: null,
|
|
592
|
+
transcriptPath: null,
|
|
593
|
+
});
|
|
594
|
+
store.close();
|
|
595
|
+
|
|
596
|
+
// noTmux=false but tmuxSession="" — should skip tmux capture without error
|
|
597
|
+
const data = await gatherInspectData(tempDir, "headless-agent", { noTmux: false });
|
|
598
|
+
// tmuxOutput is null (no tmux) and no events yet → no fallback either
|
|
599
|
+
expect(data.session.agentName).toBe("headless-agent");
|
|
600
|
+
expect(data.session.tmuxSession).toBe("");
|
|
601
|
+
// tmuxOutput may be null (no events) or a string (fallback) — must not throw
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("gatherInspectData provides event-based output for headless agents with tool calls", async () => {
|
|
605
|
+
const overstoryDir = join(tempDir, ".overstory");
|
|
606
|
+
const sessionsDbPath = join(overstoryDir, "sessions.db");
|
|
607
|
+
const eventsDbPath = join(overstoryDir, "events.db");
|
|
608
|
+
|
|
609
|
+
const store = createSessionStore(sessionsDbPath);
|
|
610
|
+
store.upsert({
|
|
611
|
+
id: "sess-h2",
|
|
612
|
+
agentName: "headless-events",
|
|
613
|
+
capability: "builder",
|
|
614
|
+
worktreePath: "/tmp/wt",
|
|
615
|
+
branchName: "overstory/headless/task-2",
|
|
616
|
+
taskId: "overstory-h02",
|
|
617
|
+
tmuxSession: "", // headless
|
|
618
|
+
state: "working",
|
|
619
|
+
pid: process.pid,
|
|
620
|
+
parentAgent: null,
|
|
621
|
+
depth: 0,
|
|
622
|
+
runId: null,
|
|
623
|
+
startedAt: new Date().toISOString(),
|
|
624
|
+
lastActivity: new Date().toISOString(),
|
|
625
|
+
escalationLevel: 0,
|
|
626
|
+
stalledSince: null,
|
|
627
|
+
transcriptPath: null,
|
|
628
|
+
});
|
|
629
|
+
store.close();
|
|
630
|
+
|
|
631
|
+
const eventStore = createEventStore(eventsDbPath);
|
|
632
|
+
eventStore.insert(
|
|
633
|
+
makeEvent({ agentName: "headless-events", toolName: "Read", toolDurationMs: 50 }),
|
|
634
|
+
);
|
|
635
|
+
eventStore.insert(
|
|
636
|
+
makeEvent({ agentName: "headless-events", toolName: "Edit", toolDurationMs: 100 }),
|
|
637
|
+
);
|
|
638
|
+
eventStore.close();
|
|
639
|
+
|
|
640
|
+
const data = await gatherInspectData(tempDir, "headless-events", { noTmux: false });
|
|
641
|
+
|
|
642
|
+
// Should have fallback output
|
|
643
|
+
expect(data.tmuxOutput).not.toBeNull();
|
|
644
|
+
expect(data.tmuxOutput).toContain("Headless agent");
|
|
645
|
+
expect(data.tmuxOutput).toContain("Read");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("printInspectData shows PID instead of tmux session for headless agents", () => {
|
|
649
|
+
const data = {
|
|
650
|
+
session: {
|
|
651
|
+
id: "sess-h3",
|
|
652
|
+
agentName: "headless-display",
|
|
653
|
+
capability: "builder",
|
|
654
|
+
worktreePath: "/tmp/wt",
|
|
655
|
+
branchName: "overstory/headless/task-3",
|
|
656
|
+
taskId: "overstory-h03",
|
|
657
|
+
tmuxSession: "", // headless
|
|
658
|
+
state: "working" as const,
|
|
659
|
+
pid: 99999,
|
|
660
|
+
parentAgent: null,
|
|
661
|
+
depth: 0,
|
|
662
|
+
runId: null,
|
|
663
|
+
startedAt: new Date().toISOString(),
|
|
664
|
+
lastActivity: new Date().toISOString(),
|
|
665
|
+
escalationLevel: 0,
|
|
666
|
+
stalledSince: null,
|
|
667
|
+
transcriptPath: null,
|
|
668
|
+
},
|
|
669
|
+
timeSinceLastActivity: 5000,
|
|
670
|
+
recentToolCalls: [],
|
|
671
|
+
currentFile: null,
|
|
672
|
+
toolStats: [],
|
|
673
|
+
tokenUsage: null,
|
|
674
|
+
tmuxOutput: null,
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
printInspectData(data);
|
|
678
|
+
|
|
679
|
+
const out = output();
|
|
680
|
+
expect(out).toContain("Process: PID");
|
|
681
|
+
expect(out).toContain("99999");
|
|
682
|
+
expect(out).toContain("headless");
|
|
683
|
+
expect(out).not.toContain("Tmux:");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("printInspectData shows Recent Activity header for headless agents with tmuxOutput", () => {
|
|
687
|
+
const data = {
|
|
688
|
+
session: {
|
|
689
|
+
id: "sess-h4",
|
|
690
|
+
agentName: "headless-activity",
|
|
691
|
+
capability: "builder",
|
|
692
|
+
worktreePath: "/tmp/wt",
|
|
693
|
+
branchName: "overstory/headless/task-4",
|
|
694
|
+
taskId: "overstory-h04",
|
|
695
|
+
tmuxSession: "", // headless
|
|
696
|
+
state: "working" as const,
|
|
697
|
+
pid: 99998,
|
|
698
|
+
parentAgent: null,
|
|
699
|
+
depth: 0,
|
|
700
|
+
runId: null,
|
|
701
|
+
startedAt: new Date().toISOString(),
|
|
702
|
+
lastActivity: new Date().toISOString(),
|
|
703
|
+
escalationLevel: 0,
|
|
704
|
+
stalledSince: null,
|
|
705
|
+
transcriptPath: null,
|
|
706
|
+
},
|
|
707
|
+
timeSinceLastActivity: 5000,
|
|
708
|
+
recentToolCalls: [],
|
|
709
|
+
currentFile: null,
|
|
710
|
+
toolStats: [],
|
|
711
|
+
tokenUsage: null,
|
|
712
|
+
tmuxOutput: "[Headless agent — showing recent tool events]",
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
printInspectData(data);
|
|
716
|
+
|
|
717
|
+
const out = output();
|
|
718
|
+
expect(out).toContain("Recent Activity (headless)");
|
|
719
|
+
expect(out).not.toContain("Live Tmux Output");
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
|
|
568
723
|
// === Human-readable output ===
|
|
569
724
|
|
|
570
725
|
describe("human-readable output", () => {
|
package/src/commands/inspect.ts
CHANGED
|
@@ -193,13 +193,24 @@ export async function gatherInspectData(
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
// tmux capture
|
|
196
|
+
// tmux capture (skipped for headless agents where tmuxSession is empty)
|
|
197
197
|
let tmuxOutput: string | null = null;
|
|
198
198
|
if (!opts.noTmux && session.tmuxSession) {
|
|
199
199
|
const lines = opts.tmuxLines ?? 30;
|
|
200
200
|
tmuxOutput = await captureTmux(session.tmuxSession, lines);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
// Headless fallback: show recent events as live output when no tmux
|
|
204
|
+
if (!tmuxOutput && session.tmuxSession === "" && recentToolCalls.length > 0) {
|
|
205
|
+
const lines: string[] = ["[Headless agent — showing recent tool events]", ""];
|
|
206
|
+
for (const call of recentToolCalls.slice(0, 15)) {
|
|
207
|
+
const time = new Date(call.timestamp).toLocaleTimeString();
|
|
208
|
+
const dur = call.durationMs !== null ? `${call.durationMs}ms` : "pending";
|
|
209
|
+
lines.push(` [${time}] ${call.toolName.padEnd(15)} ${dur}`);
|
|
210
|
+
}
|
|
211
|
+
tmuxOutput = lines.join("\n");
|
|
212
|
+
}
|
|
213
|
+
|
|
203
214
|
return {
|
|
204
215
|
session,
|
|
205
216
|
timeSinceLastActivity,
|
|
@@ -233,7 +244,11 @@ export function printInspectData(data: InspectData): void {
|
|
|
233
244
|
w(`Parent: ${accent(session.parentAgent)} (depth: ${session.depth})\n`);
|
|
234
245
|
}
|
|
235
246
|
w(`Started: ${session.startedAt}\n`);
|
|
236
|
-
|
|
247
|
+
if (session.tmuxSession) {
|
|
248
|
+
w(`Tmux: ${accent(session.tmuxSession)}\n`);
|
|
249
|
+
} else if (session.pid !== null) {
|
|
250
|
+
w(`Process: PID ${accent(String(session.pid))} (headless)\n`);
|
|
251
|
+
}
|
|
237
252
|
w("\n");
|
|
238
253
|
|
|
239
254
|
// Current file
|
|
@@ -287,9 +302,9 @@ export function printInspectData(data: InspectData): void {
|
|
|
287
302
|
w("\n");
|
|
288
303
|
}
|
|
289
304
|
|
|
290
|
-
// tmux output
|
|
305
|
+
// tmux output (or headless fallback)
|
|
291
306
|
if (data.tmuxOutput) {
|
|
292
|
-
w("Live Tmux Output\n");
|
|
307
|
+
w(data.session.tmuxSession ? "Live Tmux Output\n" : "Recent Activity (headless)\n");
|
|
293
308
|
w(`${separator()}\n`);
|
|
294
309
|
w(`${data.tmuxOutput}\n`);
|
|
295
310
|
w(`${separator()}\n`);
|
|
@@ -701,6 +701,10 @@ describe("replayCommand", () => {
|
|
|
701
701
|
"spawn",
|
|
702
702
|
"error",
|
|
703
703
|
"custom",
|
|
704
|
+
"turn_start",
|
|
705
|
+
"turn_end",
|
|
706
|
+
"progress",
|
|
707
|
+
"result",
|
|
704
708
|
] as const;
|
|
705
709
|
for (const eventType of eventTypes) {
|
|
706
710
|
store.insert(
|
|
@@ -724,6 +728,10 @@ describe("replayCommand", () => {
|
|
|
724
728
|
expect(out).toContain("SPAWN");
|
|
725
729
|
expect(out).toContain("ERROR");
|
|
726
730
|
expect(out).toContain("CUSTOM");
|
|
731
|
+
expect(out).toContain("TURN START");
|
|
732
|
+
expect(out).toContain("TURN END");
|
|
733
|
+
expect(out).toContain("PROGRESS");
|
|
734
|
+
expect(out).toContain("RESULT");
|
|
727
735
|
});
|
|
728
736
|
|
|
729
737
|
test("long data values are truncated", async () => {
|
package/src/commands/sling.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* 14. Return AgentSession
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { mkdirSync } from "node:fs";
|
|
21
22
|
import { mkdir } from "node:fs/promises";
|
|
22
23
|
import { join, resolve } from "node:path";
|
|
23
24
|
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
@@ -31,6 +32,7 @@ import { printSuccess } from "../logging/color.ts";
|
|
|
31
32
|
import { createMailClient } from "../mail/client.ts";
|
|
32
33
|
import { createMailStore } from "../mail/store.ts";
|
|
33
34
|
import { createMulchClient } from "../mulch/client.ts";
|
|
35
|
+
import { setConnection } from "../runtimes/connections.ts";
|
|
34
36
|
import { getRuntime } from "../runtimes/registry.ts";
|
|
35
37
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
36
38
|
import { createRunStore } from "../sessions/store.ts";
|
|
@@ -38,6 +40,7 @@ import type { TrackerIssue } from "../tracker/factory.ts";
|
|
|
38
40
|
import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
39
41
|
import type { AgentSession, OverlayConfig } from "../types.ts";
|
|
40
42
|
import { createWorktree } from "../worktree/manager.ts";
|
|
43
|
+
import { spawnHeadlessAgent } from "../worktree/process.ts";
|
|
41
44
|
import {
|
|
42
45
|
capturePaneContent,
|
|
43
46
|
createSession,
|
|
@@ -836,142 +839,236 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
836
839
|
}
|
|
837
840
|
}
|
|
838
841
|
|
|
839
|
-
// 11c.
|
|
840
|
-
|
|
842
|
+
// 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
|
|
843
|
+
if (runtime.headless === true && runtime.buildDirectSpawn) {
|
|
844
|
+
const directEnv = {
|
|
845
|
+
...runtime.buildEnv(resolvedModel),
|
|
846
|
+
OVERSTORY_AGENT_NAME: name,
|
|
847
|
+
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
848
|
+
};
|
|
849
|
+
const argv = runtime.buildDirectSpawn({
|
|
850
|
+
cwd: worktreePath,
|
|
851
|
+
env: directEnv,
|
|
852
|
+
model: resolvedModel.model,
|
|
853
|
+
instructionPath: runtime.instructionPath,
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Create a timestamped log dir for this headless agent session.
|
|
857
|
+
// Redirecting stdout/stderr to files prevents OS pipe buffer backpressure:
|
|
858
|
+
// when nobody reads the pipe, the child blocks on write() after ~64 KB and
|
|
859
|
+
// becomes a zombie. File writes have no such limit.
|
|
860
|
+
//
|
|
861
|
+
// Exception: RPC-capable runtimes need a live stdout pipe to receive
|
|
862
|
+
// JSON-RPC 2.0 responses (getState). In that case stdoutFile is omitted
|
|
863
|
+
// and the caller consumes the stream via the RuntimeConnection.
|
|
864
|
+
const hasRpcConnect = typeof runtime.connect === "function";
|
|
865
|
+
const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
866
|
+
const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
|
|
867
|
+
mkdirSync(agentLogDir, { recursive: true });
|
|
868
|
+
|
|
869
|
+
const headlessProc = await spawnHeadlessAgent(argv, {
|
|
870
|
+
cwd: worktreePath,
|
|
871
|
+
env: { ...(process.env as Record<string, string>), ...directEnv },
|
|
872
|
+
stdoutFile: hasRpcConnect ? undefined : join(agentLogDir, "stdout.log"),
|
|
873
|
+
stderrFile: join(agentLogDir, "stderr.log"),
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// Wire up RPC connection for runtimes that support it (e.g., Sapling).
|
|
877
|
+
// The connection is stored in the module-level registry so the watchdog
|
|
878
|
+
// and other subsystems can call getState() for health checks.
|
|
879
|
+
if (hasRpcConnect && headlessProc.stdout && runtime.connect) {
|
|
880
|
+
const connection = runtime.connect({
|
|
881
|
+
stdin: headlessProc.stdin,
|
|
882
|
+
stdout: headlessProc.stdout,
|
|
883
|
+
});
|
|
884
|
+
setConnection(name, connection);
|
|
885
|
+
}
|
|
841
886
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
887
|
+
// 13. Record session with empty tmuxSession (no tmux pane for headless agents).
|
|
888
|
+
const session: AgentSession = {
|
|
889
|
+
id: `session-${Date.now()}-${name}`,
|
|
890
|
+
agentName: name,
|
|
891
|
+
capability,
|
|
892
|
+
worktreePath,
|
|
893
|
+
branchName,
|
|
894
|
+
taskId: taskId,
|
|
895
|
+
tmuxSession: "",
|
|
896
|
+
state: "booting",
|
|
897
|
+
pid: headlessProc.pid,
|
|
898
|
+
parentAgent: parentAgent,
|
|
899
|
+
depth,
|
|
900
|
+
runId,
|
|
901
|
+
startedAt: new Date().toISOString(),
|
|
902
|
+
lastActivity: new Date().toISOString(),
|
|
903
|
+
escalationLevel: 0,
|
|
904
|
+
stalledSince: null,
|
|
905
|
+
transcriptPath: null,
|
|
906
|
+
};
|
|
907
|
+
store.upsert(session);
|
|
908
|
+
|
|
909
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
910
|
+
try {
|
|
911
|
+
runStore.incrementAgentCount(runId);
|
|
912
|
+
} finally {
|
|
913
|
+
runStore.close();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// 14. Output result (headless)
|
|
917
|
+
if (opts.json ?? false) {
|
|
918
|
+
jsonOutput("sling", {
|
|
919
|
+
agentName: name,
|
|
920
|
+
capability,
|
|
921
|
+
taskId,
|
|
922
|
+
branch: branchName,
|
|
923
|
+
worktree: worktreePath,
|
|
924
|
+
tmuxSession: "",
|
|
925
|
+
pid: headlessProc.pid,
|
|
926
|
+
});
|
|
927
|
+
} else {
|
|
928
|
+
printSuccess("Agent launched (headless)", name);
|
|
929
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
930
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
931
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
932
|
+
process.stdout.write(` PID: ${headlessProc.pid}\n`);
|
|
933
|
+
}
|
|
934
|
+
} else {
|
|
935
|
+
// 11c. Preflight: verify tmux is available before attempting session creation
|
|
936
|
+
await ensureTmuxAvailable();
|
|
937
|
+
|
|
938
|
+
// 12. Create tmux session running claude in interactive mode
|
|
939
|
+
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
940
|
+
const spawnCmd = runtime.buildSpawnCommand({
|
|
941
|
+
model: resolvedModel.model,
|
|
942
|
+
permissionMode: "bypass",
|
|
943
|
+
cwd: worktreePath,
|
|
944
|
+
env: {
|
|
945
|
+
...runtime.buildEnv(resolvedModel),
|
|
946
|
+
OVERSTORY_AGENT_NAME: name,
|
|
947
|
+
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
948
|
+
},
|
|
949
|
+
});
|
|
950
|
+
const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
|
|
849
951
|
...runtime.buildEnv(resolvedModel),
|
|
850
952
|
OVERSTORY_AGENT_NAME: name,
|
|
851
953
|
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
852
|
-
}
|
|
853
|
-
});
|
|
854
|
-
const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
|
|
855
|
-
...runtime.buildEnv(resolvedModel),
|
|
856
|
-
OVERSTORY_AGENT_NAME: name,
|
|
857
|
-
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
858
|
-
});
|
|
954
|
+
});
|
|
859
955
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
956
|
+
// 13. Record session BEFORE sending the beacon so that hook-triggered
|
|
957
|
+
// updateLastActivity() can find the entry and transition booting->working.
|
|
958
|
+
// Without this, a race exists: hooks fire before the session is persisted,
|
|
959
|
+
// leaving the agent stuck in "booting" (overstory-036f).
|
|
960
|
+
const session: AgentSession = {
|
|
961
|
+
id: `session-${Date.now()}-${name}`,
|
|
962
|
+
agentName: name,
|
|
963
|
+
capability,
|
|
964
|
+
worktreePath,
|
|
965
|
+
branchName,
|
|
966
|
+
taskId: taskId,
|
|
967
|
+
tmuxSession: tmuxSessionName,
|
|
968
|
+
state: "booting",
|
|
969
|
+
pid,
|
|
970
|
+
parentAgent: parentAgent,
|
|
971
|
+
depth,
|
|
972
|
+
runId,
|
|
973
|
+
startedAt: new Date().toISOString(),
|
|
974
|
+
lastActivity: new Date().toISOString(),
|
|
975
|
+
escalationLevel: 0,
|
|
976
|
+
stalledSince: null,
|
|
977
|
+
transcriptPath: null,
|
|
978
|
+
};
|
|
883
979
|
|
|
884
|
-
|
|
980
|
+
store.upsert(session);
|
|
885
981
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
982
|
+
// Increment agent count for the run
|
|
983
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
984
|
+
try {
|
|
985
|
+
runStore.incrementAgentCount(runId);
|
|
986
|
+
} finally {
|
|
987
|
+
runStore.close();
|
|
988
|
+
}
|
|
893
989
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
990
|
+
// 13b. Give slow shells time to finish initializing before polling for TUI readiness.
|
|
991
|
+
const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
|
|
992
|
+
if (shellDelay > 0) {
|
|
993
|
+
await Bun.sleep(shellDelay);
|
|
994
|
+
}
|
|
899
995
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
996
|
+
// Wait for Claude Code TUI to render before sending input.
|
|
997
|
+
// Polling capture-pane is more reliable than a fixed sleep because
|
|
998
|
+
// TUI init time varies by machine load and model state.
|
|
999
|
+
await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));
|
|
1000
|
+
// Buffer for the input handler to attach after initial render
|
|
1001
|
+
await Bun.sleep(1_000);
|
|
906
1002
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1003
|
+
const beacon = buildBeacon({
|
|
1004
|
+
agentName: name,
|
|
1005
|
+
capability,
|
|
1006
|
+
taskId,
|
|
1007
|
+
parentAgent,
|
|
1008
|
+
depth,
|
|
1009
|
+
instructionPath: runtime.instructionPath,
|
|
1010
|
+
});
|
|
1011
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
1012
|
+
|
|
1013
|
+
// 13c. Follow-up Enters with increasing delays to ensure submission.
|
|
1014
|
+
// Claude Code's TUI may consume early Enters during late initialization
|
|
1015
|
+
// (overstory-yhv6). An Enter on an empty input line is harmless.
|
|
1016
|
+
for (const delay of [1_000, 2_000, 3_000, 5_000]) {
|
|
1017
|
+
await Bun.sleep(delay);
|
|
1018
|
+
await sendKeys(tmuxSessionName, "");
|
|
1019
|
+
}
|
|
924
1020
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1021
|
+
// 13d. Verify beacon was received — if pane still shows the welcome
|
|
1022
|
+
// screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
|
|
1023
|
+
// sometimes consumes the Enter keystroke during late initialization, swallowing
|
|
1024
|
+
// the beacon text entirely (overstory-3271).
|
|
1025
|
+
//
|
|
1026
|
+
// Skipped for runtimes that return false from requiresBeaconVerification().
|
|
1027
|
+
// Pi's TUI idle and processing states are indistinguishable via detectReady
|
|
1028
|
+
// (both show "pi v..." header and the token-usage status bar), so the loop
|
|
1029
|
+
// would incorrectly conclude the beacon was not received and spam duplicate
|
|
1030
|
+
// startup messages.
|
|
1031
|
+
const needsVerification =
|
|
1032
|
+
!runtime.requiresBeaconVerification || runtime.requiresBeaconVerification();
|
|
1033
|
+
if (needsVerification) {
|
|
1034
|
+
const verifyAttempts = 5;
|
|
1035
|
+
for (let v = 0; v < verifyAttempts; v++) {
|
|
1036
|
+
await Bun.sleep(2_000);
|
|
1037
|
+
const paneContent = await capturePaneContent(tmuxSessionName);
|
|
1038
|
+
if (paneContent) {
|
|
1039
|
+
const readyState = runtime.detectReady(paneContent);
|
|
1040
|
+
if (readyState.phase !== "ready") {
|
|
1041
|
+
break; // Agent is processing — beacon was received
|
|
1042
|
+
}
|
|
946
1043
|
}
|
|
1044
|
+
// Still at welcome/idle screen — resend beacon
|
|
1045
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
1046
|
+
await Bun.sleep(1_000);
|
|
1047
|
+
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
947
1048
|
}
|
|
948
|
-
// Still at welcome/idle screen — resend beacon
|
|
949
|
-
await sendKeys(tmuxSessionName, beacon);
|
|
950
|
-
await Bun.sleep(1_000);
|
|
951
|
-
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
952
1049
|
}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
// 14. Output result
|
|
956
|
-
const output = {
|
|
957
|
-
agentName: name,
|
|
958
|
-
capability,
|
|
959
|
-
taskId,
|
|
960
|
-
branch: branchName,
|
|
961
|
-
worktree: worktreePath,
|
|
962
|
-
tmuxSession: tmuxSessionName,
|
|
963
|
-
pid,
|
|
964
|
-
};
|
|
965
1050
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1051
|
+
// 14. Output result
|
|
1052
|
+
const output = {
|
|
1053
|
+
agentName: name,
|
|
1054
|
+
capability,
|
|
1055
|
+
taskId,
|
|
1056
|
+
branch: branchName,
|
|
1057
|
+
worktree: worktreePath,
|
|
1058
|
+
tmuxSession: tmuxSessionName,
|
|
1059
|
+
pid,
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
if (opts.json ?? false) {
|
|
1063
|
+
jsonOutput("sling", output);
|
|
1064
|
+
} else {
|
|
1065
|
+
printSuccess("Agent launched", name);
|
|
1066
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
1067
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
1068
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
1069
|
+
process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
|
|
1070
|
+
process.stdout.write(` PID: ${pid}\n`);
|
|
1071
|
+
}
|
|
975
1072
|
}
|
|
976
1073
|
} finally {
|
|
977
1074
|
store.close();
|