@os-eco/overstory-cli 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +4 -2
  2. package/agents/coordinator.md +52 -4
  3. package/package.json +1 -1
  4. package/src/agents/manifest.test.ts +33 -8
  5. package/src/agents/manifest.ts +4 -3
  6. package/src/commands/clean.test.ts +136 -0
  7. package/src/commands/clean.ts +198 -4
  8. package/src/commands/coordinator.test.ts +420 -1
  9. package/src/commands/coordinator.ts +173 -1
  10. package/src/commands/init.test.ts +137 -0
  11. package/src/commands/init.ts +57 -1
  12. package/src/commands/inspect.test.ts +398 -1
  13. package/src/commands/inspect.ts +234 -0
  14. package/src/commands/log.test.ts +10 -11
  15. package/src/commands/log.ts +31 -32
  16. package/src/commands/prime.ts +30 -5
  17. package/src/commands/sling.ts +312 -322
  18. package/src/commands/spec.ts +8 -2
  19. package/src/commands/stop.test.ts +127 -6
  20. package/src/commands/stop.ts +95 -43
  21. package/src/commands/watch.ts +29 -9
  22. package/src/config.test.ts +72 -0
  23. package/src/config.ts +26 -1
  24. package/src/events/tailer.test.ts +461 -0
  25. package/src/events/tailer.ts +235 -0
  26. package/src/index.ts +4 -1
  27. package/src/merge/resolver.test.ts +243 -19
  28. package/src/merge/resolver.ts +235 -95
  29. package/src/runtimes/claude.test.ts +1 -1
  30. package/src/runtimes/opencode.test.ts +325 -0
  31. package/src/runtimes/opencode.ts +185 -0
  32. package/src/runtimes/pi.test.ts +119 -2
  33. package/src/runtimes/pi.ts +61 -12
  34. package/src/runtimes/registry.test.ts +21 -1
  35. package/src/runtimes/registry.ts +3 -0
  36. package/src/runtimes/sapling.test.ts +30 -0
  37. package/src/runtimes/sapling.ts +27 -24
  38. package/src/runtimes/types.ts +2 -2
  39. package/src/types.ts +19 -0
  40. package/src/watchdog/daemon.test.ts +257 -0
  41. package/src/watchdog/daemon.ts +123 -23
  42. package/src/worktree/manager.test.ts +65 -1
  43. package/src/worktree/manager.ts +36 -0
@@ -179,10 +179,12 @@ export class PiRuntime implements AgentRuntime {
179
179
  /**
180
180
  * Parse a Pi transcript JSONL file into normalized token usage.
181
181
  *
182
- * Pi JSONL format differs from Claude Code:
183
- * - Token counts are in `message_end` events with TOP-LEVEL `inputTokens` / `outputTokens`
184
- * (not nested under message.usage)
185
- * - Model identity comes from `model_change` events with a `model` field
182
+ * Pi JSONL format (version 3):
183
+ * - Session metadata: `{ type: "session", version: 3, id, cwd }`
184
+ * - Model identity: `{ type: "model_change", provider, modelId }`
185
+ * - Token usage: on `{ type: "message" }` events where `message.role === "assistant"`,
186
+ * nested under `message.usage`: `{ input, output, cacheRead, cacheWrite, totalTokens, cost }`
187
+ * - Cost data: `message.usage.cost.total` (USD)
186
188
  *
187
189
  * Returns null if the file does not exist or cannot be parsed.
188
190
  *
@@ -212,18 +214,49 @@ export class PiRuntime implements AgentRuntime {
212
214
  continue;
213
215
  }
214
216
 
217
+ // Model identity from model_change events.
218
+ if (entry.type === "model_change") {
219
+ if (typeof entry.modelId === "string") {
220
+ model = entry.modelId;
221
+ } else if (typeof entry.model === "string") {
222
+ model = entry.model;
223
+ }
224
+ }
225
+
226
+ // Token usage from assistant message events.
227
+ // Pi v3 format: message.usage.input / message.usage.output
228
+ if (entry.type === "message") {
229
+ const msg = entry.message as Record<string, unknown> | undefined;
230
+ if (msg?.role === "assistant") {
231
+ const usage = msg.usage as Record<string, unknown> | undefined;
232
+ if (usage) {
233
+ if (typeof usage.input === "number") {
234
+ inputTokens += usage.input;
235
+ }
236
+ if (typeof usage.output === "number") {
237
+ outputTokens += usage.output;
238
+ }
239
+ // Also count cache tokens toward input for compatibility.
240
+ if (typeof usage.cacheRead === "number") {
241
+ inputTokens += usage.cacheRead;
242
+ }
243
+ }
244
+
245
+ // Capture model from message if model_change was missed.
246
+ if (typeof msg.model === "string" && model === "") {
247
+ model = msg.model;
248
+ }
249
+ }
250
+ }
251
+
252
+ // Fallback: message_end events (older Pi versions).
215
253
  if (entry.type === "message_end") {
216
- // Pi top-level token fields (not nested under message.usage).
217
254
  if (typeof entry.inputTokens === "number") {
218
255
  inputTokens += entry.inputTokens;
219
256
  }
220
257
  if (typeof entry.outputTokens === "number") {
221
258
  outputTokens += entry.outputTokens;
222
259
  }
223
- } else if (entry.type === "model_change") {
224
- if (typeof entry.model === "string") {
225
- model = entry.model;
226
- }
227
260
  }
228
261
  }
229
262
 
@@ -246,8 +279,24 @@ export class PiRuntime implements AgentRuntime {
246
279
  return model.env ?? {};
247
280
  }
248
281
 
249
- /** Pi uses RPC — no transcript files. */
250
- getTranscriptDir(_projectRoot: string): string | null {
251
- return null;
282
+ /**
283
+ * Return the directory containing Pi session transcript files.
284
+ *
285
+ * Pi stores JSONL transcripts in `~/.pi/agent/sessions/{encoded-project-path}/`.
286
+ * The project path is encoded by replacing path separators with `--` and
287
+ * prefixing/suffixing with `--`.
288
+ *
289
+ * Example: `/home/user/project` → `~/.pi/agent/sessions/--home-user-project--/`
290
+ *
291
+ * @param projectRoot - Absolute path to the project root
292
+ * @returns Absolute path to the transcript directory
293
+ */
294
+ getTranscriptDir(projectRoot: string): string | null {
295
+ const home = process.env.HOME ?? process.env.USERPROFILE;
296
+ if (!home) return null;
297
+
298
+ // Pi encodes the project path: replace separators with dashes, wrap with --
299
+ const encoded = `--${projectRoot.replace(/[\\/]/g, "-").replace(/:/g, "")}--`;
300
+ return join(home, ".pi", "agent", "sessions", encoded);
252
301
  }
253
302
  }
@@ -4,6 +4,7 @@ import { ClaudeRuntime } from "./claude.ts";
4
4
  import { CodexRuntime } from "./codex.ts";
5
5
  import { CopilotRuntime } from "./copilot.ts";
6
6
  import { GeminiRuntime } from "./gemini.ts";
7
+ import { OpenCodeRuntime } from "./opencode.ts";
7
8
  import { PiRuntime } from "./pi.ts";
8
9
  import { getRuntime } from "./registry.ts";
9
10
 
@@ -22,7 +23,7 @@ describe("getRuntime", () => {
22
23
 
23
24
  it("throws with a helpful message for an unknown runtime", () => {
24
25
  expect(() => getRuntime("unknown-runtime")).toThrow(
25
- 'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini, sapling',
26
+ 'Unknown runtime: "unknown-runtime". Available: claude, codex, pi, copilot, gemini, sapling, opencode',
26
27
  );
27
28
  });
28
29
 
@@ -118,6 +119,25 @@ describe("getRuntime", () => {
118
119
  expect(runtime.id).toBe("gemini");
119
120
  });
120
121
 
122
+ it("returns OpenCodeRuntime when name is 'opencode'", () => {
123
+ const runtime = getRuntime("opencode");
124
+ expect(runtime).toBeInstanceOf(OpenCodeRuntime);
125
+ expect(runtime.id).toBe("opencode");
126
+ });
127
+
128
+ it("uses config.runtime.default 'opencode' when name is omitted", () => {
129
+ const config = { runtime: { default: "opencode" } } as OverstoryConfig;
130
+ const runtime = getRuntime(undefined, config);
131
+ expect(runtime).toBeInstanceOf(OpenCodeRuntime);
132
+ expect(runtime.id).toBe("opencode");
133
+ });
134
+
135
+ it("opencode runtime returns a new instance on each call", () => {
136
+ const a = getRuntime("opencode");
137
+ const b = getRuntime("opencode");
138
+ expect(a).not.toBe(b);
139
+ });
140
+
121
141
  describe("capability routing", () => {
122
142
  it("resolves capability-specific runtime from config", () => {
123
143
  const config = {
@@ -6,6 +6,7 @@ import { ClaudeRuntime } from "./claude.ts";
6
6
  import { CodexRuntime } from "./codex.ts";
7
7
  import { CopilotRuntime } from "./copilot.ts";
8
8
  import { GeminiRuntime } from "./gemini.ts";
9
+ import { OpenCodeRuntime } from "./opencode.ts";
9
10
  import { PiRuntime } from "./pi.ts";
10
11
  import { SaplingRuntime } from "./sapling.ts";
11
12
  import type { AgentRuntime } from "./types.ts";
@@ -18,6 +19,7 @@ const runtimes = new Map<string, () => AgentRuntime>([
18
19
  ["copilot", () => new CopilotRuntime()],
19
20
  ["gemini", () => new GeminiRuntime()],
20
21
  ["sapling", () => new SaplingRuntime()],
22
+ ["opencode", () => new OpenCodeRuntime()],
21
23
  ]);
22
24
 
23
25
  /**
@@ -37,6 +39,7 @@ export function getAllRuntimes(): AgentRuntime[] {
37
39
  new CopilotRuntime(),
38
40
  new GeminiRuntime(),
39
41
  new SaplingRuntime(),
42
+ new OpenCodeRuntime(),
40
43
  ];
41
44
  }
42
45
 
@@ -365,6 +365,21 @@ describe("SaplingRuntime", () => {
365
365
  const argv = runtime.buildDirectSpawn(opts);
366
366
  expect(argv[3]).toBe("claude-sonnet-4-6");
367
367
  });
368
+
369
+ test("omits --model when model is undefined (sapling uses own config)", () => {
370
+ const opts: DirectSpawnOpts = {
371
+ cwd: "/project/.overstory/worktrees/builder-1",
372
+ env: {},
373
+ instructionPath: "/project/.overstory/worktrees/builder-1/SAPLING.md",
374
+ };
375
+ const argv = runtime.buildDirectSpawn(opts);
376
+ expect(argv).not.toContain("--model");
377
+ expect(argv[0]).toBe("sp");
378
+ expect(argv[1]).toBe("run");
379
+ expect(argv).toContain("--json");
380
+ expect(argv).toContain("--cwd");
381
+ expect(argv).toContain("--system-prompt-file");
382
+ });
368
383
  });
369
384
 
370
385
  describe("buildEnv", () => {
@@ -464,6 +479,21 @@ describe("SaplingRuntime", () => {
464
479
  expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("custom/haiku-gateway-model");
465
480
  });
466
481
 
482
+ test("clears ANTHROPIC_API_KEY by default (no gateway)", () => {
483
+ const model: ResolvedModel = { model: "sonnet" };
484
+ const env = runtime.buildEnv(model);
485
+ expect(env.ANTHROPIC_API_KEY).toBe("");
486
+ });
487
+
488
+ test("buildEnv sets ANTHROPIC_API_KEY from gateway provider ANTHROPIC_AUTH_TOKEN", () => {
489
+ const model: ResolvedModel = {
490
+ model: "sonnet",
491
+ env: { ANTHROPIC_AUTH_TOKEN: "sk-gw-test" },
492
+ };
493
+ const env = runtime.buildEnv(model);
494
+ expect(env.ANTHROPIC_API_KEY).toBe("sk-gw-test");
495
+ });
496
+
467
497
  test("does NOT forward non-model env vars from model.env", () => {
468
498
  const model: ResolvedModel = {
469
499
  model: "sonnet",
@@ -424,39 +424,39 @@ export class SaplingRuntime implements AgentRuntime {
424
424
  * @returns Argv array for Bun.spawn — do not shell-interpolate
425
425
  */
426
426
  buildDirectSpawn(opts: DirectSpawnOpts): string[] {
427
- // Resolve the actual model name: if this is an alias (e.g. "sonnet") routed
428
- // through a gateway, the real model ID is in the env vars. Sapling passes
429
- // --model directly to the SDK, so it needs the actual model ID, not the alias.
430
- let model = opts.model;
431
- let resolved = false;
432
- if (opts.env) {
433
- const aliasKey = `ANTHROPIC_DEFAULT_${model.toUpperCase()}_MODEL`;
434
- const envResolved = opts.env[aliasKey];
435
- if (envResolved) {
436
- model = envResolved;
437
- resolved = true;
427
+ const argv = ["sp", "run"];
428
+ if (opts.model !== undefined) {
429
+ // Resolve the actual model name: if this is an alias (e.g. "sonnet") routed
430
+ // through a gateway, the real model ID is in the env vars. Sapling passes
431
+ // --model directly to the SDK, so it needs the actual model ID, not the alias.
432
+ let model = opts.model;
433
+ let resolved = false;
434
+ if (opts.env) {
435
+ const aliasKey = `ANTHROPIC_DEFAULT_${model.toUpperCase()}_MODEL`;
436
+ const envResolved = opts.env[aliasKey];
437
+ if (envResolved) {
438
+ model = envResolved;
439
+ resolved = true;
440
+ }
438
441
  }
439
- }
440
- // Fallback: bare aliases (haiku/sonnet/opus) with no gateway env var → concrete model ID.
441
- if (!resolved) {
442
- const fallback = SAPLING_ALIAS_FALLBACKS[model];
443
- if (fallback !== undefined) {
444
- model = fallback;
442
+ // Fallback: bare aliases (haiku/sonnet/opus) with no gateway env var → concrete model ID.
443
+ if (!resolved) {
444
+ const fallback = SAPLING_ALIAS_FALLBACKS[model];
445
+ if (fallback !== undefined) {
446
+ model = fallback;
447
+ }
445
448
  }
449
+ argv.push("--model", model);
446
450
  }
447
-
448
- return [
449
- "sp",
450
- "run",
451
- "--model",
452
- model,
451
+ argv.push(
453
452
  "--json",
454
453
  "--cwd",
455
454
  opts.cwd,
456
455
  "--system-prompt-file",
457
456
  opts.instructionPath,
458
457
  "Read SAPLING.md for your task assignment and begin immediately.",
459
- ];
458
+ );
459
+ return argv;
460
460
  }
461
461
 
462
462
  /**
@@ -667,6 +667,9 @@ export class SaplingRuntime implements AgentRuntime {
667
667
  CLAUDECODE: "",
668
668
  CLAUDE_CODE_SSE_PORT: "",
669
669
  CLAUDE_CODE_ENTRYPOINT: "",
670
+ // Clear ANTHROPIC_API_KEY so the parent session's key doesn't leak
671
+ // into the sapling subprocess. Gateway providers re-set this below.
672
+ ANTHROPIC_API_KEY: "",
670
673
  };
671
674
 
672
675
  const providerEnv = model.env ?? {};
@@ -118,8 +118,8 @@ export interface DirectSpawnOpts {
118
118
  cwd: string;
119
119
  /** Environment variables for the subprocess. */
120
120
  env: Record<string, string>;
121
- /** Model ref (alias or provider-qualified). */
122
- model: string;
121
+ /** Model ref (alias or provider-qualified). When undefined, the runtime omits --model and lets the agent use its own config. */
122
+ model?: string;
123
123
  /** Path to the instruction/overlay file for this agent. */
124
124
  instructionPath: string;
125
125
  }
package/src/types.ts CHANGED
@@ -20,6 +20,8 @@ export interface ProviderConfig {
20
20
  export interface ResolvedModel {
21
21
  model: string;
22
22
  env?: Record<string, string>;
23
+ /** True when the model was explicitly set via config.models[capability]. */
24
+ isExplicitOverride?: boolean;
23
25
  }
24
26
 
25
27
  /** Configuration for the Pi runtime's model alias expansion. */
@@ -37,6 +39,19 @@ export type TaskTrackerBackend = "auto" | "seeds" | "beads";
37
39
 
38
40
  // === Project Configuration ===
39
41
 
42
+ /**
43
+ * Conditions that trigger automatic coordinator shutdown.
44
+ * All triggers default to false for backward compatibility.
45
+ */
46
+ export interface CoordinatorExitTriggers {
47
+ /** Exit when all spawned agents have completed and their branches have been merged. */
48
+ allAgentsDone: boolean;
49
+ /** Exit when the task tracker reports no unblocked work (sd/bd ready returns empty). */
50
+ taskTrackerEmpty: boolean;
51
+ /** Exit when a typed shutdown mail is received from an external caller (e.g., greenhouse). */
52
+ onShutdownSignal: boolean;
53
+ }
54
+
40
55
  /** A single quality gate command that agents must pass before reporting completion. */
41
56
  export interface QualityGate {
42
57
  /** Display name shown in the overlay (e.g., "Tests"). */
@@ -94,6 +109,10 @@ export interface OverstoryConfig {
94
109
  verbose: boolean;
95
110
  redactSecrets: boolean;
96
111
  };
112
+ coordinator?: {
113
+ /** Conditions that trigger automatic coordinator shutdown. */
114
+ exitTriggers: CoordinatorExitTriggers;
115
+ };
97
116
  runtime?: {
98
117
  /** Default runtime adapter name (default: "claude"). */
99
118
  default: string;
@@ -1977,3 +1977,260 @@ describe("buildCompletionMessage", () => {
1977
1977
  expect(msg).toContain("3");
1978
1978
  });
1979
1979
  });
1980
+
1981
+ // === Bug fix tests: headless agent kill blast radius + stale detection ===
1982
+
1983
+ describe("headless agent kill blast radius fix (Bug 1)", () => {
1984
+ /**
1985
+ * Track PID kill calls without spawning real processes.
1986
+ * Also surfaces killTree calls so tests can assert on them.
1987
+ */
1988
+ function processTracker(): {
1989
+ isAlive: (pid: number) => boolean;
1990
+ killTree: (pid: number) => Promise<void>;
1991
+ killed: number[];
1992
+ } {
1993
+ const killed: number[] = [];
1994
+ return {
1995
+ isAlive: (pid: number) => {
1996
+ try {
1997
+ process.kill(pid, 0);
1998
+ return true;
1999
+ } catch {
2000
+ return false;
2001
+ }
2002
+ },
2003
+ killTree: async (pid: number) => {
2004
+ killed.push(pid);
2005
+ },
2006
+ killed,
2007
+ };
2008
+ }
2009
+
2010
+ test("headless agent at escalation level 3 kills PID, not tmux session", async () => {
2011
+ const nudgeIntervalMs = 60_000;
2012
+ // stalledSince is 4 intervals ago — expectedLevel = floor(4) = 4, clamped to MAX (3)
2013
+ const stalledSince = new Date(Date.now() - 4 * nudgeIntervalMs).toISOString();
2014
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
2015
+
2016
+ const session = makeSession({
2017
+ agentName: "headless-stalled",
2018
+ tmuxSession: "", // headless
2019
+ pid: process.pid, // alive PID — ZFC won't trigger direct terminate
2020
+ state: "stalled",
2021
+ lastActivity: staleActivity,
2022
+ escalationLevel: 2,
2023
+ stalledSince,
2024
+ });
2025
+
2026
+ writeSessionsToStore(tempRoot, [session]);
2027
+
2028
+ const proc = processTracker();
2029
+ // tmux mock: isSessionAlive("") returns true — simulates prefix-match bug scenario
2030
+ const tmuxMock = tmuxWithLiveness({ "": true });
2031
+
2032
+ await runDaemonTick({
2033
+ root: tempRoot,
2034
+ ...THRESHOLDS,
2035
+ nudgeIntervalMs,
2036
+ tier1Enabled: false,
2037
+ _tmux: tmuxMock,
2038
+ _triage: triageAlways("extend"),
2039
+ _process: proc,
2040
+ _eventStore: null,
2041
+ _recordFailure: async () => {},
2042
+ _getConnection: () => undefined,
2043
+ _removeConnection: () => {},
2044
+ _tailerRegistry: new Map(),
2045
+ _findLatestStdoutLog: async () => null,
2046
+ });
2047
+
2048
+ // PID was killed via killTree, NOT via tmux killSession("")
2049
+ expect(proc.killed).toContain(process.pid);
2050
+ expect(tmuxMock.killed).not.toContain("");
2051
+ });
2052
+
2053
+ test("headless agent direct terminate kills PID, not tmux", async () => {
2054
+ // PID 999999 is virtually guaranteed not to exist — health check sees it as dead
2055
+ const deadPid = 999999;
2056
+ const session = makeSession({
2057
+ agentName: "headless-dead-pid",
2058
+ tmuxSession: "", // headless
2059
+ pid: deadPid,
2060
+ state: "working",
2061
+ lastActivity: new Date().toISOString(),
2062
+ });
2063
+
2064
+ writeSessionsToStore(tempRoot, [session]);
2065
+
2066
+ const proc = processTracker();
2067
+ // tmux mock: isSessionAlive("") returns true — would kill everything without the fix
2068
+ const tmuxMock = tmuxWithLiveness({ "": true });
2069
+
2070
+ await runDaemonTick({
2071
+ root: tempRoot,
2072
+ ...THRESHOLDS,
2073
+ _tmux: tmuxMock,
2074
+ _triage: triageAlways("extend"),
2075
+ _process: proc,
2076
+ _eventStore: null,
2077
+ _recordFailure: async () => {},
2078
+ _getConnection: () => undefined,
2079
+ _removeConnection: () => {},
2080
+ _tailerRegistry: new Map(),
2081
+ _findLatestStdoutLog: async () => null,
2082
+ });
2083
+
2084
+ // Should have attempted PID kill, NOT tmux killSession("")
2085
+ expect(proc.killed).toContain(deadPid);
2086
+ expect(tmuxMock.killed).not.toContain("");
2087
+ });
2088
+
2089
+ test("triage terminate on headless agent kills PID, not tmux", async () => {
2090
+ const nudgeIntervalMs = 60_000;
2091
+ // stalledSince is 2.5 intervals ago — expectedLevel = floor(2.5) = 2 → triage fires
2092
+ const stalledSince = new Date(Date.now() - 2.5 * nudgeIntervalMs).toISOString();
2093
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
2094
+
2095
+ const session = makeSession({
2096
+ agentName: "headless-triage-terminate",
2097
+ tmuxSession: "", // headless
2098
+ pid: process.pid, // alive
2099
+ state: "stalled",
2100
+ lastActivity: staleActivity,
2101
+ escalationLevel: 1,
2102
+ stalledSince,
2103
+ });
2104
+
2105
+ writeSessionsToStore(tempRoot, [session]);
2106
+
2107
+ const proc = processTracker();
2108
+ const tmuxMock = tmuxWithLiveness({ "": true });
2109
+
2110
+ await runDaemonTick({
2111
+ root: tempRoot,
2112
+ ...THRESHOLDS,
2113
+ nudgeIntervalMs,
2114
+ tier1Enabled: true,
2115
+ _tmux: tmuxMock,
2116
+ _triage: triageAlways("terminate"), // AI triage says terminate
2117
+ _nudge: nudgeTracker().nudge,
2118
+ _process: proc,
2119
+ _eventStore: null,
2120
+ _recordFailure: async () => {},
2121
+ _getConnection: () => undefined,
2122
+ _removeConnection: () => {},
2123
+ _tailerRegistry: new Map(),
2124
+ _findLatestStdoutLog: async () => null,
2125
+ });
2126
+
2127
+ // Should have killed the PID, not the tmux session
2128
+ expect(proc.killed).toContain(process.pid);
2129
+ expect(tmuxMock.killed).not.toContain("");
2130
+ });
2131
+ });
2132
+
2133
+ describe("headless agent stale detection via events.db (Bug 2)", () => {
2134
+ test("headless agent with recent events in events.db is not flagged stale", async () => {
2135
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
2136
+
2137
+ const session = makeSession({
2138
+ agentName: "headless-active",
2139
+ tmuxSession: "", // headless
2140
+ pid: process.pid, // alive
2141
+ state: "working",
2142
+ lastActivity: staleActivity, // stale — would trigger escalate without event fallback
2143
+ });
2144
+
2145
+ writeSessionsToStore(tempRoot, [session]);
2146
+
2147
+ const eventsDbPath = join(tempRoot, ".overstory", "events.db");
2148
+ const eventStore = createEventStore(eventsDbPath);
2149
+
2150
+ try {
2151
+ // Insert a recent event for this agent (within the stale threshold window)
2152
+ eventStore.insert({
2153
+ runId: null,
2154
+ agentName: "headless-active",
2155
+ sessionId: null,
2156
+ eventType: "tool_end",
2157
+ toolName: "Read",
2158
+ toolArgs: null,
2159
+ toolDurationMs: 100,
2160
+ level: "info",
2161
+ data: null,
2162
+ });
2163
+
2164
+ const checks: HealthCheck[] = [];
2165
+
2166
+ await runDaemonTick({
2167
+ root: tempRoot,
2168
+ ...THRESHOLDS,
2169
+ onHealthCheck: (c) => checks.push(c),
2170
+ _tmux: tmuxAllAlive(),
2171
+ _triage: triageAlways("extend"),
2172
+ _process: { isAlive: () => true, killTree: async () => {} },
2173
+ _eventStore: eventStore,
2174
+ _recordFailure: async () => {},
2175
+ _getConnection: () => undefined,
2176
+ _removeConnection: () => {},
2177
+ _tailerRegistry: new Map(),
2178
+ _findLatestStdoutLog: async () => null,
2179
+ });
2180
+
2181
+ // Recent events found — lastActivity was refreshed, agent is NOT stalled
2182
+ expect(checks).toHaveLength(1);
2183
+ expect(checks[0]?.action).toBe("none");
2184
+ expect(checks[0]?.state).toBe("working");
2185
+
2186
+ const reloaded = readSessionsFromStore(tempRoot);
2187
+ expect(reloaded[0]?.state).toBe("working");
2188
+ } finally {
2189
+ eventStore.close();
2190
+ }
2191
+ });
2192
+
2193
+ test("headless agent with no recent events IS flagged stale", async () => {
2194
+ const staleActivity = new Date(Date.now() - THRESHOLDS.staleThresholdMs * 2).toISOString();
2195
+
2196
+ const session = makeSession({
2197
+ agentName: "headless-silent",
2198
+ tmuxSession: "", // headless
2199
+ pid: process.pid, // alive
2200
+ state: "working",
2201
+ lastActivity: staleActivity, // stale
2202
+ });
2203
+
2204
+ writeSessionsToStore(tempRoot, [session]);
2205
+
2206
+ const eventsDbPath = join(tempRoot, ".overstory", "events.db");
2207
+ const eventStore = createEventStore(eventsDbPath);
2208
+
2209
+ try {
2210
+ // No events inserted for this agent — event fallback finds nothing
2211
+
2212
+ const checks: HealthCheck[] = [];
2213
+
2214
+ await runDaemonTick({
2215
+ root: tempRoot,
2216
+ ...THRESHOLDS,
2217
+ onHealthCheck: (c) => checks.push(c),
2218
+ _tmux: tmuxAllAlive(),
2219
+ _triage: triageAlways("extend"),
2220
+ _process: { isAlive: () => true, killTree: async () => {} },
2221
+ _eventStore: eventStore,
2222
+ _recordFailure: async () => {},
2223
+ _getConnection: () => undefined,
2224
+ _removeConnection: () => {},
2225
+ _tailerRegistry: new Map(),
2226
+ _findLatestStdoutLog: async () => null,
2227
+ });
2228
+
2229
+ // No recent events — lastActivity stays stale, agent IS flagged stalled
2230
+ expect(checks).toHaveLength(1);
2231
+ expect(checks[0]?.action).toBe("escalate");
2232
+ } finally {
2233
+ eventStore.close();
2234
+ }
2235
+ });
2236
+ });