@os-eco/overstory-cli 0.8.0 → 0.8.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 +3 -1
- package/agents/builder.md +2 -2
- package/agents/lead.md +2 -2
- package/agents/merger.md +2 -2
- package/agents/orchestrator.md +1 -1
- package/agents/reviewer.md +2 -2
- package/agents/scout.md +2 -2
- package/agents/supervisor.md +3 -3
- package/package.json +1 -1
- package/src/agents/overlay.test.ts +42 -0
- package/src/agents/overlay.ts +1 -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/inspect.test.ts +156 -1
- package/src/commands/inspect.ts +19 -4
- package/src/commands/replay.test.ts +8 -0
- package/src/commands/sling.test.ts +34 -10
- package/src/commands/sling.ts +249 -136
- package/src/commands/status.test.ts +77 -0
- package/src/commands/status.ts +6 -3
- package/src/commands/stop.test.ts +186 -4
- package/src/commands/stop.ts +46 -14
- package/src/commands/trace.test.ts +8 -0
- package/src/config.test.ts +63 -0
- package/src/config.ts +29 -5
- package/src/index.ts +2 -2
- package/src/logging/theme.ts +4 -0
- package/src/runtimes/codex.test.ts +22 -8
- package/src/runtimes/codex.ts +21 -16
- 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 +7 -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
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
checkRunSessionLimit,
|
|
21
21
|
checkTaskLock,
|
|
22
22
|
extractMulchRecordIds,
|
|
23
|
+
generateAgentName,
|
|
23
24
|
getCurrentBranch,
|
|
24
25
|
inferDomainsFromFiles,
|
|
25
26
|
isRunningAsRoot,
|
|
@@ -342,6 +343,31 @@ describe("shouldShowScoutWarning", () => {
|
|
|
342
343
|
});
|
|
343
344
|
});
|
|
344
345
|
|
|
346
|
+
describe("generateAgentName", () => {
|
|
347
|
+
test("returns capability-taskId when no collision", () => {
|
|
348
|
+
expect(generateAgentName("builder", "overstory-2f10", [])).toBe("builder-overstory-2f10");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("returns capability-taskId when takenNames is empty", () => {
|
|
352
|
+
expect(generateAgentName("scout", "task-123", [])).toBe("scout-task-123");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("appends -2 when base name is taken", () => {
|
|
356
|
+
expect(generateAgentName("builder", "overstory-2f10", ["builder-overstory-2f10"])).toBe(
|
|
357
|
+
"builder-overstory-2f10-2",
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("skips taken suffixes and returns -3 when -2 is also taken", () => {
|
|
362
|
+
expect(
|
|
363
|
+
generateAgentName("builder", "overstory-2f10", [
|
|
364
|
+
"builder-overstory-2f10",
|
|
365
|
+
"builder-overstory-2f10-2",
|
|
366
|
+
]),
|
|
367
|
+
).toBe("builder-overstory-2f10-3");
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
345
371
|
/**
|
|
346
372
|
* Tests for hierarchy validation in sling.
|
|
347
373
|
*
|
|
@@ -352,14 +378,12 @@ describe("shouldShowScoutWarning", () => {
|
|
|
352
378
|
*/
|
|
353
379
|
|
|
354
380
|
describe("validateHierarchy", () => {
|
|
355
|
-
test("
|
|
356
|
-
expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).toThrow(
|
|
357
|
-
HierarchyError,
|
|
358
|
-
);
|
|
381
|
+
test("allows builder when parentAgent is null", () => {
|
|
382
|
+
expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).not.toThrow();
|
|
359
383
|
});
|
|
360
384
|
|
|
361
|
-
test("
|
|
362
|
-
expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).toThrow(
|
|
385
|
+
test("allows scout when parentAgent is null", () => {
|
|
386
|
+
expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).not.toThrow();
|
|
363
387
|
});
|
|
364
388
|
|
|
365
389
|
test("rejects reviewer when parentAgent is null", () => {
|
|
@@ -404,15 +428,15 @@ describe("validateHierarchy", () => {
|
|
|
404
428
|
|
|
405
429
|
test("error has correct fields and code", () => {
|
|
406
430
|
try {
|
|
407
|
-
validateHierarchy(null, "
|
|
431
|
+
validateHierarchy(null, "reviewer", "my-reviewer", 0, false);
|
|
408
432
|
expect.unreachable("should have thrown");
|
|
409
433
|
} catch (err) {
|
|
410
434
|
expect(err).toBeInstanceOf(HierarchyError);
|
|
411
435
|
const he = err as HierarchyError;
|
|
412
436
|
expect(he.code).toBe("HIERARCHY_VIOLATION");
|
|
413
|
-
expect(he.agentName).toBe("my-
|
|
414
|
-
expect(he.requestedCapability).toBe("
|
|
415
|
-
expect(he.message).toContain("
|
|
437
|
+
expect(he.agentName).toBe("my-reviewer");
|
|
438
|
+
expect(he.requestedCapability).toBe("reviewer");
|
|
439
|
+
expect(he.message).toContain("reviewer");
|
|
416
440
|
expect(he.message).toContain("lead");
|
|
417
441
|
}
|
|
418
442
|
});
|
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";
|
|
@@ -38,6 +39,7 @@ import type { TrackerIssue } from "../tracker/factory.ts";
|
|
|
38
39
|
import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
39
40
|
import type { AgentSession, OverlayConfig } from "../types.ts";
|
|
40
41
|
import { createWorktree } from "../worktree/manager.ts";
|
|
42
|
+
import { spawnHeadlessAgent } from "../worktree/process.ts";
|
|
41
43
|
import {
|
|
42
44
|
capturePaneContent,
|
|
43
45
|
createSession,
|
|
@@ -75,6 +77,29 @@ export function calculateStaggerDelay(
|
|
|
75
77
|
return remaining > 0 ? remaining : 0;
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Generate a unique agent name from capability and taskId.
|
|
82
|
+
* Base: capability-taskId. If that collides with takenNames,
|
|
83
|
+
* appends -2, -3, etc. up to 100. Falls back to -Date.now() for guaranteed uniqueness.
|
|
84
|
+
*/
|
|
85
|
+
export function generateAgentName(
|
|
86
|
+
capability: string,
|
|
87
|
+
taskId: string,
|
|
88
|
+
takenNames: readonly string[],
|
|
89
|
+
): string {
|
|
90
|
+
const base = `${capability}-${taskId}`;
|
|
91
|
+
if (!takenNames.includes(base)) {
|
|
92
|
+
return base;
|
|
93
|
+
}
|
|
94
|
+
for (let i = 2; i <= 100; i++) {
|
|
95
|
+
const candidate = `${base}-${i}`;
|
|
96
|
+
if (!takenNames.includes(candidate)) {
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return `${base}-${Date.now()}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
78
103
|
/**
|
|
79
104
|
* Check if the current process is running as root (UID 0).
|
|
80
105
|
* Returns true if running as root, false otherwise.
|
|
@@ -345,9 +370,10 @@ export function validateHierarchy(
|
|
|
345
370
|
return;
|
|
346
371
|
}
|
|
347
372
|
|
|
348
|
-
|
|
373
|
+
const directSpawnCapabilities = ["lead", "scout", "builder"];
|
|
374
|
+
if (parentAgent === null && !directSpawnCapabilities.includes(capability)) {
|
|
349
375
|
throw new HierarchyError(
|
|
350
|
-
`Coordinator cannot spawn "${capability}" directly. Only
|
|
376
|
+
`Coordinator cannot spawn "${capability}" directly. Only lead, scout, and builder are allowed without --parent. Use a lead as intermediary, or pass --force-hierarchy to bypass.`,
|
|
351
377
|
{ agentName: name, requestedCapability: capability },
|
|
352
378
|
);
|
|
353
379
|
}
|
|
@@ -426,7 +452,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
426
452
|
}
|
|
427
453
|
|
|
428
454
|
const capability = opts.capability ?? "builder";
|
|
429
|
-
const
|
|
455
|
+
const rawName = opts.name?.trim() ?? "";
|
|
456
|
+
const nameWasAutoGenerated = rawName.length === 0;
|
|
457
|
+
let name = nameWasAutoGenerated ? `${capability}-${taskId}` : rawName;
|
|
430
458
|
const specPath = opts.spec ?? null;
|
|
431
459
|
const filesRaw = opts.files;
|
|
432
460
|
const parentAgent = opts.parent ?? null;
|
|
@@ -436,10 +464,6 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
436
464
|
const skipScout = opts.skipScout ?? false;
|
|
437
465
|
const skipTaskCheck = opts.skipTaskCheck ?? false;
|
|
438
466
|
|
|
439
|
-
if (!name || name.trim().length === 0) {
|
|
440
|
-
throw new ValidationError("--name is required for sling", { field: "name" });
|
|
441
|
-
}
|
|
442
|
-
|
|
443
467
|
if (Number.isNaN(depth) || depth < 0) {
|
|
444
468
|
throw new ValidationError("--depth must be a non-negative integer", {
|
|
445
469
|
field: "depth",
|
|
@@ -594,11 +618,16 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
594
618
|
);
|
|
595
619
|
}
|
|
596
620
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
621
|
+
if (nameWasAutoGenerated) {
|
|
622
|
+
const takenNames = activeSessions.map((s) => s.agentName);
|
|
623
|
+
name = generateAgentName(capability, taskId, takenNames);
|
|
624
|
+
} else {
|
|
625
|
+
const existing = store.getByName(name);
|
|
626
|
+
if (existing && existing.state !== "zombie" && existing.state !== "completed") {
|
|
627
|
+
throw new AgentError(`Agent name "${name}" is already in use (state: ${existing.state})`, {
|
|
628
|
+
agentName: name,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
602
631
|
}
|
|
603
632
|
|
|
604
633
|
// 5d. Task-level locking: prevent concurrent agents on the same task ID.
|
|
@@ -714,6 +743,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
714
743
|
}
|
|
715
744
|
}
|
|
716
745
|
|
|
746
|
+
// Resolve runtime before overlayConfig so we can pass runtime.instructionPath
|
|
747
|
+
const runtime = getRuntime(opts.runtime, config);
|
|
748
|
+
|
|
717
749
|
const overlayConfig: OverlayConfig = {
|
|
718
750
|
agentName: name,
|
|
719
751
|
taskId: taskId,
|
|
@@ -739,11 +771,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
739
771
|
qualityGates: config.project.qualityGates,
|
|
740
772
|
trackerCli: trackerCliName(resolvedBackend),
|
|
741
773
|
trackerName: resolvedBackend,
|
|
774
|
+
instructionPath: runtime.instructionPath,
|
|
742
775
|
};
|
|
743
776
|
|
|
744
|
-
// Resolve runtime before writeOverlay so we can pass runtime.instructionPath
|
|
745
|
-
const runtime = getRuntime(opts.runtime, config);
|
|
746
|
-
|
|
747
777
|
try {
|
|
748
778
|
await writeOverlay(worktreePath, overlayConfig, config.project.root, runtime.instructionPath);
|
|
749
779
|
} catch (err) {
|
|
@@ -836,142 +866,225 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
836
866
|
}
|
|
837
867
|
}
|
|
838
868
|
|
|
839
|
-
// 11c.
|
|
840
|
-
|
|
869
|
+
// 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
|
|
870
|
+
if (runtime.headless === true && runtime.buildDirectSpawn) {
|
|
871
|
+
const directEnv = {
|
|
872
|
+
...runtime.buildEnv(resolvedModel),
|
|
873
|
+
OVERSTORY_AGENT_NAME: name,
|
|
874
|
+
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
875
|
+
};
|
|
876
|
+
const argv = runtime.buildDirectSpawn({
|
|
877
|
+
cwd: worktreePath,
|
|
878
|
+
env: directEnv,
|
|
879
|
+
model: resolvedModel.model,
|
|
880
|
+
instructionPath: runtime.instructionPath,
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// Create a timestamped log dir for this headless agent session.
|
|
884
|
+
// Always redirect stdout to a file. This prevents SIGPIPE death:
|
|
885
|
+
// ov sling exits after spawning, closing the pipe's read end.
|
|
886
|
+
// If stdout is a pipe, the agent dies on the next write (SIGPIPE).
|
|
887
|
+
// File writes have no such limit, and the agent survives the CLI exit.
|
|
888
|
+
//
|
|
889
|
+
// Note: RPC connection wiring is intentionally omitted here. The RPC pipe
|
|
890
|
+
// is only useful when the spawner stays alive to consume it. ov sling is
|
|
891
|
+
// a short-lived CLI — any connection created here dies with the process.
|
|
892
|
+
const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
893
|
+
const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
|
|
894
|
+
mkdirSync(agentLogDir, { recursive: true });
|
|
895
|
+
|
|
896
|
+
const headlessProc = await spawnHeadlessAgent(argv, {
|
|
897
|
+
cwd: worktreePath,
|
|
898
|
+
env: { ...(process.env as Record<string, string>), ...directEnv },
|
|
899
|
+
stdoutFile: join(agentLogDir, "stdout.log"),
|
|
900
|
+
stderrFile: join(agentLogDir, "stderr.log"),
|
|
901
|
+
});
|
|
841
902
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
903
|
+
// 13. Record session with empty tmuxSession (no tmux pane for headless agents).
|
|
904
|
+
const session: AgentSession = {
|
|
905
|
+
id: `session-${Date.now()}-${name}`,
|
|
906
|
+
agentName: name,
|
|
907
|
+
capability,
|
|
908
|
+
worktreePath,
|
|
909
|
+
branchName,
|
|
910
|
+
taskId: taskId,
|
|
911
|
+
tmuxSession: "",
|
|
912
|
+
state: "booting",
|
|
913
|
+
pid: headlessProc.pid,
|
|
914
|
+
parentAgent: parentAgent,
|
|
915
|
+
depth,
|
|
916
|
+
runId,
|
|
917
|
+
startedAt: new Date().toISOString(),
|
|
918
|
+
lastActivity: new Date().toISOString(),
|
|
919
|
+
escalationLevel: 0,
|
|
920
|
+
stalledSince: null,
|
|
921
|
+
transcriptPath: null,
|
|
922
|
+
};
|
|
923
|
+
store.upsert(session);
|
|
924
|
+
|
|
925
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
926
|
+
try {
|
|
927
|
+
runStore.incrementAgentCount(runId);
|
|
928
|
+
} finally {
|
|
929
|
+
runStore.close();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// 14. Output result (headless)
|
|
933
|
+
if (opts.json ?? false) {
|
|
934
|
+
jsonOutput("sling", {
|
|
935
|
+
agentName: name,
|
|
936
|
+
capability,
|
|
937
|
+
taskId,
|
|
938
|
+
branch: branchName,
|
|
939
|
+
worktree: worktreePath,
|
|
940
|
+
tmuxSession: "",
|
|
941
|
+
pid: headlessProc.pid,
|
|
942
|
+
});
|
|
943
|
+
} else {
|
|
944
|
+
printSuccess("Agent launched (headless)", name);
|
|
945
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
946
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
947
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
948
|
+
process.stdout.write(` PID: ${headlessProc.pid}\n`);
|
|
949
|
+
}
|
|
950
|
+
} else {
|
|
951
|
+
// 11c. Preflight: verify tmux is available before attempting session creation
|
|
952
|
+
await ensureTmuxAvailable();
|
|
953
|
+
|
|
954
|
+
// 12. Create tmux session running claude in interactive mode
|
|
955
|
+
const tmuxSessionName = `overstory-${config.project.name}-${name}`;
|
|
956
|
+
const spawnCmd = runtime.buildSpawnCommand({
|
|
957
|
+
model: resolvedModel.model,
|
|
958
|
+
permissionMode: "bypass",
|
|
959
|
+
cwd: worktreePath,
|
|
960
|
+
env: {
|
|
961
|
+
...runtime.buildEnv(resolvedModel),
|
|
962
|
+
OVERSTORY_AGENT_NAME: name,
|
|
963
|
+
OVERSTORY_WORKTREE_PATH: worktreePath,
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
|
|
849
967
|
...runtime.buildEnv(resolvedModel),
|
|
850
968
|
OVERSTORY_AGENT_NAME: name,
|
|
851
969
|
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
|
-
});
|
|
970
|
+
});
|
|
859
971
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
972
|
+
// 13. Record session BEFORE sending the beacon so that hook-triggered
|
|
973
|
+
// updateLastActivity() can find the entry and transition booting->working.
|
|
974
|
+
// Without this, a race exists: hooks fire before the session is persisted,
|
|
975
|
+
// leaving the agent stuck in "booting" (overstory-036f).
|
|
976
|
+
const session: AgentSession = {
|
|
977
|
+
id: `session-${Date.now()}-${name}`,
|
|
978
|
+
agentName: name,
|
|
979
|
+
capability,
|
|
980
|
+
worktreePath,
|
|
981
|
+
branchName,
|
|
982
|
+
taskId: taskId,
|
|
983
|
+
tmuxSession: tmuxSessionName,
|
|
984
|
+
state: "booting",
|
|
985
|
+
pid,
|
|
986
|
+
parentAgent: parentAgent,
|
|
987
|
+
depth,
|
|
988
|
+
runId,
|
|
989
|
+
startedAt: new Date().toISOString(),
|
|
990
|
+
lastActivity: new Date().toISOString(),
|
|
991
|
+
escalationLevel: 0,
|
|
992
|
+
stalledSince: null,
|
|
993
|
+
transcriptPath: null,
|
|
994
|
+
};
|
|
883
995
|
|
|
884
|
-
|
|
996
|
+
store.upsert(session);
|
|
885
997
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
998
|
+
// Increment agent count for the run
|
|
999
|
+
const runStore = createRunStore(join(overstoryDir, "sessions.db"));
|
|
1000
|
+
try {
|
|
1001
|
+
runStore.incrementAgentCount(runId);
|
|
1002
|
+
} finally {
|
|
1003
|
+
runStore.close();
|
|
1004
|
+
}
|
|
893
1005
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1006
|
+
// 13b. Give slow shells time to finish initializing before polling for TUI readiness.
|
|
1007
|
+
const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
|
|
1008
|
+
if (shellDelay > 0) {
|
|
1009
|
+
await Bun.sleep(shellDelay);
|
|
1010
|
+
}
|
|
899
1011
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1012
|
+
// Wait for Claude Code TUI to render before sending input.
|
|
1013
|
+
// Polling capture-pane is more reliable than a fixed sleep because
|
|
1014
|
+
// TUI init time varies by machine load and model state.
|
|
1015
|
+
await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));
|
|
1016
|
+
// Buffer for the input handler to attach after initial render
|
|
1017
|
+
await Bun.sleep(1_000);
|
|
906
1018
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1019
|
+
const beacon = buildBeacon({
|
|
1020
|
+
agentName: name,
|
|
1021
|
+
capability,
|
|
1022
|
+
taskId,
|
|
1023
|
+
parentAgent,
|
|
1024
|
+
depth,
|
|
1025
|
+
instructionPath: runtime.instructionPath,
|
|
1026
|
+
});
|
|
1027
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
1028
|
+
|
|
1029
|
+
// 13c. Follow-up Enters with increasing delays to ensure submission.
|
|
1030
|
+
// Claude Code's TUI may consume early Enters during late initialization
|
|
1031
|
+
// (overstory-yhv6). An Enter on an empty input line is harmless.
|
|
1032
|
+
for (const delay of [1_000, 2_000, 3_000, 5_000]) {
|
|
1033
|
+
await Bun.sleep(delay);
|
|
1034
|
+
await sendKeys(tmuxSessionName, "");
|
|
1035
|
+
}
|
|
924
1036
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1037
|
+
// 13d. Verify beacon was received — if pane still shows the welcome
|
|
1038
|
+
// screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
|
|
1039
|
+
// sometimes consumes the Enter keystroke during late initialization, swallowing
|
|
1040
|
+
// the beacon text entirely (overstory-3271).
|
|
1041
|
+
//
|
|
1042
|
+
// Skipped for runtimes that return false from requiresBeaconVerification().
|
|
1043
|
+
// Pi's TUI idle and processing states are indistinguishable via detectReady
|
|
1044
|
+
// (both show "pi v..." header and the token-usage status bar), so the loop
|
|
1045
|
+
// would incorrectly conclude the beacon was not received and spam duplicate
|
|
1046
|
+
// startup messages.
|
|
1047
|
+
const needsVerification =
|
|
1048
|
+
!runtime.requiresBeaconVerification || runtime.requiresBeaconVerification();
|
|
1049
|
+
if (needsVerification) {
|
|
1050
|
+
const verifyAttempts = 5;
|
|
1051
|
+
for (let v = 0; v < verifyAttempts; v++) {
|
|
1052
|
+
await Bun.sleep(2_000);
|
|
1053
|
+
const paneContent = await capturePaneContent(tmuxSessionName);
|
|
1054
|
+
if (paneContent) {
|
|
1055
|
+
const readyState = runtime.detectReady(paneContent);
|
|
1056
|
+
if (readyState.phase !== "ready") {
|
|
1057
|
+
break; // Agent is processing — beacon was received
|
|
1058
|
+
}
|
|
946
1059
|
}
|
|
1060
|
+
// Still at welcome/idle screen — resend beacon
|
|
1061
|
+
await sendKeys(tmuxSessionName, beacon);
|
|
1062
|
+
await Bun.sleep(1_000);
|
|
1063
|
+
await sendKeys(tmuxSessionName, ""); // Follow-up Enter
|
|
947
1064
|
}
|
|
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
1065
|
}
|
|
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
1066
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1067
|
+
// 14. Output result
|
|
1068
|
+
const output = {
|
|
1069
|
+
agentName: name,
|
|
1070
|
+
capability,
|
|
1071
|
+
taskId,
|
|
1072
|
+
branch: branchName,
|
|
1073
|
+
worktree: worktreePath,
|
|
1074
|
+
tmuxSession: tmuxSessionName,
|
|
1075
|
+
pid,
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
if (opts.json ?? false) {
|
|
1079
|
+
jsonOutput("sling", output);
|
|
1080
|
+
} else {
|
|
1081
|
+
printSuccess("Agent launched", name);
|
|
1082
|
+
process.stdout.write(` Task: ${taskId}\n`);
|
|
1083
|
+
process.stdout.write(` Branch: ${branchName}\n`);
|
|
1084
|
+
process.stdout.write(` Worktree: ${worktreePath}\n`);
|
|
1085
|
+
process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
|
|
1086
|
+
process.stdout.write(` PID: ${pid}\n`);
|
|
1087
|
+
}
|
|
975
1088
|
}
|
|
976
1089
|
} finally {
|
|
977
1090
|
store.close();
|
|
@@ -349,6 +349,83 @@ describe("run scoping", () => {
|
|
|
349
349
|
});
|
|
350
350
|
});
|
|
351
351
|
|
|
352
|
+
describe("headless agent alive markers", () => {
|
|
353
|
+
let chunks: string[];
|
|
354
|
+
let originalWrite: typeof process.stdout.write;
|
|
355
|
+
|
|
356
|
+
beforeEach(() => {
|
|
357
|
+
chunks = [];
|
|
358
|
+
originalWrite = process.stdout.write;
|
|
359
|
+
process.stdout.write = ((chunk: string) => {
|
|
360
|
+
chunks.push(chunk);
|
|
361
|
+
return true;
|
|
362
|
+
}) as typeof process.stdout.write;
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
afterEach(() => {
|
|
366
|
+
process.stdout.write = originalWrite;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
function output(): string {
|
|
370
|
+
return chunks.join("");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
test("printStatus shows green marker for headless agent with alive PID", () => {
|
|
374
|
+
// Use own process PID — guaranteed alive
|
|
375
|
+
const alivePid = process.pid;
|
|
376
|
+
const agent = makeAgent({
|
|
377
|
+
agentName: "headless-builder",
|
|
378
|
+
tmuxSession: "", // headless: no tmux
|
|
379
|
+
pid: alivePid,
|
|
380
|
+
state: "working",
|
|
381
|
+
});
|
|
382
|
+
const data = makeStatusData({
|
|
383
|
+
agents: [agent],
|
|
384
|
+
tmuxSessions: [], // no tmux sessions
|
|
385
|
+
});
|
|
386
|
+
printStatus(data);
|
|
387
|
+
const out = output();
|
|
388
|
+
// Green marker is ">" — check it appears in the output
|
|
389
|
+
expect(out).toContain("headless-builder");
|
|
390
|
+
expect(out).toContain(">");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("printStatus shows red marker for headless agent with dead PID", () => {
|
|
394
|
+
const deadPid = 2_147_483_647; // max int, virtually guaranteed non-existent
|
|
395
|
+
const agent = makeAgent({
|
|
396
|
+
agentName: "dead-headless-builder",
|
|
397
|
+
tmuxSession: "", // headless: no tmux
|
|
398
|
+
pid: deadPid,
|
|
399
|
+
state: "working",
|
|
400
|
+
});
|
|
401
|
+
const data = makeStatusData({
|
|
402
|
+
agents: [agent],
|
|
403
|
+
tmuxSessions: [],
|
|
404
|
+
});
|
|
405
|
+
printStatus(data);
|
|
406
|
+
const out = output();
|
|
407
|
+
expect(out).toContain("dead-headless-builder");
|
|
408
|
+
expect(out).toContain("x");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("printStatus uses tmux check (not PID) for tmux-based agents", () => {
|
|
412
|
+
const agent = makeAgent({
|
|
413
|
+
agentName: "tmux-builder",
|
|
414
|
+
tmuxSession: "overstory-test-builder",
|
|
415
|
+
pid: process.pid, // alive PID, but should use tmux check
|
|
416
|
+
state: "working",
|
|
417
|
+
});
|
|
418
|
+
// tmuxSessions empty → tmux dead → red marker
|
|
419
|
+
const data = makeStatusData({
|
|
420
|
+
agents: [agent],
|
|
421
|
+
tmuxSessions: [],
|
|
422
|
+
});
|
|
423
|
+
printStatus(data);
|
|
424
|
+
const out = output();
|
|
425
|
+
expect(out).toContain("x");
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
352
429
|
describe("--watch deprecation", () => {
|
|
353
430
|
test("help text marks --watch as deprecated", async () => {
|
|
354
431
|
const chunks: string[] = [];
|
package/src/commands/status.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { openSessionStore } from "../sessions/compat.ts";
|
|
|
20
20
|
import type { AgentSession } from "../types.ts";
|
|
21
21
|
import { evaluateHealth } from "../watchdog/health.ts";
|
|
22
22
|
import { listWorktrees } from "../worktree/manager.ts";
|
|
23
|
-
import { listSessions } from "../worktree/tmux.ts";
|
|
23
|
+
import { isProcessAlive, listSessions } from "../worktree/tmux.ts";
|
|
24
24
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
// Subprocess result cache (TTL-based, module-level)
|
|
@@ -260,8 +260,11 @@ export function printStatus(data: StatusData): void {
|
|
|
260
260
|
? new Date(agent.lastActivity).getTime()
|
|
261
261
|
: now;
|
|
262
262
|
const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
|
|
263
|
-
const
|
|
264
|
-
const
|
|
263
|
+
const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
|
|
264
|
+
const alive = isHeadless
|
|
265
|
+
? agent.pid !== null && isProcessAlive(agent.pid)
|
|
266
|
+
: tmuxSessionNames.has(agent.tmuxSession);
|
|
267
|
+
const aliveMarker = alive ? color.green(">") : color.red("x");
|
|
265
268
|
w(` ${aliveMarker} ${accent(agent.agentName)} [${agent.capability}] `);
|
|
266
269
|
w(`${agent.state} | ${accent(agent.taskId)} | ${duration}\n`);
|
|
267
270
|
|