@os-eco/overstory-cli 0.9.3 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/monitor.ts +2 -1
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +85 -1
- package/src/commands/sling.ts +153 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +39 -0
- package/src/worktree/tmux.ts +23 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
package/src/utils/pid.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PID file management for daemon processes.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
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
|
+
}
|