@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.
Files changed (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
package/src/utils/pid.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * PID file management for daemon processes.
3
3
  */
4
- import { unlink } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { link, mkdir, unlink, writeFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
5
7
 
6
8
  /**
7
9
  * Read the PID from a PID file.
@@ -43,3 +45,86 @@ export async function removePidFile(pidFilePath: string): Promise<void> {
43
45
  // File may already be gone — not an error
44
46
  }
45
47
  }
48
+
49
+ /**
50
+ * Result of acquirePidLock.
51
+ *
52
+ * `acquired: true` — caller owns the lock and is responsible for removing the
53
+ * PID file on shutdown.
54
+ *
55
+ * `acquired: false` — a live foreign process already owns the lock; caller
56
+ * must not start. `existingPid` is the live owner. `existingPid === -1` means
57
+ * the lock file existed but was unreadable and could not be reclaimed.
58
+ */
59
+ export type AcquirePidLockResult = { acquired: true } | { acquired: false; existingPid: number };
60
+
61
+ /**
62
+ * Atomically acquire a PID-file lock.
63
+ *
64
+ * Uses the write-temp-then-link pattern so the lock file appears at its final
65
+ * path with PID contents already present (no empty-file window): a competing
66
+ * reader can never observe an in-flight write. Behavior:
67
+ *
68
+ * - Lock file does not exist → atomic create via link(). Caller owns the lock.
69
+ * - Lock file exists, contains the caller's own PID → idempotent acquire
70
+ * (caller already owns it; e.g. background-mode parent wrote child.pid
71
+ * before spawn).
72
+ * - Lock file exists with a live foreign PID → refuse; return existingPid.
73
+ * - Lock file exists with a dead PID (or unreadable) → reclaim by unlinking
74
+ * and retrying once. If the retry races and loses to a live foreign
75
+ * watchdog, the call returns acquired=false with that foreign PID.
76
+ *
77
+ * Parent directory is created if missing (matches the implicit Bun.write
78
+ * behavior the legacy writePidFile relied on).
79
+ */
80
+ export async function acquirePidLock(
81
+ pidFilePath: string,
82
+ pid: number,
83
+ isAlive: (pid: number) => boolean,
84
+ ): Promise<AcquirePidLockResult> {
85
+ await mkdir(dirname(pidFilePath), { recursive: true });
86
+
87
+ // Stage the PID content at a unique temp path. After link() succeeds, the
88
+ // lock path appears with full content already present.
89
+ const tempPath = `${pidFilePath}.tmp.${pid}.${randomUUID()}`;
90
+ await writeFile(tempPath, `${pid}\n`);
91
+
92
+ try {
93
+ // Two attempts: first try, then one stale-lock reclaim retry. A second
94
+ // EEXIST after reclaim means a live foreign process raced in.
95
+ for (let attempt = 0; attempt < 2; attempt++) {
96
+ try {
97
+ await link(tempPath, pidFilePath);
98
+ return { acquired: true };
99
+ } catch (err: unknown) {
100
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
101
+ if (code !== "EEXIST") {
102
+ throw err;
103
+ }
104
+ const existing = await readPidFile(pidFilePath);
105
+ if (existing === null) {
106
+ // Unreadable/corrupted lock file — treat as stale.
107
+ await removePidFile(pidFilePath);
108
+ continue;
109
+ }
110
+ if (existing === pid) {
111
+ // Idempotent: caller already owns it (parent pre-wrote child PID).
112
+ return { acquired: true };
113
+ }
114
+ if (isAlive(existing)) {
115
+ return { acquired: false, existingPid: existing };
116
+ }
117
+ // Stale: reclaim and retry once.
118
+ await removePidFile(pidFilePath);
119
+ }
120
+ }
121
+
122
+ // Two stale-then-retry attempts both failed. Another writer raced in
123
+ // between our reclaim and our retry — they own the lock now.
124
+ const existing = await readPidFile(pidFilePath);
125
+ return { acquired: false, existingPid: existing ?? -1 };
126
+ } finally {
127
+ // Drop the temp inode link (lock path retains the data via the second link).
128
+ await unlink(tempPath).catch(() => {});
129
+ }
130
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { findRunningWatchdogProcesses } from "./process-scan.ts";
3
+
4
+ describe("findRunningWatchdogProcesses", () => {
5
+ test("returns an array (does not throw)", async () => {
6
+ const results = await findRunningWatchdogProcesses();
7
+ expect(Array.isArray(results)).toBe(true);
8
+ // We can't assert specifics — depends on what's running on the host —
9
+ // but each entry should have a numeric pid and string command.
10
+ for (const proc of results) {
11
+ expect(typeof proc.pid).toBe("number");
12
+ expect(proc.pid).toBeGreaterThan(0);
13
+ expect(typeof proc.command).toBe("string");
14
+ }
15
+ });
16
+
17
+ test("excludes own process even if command matches", async () => {
18
+ // The test process itself runs `bun test ...` not `ov watch`, so it
19
+ // would not match anyway. But we still verify own-pid is filtered out
20
+ // by checking no result has our PID.
21
+ const results = await findRunningWatchdogProcesses();
22
+ const ownPid = process.pid;
23
+ for (const proc of results) {
24
+ expect(proc.pid).not.toBe(ownPid);
25
+ }
26
+ });
27
+
28
+ test("matches `ov watch` and `bun run ov watch` invocations", async () => {
29
+ // Spawn a sleeper whose command line contains the `ov watch` substring,
30
+ // then verify the scanner finds it. We use `sh -c` so the argv string
31
+ // passed to ps contains our marker tokens.
32
+ const sleeper = Bun.spawn(["sh", "-c", "exec -a 'bun run ov watch' sleep 30"], {
33
+ stdout: "ignore",
34
+ stderr: "ignore",
35
+ });
36
+ try {
37
+ // Give ps a moment to see the new process.
38
+ await Bun.sleep(150);
39
+ const results = await findRunningWatchdogProcesses();
40
+ const found = results.find((p) => p.pid === sleeper.pid);
41
+ // On macOS BSD ps, `exec -a` may or may not change the displayed
42
+ // argv depending on shell version. We accept either: if the
43
+ // command is detected, it must look right; if not, we don't fail
44
+ // the test (env-dependent).
45
+ if (found) {
46
+ expect(found.command).toMatch(/\b(ov|overstory)\b.*\bwatch\b/);
47
+ }
48
+ } finally {
49
+ sleeper.kill("SIGTERM");
50
+ await sleeper.exited.catch(() => {});
51
+ }
52
+ });
53
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Process-table scanning helpers.
3
+ *
4
+ * Used to detect runaway daemon processes that are not tracked by a PID file —
5
+ * for example, the multi-`ov watch` situation observed on 2026-04-30 where
6
+ * three concurrent watchdogs were running because earlier releases had no
7
+ * PID-file exclusion lock.
8
+ *
9
+ * Implementation note: `ps` is used directly because we only need to find
10
+ * processes by command-line substring, and Bun has no built-in process-table
11
+ * API. The `ps -o pid=,command=` form is portable across macOS (BSD) and
12
+ * Linux (procps) for the columns we read.
13
+ */
14
+
15
+ export interface WatchdogProcess {
16
+ pid: number;
17
+ /** The full command line as reported by `ps`. */
18
+ command: string;
19
+ }
20
+
21
+ /**
22
+ * Find running processes that look like an `ov watch` daemon.
23
+ *
24
+ * Matches on the command-line substring `ov watch` (the daemon spawn form)
25
+ * and excludes the current process so callers do not accidentally treat
26
+ * themselves as a foreign daemon.
27
+ *
28
+ * Returns an empty list if `ps` is unavailable or fails — callers must not
29
+ * rely on this for correctness, only for diagnostics and `--kill-others`.
30
+ */
31
+ export async function findRunningWatchdogProcesses(): Promise<WatchdogProcess[]> {
32
+ const proc = Bun.spawn(["ps", "-A", "-o", "pid=,command="], {
33
+ stdout: "pipe",
34
+ stderr: "ignore",
35
+ });
36
+ const exitCode = await proc.exited;
37
+ if (exitCode !== 0) {
38
+ return [];
39
+ }
40
+ const text = await new Response(proc.stdout).text();
41
+ const ownPid = process.pid;
42
+ const out: WatchdogProcess[] = [];
43
+
44
+ for (const rawLine of text.split("\n")) {
45
+ const line = rawLine.trim();
46
+ if (line === "") continue;
47
+
48
+ // `ps -o pid=,command=` outputs: ` 1234 /path/to/binary args...`
49
+ // (leading whitespace is allowed, then PID, then a single space, then
50
+ // the rest of the command).
51
+ const match = line.match(/^(\d+)\s+(.+)$/);
52
+ if (!match) continue;
53
+ const pidStr = match[1];
54
+ const command = match[2];
55
+ if (pidStr === undefined || command === undefined) continue;
56
+ const pid = Number.parseInt(pidStr, 10);
57
+ if (!Number.isFinite(pid) || pid <= 0) continue;
58
+ if (pid === ownPid) continue;
59
+
60
+ // Match the spawn form: `bun run /path/to/ov watch`. We also tolerate
61
+ // direct invocation `overstory watch` and `ov watch`.
62
+ if (!isWatchdogCommand(command)) continue;
63
+
64
+ out.push({ pid, command });
65
+ }
66
+
67
+ return out;
68
+ }
69
+
70
+ function isWatchdogCommand(command: string): boolean {
71
+ // Anchor on a `watch` token preceded by an `ov` or `overstory` token.
72
+ // Avoids false positives like "watch ov.log" or unrelated `watch` commands.
73
+ if (!/\bwatch\b/.test(command)) return false;
74
+ if (/\b(ov|overstory)\b[^\n]*\bwatch\b/.test(command)) return true;
75
+ return false;
76
+ }