gentle-pi 2.4.0 → 2.6.0

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 (227) hide show
  1. package/README.md +292 -25
  2. package/assets/agents/gentle-ai-worker.md +13 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +39 -11
  25. package/assets/orchestrator.md +5 -5
  26. package/assets/sdd-orchestrator-workflow.md +54 -21
  27. package/assets/support/sdd-status-contract.md +34 -90
  28. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  29. package/docs/delegated-verification.md +25 -0
  30. package/docs/telemetry.md +94 -0
  31. package/docs/windows-startup-console-visibility.md +18 -0
  32. package/extensions/ask-user-choice.ts +159 -25
  33. package/extensions/codegraph-tools.ts +95 -5
  34. package/extensions/gentle-agents.ts +1337 -0
  35. package/extensions/gentle-ai.ts +2916 -386
  36. package/extensions/gentle-shell.ts +650 -0
  37. package/extensions/gentle-todo.ts +234 -0
  38. package/extensions/quiet-tools.ts +2 -1
  39. package/extensions/runtime-metrics.ts +130 -0
  40. package/extensions/sdd-init.ts +2 -2
  41. package/extensions/startup-banner.ts +52 -75
  42. package/lib/agent-profiles.ts +550 -0
  43. package/lib/agents-completion-delivery.ts +72 -0
  44. package/lib/agents-config.ts +315 -0
  45. package/lib/agents-history.ts +88 -0
  46. package/lib/agents-messaging.ts +187 -0
  47. package/lib/agents-protocol.ts +501 -0
  48. package/lib/agents-runner.ts +1012 -0
  49. package/lib/agents-thread-view.ts +57 -0
  50. package/lib/agents-transcript.ts +87 -0
  51. package/lib/agents-view-layout.ts +40 -0
  52. package/lib/agents-view.ts +914 -0
  53. package/lib/agents-widget.ts +241 -0
  54. package/lib/gentle-ai-binary.ts +3 -1
  55. package/lib/gentle-ai-renderer.ts +143 -25
  56. package/lib/native-choice-list.ts +194 -0
  57. package/lib/native-fullscreen-interaction.ts +47 -0
  58. package/lib/native-pointer-region.ts +164 -0
  59. package/lib/native-review-cli.ts +371 -13
  60. package/lib/orchestrator-presence.ts +337 -0
  61. package/lib/profiles-orchestrator.ts +203 -0
  62. package/lib/review-candidate-view-owner.ts +427 -0
  63. package/lib/review-candidate-view.ts +150 -48
  64. package/lib/review-consent-component.ts +247 -0
  65. package/lib/review-consent-ui.ts +110 -0
  66. package/lib/review-host-relay.ts +28 -0
  67. package/lib/review-integration-v2.ts +243 -11
  68. package/lib/review-last-event-controller.ts +8 -4
  69. package/lib/review-relay-contract.ts +11 -0
  70. package/lib/review-reminder-receipt.ts +74 -0
  71. package/lib/review-repository.ts +2 -2
  72. package/lib/review-risk-assessment.ts +339 -0
  73. package/lib/review-session-standing-permission-ipc.ts +309 -0
  74. package/lib/review-session-standing-permission.ts +240 -0
  75. package/lib/runtime-metrics-children.ts +199 -0
  76. package/lib/runtime-metrics-delivery.ts +68 -0
  77. package/lib/runtime-metrics-native.ts +166 -0
  78. package/lib/runtime-metrics-pi-identity.ts +113 -0
  79. package/lib/runtime-metrics-policy.ts +51 -0
  80. package/lib/runtime-metrics.ts +255 -0
  81. package/lib/sdd-preflight.ts +362 -81
  82. package/lib/sdd-research-capabilities.ts +228 -0
  83. package/lib/sdd-status.ts +29 -7
  84. package/lib/session-worktree-registry.ts +118 -0
  85. package/lib/shell-bar.ts +184 -0
  86. package/lib/shell-card.ts +133 -0
  87. package/lib/shell-changes-view.ts +530 -0
  88. package/lib/shell-changes.ts +290 -0
  89. package/lib/shell-gauge.ts +40 -0
  90. package/lib/shell-prompt.ts +115 -0
  91. package/lib/shell-sidebar-banner.ts +11 -0
  92. package/lib/shell-sidebar-layout.ts +213 -0
  93. package/lib/shell-sidebar.ts +41 -0
  94. package/lib/shell-todo.ts +297 -0
  95. package/lib/shell-usage-view.ts +76 -0
  96. package/lib/shell-usage.ts +246 -0
  97. package/lib/telemetry-trigger.ts +153 -0
  98. package/package.json +8 -5
  99. package/runtime/gentle-ai-binary.mjs +3 -1
  100. package/runtime/native-review-cli.mjs +370 -12
  101. package/runtime/review-integration-v2.mjs +243 -11
  102. package/runtime/review-relay-contract.mjs +11 -0
  103. package/runtime/review-risk-assessment.mjs +340 -0
  104. package/runtime/telemetry-trigger.mjs +154 -0
  105. package/scripts/build-runtime-modules.mjs +11 -1
  106. package/scripts/check-types.mjs +125 -0
  107. package/scripts/gentle-ai-installer.mjs +10 -10
  108. package/scripts/install-gentle-ai.mjs +12 -0
  109. package/scripts/install-tui-mode-setting.mjs +114 -0
  110. package/scripts/test-packed-runner.mjs +38 -2
  111. package/scripts/types-baseline.json +99 -0
  112. package/scripts/verify-package-files.mjs +8 -2
  113. package/skills/_shared/review-ledger-contract.md +20 -2
  114. package/skills/issue-creation/SKILL.md +3 -3
  115. package/skills/judgment-day/SKILL.md +17 -3
  116. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  117. package/tests/agent-profiles.test.ts +722 -0
  118. package/tests/agents-completion-delivery.test.ts +94 -0
  119. package/tests/agents-config.test.ts +205 -0
  120. package/tests/agents-fake-child.ts +66 -0
  121. package/tests/agents-grouping.test.ts +179 -0
  122. package/tests/agents-history.test.ts +54 -0
  123. package/tests/agents-integration.test.ts +100 -0
  124. package/tests/agents-messaging.test.ts +94 -0
  125. package/tests/agents-protocol.test.ts +198 -0
  126. package/tests/agents-queries.test.ts +190 -0
  127. package/tests/agents-responsive.test.ts +43 -0
  128. package/tests/agents-runner-process.test.ts +111 -0
  129. package/tests/agents-runner.test.ts +959 -0
  130. package/tests/agents-thread-view.test.ts +45 -0
  131. package/tests/agents-transcript.test.ts +30 -0
  132. package/tests/agents-view.test.ts +685 -0
  133. package/tests/agents-widget.test.ts +141 -0
  134. package/tests/artifact-language.test.ts +25 -2
  135. package/tests/ask-user-choice.test.ts +325 -5
  136. package/tests/asset-installation-runtime.test.ts +108 -0
  137. package/tests/autonomous-guard.test.ts +116 -1
  138. package/tests/codegraph-tools.test.ts +112 -2
  139. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  140. package/tests/devbinary/native-review-parity.devtest.ts +110 -0
  141. package/tests/feature-request-form.test.ts +67 -0
  142. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  143. package/tests/fixtures/agents-process-child.mjs +23 -0
  144. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  145. package/tests/gentle-agents.test.ts +2168 -0
  146. package/tests/gentle-ai-binary.test.ts +7 -2
  147. package/tests/gentle-ai-installer.test.ts +47 -47
  148. package/tests/gentle-ai-renderer.test.ts +103 -0
  149. package/tests/gentle-ai.test.ts +971 -15
  150. package/tests/gentle-card-text.ts +35 -0
  151. package/tests/gentle-shell.test.ts +818 -0
  152. package/tests/gentle-todo.test.ts +226 -0
  153. package/tests/install-tui-mode-setting.test.ts +324 -0
  154. package/tests/issue-creation-skill.test.ts +22 -0
  155. package/tests/model-routing-authority.test.ts +12 -0
  156. package/tests/native-choice-list.test.ts +202 -0
  157. package/tests/native-fullscreen-interaction.test.ts +125 -0
  158. package/tests/native-pointer-region.test.ts +245 -0
  159. package/tests/native-review-capability-contract.test.ts +27 -1
  160. package/tests/native-review-cli.test.ts +317 -3
  161. package/tests/native-review-consent.test.ts +91 -0
  162. package/tests/native-review-parity-runtime.test.ts +8 -2
  163. package/tests/native-review-parity.test.ts +43 -29
  164. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  165. package/tests/orchestrator-budget.test.ts +69 -0
  166. package/tests/orchestrator-presence.test.ts +389 -0
  167. package/tests/orchestrator-rdd-ownership.test.ts +9 -0
  168. package/tests/package-manifest.test.ts +243 -7
  169. package/tests/profiles-orchestrator.test.ts +208 -0
  170. package/tests/quiet-tool-rendering.test.ts +97 -37
  171. package/tests/rdd-aware-verification-contract.test.ts +226 -0
  172. package/tests/rdd-status-line.test.ts +286 -0
  173. package/tests/review-agent-end-preflight.test.ts +332 -24
  174. package/tests/review-candidate-view.test.ts +751 -7
  175. package/tests/review-consent-ui.test.ts +352 -0
  176. package/tests/review-contract-prompt.test.ts +17 -0
  177. package/tests/review-controller-native-recovery.test.ts +29 -4
  178. package/tests/review-controller-native-routing.test.ts +884 -7
  179. package/tests/review-controller-workspace-root.test.ts +45 -2
  180. package/tests/review-controller.test.ts +26 -1
  181. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  182. package/tests/review-host-relay-routing.test.ts +384 -8
  183. package/tests/review-host-relay.test.ts +29 -0
  184. package/tests/review-integration-v2-forward.test.ts +44 -0
  185. package/tests/review-integration-v2.test.ts +276 -0
  186. package/tests/review-last-event-closure.test.ts +112 -3
  187. package/tests/review-ledger-contract.test.ts +61 -6
  188. package/tests/review-relay-contract.test.ts +26 -0
  189. package/tests/review-reminder-receipt.test.ts +62 -0
  190. package/tests/review-repository.test.ts +28 -1
  191. package/tests/review-risk-assessment.test.ts +626 -0
  192. package/tests/review-session-standing-permission-controller.test.ts +656 -0
  193. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  194. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  195. package/tests/review-session-standing-permission.test.ts +156 -0
  196. package/tests/runtime-harness.mjs +447 -39
  197. package/tests/runtime-metrics-children.test.ts +206 -0
  198. package/tests/runtime-metrics-delivery.test.ts +85 -0
  199. package/tests/runtime-metrics-extension.test.ts +187 -0
  200. package/tests/runtime-metrics-native.test.ts +209 -0
  201. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  202. package/tests/runtime-metrics-policy.test.ts +62 -0
  203. package/tests/runtime-metrics.test.ts +184 -0
  204. package/tests/sdd-agent-tools.test.ts +10 -1
  205. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  206. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  207. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  208. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  209. package/tests/sdd-preflight.test.ts +252 -8
  210. package/tests/sdd-research-capabilities.test.ts +256 -0
  211. package/tests/sdd-research-live.test.ts +241 -0
  212. package/tests/sdd-selection-transport.test.ts +504 -0
  213. package/tests/sdd-status.test.ts +51 -0
  214. package/tests/session-worktree-registry.test.ts +135 -0
  215. package/tests/shell-bar.test.ts +176 -0
  216. package/tests/shell-card.test.ts +139 -0
  217. package/tests/shell-changes-view.test.ts +609 -0
  218. package/tests/shell-changes.test.ts +350 -0
  219. package/tests/shell-prompt.test.ts +140 -0
  220. package/tests/shell-sidebar-banner.test.ts +23 -0
  221. package/tests/shell-sidebar-layout.test.ts +387 -0
  222. package/tests/shell-sidebar.test.ts +50 -0
  223. package/tests/shell-todo.test.ts +259 -0
  224. package/tests/shell-usage-view.test.ts +62 -0
  225. package/tests/shell-usage.test.ts +197 -0
  226. package/tests/startup-banner.test.ts +126 -0
  227. package/tests/telemetry-trigger.test.ts +351 -0
@@ -0,0 +1,1012 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Duplex, Readable, Writable } from "node:stream";
3
+ import { RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, type ResearchArtifactIntent } from "./sdd-research-capabilities.ts";
4
+ import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
5
+ import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
6
+ import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
7
+ import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type RemediationTaskState, type TaskStore } from "./agents-protocol.ts";
8
+
9
+ // Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
10
+ // the host never runs subagent work on the TUI thread. It writes JSON
11
+ // commands, reads JSON lines, applies deltas to the store, answers dialogs,
12
+ // and enforces an inactivity watchdog per task.
13
+
14
+ export interface ChildLike {
15
+ pid: number | undefined;
16
+ stdin: Writable;
17
+ stdout: Readable;
18
+ stderr: Readable | null | undefined;
19
+ stdio?: Array<Duplex | null | undefined>;
20
+ kill(signal?: NodeJS.Signals): boolean;
21
+ send?(message: Record<string, unknown>, callback?: (error: Error | null) => void): boolean;
22
+ disconnect?(): void;
23
+ channel?: { unref?(): void };
24
+ on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
25
+ on(event: "error", listener: (error: Error) => void): unknown;
26
+ on(event: "spawn" | "message" | "disconnect", listener: (...args: unknown[]) => void): unknown;
27
+ }
28
+
29
+ export interface SpawnOptions {
30
+ cwd: string;
31
+ env: NodeJS.ProcessEnv;
32
+ detached?: boolean;
33
+ stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc">;
34
+ }
35
+
36
+ export type Spawn = (command: string, args: string[], options: SpawnOptions) => ChildLike;
37
+
38
+ export interface ProcessControl {
39
+ platform: NodeJS.Platform;
40
+ kill(pid: number, signal: NodeJS.Signals | 0): void;
41
+ }
42
+
43
+ export interface PiCommand {
44
+ command: string;
45
+ args: string[];
46
+ }
47
+
48
+ export interface RunnerDeps {
49
+ spawn: Spawn;
50
+ now(): number;
51
+ schedule(fn: () => void, ms: number): () => void;
52
+ pi: PiCommand;
53
+ process?: ProcessControl;
54
+ }
55
+
56
+ export interface RunnerLimits {
57
+ maxConcurrency: number;
58
+ stallTimeoutMs: number;
59
+ }
60
+
61
+ export interface AskAnswer {
62
+ value?: string;
63
+ confirmed?: boolean;
64
+ cancelled?: boolean;
65
+ }
66
+
67
+ export interface TaskQuery {
68
+ taskId: string;
69
+ requestId: string;
70
+ }
71
+
72
+ export const MAX_CHILD_RESPONSE_OBSERVATIONS = 128;
73
+
74
+ /** Local producer snapshot only; never native workflow success or export authority.
75
+ * Scope excludes tools, compaction, hidden provider retries and history replay.
76
+ * Each message_end is retained separately; RPC supplies no stable dedupe key.
77
+ * Unsettled shutdown may lose in-flight responses even when droppedResponses is 0.
78
+ */
79
+ export interface ChildObservationSnapshot {
80
+ readonly coverage: "final_assistant_messages_only";
81
+ readonly agentSettled: boolean;
82
+ readonly responses: readonly ChildResponseObservation[];
83
+ readonly droppedResponses: number;
84
+ }
85
+ interface ChildObservationBuffer {
86
+ agentSettled: boolean;
87
+ responses: ChildResponseObservation[];
88
+ droppedResponses: number;
89
+ }
90
+
91
+ export interface RunnerHooks {
92
+ askUser(taskId: string, request: AskRequest, raw: Record<string, unknown>): Promise<AskAnswer>;
93
+ /** Optional immutable snapshot, delivered once at existing finalization.
94
+ * Undefined when collection was disabled or no child handle was created.
95
+ * Parent must check task.status AND agentSettled; observations are not success.
96
+ */
97
+ onFinish?(task: TaskRecord, observations?: ChildObservationSnapshot): void;
98
+ // Accepts a child notification only while the originating parent session is active.
99
+ onNotification?(task: TaskRecord, message: string): boolean | void;
100
+ onQuery?(task: TaskRecord, requestId: string, message: string): boolean | void;
101
+ // Parent-only observation of a paired successful filesystem tool, not prose.
102
+ onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string }): void | Promise<void>;
103
+ }
104
+
105
+ export interface RemediationHarnessPlan { command?: string; naReason?: string }
106
+ export interface RemediationRollbackPlan { boundary: string; command: string }
107
+ export interface RemediationScope { cwd: string; editPaths: string[]; commands: string[]; allowedEditRoots: string[] }
108
+ export interface RemediationPlan {
109
+ editPaths?: string[];
110
+ cwd: string;
111
+ commands: string[];
112
+ runtimeHarness: RemediationHarnessPlan;
113
+ rollback: RemediationRollbackPlan;
114
+ }
115
+ export interface RemediationObservation {
116
+ slot: number;
117
+ toolCallId: string;
118
+ command: string;
119
+ cwd: string;
120
+ exitCode: number | null;
121
+ result: string;
122
+ }
123
+ export interface RemediationObservations {
124
+ failedEvidenceRevision: string;
125
+ plan: RemediationPlan;
126
+ observations: RemediationObservation[];
127
+ pending: Record<string, number>;
128
+ invalid: boolean;
129
+ }
130
+ const concrete = (value: unknown): value is string => typeof value === "string" && value.trim() === value && value.length > 3 && value.length <= 4096 && !/[\0\r\n]/.test(value);
131
+ export function parseRemediationPlan(value: unknown, cwd: string): RemediationPlan {
132
+ const plan = value as RemediationPlan;
133
+ if (!plan || plan.cwd !== cwd || !Array.isArray(plan.commands) || plan.commands.length < 1 || plan.commands.length > 16 || !plan.commands.every(concrete) ||
134
+ !concrete(plan.rollback?.boundary) || !concrete(plan.rollback?.command) || !plan.runtimeHarness ||
135
+ !(concrete(plan.runtimeHarness.command) && plan.runtimeHarness.naReason === undefined || plan.runtimeHarness.command === undefined && concrete(plan.runtimeHarness.naReason) && plan.runtimeHarness.naReason.length >= 20 && /because/i.test(plan.runtimeHarness.naReason))) throw new TypeError("Invalid remediation evidence plan");
136
+ return structuredClone(plan);
137
+ }
138
+ export const plannedCommands = (plan: RemediationPlan) => [...plan.commands, ...(plan.runtimeHarness.command ? [plan.runtimeHarness.command] : []), plan.rollback.command];
139
+ const evidenceDigest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
140
+
141
+ // Only paired stock-shell observations may fill this remediation-only plan.
142
+ export function observeRemediationTool(state: RemediationObservations, raw: Record<string, unknown>): void {
143
+ if (raw.toolName !== "bash" || typeof raw.toolCallId !== "string") return;
144
+ const id = raw.toolCallId;
145
+ if (raw.type === "tool_execution_start") {
146
+ const command = (raw.args as { command?: unknown } | undefined)?.command;
147
+ if (typeof command !== "string" || !plannedCommands(state.plan).includes(command)) return;
148
+ if (Object.hasOwn(state.pending, id) || state.observations.some(item => item.toolCallId === id) || Object.keys(state.pending).length >= 32) { state.invalid = true; return; }
149
+ const slot = plannedCommands(state.plan).findIndex((item, index) => item === command && !state.observations.some(observation => observation.slot === index) && !Object.values(state.pending).includes(index));
150
+ if (slot < 0) { state.invalid = true; return; }
151
+ state.pending[id] = slot;
152
+ }
153
+ if (raw.type !== "tool_execution_end" || !Object.hasOwn(state.pending, id)) return;
154
+ const slot = state.pending[id];
155
+ const command = plannedCommands(state.plan)[slot];
156
+ delete state.pending[id];
157
+ const result = raw.result as { content?: unknown; details?: { truncation?: unknown; fullOutputPath?: unknown; remediationCommand?: RemediationObservation & { truncated?: boolean } } } | undefined;
158
+ const observed = result?.details?.remediationCommand;
159
+ const output = JSON.stringify(result?.content ?? null);
160
+ if (!observed || observed.toolCallId !== id || observed.command !== command || observed.cwd !== state.plan.cwd ||
161
+ !(observed.exitCode === null || Number.isInteger(observed.exitCode)) || state.observations.length >= 32) { state.invalid = true; return; }
162
+ const contentValid = Array.isArray(result?.content) && result.content.length > 0 && result.content.every(part =>
163
+ part && typeof part === "object" && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string");
164
+ if (raw.isError !== false || observed.exitCode !== 0 || observed.truncated || result?.details?.truncation || result?.details?.fullOutputPath || output.length > 16_000 || !contentValid) state.invalid = true;
165
+ state.observations.push({ slot, toolCallId: id, command, cwd: observed.cwd, exitCode: observed.exitCode, result: `Observed command output ${evidenceDigest(output)}: ${output.slice(0, 400)}` });
166
+ }
167
+ export function remediationEvidence(state: RemediationObservations) {
168
+ const find = (slot: number) => state.observations.find(item => item.slot === slot && item.command === plannedCommands(state.plan)[slot] && item.cwd === state.plan.cwd && item.exitCode === 0);
169
+ if (state.invalid || Object.keys(state.pending).length || !/^sha256:[0-9a-f]{64}$/.test(state.failedEvidenceRevision) || !plannedCommands(state.plan).every((_, slot) => find(slot))) return undefined;
170
+ const result = (slot: number) => `cwd ${state.plan.cwd}; retained command observation ${evidenceDigest(JSON.stringify(find(slot)))}`;
171
+ return {
172
+ schema: "gentle-ai.remediation-evidence/v1",
173
+ failed_evidence_revision: state.failedEvidenceRevision,
174
+ commands: state.plan.commands.map((command, slot) => ({ command, exit_code: 0, result: result(slot) })),
175
+ runtime_harness: state.plan.runtimeHarness.command ? { status: "passed", command: state.plan.runtimeHarness.command, result: result(state.plan.commands.length) } : { status: "not_applicable", na_reason: state.plan.runtimeHarness.naReason },
176
+ rollback: { boundary: state.plan.rollback.boundary, evidence: result(plannedCommands(state.plan).length - 1) },
177
+ };
178
+ }
179
+
180
+ export interface SddChangeSelection {
181
+ changeName: string;
182
+ workspaceRoot: string;
183
+ phase: "apply" | "verify" | "sync" | "archive" | "remediate";
184
+ failedEvidenceRevision?: string;
185
+ }
186
+
187
+ export const SDD_CHANGE_FLAG = "--gentle-sdd-change";
188
+
189
+ export interface RemediationTerminalFacts { spawned: boolean; exited: boolean; cleanupConfirmed: boolean }
190
+
191
+ export const REMEDIATION_PLAN_ENV = "GENTLE_PI_SDD_REMEDIATION_PLAN";
192
+
193
+ export interface TaskRequest {
194
+ remediationIntent?: unknown;
195
+ sddRemediation?: RemediationTaskState;
196
+ sddPreflightContext?: string;
197
+ finalizeRemediation?: (task: TaskRecord, facts: RemediationTerminalFacts) => Promise<void>;
198
+ agent: AgentDefinition;
199
+ prompt: string;
200
+ label: string | undefined;
201
+ context: string | undefined;
202
+ mode: AgentMode;
203
+ cwd: string;
204
+ parentSessionId: string;
205
+ model: ModelRef | undefined;
206
+ thinking: string | undefined;
207
+ sessionDir: string;
208
+ resumeSessionPath: string | undefined;
209
+ env: NodeJS.ProcessEnv;
210
+ // A launch-local SDD identity. It is never prompt text or shared state.
211
+ sddChange?: SddChangeSelection;
212
+ // Untrusted narrowing intent; paths come only from matching host provenance.
213
+ researchSelection?: unknown;
214
+ researchArtifact?: ResearchArtifactIntent;
215
+ extensionPaths?: string[];
216
+ // Captures the originating session; invoked only after successful OS spawn.
217
+ onLaunch?: () => void;
218
+ /** Default off. Parent owns policy before opting into bounded local buffering,
219
+ * and must recheck policy/catalog privacy before recording or forwarding.
220
+ * This flag does not authorize telemetry export or perform policy subprocesses.
221
+ */
222
+ collectResponseObservations?: boolean;
223
+ /** Optional parallel preparation after dequeue; never delays OS spawn.
224
+ * Unready at the first observation checkpoint permanently drops collection. */
225
+ prepareResponseObservations?: () => Promise<boolean>;
226
+ /** Optional synchronous parent-local grant check; never perform I/O here.
227
+ * Parent checks environment, known revocation and a monotonic expiry against
228
+ * its fresh native policy grant. False/throw permanently discards this task's
229
+ * buffer. Checked once ready, on RPC values, and finish; this is not a watcher.
230
+ * Omission preserves the explicit opt-in producer API, not policy authority.
231
+ */
232
+ canCollectResponseObservations?: () => boolean;
233
+ // This closure stays only in the parent process. Its presence creates an
234
+ // inherited fd, never an environment boolean or model-visible permission.
235
+ authorizeParentStandingReviewPermission?: (repositoryIdentity: string) => boolean;
236
+ }
237
+
238
+ interface ProcessLike {
239
+ execPath: string;
240
+ argv: string[];
241
+ env: NodeJS.ProcessEnv;
242
+ }
243
+
244
+ interface Pending {
245
+ resolve(value: Record<string, unknown>): void;
246
+ }
247
+
248
+ interface PendingQuery {
249
+ cancel: () => void;
250
+ replying: boolean;
251
+ }
252
+
253
+ interface PendingReply {
254
+ resolve(value: boolean): void;
255
+ }
256
+
257
+ interface LiveTask {
258
+ child: ChildLike;
259
+ observations?: ChildObservationBuffer;
260
+ observationGuard?: () => boolean;
261
+ observationPreparation?: () => boolean;
262
+ pending: Map<string, Pending>;
263
+ queries: Map<string, PendingQuery>;
264
+ replies: Map<string, PendingReply>;
265
+ cancelStall: () => void;
266
+ cancelGrace: () => void;
267
+ processGroup: number | undefined;
268
+ terminal: { status: TaskRecord["status"]; error: string | null } | undefined;
269
+ childExit: number | null | undefined;
270
+ cleanupDeadlineAt: number | undefined;
271
+ quarantined: boolean;
272
+ nextId: number;
273
+ permissionBroker?: ParentStandingReviewPermissionBroker;
274
+ ipcClosed: boolean;
275
+ acknowledgedIpcIds: Set<string>;
276
+ acknowledgedIpcOrder: string[];
277
+ mutationStarts: Map<string, { toolName: "write" | "edit"; toolCallId: string; path: string }>;
278
+ }
279
+
280
+ const CHILD_MARKER = "GENTLE_PI_AGENTS_CHILD";
281
+ const IPC_MARKER = "GENTLE_PI_AGENTS_OWNED_IPC";
282
+ const PARENT_NOTIFICATION_TOOL = "subagent_parent_message";
283
+ const DEFAULT_TOOLS: readonly string[] = [];
284
+ const TERMINATION_GRACE_MS = 250;
285
+ const GROUP_CONFIRM_MS = 25;
286
+ const GROUP_CONFIRM_DEADLINE_MS = 1_000;
287
+ const QUERY_REJECTION_ERRORS = new Set([
288
+ "invalid child IPC frame",
289
+ "invalid child IPC correlation",
290
+ "unsupported child IPC kind",
291
+ "invalid child IPC message",
292
+ "task is not a live owned recipient",
293
+ "task parent cannot accept queries",
294
+ "task parent is not the active host session",
295
+ "duplicate query request",
296
+ "too many pending parent queries",
297
+ "parent query timed out",
298
+ "parent rejected query",
299
+ ]);
300
+ const QUERY_REJECTION = Symbol("query rejection");
301
+
302
+ function rejectQuery(error: string): never {
303
+ throw { [QUERY_REJECTION]: error };
304
+ }
305
+
306
+ function queryRejection(error: unknown): string {
307
+ if (error && typeof error === "object" && QUERY_REJECTION in error) {
308
+ const value = (error as { [QUERY_REJECTION]?: unknown })[QUERY_REJECTION];
309
+ if (typeof value === "string" && QUERY_REJECTION_ERRORS.has(value)) return value;
310
+ }
311
+ return "parent rejected query";
312
+ }
313
+
314
+ const hostProcess: ProcessControl = { platform: process.platform, kill: (pid, signal) => process.kill(pid, signal) };
315
+
316
+ export function childArguments(request: TaskRequest): string[] {
317
+ const args = ["--mode", "rpc", "--session-dir", request.sessionDir];
318
+ for (const path of request.extensionPaths ?? []) args.push("--extension", path);
319
+ if (request.sddChange) args.push(SDD_CHANGE_FLAG, JSON.stringify(request.sddChange));
320
+ if (request.resumeSessionPath) args.push("--session", request.resumeSessionPath);
321
+ if (request.model) args.push("--model", request.thinking ? `${formatModelRef(request.model)}:${request.thinking}` : formatModelRef(request.model));
322
+ else if (request.thinking) args.push("--thinking", request.thinking);
323
+ const tools = request.agent.tools.length > 0 ? [...new Set([...request.agent.tools, PARENT_NOTIFICATION_TOOL])] : DEFAULT_TOOLS;
324
+ if (tools.length > 0) args.push("--tools", tools.join(","));
325
+ if (request.agent.instructions.length > 0) args.push("--append-system-prompt", request.agent.instructions);
326
+ return args;
327
+ }
328
+
329
+ // The child is the same pi that is running us: node plus its cli entry.
330
+ // GENTLE_PI_AGENTS_PI overrides it with a command line.
331
+ export function piCommand(proc: ProcessLike = process): PiCommand {
332
+ const override = proc.env.GENTLE_PI_AGENTS_PI?.trim();
333
+ if (override) {
334
+ const [command, ...args] = override.split(/\s+/);
335
+ return { command, args };
336
+ }
337
+ const entry = proc.argv[1];
338
+ if (entry && /(^|[\\/])cli\.js$/.test(entry)) return { command: proc.execPath, args: [entry] };
339
+ return { command: "pi", args: [] };
340
+ }
341
+
342
+ // RPC framing is strict JSONL: LF only, optional CR. Lines that do not parse
343
+ // are dropped (pi's own parse errors arrive as responses anyway).
344
+ export class JsonLines {
345
+ private buffer = "";
346
+ private readonly onValue: (value: unknown) => void;
347
+
348
+ constructor(onValue: (value: unknown) => void) {
349
+ this.onValue = onValue;
350
+ }
351
+
352
+ push(chunk: string): void {
353
+ this.buffer += chunk;
354
+ const lines = this.buffer.split("\n");
355
+ this.buffer = lines.pop() ?? "";
356
+ for (const raw of lines) {
357
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
358
+ if (line.length === 0) continue;
359
+ try {
360
+ this.onValue(JSON.parse(line));
361
+ } catch {
362
+ // not JSON: ignore
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ export function promptText(request: TaskRequest): string {
369
+ const prompt = request.context ? `${request.prompt}\n\n## Context\n${request.context}` : request.prompt;
370
+ return request.sddRemediation ? `${prompt}\n\n## Host-owned remediation evidence plan\nExecute these exact commands in the selected cwd; do not perform native acquire or settle.\n${JSON.stringify(request.sddRemediation.plan)}\nFailed evidence: ${request.sddRemediation.failedEvidenceRevision}` : prompt;
371
+ }
372
+
373
+ export class AgentRunner {
374
+ private readonly store: TaskStore;
375
+ private readonly limits: RunnerLimits;
376
+ private readonly deps: RunnerDeps;
377
+ private readonly hooks: RunnerHooks;
378
+ private readonly processControl: ProcessControl;
379
+ private readonly queue: Array<{ task: TaskRecord; request: TaskRequest }> = [];
380
+ private readonly live = new Map<string, LiveTask>();
381
+ private readonly remediationFinalizers = new Map<string, NonNullable<TaskRequest["finalizeRemediation"]>>();
382
+ private readonly finalizingRemediation = new Set<string>();
383
+ private readonly waiters = new Map<string, Array<(task: TaskRecord) => void>>();
384
+ private readonly queryWaiters = new Map<string, Array<(query: TaskQuery | undefined) => void>>();
385
+ private readonly firstQueries = new Map<string, TaskQuery>();
386
+ private counter = 0;
387
+
388
+ constructor(store: TaskStore, limits: RunnerLimits, deps: RunnerDeps, hooks: RunnerHooks) {
389
+ this.store = store;
390
+ this.limits = limits;
391
+ this.deps = deps;
392
+ this.hooks = hooks;
393
+ this.processControl = deps.process ?? hostProcess;
394
+ }
395
+
396
+ prepareRemediation(request: TaskRequest): TaskRecord {
397
+ if (request.agent.name !== "sdd-remediate" || this.store.list().some(task => task.agent === "sdd-remediate" && task.cwd === request.cwd && !isFinished(task.status))) throw new Error("Remediation already preparing/running; reconcile its retained task before another actor");
398
+ return this.createTask(request);
399
+ }
400
+
401
+ private createTask(request: TaskRequest): TaskRecord {
402
+ const now = this.deps.now();
403
+ this.counter += 1;
404
+ const task: TaskRecord = {
405
+ id: `${now.toString(36)}-${this.counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
406
+ agent: request.agent.name,
407
+ ...(request.sddRemediation ? { sddRemediation: structuredClone(request.sddRemediation) } : {}),
408
+ ...(request.sddPreflightContext ? { sddPreflightContext: request.sddPreflightContext } : {}),
409
+ mode: request.mode,
410
+ prompt: request.prompt,
411
+ ...(request.researchArtifact ? { researchArtifact: structuredClone(request.researchArtifact) } : {}),
412
+ label: taskLabel(request.prompt, request.label),
413
+ cwd: request.cwd,
414
+ parentSessionId: request.parentSessionId,
415
+ status: TASK_STATUS.QUEUED,
416
+ createdAt: now,
417
+ startedAt: null,
418
+ endedAt: null,
419
+ model: formatModelRef(request.model),
420
+ thinking: request.thinking,
421
+ sessionPath: request.resumeSessionPath ?? null,
422
+ error: null,
423
+ result: null,
424
+ lastStep: "queued",
425
+ lastActivityAt: now,
426
+ turns: 0,
427
+ toolCalls: 0,
428
+ tokens: 0,
429
+ cost: 0,
430
+ };
431
+ this.store.add(task);
432
+ return task;
433
+ }
434
+
435
+ run(request: TaskRequest, preparedRemediation?: TaskRecord): TaskRecord {
436
+ if (preparedRemediation && (this.store.get(preparedRemediation.id) !== preparedRemediation || preparedRemediation.cwd !== request.cwd || preparedRemediation.agent !== "sdd-remediate" || preparedRemediation.status !== TASK_STATUS.QUEUED || this.queue.some(entry => entry.task.id === preparedRemediation.id))) throw new Error("Invalid or already dispatched remediation task");
437
+ const task = preparedRemediation ?? this.createTask(request);
438
+ if (request.sddRemediation) task.sddRemediation = structuredClone(request.sddRemediation);
439
+ if (request.finalizeRemediation) this.remediationFinalizers.set(task.id, request.finalizeRemediation);
440
+ // A caller can retain and mutate its request after dispatch. Preserve only
441
+ // the identity selected at construction for this child launch.
442
+ const launchRequest = {
443
+ ...request,
444
+ sddChange: request.sddChange && { ...request.sddChange },
445
+ researchArtifact: request.researchArtifact && structuredClone(request.researchArtifact),
446
+ };
447
+ this.queue.push({ task, request: launchRequest });
448
+ queueMicrotask(() => this.pump());
449
+ return task;
450
+ }
451
+
452
+ waitFor(id: string): Promise<TaskRecord> {
453
+ const current = this.store.get(id);
454
+ if (!current) return Promise.reject(new Error(`no task ${id}`));
455
+ if (isFinished(current.status)) return Promise.resolve(current);
456
+ return new Promise((resolve) => {
457
+ const list = this.waiters.get(id) ?? [];
458
+ list.push(resolve);
459
+ this.waiters.set(id, list);
460
+ });
461
+ }
462
+
463
+ waitForQuery(id: string): Promise<TaskQuery | undefined> {
464
+ const current = this.store.get(id);
465
+ if (!current || isFinished(current.status)) return Promise.resolve(undefined);
466
+ const first = this.firstQueries.get(id);
467
+ if (first) return Promise.resolve(first);
468
+ return new Promise((resolve) => {
469
+ const list = this.queryWaiters.get(id) ?? [];
470
+ list.push(resolve);
471
+ this.queryWaiters.set(id, list);
472
+ });
473
+ }
474
+
475
+ async reply(id: string, requestId: string, message: string, parentSessionId: string): Promise<boolean> {
476
+ const task = this.store.get(id);
477
+ const live = this.live.get(id);
478
+ if (!task || !live || live.terminal || task.parentSessionId !== parentSessionId || !validChildMessage(message)) return false;
479
+ const query = live.queries.get(requestId);
480
+ if (!query || query.replying) return false;
481
+ query.replying = true;
482
+ const accepted = await this.sendReply(live, requestId, { id: requestId, kind: "reply", message });
483
+ if (live.queries.get(requestId) === query) {
484
+ query.cancel();
485
+ live.queries.delete(requestId);
486
+ }
487
+ return accepted;
488
+ }
489
+
490
+ cancel(id: string): boolean {
491
+ const queued = this.queue.findIndex((entry) => entry.task.id === id);
492
+ if (queued >= 0) {
493
+ this.queue.splice(queued, 1);
494
+ this.finish(id, TASK_STATUS.CANCELLED, "cancelled before start");
495
+ return true;
496
+ }
497
+ if (!this.live.has(id)) return false;
498
+ this.requestStop(id, TASK_STATUS.CANCELLED, "cancelled", true);
499
+ return true;
500
+ }
501
+
502
+ /** Parent-known revocation clears buffered metadata immediately, including
503
+ * during an idle provider call. No task/store/status mutation. */
504
+ discardResponseObservations(id: string): void {
505
+ const live = this.live.get(id);
506
+ if (live) { live.observations = undefined; live.observationGuard = undefined; }
507
+ }
508
+
509
+ cancelAll(): number {
510
+ const ids = [...this.queue.map((entry) => entry.task.id), ...this.live.keys()];
511
+ return ids.filter((id) => this.cancel(id)).length;
512
+ }
513
+
514
+ steer(id: string, message: string): boolean {
515
+ if (!this.live.has(id)) return false;
516
+ void this.send(id, { type: "steer", message });
517
+ this.store.apply(id, { type: TASK_EVENT.NOTE, text: `steered: ${message}` }, this.deps.now());
518
+ return true;
519
+ }
520
+
521
+ private pump(): void {
522
+ while (this.live.size < this.limits.maxConcurrency && this.queue.length > 0) {
523
+ const entry = this.queue.shift();
524
+ if (!entry) continue;
525
+ const { task, request } = entry;
526
+ this.launch(task.id, request);
527
+ }
528
+ }
529
+
530
+ // A child that cannot start (missing pi, bad cwd) fails only its task:
531
+ // spawn exceptions and process errors settle without uncaught host errors.
532
+ private launch(id: string, request: TaskRequest): void {
533
+ const detached = this.processControl.platform !== "win32";
534
+ const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
535
+ const env = {
536
+ ...request.env,
537
+ ...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null), [RESEARCH_ARTIFACT_ENV]: JSON.stringify(request.researchArtifact ?? null) } : {}),
538
+ [CHILD_MARKER]: "1",
539
+ [IPC_MARKER]: `${this.deps.now()}-${Math.random().toString(36).slice(2)}`,
540
+ ...(hasParentPermissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}),
541
+ };
542
+ delete env[REMEDIATION_PLAN_ENV];
543
+ if (request.sddRemediation) env[REMEDIATION_PLAN_ENV] = JSON.stringify({ plan: request.sddRemediation.plan, scope: request.sddRemediation.scope, selection: request.sddChange });
544
+ let child: ChildLike;
545
+ try {
546
+ child = this.deps.spawn(this.deps.pi.command, [...this.deps.pi.args, ...childArguments(request)], {
547
+ cwd: request.cwd,
548
+ env,
549
+ detached,
550
+ stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
551
+ });
552
+ } catch (error) {
553
+ this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
554
+ this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error instanceof Error ? error.message : String(error)}`);
555
+ return;
556
+ }
557
+ const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
558
+ const live: LiveTask = { child, mutationStarts: new Map(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [] };
559
+ if (request.prepareResponseObservations) {
560
+ let ready = false;
561
+ live.observationPreparation = () => ready;
562
+ void Promise.resolve().then(() => this.live.get(id) === live && !live.terminal
563
+ ? request.prepareResponseObservations!() : false).then(allowed => { ready = allowed === true; }).catch(() => {});
564
+ }
565
+ if (request.collectResponseObservations === true || request.prepareResponseObservations) {
566
+ live.observationGuard = request.canCollectResponseObservations;
567
+ live.observations = { agentSettled: false, responses: [], droppedResponses: 0 };
568
+ if (!request.prepareResponseObservations) this.checkObservationGrant(live);
569
+ }
570
+ this.live.set(id, live);
571
+ const permissionPipe = child.stdio?.[3];
572
+ if (hasParentPermissionChannel && permissionPipe !== undefined && permissionPipe !== null) {
573
+ live.permissionBroker = new ParentStandingReviewPermissionBroker(
574
+ { readable: permissionPipe, writable: permissionPipe },
575
+ (repositoryIdentity) => this.live.get(id) === live && !live.terminal && request.authorizeParentStandingReviewPermission?.(repositoryIdentity) === true,
576
+ );
577
+ }
578
+ this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
579
+ child.channel?.unref?.();
580
+ child.on("error", (error) => this.childError(id, error));
581
+ child.on("message", (value) => this.receiveChildMessage(id, value));
582
+ child.on("disconnect", () => this.closeIpc(live));
583
+ let announced = false;
584
+ child.on("spawn", () => {
585
+ if (announced || this.live.get(id) !== live || live.terminal) return;
586
+ announced = true;
587
+ try { request.onLaunch?.(); }
588
+ catch (error) { this.requestStop(id, TASK_STATUS.FAILED, `could not register launched worktree: ${error instanceof Error ? error.message : String(error)}`); }
589
+ });
590
+ child.stdin.on("error", () => {});
591
+ this.armStall(id, live);
592
+ const lines = new JsonLines((value) => this.receive(id, request, value));
593
+ child.stdout.setEncoding("utf8");
594
+ child.stdout.on("data", (chunk: string) => lines.push(chunk));
595
+ child.stderr?.on("data", () => {});
596
+ child.on("exit", (code) => this.exited(id, code));
597
+ if (request.sddRemediation && child.pid === undefined) {
598
+ this.childError(id, new Error("remediation child has no process ID"));
599
+ return;
600
+ }
601
+ void this.send(id, { type: "get_state" }).then((response) => {
602
+ const data = response.data as { sessionFile?: unknown; model?: { provider?: unknown; id?: unknown } | null; thinkingLevel?: unknown } | undefined;
603
+ if (response.success !== true || live.terminal || this.live.get(id) !== live || !data) return;
604
+ const resolved: Partial<TaskRecord> = {};
605
+ if (typeof data.sessionFile === "string" && data.sessionFile) resolved.sessionPath = data.sessionFile;
606
+ if (data.model === null) resolved.model = "default";
607
+ else if (typeof data.model?.provider === "string" && data.model.provider && typeof data.model.id === "string" && data.model.id) {
608
+ resolved.model = formatModelRef({ provider: data.model.provider, id: data.model.id });
609
+ }
610
+ if (typeof data.thinkingLevel === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(data.thinkingLevel)) resolved.thinking = data.thinkingLevel;
611
+ this.store.update(id, resolved);
612
+ });
613
+ void this.send(id, { type: "prompt", message: promptText(request) }).then((response) => {
614
+ if (response.success === false) this.requestStop(id, TASK_STATUS.FAILED, String(response.error ?? "prompt rejected"));
615
+ });
616
+ }
617
+
618
+ private armStall(id: string, live: LiveTask): void {
619
+ live.cancelStall();
620
+ live.cancelStall = this.deps.schedule(() => this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${Math.round(this.limits.stallTimeoutMs / 60_000)} min`), this.limits.stallTimeoutMs);
621
+ }
622
+
623
+ private send(id: string, command: Record<string, unknown>): Promise<Record<string, unknown>> {
624
+ const live = this.live.get(id);
625
+ if (!live) return Promise.resolve({ success: false, error: "task is not running" });
626
+ live.nextId += 1;
627
+ const requestId = `r${live.nextId}`;
628
+ return new Promise((resolve) => {
629
+ live.pending.set(requestId, { resolve });
630
+ this.write(live, { id: requestId, ...command });
631
+ });
632
+ }
633
+
634
+ private receiveChildMessage(id: string, value: unknown): void {
635
+ const live = this.live.get(id);
636
+ if (!live || live.ipcClosed) return;
637
+ const parsed = parseChildFrame(value);
638
+ if (!parsed.frame) {
639
+ if (parsed.id && validChildQueryId(parsed.id)) this.sendQueryError(live, parsed.id, parsed.error ?? "invalid child IPC frame");
640
+ else if (parsed.id) this.acknowledge(live, parsed.id, false, parsed.error ?? "invalid child IPC frame");
641
+ return;
642
+ }
643
+ const task = this.store.get(id);
644
+ if (!task || live.terminal || isFinished(task.status) || task.parentSessionId === "") {
645
+ if (parsed.frame.kind === "notification") this.acknowledge(live, parsed.frame.id, false, "task is not a live owned recipient");
646
+ else this.sendQueryError(live, parsed.frame.id, "task is not a live owned recipient");
647
+ return;
648
+ }
649
+ if (parsed.frame.kind === "notification") {
650
+ if (live.acknowledgedIpcIds.has(parsed.frame.id)) return;
651
+ try {
652
+ if (this.hooks.onNotification?.(task, parsed.frame.message) === false) this.acknowledge(live, parsed.frame.id, false, "task parent is not the active host session");
653
+ else this.acknowledge(live, parsed.frame.id, true);
654
+ } catch { this.acknowledge(live, parsed.frame.id, false, "parent rejected notification"); }
655
+ return;
656
+ }
657
+ let query: PendingQuery | undefined;
658
+ try {
659
+ if (live.queries.has(parsed.frame.id)) rejectQuery("duplicate query request");
660
+ if (live.queries.size >= CHILD_QUERY_MAX_INFLIGHT) rejectQuery("too many pending parent queries");
661
+ query = { replying: false, cancel: this.deps.schedule(() => this.expireQuery(live, parsed.frame!.id), CHILD_QUERY_TIMEOUT_MS) };
662
+ live.queries.set(parsed.frame.id, query);
663
+ if (!this.hooks.onQuery) rejectQuery("task parent cannot accept queries");
664
+ if (this.hooks.onQuery(task, parsed.frame.id, parsed.frame.message) === false) rejectQuery("task parent is not the active host session");
665
+ if (task.mode === AGENT_MODE.TASK && !this.firstQueries.has(id)) {
666
+ const first = { taskId: id, requestId: parsed.frame.id };
667
+ this.firstQueries.set(id, first);
668
+ for (const resolve of this.queryWaiters.get(id) ?? []) resolve(first);
669
+ this.queryWaiters.delete(id);
670
+ }
671
+ } catch (error) {
672
+ if (query && live.queries.get(parsed.frame.id) === query) {
673
+ query.cancel();
674
+ live.queries.delete(parsed.frame.id);
675
+ }
676
+ this.sendQueryError(live, parsed.frame.id, queryRejection(error));
677
+ }
678
+ }
679
+
680
+ private expireQuery(live: LiveTask, id: string): void {
681
+ const query = live.queries.get(id);
682
+ if (!query) return;
683
+ live.queries.delete(id);
684
+ if (query.replying) this.settleReply(live, id, false);
685
+ else this.sendQueryError(live, id, "parent query timed out");
686
+ }
687
+
688
+ private sendQueryError(live: LiveTask, id: string, error: string): void {
689
+ const safeError = QUERY_REJECTION_ERRORS.has(error) ? error : "parent rejected query";
690
+ try { live.child.send?.({ id, kind: "reply", error: safeError }, () => {}); }
691
+ catch { /* Child-owned IPC callback reports transport failure. */ }
692
+ }
693
+
694
+ private acknowledge(live: LiveTask, id: string, accepted: boolean, error?: string): void {
695
+ if (live.ipcClosed || live.acknowledgedIpcIds.has(id) || !live.child.send) return;
696
+ live.acknowledgedIpcIds.add(id);
697
+ live.acknowledgedIpcOrder.push(id);
698
+ if (live.acknowledgedIpcOrder.length > 64) live.acknowledgedIpcIds.delete(live.acknowledgedIpcOrder.shift()!);
699
+ try { live.child.send({ id, kind: "ack", accepted, ...(error ? { error } : {}) }, () => {}); }
700
+ catch { /* Child-owned IPC callback reports transport failure. */ }
701
+ }
702
+
703
+ private sendReply(live: LiveTask, id: string, frame: Record<string, unknown>): Promise<boolean> {
704
+ return new Promise((resolve) => {
705
+ live.replies.set(id, { resolve });
706
+ try {
707
+ if (!live.child.send) this.settleReply(live, id, false);
708
+ else live.child.send(frame, (error) => this.settleReply(live, id, !error));
709
+ } catch { this.settleReply(live, id, false); }
710
+ });
711
+ }
712
+
713
+ private settleReply(live: LiveTask, id: string, accepted: boolean): void {
714
+ const pending = live.replies.get(id);
715
+ if (!pending) return;
716
+ live.replies.delete(id);
717
+ pending.resolve(accepted);
718
+ }
719
+
720
+ private closeIpc(live: LiveTask): void {
721
+ if (live.ipcClosed) return;
722
+ live.ipcClosed = true;
723
+ for (const query of live.queries.values()) query.cancel();
724
+ live.queries.clear();
725
+ for (const pending of live.replies.values()) pending.resolve(false);
726
+ live.replies.clear();
727
+ live.child.channel?.unref?.();
728
+ try { live.child.disconnect?.(); }
729
+ catch { /* Channel may already be disconnected. */ }
730
+ }
731
+
732
+ private write(live: LiveTask, payload: Record<string, unknown>): void {
733
+ try {
734
+ live.child.stdin.write(`${JSON.stringify(payload)}\n`);
735
+ } catch {
736
+ // the child is gone; the exit handler settles the task
737
+ }
738
+ }
739
+
740
+ private checkObservationGrant(live: LiveTask): void {
741
+ if (live.observationPreparation) {
742
+ if (!live.observationPreparation()) live.observations = undefined;
743
+ live.observationPreparation = undefined; // One chance; late readiness cannot attach.
744
+ }
745
+ if (!live.observations || !live.observationGuard) return;
746
+ try {
747
+ if (live.observationGuard() === true) return;
748
+ } catch { /* Policy bookkeeping must not interrupt child execution. */ }
749
+ live.observations = undefined;
750
+ live.observationGuard = undefined;
751
+ }
752
+
753
+ private receive(id: string, request: TaskRequest, value: unknown): void {
754
+ const live = this.live.get(id);
755
+ if (!live || live.terminal || !value || typeof value !== "object") return;
756
+ const raw = value as Record<string, unknown>;
757
+ const remediation = this.store.get(id)?.sddRemediation;
758
+ if (remediation) observeRemediationTool(remediation, raw);
759
+ this.armStall(id, live);
760
+ if (raw.type === "response") {
761
+ if (!live.observationPreparation) this.checkObservationGrant(live);
762
+ const pending = typeof raw.id === "string" ? live.pending.get(raw.id) : undefined;
763
+ if (pending) {
764
+ live.pending.delete(raw.id as string);
765
+ pending.resolve(raw);
766
+ }
767
+ return;
768
+ }
769
+ this.checkObservationGrant(live);
770
+ for (const event of normalizeRpcEvent(raw, { observeResponses: live.observations !== undefined })) {
771
+ if (event.type === TASK_EVENT.RESPONSE_OBSERVATION) {
772
+ const buffer = live.observations;
773
+ if (buffer) {
774
+ if (buffer.responses.length < MAX_CHILD_RESPONSE_OBSERVATIONS) buffer.responses.push(event.observation);
775
+ else buffer.droppedResponses = Math.min(Number.MAX_SAFE_INTEGER, buffer.droppedResponses + 1);
776
+ }
777
+ continue; // Separate from store persistence, UI totals and notifications.
778
+ }
779
+ this.store.apply(id, event, this.deps.now());
780
+ if (event.type === TASK_EVENT.TOOL_START && event.callId) {
781
+ live.mutationStarts.delete(event.callId);
782
+ if ((event.name === "write" || event.name === "edit") && typeof event.args.path === "string" && event.args.path.trim()) {
783
+ live.mutationStarts.set(event.callId, { toolName: event.name, toolCallId: event.callId, path: event.args.path });
784
+ }
785
+ }
786
+ if (event.type === TASK_EVENT.TOOL_END) {
787
+ const mutation = live.mutationStarts.get(event.callId);
788
+ live.mutationStarts.delete(event.callId);
789
+ const task = this.store.get(id);
790
+ if (mutation && task && raw.isError === false && !event.isError) {
791
+ try { void Promise.resolve(this.hooks.onSuccessfulMutation?.(task, mutation)).catch(() => {}); }
792
+ catch { /* Bookkeeping failure must not rewrite a successful tool or stop the child. */ }
793
+ }
794
+ }
795
+ if (event.type === TASK_EVENT.ASK) void this.answer(id, request, live, event.request, raw);
796
+ if (event.type === TASK_EVENT.AGENT_SETTLED) {
797
+ if (live.observations) live.observations.agentSettled = true;
798
+ const terminal = this.store.get(id);
799
+ if (terminal?.error) this.requestStop(id, TASK_STATUS.FAILED, terminal.error);
800
+ else if (terminal?.result) this.requestStop(id, TASK_STATUS.COMPLETED, null);
801
+ else this.requestStop(id, TASK_STATUS.FAILED, "assistant settled without a final report");
802
+ }
803
+ }
804
+ }
805
+
806
+ // Task-mode subagents may ask the human through the host; background ones
807
+ // get their dialog cancelled so they never block on nobody.
808
+ private async answer(id: string, request: TaskRequest, live: LiveTask, ask: AskRequest, raw: Record<string, unknown>): Promise<void> {
809
+ let answer: AskAnswer = { cancelled: true };
810
+ if (request.mode === "task") {
811
+ try {
812
+ answer = await this.hooks.askUser(id, ask, raw);
813
+ } catch {
814
+ answer = { cancelled: true };
815
+ }
816
+ }
817
+ if (this.live.get(id) !== live || live.terminal) return;
818
+ this.write(live, { type: "extension_ui_response", id: ask.id, ...answer });
819
+ const current = this.store.get(id);
820
+ if (current?.status === TASK_STATUS.WAITING) this.store.update(id, { status: TASK_STATUS.RUNNING, lastStep: answer.cancelled ? "question dismissed" : "answered" });
821
+ }
822
+
823
+ // POSIX children start detached, so their PID is the owned process-group ID.
824
+ // Windows uses ChildProcess.kill only: Node has no equivalent tree guarantee.
825
+ private signal(live: LiveTask, signal: NodeJS.Signals): void {
826
+ if (live.processGroup !== undefined) {
827
+ try {
828
+ this.processControl.kill(-live.processGroup, signal);
829
+ return;
830
+ } catch {
831
+ // The owned group is already gone; the child handle may still observe exit.
832
+ }
833
+ }
834
+ try {
835
+ live.child.kill(signal);
836
+ } catch {
837
+ // already gone
838
+ }
839
+ }
840
+
841
+ private requestStop(id: string, status: TaskRecord["status"], error: string | null, abort = false): void {
842
+ const live = this.live.get(id);
843
+ if (!live || live.terminal) return;
844
+ live.terminal = { status, error };
845
+ live.mutationStarts.clear();
846
+ live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
847
+ live.permissionBroker?.close();
848
+ this.closeIpc(live);
849
+ live.cancelStall();
850
+ if (abort) void this.send(id, { type: "abort" });
851
+ this.signal(live, "SIGTERM");
852
+ live.cancelGrace = this.deps.schedule(() => {
853
+ if (this.live.get(id) !== live) return;
854
+ this.signal(live, "SIGKILL");
855
+ this.confirmGroupExit(id, live);
856
+ }, TERMINATION_GRACE_MS);
857
+ }
858
+
859
+ // A false result is ambiguous, so the probe reports which one it is: an ESRCH
860
+ // result proves the group is gone, while an absent process group means the
861
+ // question cannot be asked at all. Only the first justifies treating the exit as
862
+ // complete without an observed exit event.
863
+ private probeGroup(live: LiveTask): "present" | "gone" | "unavailable" {
864
+ if (live.processGroup === undefined) return "unavailable";
865
+ try {
866
+ this.processControl.kill(-live.processGroup, 0);
867
+ return "present";
868
+ } catch (error) {
869
+ return (error as NodeJS.ErrnoException).code === "ESRCH" ? "gone" : "present";
870
+ }
871
+ }
872
+
873
+ private groupExists(live: LiveTask): boolean {
874
+ return this.probeGroup(live) === "present";
875
+ }
876
+
877
+ private confirmGroupExit(id: string, live: LiveTask): void {
878
+ if (this.live.get(id) !== live) return;
879
+ const group = this.probeGroup(live);
880
+ if (group === "present") {
881
+ if (this.deps.now() >= (live.cleanupDeadlineAt ?? 0)) {
882
+ live.cancelGrace();
883
+ live.quarantined = true;
884
+ this.finish(id, TASK_STATUS.FAILED, `process cleanup unconfirmed after ${GROUP_CONFIRM_DEADLINE_MS}ms; capacity quarantined`, live);
885
+ return;
886
+ }
887
+ live.cancelGrace = this.deps.schedule(() => this.confirmGroupExit(id, live), GROUP_CONFIRM_MS);
888
+ return;
889
+ }
890
+ if (group === "gone") {
891
+ // No process remains in the group, so the exit is complete whether or not
892
+ // the child's own exit event was ever observed. Completing here also frees
893
+ // the concurrency slot; finishing without it would leave the task recorded
894
+ // while its slot stayed occupied and queued work never pumped.
895
+ this.completeExit(id, live);
896
+ return;
897
+ }
898
+ if (live.childExit !== undefined) {
899
+ this.completeExit(id, live);
900
+ return;
901
+ }
902
+ // With no process group to probe, an observed exit is the only confirmation
903
+ // available. Wait for it within the deadline, then quarantine instead of
904
+ // completing on an assumption, and never return without either.
905
+ if (this.deps.now() >= (live.cleanupDeadlineAt ?? 0)) {
906
+ live.cancelGrace();
907
+ live.quarantined = true;
908
+ this.finish(id, TASK_STATUS.FAILED, `child exit unconfirmed after ${GROUP_CONFIRM_DEADLINE_MS}ms; capacity quarantined`, live);
909
+ return;
910
+ }
911
+ live.cancelGrace = this.deps.schedule(() => this.confirmGroupExit(id, live), GROUP_CONFIRM_MS);
912
+ }
913
+
914
+ private childError(id: string, error: Error): void {
915
+ const live = this.live.get(id);
916
+ if (!live) return;
917
+ // Node leaves pid undefined when spawn failed; a live PID must still exit
918
+ // before its slot is released, even if its handle later emits an error.
919
+ if (live.child.pid !== undefined) {
920
+ this.requestStop(id, TASK_STATUS.FAILED, `pi process error: ${error.message}`);
921
+ return;
922
+ }
923
+ live.permissionBroker?.close();
924
+ this.closeIpc(live);
925
+ live.cancelStall();
926
+ live.cancelGrace();
927
+ this.live.delete(id);
928
+ this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error.message}`, live);
929
+ }
930
+
931
+ private exited(id: string, code: number | null): void {
932
+ const live = this.live.get(id);
933
+ if (!live) return;
934
+ live.childExit = code;
935
+ if (this.groupExists(live)) {
936
+ if (!live.terminal) this.requestStop(id, TASK_STATUS.FAILED, `pi exited with code ${code ?? "unknown"} before agent_settled`);
937
+ return;
938
+ }
939
+ this.completeExit(id, live);
940
+ }
941
+
942
+ private completeExit(id: string, live: LiveTask): void {
943
+ live.permissionBroker?.close();
944
+ this.closeIpc(live);
945
+ live.cancelStall();
946
+ live.cancelGrace();
947
+ this.live.delete(id);
948
+ // Quarantine already notified completion, but its retained slot is now free.
949
+ if (live.quarantined) {
950
+ queueMicrotask(() => this.pump());
951
+ return;
952
+ }
953
+ const terminal = live.terminal;
954
+ this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled`, live);
955
+ }
956
+
957
+ private finish(id: string, status: TaskRecord["status"], error: string | null, live?: LiveTask): void {
958
+ const current = this.store.get(id);
959
+ if (!current || isFinished(current.status) || this.finalizingRemediation.has(id)) return;
960
+ const finalize = this.remediationFinalizers.get(id);
961
+ if (finalize) {
962
+ this.finalizingRemediation.add(id);
963
+ const terminal = { ...current, status, error };
964
+ void finalize(terminal, { spawned: typeof live?.child.pid === "number", exited: live?.childExit !== undefined, cleanupConfirmed: !live || !live.quarantined && live.childExit !== undefined }).then(() => {
965
+ status = terminal.status;
966
+ error = terminal.error;
967
+ }).catch(() => {
968
+ status = TASK_STATUS.FAILED;
969
+ error = "Native remediation settlement could not be durably finalized; retain task history and reconcile before further execution";
970
+ }).finally(() => {
971
+ this.remediationFinalizers.delete(id);
972
+ this.finalizingRemediation.delete(id);
973
+ this.finish(id, status, error, live);
974
+ });
975
+ return;
976
+ }
977
+ const finished = this.store.update(id, { status, endedAt: this.deps.now(), error, lastStep: error ?? "done" });
978
+ if (finished) {
979
+ if (live) this.checkObservationGrant(live);
980
+ const buffer = live?.observations;
981
+ const snapshot: ChildObservationSnapshot | undefined = buffer ? Object.freeze({
982
+ coverage: "final_assistant_messages_only", agentSettled: buffer.agentSettled,
983
+ responses: Object.freeze(buffer.responses.slice()), droppedResponses: buffer.droppedResponses,
984
+ }) : undefined;
985
+ if (live) {
986
+ live.observations = undefined; // Also release quarantined buffers.
987
+ live.observationGuard = undefined;
988
+ }
989
+ this.hooks.onFinish?.(finished, snapshot);
990
+ for (const resolve of this.waiters.get(id) ?? []) resolve(finished);
991
+ this.waiters.delete(id);
992
+ for (const resolve of this.queryWaiters.get(id) ?? []) resolve(undefined);
993
+ this.queryWaiters.delete(id);
994
+ this.firstQueries.delete(id);
995
+ }
996
+ queueMicrotask(() => this.pump());
997
+ }
998
+ }
999
+
1000
+ // Human-readable suffix for an abort signal's reason, so a cancelled tool call is
1001
+ // distinguishable in the record and the notification rather than reported only as
1002
+ // "aborted". Returns an empty string when there is no usable reason.
1003
+ export function abortReasonText(reason: unknown): string {
1004
+ if (reason === undefined) return "";
1005
+ const message =
1006
+ reason instanceof Error && reason.message.length > 0
1007
+ ? reason.message
1008
+ : typeof reason === "string" && reason.length > 0
1009
+ ? reason
1010
+ : "";
1011
+ return message.length > 0 ? ` (${message})` : "";
1012
+ }