pi-agent-replicate 0.0.0-stage → 1.4.1

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 (82) hide show
  1. package/AGENTS.md +27 -0
  2. package/CHANGELOG.md +152 -0
  3. package/CONTEXT.md +63 -0
  4. package/LICENSE +21 -0
  5. package/README.md +68 -2
  6. package/RELEASING.md +132 -0
  7. package/assets/banner.png +0 -0
  8. package/config.json.example +6 -0
  9. package/docs/README.md +23 -0
  10. package/docs/adr/0003-installable-role-packs.md +29 -0
  11. package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
  12. package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
  13. package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
  14. package/docs/configuration.md +14 -0
  15. package/docs/development.md +27 -0
  16. package/docs/public-subagent-tree-api.md +58 -0
  17. package/docs/retained-roles.md +71 -0
  18. package/docs/settled-lifecycle-contract.md +71 -0
  19. package/docs/worktree-subagents.md +58 -0
  20. package/examples/role-pack/extension.ts +18 -0
  21. package/examples/role-pack/package.json +16 -0
  22. package/examples/role-pack/roles/example-reviewer.md +12 -0
  23. package/index.d.ts +116 -0
  24. package/index.js +1064 -0
  25. package/index.ts +155 -0
  26. package/package.json +70 -4
  27. package/pi-extension/subagents/activity.js +575 -0
  28. package/pi-extension/subagents/activity.ts +980 -0
  29. package/pi-extension/subagents/completion.js +184 -0
  30. package/pi-extension/subagents/completion.ts +229 -0
  31. package/pi-extension/subagents/delivery-drain.js +15 -0
  32. package/pi-extension/subagents/delivery-drain.ts +20 -0
  33. package/pi-extension/subagents/delivery-files.js +86 -0
  34. package/pi-extension/subagents/delivery-files.ts +105 -0
  35. package/pi-extension/subagents/delivery-log.js +51 -0
  36. package/pi-extension/subagents/delivery-log.ts +51 -0
  37. package/pi-extension/subagents/delivery-payload.ts +55 -0
  38. package/pi-extension/subagents/delivery.ts +219 -0
  39. package/pi-extension/subagents/execution-owner.js +30 -0
  40. package/pi-extension/subagents/execution-owner.ts +38 -0
  41. package/pi-extension/subagents/herdr.js +645 -0
  42. package/pi-extension/subagents/herdr.ts +927 -0
  43. package/pi-extension/subagents/index.ts +3400 -0
  44. package/pi-extension/subagents/launch.js +774 -0
  45. package/pi-extension/subagents/launch.ts +1124 -0
  46. package/pi-extension/subagents/lifecycle.js +477 -0
  47. package/pi-extension/subagents/lifecycle.ts +582 -0
  48. package/pi-extension/subagents/lineage.js +806 -0
  49. package/pi-extension/subagents/lineage.ts +841 -0
  50. package/pi-extension/subagents/replica-background.js +32 -0
  51. package/pi-extension/subagents/replica-background.ts +38 -0
  52. package/pi-extension/subagents/replica-branch.ts +45 -0
  53. package/pi-extension/subagents/replica-directory-settings.ts +51 -0
  54. package/pi-extension/subagents/replica-history.js +22 -0
  55. package/pi-extension/subagents/replica-history.ts +21 -0
  56. package/pi-extension/subagents/replica-work.js +326 -0
  57. package/pi-extension/subagents/replica-work.ts +336 -0
  58. package/pi-extension/subagents/replica-worker.js +74 -0
  59. package/pi-extension/subagents/replica-worker.ts +71 -0
  60. package/pi-extension/subagents/replication.ts +99 -0
  61. package/pi-extension/subagents/result-delivery-queue.ts +33 -0
  62. package/pi-extension/subagents/role-catalog.js +123 -0
  63. package/pi-extension/subagents/role-catalog.ts +24 -0
  64. package/pi-extension/subagents/runtime-routing.js +154 -0
  65. package/pi-extension/subagents/runtime-routing.ts +239 -0
  66. package/pi-extension/subagents/session-binding.js +346 -0
  67. package/pi-extension/subagents/session-binding.ts +339 -0
  68. package/pi-extension/subagents/session-work.js +219 -0
  69. package/pi-extension/subagents/session-work.ts +167 -0
  70. package/pi-extension/subagents/session.js +318 -0
  71. package/pi-extension/subagents/session.ts +448 -0
  72. package/pi-extension/subagents/settled-contract.js +47 -0
  73. package/pi-extension/subagents/settled-contract.ts +175 -0
  74. package/pi-extension/subagents/source-state.js +180 -0
  75. package/pi-extension/subagents/source-state.ts +184 -0
  76. package/pi-extension/subagents/status.ts +493 -0
  77. package/pi-extension/subagents/subagent-done.js +805 -0
  78. package/pi-extension/subagents/subagent-done.ts +878 -0
  79. package/pi-extension/subagents/terminal.js +111 -0
  80. package/pi-extension/subagents/terminal.ts +208 -0
  81. package/pi-extension/subagents/watchdog.js +122 -0
  82. package/pi-extension/subagents/watchdog.ts +171 -0
@@ -0,0 +1,111 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { closeHerdrSurface, createHerdrSurface, createHerdrSurfaceInWorkspace, createHerdrSurfaceSplit, createHerdrWorktree, getHerdrPaneProcessInfo, getHerdrSurfaceTabId, rememberHerdrSurfaceTab, waitForHerdrPiReady, waitForHerdrShellReady, isHerdrAvailable, isProcessAlive, readHerdrScreen, readHerdrScreenAsync, inspectHerdrPane, renameHerdrTab, renameHerdrWorkspace, sendHerdrCommand, sendHerdrEscape, waitForHerdrPaneAbsence, waitForProcessesExit, } from "./herdr.js";
5
+ const SETUP_HINT = "Start pi inside herdr (`herdr`, then run `pi`).";
6
+ export function isTerminalAvailable() {
7
+ return isHerdrAvailable();
8
+ }
9
+ export function terminalSetupHint() {
10
+ return SETUP_HINT;
11
+ }
12
+ function assertTerminalAvailable() {
13
+ if (!isTerminalAvailable())
14
+ throw new Error(`herdr is not available. ${SETUP_HINT}`);
15
+ }
16
+ export function shellQuote(value) {
17
+ return "'" + value.replace(/'/g, "'\\''") + "'";
18
+ }
19
+ /** Create a new herdr tab and return its root pane ID. */
20
+ export function createSubagentPane(name) {
21
+ assertTerminalAvailable();
22
+ return createHerdrSurface(name);
23
+ }
24
+ /** Create a subagent tab in an exact existing Herdr workspace. */
25
+ export function createSubagentPaneInWorkspace(name, cwd, workspaceId) {
26
+ assertTerminalAvailable();
27
+ return createHerdrSurfaceInWorkspace(name, cwd, workspaceId);
28
+ }
29
+ /** Return the owning Herdr tab remembered for a package-created pane. */
30
+ export function getPaneTabId(paneId) {
31
+ return getHerdrSurfaceTabId(paneId);
32
+ }
33
+ /** Restore a pane-to-tab association from durable launch metadata. */
34
+ export function rememberPaneTab(paneId, tabId) {
35
+ rememberHerdrSurfaceTab(paneId, tabId);
36
+ }
37
+ /** Create a Git worktree in its own herdr workspace and return its root surface. */
38
+ export function createSubagentWorktree(name, cwd, branch, base) {
39
+ assertTerminalAvailable();
40
+ return createHerdrWorktree(name, cwd, branch, base);
41
+ }
42
+ /** Split the current herdr pane and return the child pane ID. */
43
+ export function splitCurrentPane(name, direction) {
44
+ assertTerminalAvailable();
45
+ return createHerdrSurfaceSplit(name, direction);
46
+ }
47
+ export function renameCurrentTab(title) {
48
+ assertTerminalAvailable();
49
+ renameHerdrTab(title);
50
+ }
51
+ export function renameCurrentWorkspace(title) {
52
+ assertTerminalAvailable();
53
+ renameHerdrWorkspace(title);
54
+ }
55
+ export function runInPane(paneId, command) {
56
+ assertTerminalAvailable();
57
+ sendHerdrCommand(paneId, command);
58
+ }
59
+ export function interruptPane(paneId) {
60
+ assertTerminalAvailable();
61
+ sendHerdrEscape(paneId);
62
+ }
63
+ export function runScriptInPane(paneId, command, options) {
64
+ const scriptPath = options?.scriptPath ??
65
+ join(tmpdir(), "pi-herdr-subagent-scripts", `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`);
66
+ mkdirSync(dirname(scriptPath), { recursive: true });
67
+ const scriptLines = ["#!/bin/bash"];
68
+ if (options?.scriptPreamble)
69
+ scriptLines.push(options.scriptPreamble.trimEnd());
70
+ scriptLines.push(command);
71
+ writeFileSync(scriptPath, `${scriptLines.join("\n")}\n`, { mode: 0o755 });
72
+ runInPane(paneId, `bash ${shellQuote(scriptPath)}`);
73
+ return scriptPath;
74
+ }
75
+ export function readPane(paneId, lines = 50) {
76
+ assertTerminalAvailable();
77
+ return readHerdrScreen(paneId, lines);
78
+ }
79
+ export async function readPaneAsync(paneId, lines = 50) {
80
+ assertTerminalAvailable();
81
+ return readHerdrScreenAsync(paneId, lines);
82
+ }
83
+ export async function inspectPane(paneId) {
84
+ assertTerminalAvailable();
85
+ const result = await inspectHerdrPane(paneId);
86
+ if (result.kind === "present") {
87
+ return { ...result, observedAt: Date.now() };
88
+ }
89
+ return result;
90
+ }
91
+ export function closePane(paneId) {
92
+ assertTerminalAvailable();
93
+ closeHerdrSurface(paneId);
94
+ }
95
+ export function getPaneProcessInfo(paneId) {
96
+ assertTerminalAvailable();
97
+ return getHerdrPaneProcessInfo(paneId);
98
+ }
99
+ export async function waitForShellReady(paneId, options) {
100
+ assertTerminalAvailable();
101
+ return waitForHerdrShellReady(paneId, options);
102
+ }
103
+ export async function waitForPiReady(paneId, sessionFile, cwd) {
104
+ assertTerminalAvailable();
105
+ return waitForHerdrPiReady(paneId, sessionFile, cwd);
106
+ }
107
+ export async function waitForPaneAbsence(paneId, options) {
108
+ assertTerminalAvailable();
109
+ return waitForHerdrPaneAbsence(paneId, options);
110
+ }
111
+ export { isProcessAlive, waitForProcessesExit };
@@ -0,0 +1,208 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import {
5
+ closeHerdrSurface,
6
+ createHerdrSurface,
7
+
8
+ createHerdrSurfaceInWorkspace,
9
+ createHerdrSurfaceSplit,
10
+ createHerdrWorktree,
11
+ getHerdrPaneProcessInfo,
12
+ getHerdrSurfaceTabId,
13
+ rememberHerdrSurfaceTab,
14
+ waitForHerdrPiReady,
15
+ waitForHerdrShellReady,
16
+ isHerdrAvailable,
17
+ isProcessAlive,
18
+ readHerdrScreen,
19
+ readHerdrScreenAsync,
20
+ inspectHerdrPane,
21
+ renameHerdrTab,
22
+ renameHerdrWorkspace,
23
+ sendHerdrCommand,
24
+ sendHerdrEscape,
25
+ waitForHerdrPaneAbsence,
26
+ waitForProcessesExit,
27
+ type HerdrPaneProcessInfo,
28
+ } from "./herdr.ts";
29
+
30
+ export type PaneId = string;
31
+ export type SplitDirection = "right" | "down";
32
+ export type { HerdrWorktreeSurface } from "./herdr.ts";
33
+
34
+ const SETUP_HINT = "Start pi inside herdr (`herdr`, then run `pi`).";
35
+
36
+ export function isTerminalAvailable(): boolean {
37
+ return isHerdrAvailable();
38
+ }
39
+
40
+ export function terminalSetupHint(): string {
41
+ return SETUP_HINT;
42
+ }
43
+
44
+ function assertTerminalAvailable(): void {
45
+ if (!isTerminalAvailable())
46
+ throw new Error(`herdr is not available. ${SETUP_HINT}`);
47
+ }
48
+
49
+ export function shellQuote(value: string): string {
50
+ return "'" + value.replace(/'/g, "'\\''") + "'";
51
+ }
52
+
53
+ /** Create a new herdr tab and return its root pane ID. */
54
+ export function createSubagentPane(name: string): PaneId {
55
+ assertTerminalAvailable();
56
+ return createHerdrSurface(name);
57
+ }
58
+
59
+
60
+ /** Create a subagent tab in an exact existing Herdr workspace. */
61
+ export function createSubagentPaneInWorkspace(
62
+ name: string,
63
+ cwd: string,
64
+ workspaceId: string,
65
+ ): PaneId {
66
+ assertTerminalAvailable();
67
+ return createHerdrSurfaceInWorkspace(name, cwd, workspaceId);
68
+ }
69
+
70
+
71
+ /** Return the owning Herdr tab remembered for a package-created pane. */
72
+ export function getPaneTabId(paneId: PaneId): string | undefined {
73
+ return getHerdrSurfaceTabId(paneId);
74
+ }
75
+
76
+ /** Restore a pane-to-tab association from durable launch metadata. */
77
+ export function rememberPaneTab(paneId: PaneId, tabId: string): void {
78
+ rememberHerdrSurfaceTab(paneId, tabId);
79
+ }
80
+ /** Create a Git worktree in its own herdr workspace and return its root surface. */
81
+ export function createSubagentWorktree(
82
+ name: string,
83
+ cwd: string,
84
+ branch: string,
85
+ base: string,
86
+ ): import("./herdr.ts").HerdrWorktreeSurface {
87
+ assertTerminalAvailable();
88
+ return createHerdrWorktree(name, cwd, branch, base);
89
+ }
90
+
91
+ /** Split the current herdr pane and return the child pane ID. */
92
+ export function splitCurrentPane(
93
+ name: string,
94
+ direction: SplitDirection,
95
+ ): PaneId {
96
+ assertTerminalAvailable();
97
+ return createHerdrSurfaceSplit(name, direction);
98
+ }
99
+
100
+ export function renameCurrentTab(title: string): void {
101
+ assertTerminalAvailable();
102
+ renameHerdrTab(title);
103
+ }
104
+
105
+ export function renameCurrentWorkspace(title: string): void {
106
+ assertTerminalAvailable();
107
+ renameHerdrWorkspace(title);
108
+ }
109
+
110
+
111
+ export function runInPane(paneId: PaneId, command: string): void {
112
+ assertTerminalAvailable();
113
+ sendHerdrCommand(paneId, command);
114
+ }
115
+
116
+ export function interruptPane(paneId: PaneId): void {
117
+ assertTerminalAvailable();
118
+ sendHerdrEscape(paneId);
119
+ }
120
+
121
+ export function runScriptInPane(
122
+ paneId: PaneId,
123
+ command: string,
124
+ options?: { scriptPath?: string; scriptPreamble?: string },
125
+ ): string {
126
+ const scriptPath =
127
+ options?.scriptPath ??
128
+ join(
129
+ tmpdir(),
130
+ "pi-herdr-subagent-scripts",
131
+ `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
132
+ );
133
+ mkdirSync(dirname(scriptPath), { recursive: true });
134
+
135
+ const scriptLines = ["#!/bin/bash"];
136
+ if (options?.scriptPreamble)
137
+ scriptLines.push(options.scriptPreamble.trimEnd());
138
+ scriptLines.push(command);
139
+ writeFileSync(scriptPath, `${scriptLines.join("\n")}\n`, { mode: 0o755 });
140
+
141
+ runInPane(paneId, `bash ${shellQuote(scriptPath)}`);
142
+ return scriptPath;
143
+ }
144
+
145
+ export function readPane(paneId: PaneId, lines = 50): string {
146
+ assertTerminalAvailable();
147
+ return readHerdrScreen(paneId, lines);
148
+ }
149
+
150
+ export async function readPaneAsync(
151
+ paneId: PaneId,
152
+ lines = 50,
153
+ ): Promise<string> {
154
+ assertTerminalAvailable();
155
+ return readHerdrScreenAsync(paneId, lines);
156
+ }
157
+
158
+ export type { PaneInspection, HerdrAgentStatus } from "./lifecycle.ts";
159
+
160
+ export async function inspectPane(
161
+ paneId: PaneId,
162
+ ): Promise<import("./lifecycle.ts").PaneInspection> {
163
+ assertTerminalAvailable();
164
+ const result = await inspectHerdrPane(paneId);
165
+ if (result.kind === "present") {
166
+ return { ...result, observedAt: Date.now() };
167
+ }
168
+ return result;
169
+ }
170
+
171
+ export function closePane(paneId: PaneId): void {
172
+ assertTerminalAvailable();
173
+ closeHerdrSurface(paneId);
174
+ }
175
+
176
+ export type { HerdrPaneProcessInfo };
177
+
178
+ export function getPaneProcessInfo(paneId: PaneId): HerdrPaneProcessInfo {
179
+ assertTerminalAvailable();
180
+ return getHerdrPaneProcessInfo(paneId);
181
+ }
182
+
183
+ export async function waitForShellReady(
184
+ paneId: PaneId,
185
+ options?: { timeoutMs?: number; intervalMs?: number; signal?: AbortSignal },
186
+ ): Promise<void> {
187
+ assertTerminalAvailable();
188
+ return waitForHerdrShellReady(paneId, options);
189
+ }
190
+
191
+ export async function waitForPiReady(
192
+ paneId: PaneId,
193
+ sessionFile: string,
194
+ cwd: string,
195
+ ): Promise<void> {
196
+ assertTerminalAvailable();
197
+ return waitForHerdrPiReady(paneId, sessionFile, cwd);
198
+ }
199
+
200
+ export async function waitForPaneAbsence(
201
+ paneId: PaneId,
202
+ options?: { timeoutMs?: number; intervalMs?: number },
203
+ ): Promise<boolean> {
204
+ assertTerminalAvailable();
205
+ return waitForHerdrPaneAbsence(paneId, options);
206
+ }
207
+
208
+ export { isProcessAlive, waitForProcessesExit };
@@ -0,0 +1,122 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync, } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ export const DEFAULT_QUIET_THRESHOLD_MS = 120_000;
4
+ export const MAX_RECOVERY_ATTEMPTS = 3;
5
+ export const REPLICA_STOP_GRACE_MS = 10_000;
6
+ /** A stopped replica gets a safety net, never an inactivity-based relaunch. */
7
+ export function replicaStopDue(input) {
8
+ return input.idleSince !== undefined && input.idle && !input.pendingMessages &&
9
+ !input.moving && !input.descendants && input.now - input.idleSince >= REPLICA_STOP_GRACE_MS;
10
+ }
11
+ /** Resolve the watchdog threshold with environment precedence and strict validation. */
12
+ export function resolveQuietThreshold(envValue, configuredValue) {
13
+ if (envValue !== undefined) {
14
+ const parsed = Number(envValue);
15
+ if (!Number.isInteger(parsed) || parsed <= 0) {
16
+ throw new Error("Invalid PI_SUBAGENT_QUIET_THRESHOLD_MS: expected a positive integer");
17
+ }
18
+ return parsed;
19
+ }
20
+ if (configuredValue !== undefined) {
21
+ if (!Number.isInteger(configuredValue) || configuredValue <= 0) {
22
+ throw new Error("Invalid status.quietThresholdMs: expected a positive integer");
23
+ }
24
+ return configuredValue;
25
+ }
26
+ return DEFAULT_QUIET_THRESHOLD_MS;
27
+ }
28
+ /** Classify observable inactivity without bounding tools, streams, or descendant drain. */
29
+ export function classifyStaleness(input) {
30
+ if (!input.autonomous)
31
+ return "active";
32
+ if (input.waitingForDescendants)
33
+ return "waiting";
34
+ if (input.activity?.compactionActive)
35
+ return "active";
36
+ if (input.activity?.toolActive)
37
+ return "active";
38
+ if (input.descendantActivityAt !== undefined && input.activity?.providerActive)
39
+ return "active";
40
+ const lastEventAt = Math.max(input.activity?.updatedAt ?? input.startedAt, input.descendantActivityAt ?? 0);
41
+ return input.now - lastEventAt > input.quietThresholdMs ? "stale" : "active";
42
+ }
43
+ function records(logPath, childId) {
44
+ if (!existsSync(logPath))
45
+ return [];
46
+ return readFileSync(logPath, "utf8")
47
+ .split("\n")
48
+ .filter(Boolean)
49
+ .map((line, index) => {
50
+ try {
51
+ return JSON.parse(line);
52
+ }
53
+ catch (error) {
54
+ throw new Error(`Invalid recovery ledger record ${index + 1} in ${logPath}`, { cause: error });
55
+ }
56
+ })
57
+ .filter((record) => record.event === "recovery-attempt" && record.childId === childId);
58
+ }
59
+ function appendRecoveryRecord(logPath, record) {
60
+ mkdirSync(dirname(logPath), { recursive: true });
61
+ const descriptor = openSync(logPath, "a");
62
+ try {
63
+ writeSync(descriptor, `${JSON.stringify(record)}\n`, undefined, "utf8");
64
+ fsyncSync(descriptor);
65
+ }
66
+ finally {
67
+ closeSync(descriptor);
68
+ }
69
+ }
70
+ /**
71
+ * Decide whether the parent watchdog may supervise one child as stale.
72
+ *
73
+ * An exhausted recovery budget is final: the child cannot be resumed again, so
74
+ * another stale pass would only repeat the same kill attempt forever.
75
+ */
76
+ export function shouldStartStaleRecovery(input) {
77
+ if (input.interactive)
78
+ return false;
79
+ if (input.pendingTerminalDelivery)
80
+ return false;
81
+ if (input.recoveryAttemptActive)
82
+ return false;
83
+ if (input.recoveryExhausted)
84
+ return false;
85
+ return true;
86
+ }
87
+ /** Consume one durable attempt from the shared crash-and-stale recovery budget. */
88
+ export function consumeRecoveryAttempt(input) {
89
+ const previous = records(input.logPath, input.childId);
90
+ const attemptedCauses = previous
91
+ .map((record) => record.cause)
92
+ .filter((cause) => cause === "crash" || cause === "stale");
93
+ const reasonFor = (causes) => {
94
+ if (new Set(causes).size > 1)
95
+ return "Recovery failed after mixed crash and stale attempts.";
96
+ return causes[0] === "crash"
97
+ ? "Recovery failed after repeated child process crashes."
98
+ : "Recovery failed after repeated stale watchdog kills.";
99
+ };
100
+ if (previous.length >= MAX_RECOVERY_ATTEMPTS) {
101
+ return {
102
+ attempt: MAX_RECOVERY_ATTEMPTS,
103
+ exhausted: true,
104
+ reason: reasonFor(attemptedCauses),
105
+ };
106
+ }
107
+ const attempt = previous.length + 1;
108
+ const now = input.now ?? Date.now;
109
+ appendRecoveryRecord(input.logPath, {
110
+ version: 1,
111
+ time: new Date(now()).toISOString(),
112
+ event: "recovery-attempt",
113
+ childId: input.childId,
114
+ attempt,
115
+ cause: input.cause,
116
+ });
117
+ return {
118
+ attempt,
119
+ exhausted: false,
120
+ reason: reasonFor([...attemptedCauses, input.cause]),
121
+ };
122
+ }
@@ -0,0 +1,171 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fsyncSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ writeSync,
9
+ } from "node:fs";
10
+ import { dirname } from "node:path";
11
+ import type { SubagentActivityState } from "./activity.ts";
12
+
13
+ export const DEFAULT_QUIET_THRESHOLD_MS = 120_000;
14
+ export const MAX_RECOVERY_ATTEMPTS = 3;
15
+
16
+ export const REPLICA_STOP_GRACE_MS = 10_000;
17
+
18
+ /** A stopped replica gets a safety net, never an inactivity-based relaunch. */
19
+ export function replicaStopDue(input: {
20
+ now: number;
21
+ idleSince: number | undefined;
22
+ idle: boolean;
23
+ pendingMessages: boolean;
24
+ moving: boolean;
25
+ descendants: boolean;
26
+ }): boolean {
27
+ return input.idleSince !== undefined && input.idle && !input.pendingMessages &&
28
+ !input.moving && !input.descendants && input.now - input.idleSince >= REPLICA_STOP_GRACE_MS;
29
+ }
30
+
31
+ export type RecoveryCause = "crash" | "stale";
32
+ export type StaleVerdict = "active" | "waiting" | "stale";
33
+
34
+ /** Resolve the watchdog threshold with environment precedence and strict validation. */
35
+ export function resolveQuietThreshold(
36
+ envValue: string | undefined,
37
+ configuredValue: number | undefined,
38
+ ): number {
39
+ if (envValue !== undefined) {
40
+ const parsed = Number(envValue);
41
+ if (!Number.isInteger(parsed) || parsed <= 0) {
42
+ throw new Error("Invalid PI_SUBAGENT_QUIET_THRESHOLD_MS: expected a positive integer");
43
+ }
44
+ return parsed;
45
+ }
46
+ if (configuredValue !== undefined) {
47
+ if (!Number.isInteger(configuredValue) || configuredValue <= 0) {
48
+ throw new Error("Invalid status.quietThresholdMs: expected a positive integer");
49
+ }
50
+ return configuredValue;
51
+ }
52
+ return DEFAULT_QUIET_THRESHOLD_MS;
53
+ }
54
+
55
+ /** Classify observable inactivity without bounding tools, streams, or descendant drain. */
56
+ export function classifyStaleness(input: {
57
+ autonomous: boolean;
58
+ activity?: SubagentActivityState;
59
+ startedAt: number;
60
+ now: number;
61
+ quietThresholdMs: number;
62
+ waitingForDescendants: boolean;
63
+ /** Latest result delivered by an owned descendant, which wakes the owner. */
64
+ descendantActivityAt?: number;
65
+ }): StaleVerdict {
66
+ if (!input.autonomous) return "active";
67
+ if (input.waitingForDescendants) return "waiting";
68
+ if (input.activity?.compactionActive) return "active";
69
+ if (input.activity?.toolActive) return "active";
70
+
71
+ if (input.descendantActivityAt !== undefined && input.activity?.providerActive) return "active";
72
+ const lastEventAt = Math.max(
73
+ input.activity?.updatedAt ?? input.startedAt,
74
+ input.descendantActivityAt ?? 0,
75
+ );
76
+ return input.now - lastEventAt > input.quietThresholdMs ? "stale" : "active";
77
+ }
78
+
79
+ interface RecoveryRecord {
80
+ event?: unknown;
81
+ childId?: unknown;
82
+ attempt?: unknown;
83
+ cause?: unknown;
84
+ }
85
+
86
+ function records(logPath: string, childId: string): RecoveryRecord[] {
87
+ if (!existsSync(logPath)) return [];
88
+ return readFileSync(logPath, "utf8")
89
+ .split("\n")
90
+ .filter(Boolean)
91
+ .map((line, index) => {
92
+ try {
93
+ return JSON.parse(line) as RecoveryRecord;
94
+ } catch (error) {
95
+ throw new Error(`Invalid recovery ledger record ${index + 1} in ${logPath}`, { cause: error });
96
+ }
97
+ })
98
+ .filter((record) => record.event === "recovery-attempt" && record.childId === childId);
99
+ }
100
+
101
+ function appendRecoveryRecord(logPath: string, record: Record<string, unknown>): void {
102
+ mkdirSync(dirname(logPath), { recursive: true });
103
+ const descriptor = openSync(logPath, "a");
104
+ try {
105
+ writeSync(descriptor, `${JSON.stringify(record)}\n`, undefined, "utf8");
106
+ fsyncSync(descriptor);
107
+ } finally {
108
+ closeSync(descriptor);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Decide whether the parent watchdog may supervise one child as stale.
114
+ *
115
+ * An exhausted recovery budget is final: the child cannot be resumed again, so
116
+ * another stale pass would only repeat the same kill attempt forever.
117
+ */
118
+ export function shouldStartStaleRecovery(input: {
119
+ interactive: boolean;
120
+ pendingTerminalDelivery: boolean;
121
+ recoveryAttemptActive: boolean;
122
+ recoveryExhausted: boolean;
123
+ }): boolean {
124
+ if (input.interactive) return false;
125
+ if (input.pendingTerminalDelivery) return false;
126
+ if (input.recoveryAttemptActive) return false;
127
+ if (input.recoveryExhausted) return false;
128
+ return true;
129
+ }
130
+
131
+ /** Consume one durable attempt from the shared crash-and-stale recovery budget. */
132
+ export function consumeRecoveryAttempt(input: {
133
+ logPath: string;
134
+ childId: string;
135
+ cause: RecoveryCause;
136
+ now?: () => number;
137
+ }): { attempt: number; exhausted: boolean; reason: string } {
138
+ const previous = records(input.logPath, input.childId);
139
+ const attemptedCauses = previous
140
+ .map((record) => record.cause)
141
+ .filter((cause): cause is RecoveryCause => cause === "crash" || cause === "stale");
142
+ const reasonFor = (causes: RecoveryCause[]): string => {
143
+ if (new Set(causes).size > 1) return "Recovery failed after mixed crash and stale attempts.";
144
+ return causes[0] === "crash"
145
+ ? "Recovery failed after repeated child process crashes."
146
+ : "Recovery failed after repeated stale watchdog kills.";
147
+ };
148
+ if (previous.length >= MAX_RECOVERY_ATTEMPTS) {
149
+ return {
150
+ attempt: MAX_RECOVERY_ATTEMPTS,
151
+ exhausted: true,
152
+ reason: reasonFor(attemptedCauses),
153
+ };
154
+ }
155
+
156
+ const attempt = previous.length + 1;
157
+ const now = input.now ?? Date.now;
158
+ appendRecoveryRecord(input.logPath, {
159
+ version: 1,
160
+ time: new Date(now()).toISOString(),
161
+ event: "recovery-attempt",
162
+ childId: input.childId,
163
+ attempt,
164
+ cause: input.cause,
165
+ });
166
+ return {
167
+ attempt,
168
+ exhausted: false,
169
+ reason: reasonFor([...attemptedCauses, input.cause]),
170
+ };
171
+ }