gentle-pi 2.5.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -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 +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -1,7 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { Duplex, Readable, Writable } from "node:stream";
2
- import { formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
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";
3
6
  import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
4
- import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type TaskRecord, type TaskStore } from "./agents-protocol.ts";
7
+ import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type RemediationTaskState, type TaskStore } from "./agents-protocol.ts";
5
8
 
6
9
  // Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
7
10
  // the host never runs subagent work on the TUI thread. It writes JSON
@@ -15,15 +18,19 @@ export interface ChildLike {
15
18
  stderr: Readable | null | undefined;
16
19
  stdio?: Array<Duplex | null | undefined>;
17
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 };
18
24
  on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
19
25
  on(event: "error", listener: (error: Error) => void): unknown;
26
+ on(event: "spawn" | "message" | "disconnect", listener: (...args: unknown[]) => void): unknown;
20
27
  }
21
28
 
22
29
  export interface SpawnOptions {
23
30
  cwd: string;
24
31
  env: NodeJS.ProcessEnv;
25
32
  detached?: boolean;
26
- stdio?: Array<"pipe" | "ignore" | "inherit">;
33
+ stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc">;
27
34
  }
28
35
 
29
36
  export type Spawn = (command: string, args: string[], options: SpawnOptions) => ChildLike;
@@ -57,12 +64,137 @@ export interface AskAnswer {
57
64
  cancelled?: boolean;
58
65
  }
59
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
+
60
91
  export interface RunnerHooks {
61
92
  askUser(taskId: string, request: AskRequest, raw: Record<string, unknown>): Promise<AskAnswer>;
62
- onFinish?(task: TaskRecord): void;
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;
63
185
  }
64
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
+
65
193
  export interface TaskRequest {
194
+ remediationIntent?: unknown;
195
+ sddRemediation?: RemediationTaskState;
196
+ sddPreflightContext?: string;
197
+ finalizeRemediation?: (task: TaskRecord, facts: RemediationTerminalFacts) => Promise<void>;
66
198
  agent: AgentDefinition;
67
199
  prompt: string;
68
200
  label: string | undefined;
@@ -75,6 +207,29 @@ export interface TaskRequest {
75
207
  sessionDir: string;
76
208
  resumeSessionPath: string | undefined;
77
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;
78
233
  // This closure stays only in the parent process. Its presence creates an
79
234
  // inherited fd, never an environment boolean or model-visible permission.
80
235
  authorizeParentStandingReviewPermission?: (repositoryIdentity: string) => boolean;
@@ -90,9 +245,23 @@ interface Pending {
90
245
  resolve(value: Record<string, unknown>): void;
91
246
  }
92
247
 
248
+ interface PendingQuery {
249
+ cancel: () => void;
250
+ replying: boolean;
251
+ }
252
+
253
+ interface PendingReply {
254
+ resolve(value: boolean): void;
255
+ }
256
+
93
257
  interface LiveTask {
94
258
  child: ChildLike;
259
+ observations?: ChildObservationBuffer;
260
+ observationGuard?: () => boolean;
261
+ observationPreparation?: () => boolean;
95
262
  pending: Map<string, Pending>;
263
+ queries: Map<string, PendingQuery>;
264
+ replies: Map<string, PendingReply>;
96
265
  cancelStall: () => void;
97
266
  cancelGrace: () => void;
98
267
  processGroup: number | undefined;
@@ -102,21 +271,56 @@ interface LiveTask {
102
271
  quarantined: boolean;
103
272
  nextId: number;
104
273
  permissionBroker?: ParentStandingReviewPermissionBroker;
274
+ ipcClosed: boolean;
275
+ acknowledgedIpcIds: Set<string>;
276
+ acknowledgedIpcOrder: string[];
277
+ mutationStarts: Map<string, { toolName: "write" | "edit"; toolCallId: string; path: string }>;
105
278
  }
106
279
 
107
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";
108
283
  const DEFAULT_TOOLS: readonly string[] = [];
109
284
  const TERMINATION_GRACE_MS = 250;
110
285
  const GROUP_CONFIRM_MS = 25;
111
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
+
112
314
  const hostProcess: ProcessControl = { platform: process.platform, kill: (pid, signal) => process.kill(pid, signal) };
113
315
 
114
316
  export function childArguments(request: TaskRequest): string[] {
115
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));
116
320
  if (request.resumeSessionPath) args.push("--session", request.resumeSessionPath);
117
321
  if (request.model) args.push("--model", request.thinking ? `${formatModelRef(request.model)}:${request.thinking}` : formatModelRef(request.model));
118
322
  else if (request.thinking) args.push("--thinking", request.thinking);
119
- const tools = request.agent.tools.length > 0 ? request.agent.tools : DEFAULT_TOOLS;
323
+ const tools = request.agent.tools.length > 0 ? [...new Set([...request.agent.tools, PARENT_NOTIFICATION_TOOL])] : DEFAULT_TOOLS;
120
324
  if (tools.length > 0) args.push("--tools", tools.join(","));
121
325
  if (request.agent.instructions.length > 0) args.push("--append-system-prompt", request.agent.instructions);
122
326
  return args;
@@ -162,7 +366,8 @@ export class JsonLines {
162
366
  }
163
367
 
164
368
  export function promptText(request: TaskRequest): string {
165
- return request.context ? `${request.prompt}\n\n## Context\n${request.context}` : request.prompt;
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;
166
371
  }
167
372
 
168
373
  export class AgentRunner {
@@ -173,7 +378,11 @@ export class AgentRunner {
173
378
  private readonly processControl: ProcessControl;
174
379
  private readonly queue: Array<{ task: TaskRecord; request: TaskRequest }> = [];
175
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>();
176
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>();
177
386
  private counter = 0;
178
387
 
179
388
  constructor(store: TaskStore, limits: RunnerLimits, deps: RunnerDeps, hooks: RunnerHooks) {
@@ -184,14 +393,22 @@ export class AgentRunner {
184
393
  this.processControl = deps.process ?? hostProcess;
185
394
  }
186
395
 
187
- run(request: TaskRequest): TaskRecord {
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 {
188
402
  const now = this.deps.now();
189
403
  this.counter += 1;
190
404
  const task: TaskRecord = {
191
405
  id: `${now.toString(36)}-${this.counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
192
406
  agent: request.agent.name,
407
+ ...(request.sddRemediation ? { sddRemediation: structuredClone(request.sddRemediation) } : {}),
408
+ ...(request.sddPreflightContext ? { sddPreflightContext: request.sddPreflightContext } : {}),
193
409
  mode: request.mode,
194
410
  prompt: request.prompt,
411
+ ...(request.researchArtifact ? { researchArtifact: structuredClone(request.researchArtifact) } : {}),
195
412
  label: taskLabel(request.prompt, request.label),
196
413
  cwd: request.cwd,
197
414
  parentSessionId: request.parentSessionId,
@@ -212,7 +429,22 @@ export class AgentRunner {
212
429
  cost: 0,
213
430
  };
214
431
  this.store.add(task);
215
- this.queue.push({ task, request });
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 });
216
448
  queueMicrotask(() => this.pump());
217
449
  return task;
218
450
  }
@@ -228,6 +460,33 @@ export class AgentRunner {
228
460
  });
229
461
  }
230
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
+
231
490
  cancel(id: string): boolean {
232
491
  const queued = this.queue.findIndex((entry) => entry.task.id === id);
233
492
  if (queued >= 0) {
@@ -240,6 +499,13 @@ export class AgentRunner {
240
499
  return true;
241
500
  }
242
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
+
243
509
  cancelAll(): number {
244
510
  const ids = [...this.queue.map((entry) => entry.task.id), ...this.live.keys()];
245
511
  return ids.filter((id) => this.cancel(id)).length;
@@ -255,7 +521,9 @@ export class AgentRunner {
255
521
  private pump(): void {
256
522
  while (this.live.size < this.limits.maxConcurrency && this.queue.length > 0) {
257
523
  const entry = this.queue.shift();
258
- if (entry) this.launch(entry.task.id, entry.request);
524
+ if (!entry) continue;
525
+ const { task, request } = entry;
526
+ this.launch(task.id, request);
259
527
  }
260
528
  }
261
529
 
@@ -266,16 +534,20 @@ export class AgentRunner {
266
534
  const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
267
535
  const env = {
268
536
  ...request.env,
537
+ ...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null), [RESEARCH_ARTIFACT_ENV]: JSON.stringify(request.researchArtifact ?? null) } : {}),
269
538
  [CHILD_MARKER]: "1",
539
+ [IPC_MARKER]: `${this.deps.now()}-${Math.random().toString(36).slice(2)}`,
270
540
  ...(hasParentPermissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}),
271
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 });
272
544
  let child: ChildLike;
273
545
  try {
274
546
  child = this.deps.spawn(this.deps.pi.command, [...this.deps.pi.args, ...childArguments(request)], {
275
547
  cwd: request.cwd,
276
548
  env,
277
549
  detached,
278
- ...(hasParentPermissionChannel ? { stdio: ["pipe", "pipe", "pipe", "pipe"] } : {}),
550
+ stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
279
551
  });
280
552
  } catch (error) {
281
553
  this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
@@ -283,7 +555,18 @@ export class AgentRunner {
283
555
  return;
284
556
  }
285
557
  const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
286
- const live: LiveTask = { child, pending: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0 };
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
+ }
287
570
  this.live.set(id, live);
288
571
  const permissionPipe = child.stdio?.[3];
289
572
  if (hasParentPermissionChannel && permissionPipe !== undefined && permissionPipe !== null) {
@@ -293,7 +576,17 @@ export class AgentRunner {
293
576
  );
294
577
  }
295
578
  this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
579
+ child.channel?.unref?.();
296
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
+ });
297
590
  child.stdin.on("error", () => {});
298
591
  this.armStall(id, live);
299
592
  const lines = new JsonLines((value) => this.receive(id, request, value));
@@ -301,9 +594,21 @@ export class AgentRunner {
301
594
  child.stdout.on("data", (chunk: string) => lines.push(chunk));
302
595
  child.stderr?.on("data", () => {});
303
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
+ }
304
601
  void this.send(id, { type: "get_state" }).then((response) => {
305
- const data = response.data as { sessionFile?: string } | undefined;
306
- if (!live.terminal && this.live.get(id) === live && data?.sessionFile) this.store.update(id, { sessionPath: data.sessionFile });
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);
307
612
  });
308
613
  void this.send(id, { type: "prompt", message: promptText(request) }).then((response) => {
309
614
  if (response.success === false) this.requestStop(id, TASK_STATUS.FAILED, String(response.error ?? "prompt rejected"));
@@ -326,6 +631,104 @@ export class AgentRunner {
326
631
  });
327
632
  }
328
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
+
329
732
  private write(live: LiveTask, payload: Record<string, unknown>): void {
330
733
  try {
331
734
  live.child.stdin.write(`${JSON.stringify(payload)}\n`);
@@ -334,12 +737,28 @@ export class AgentRunner {
334
737
  }
335
738
  }
336
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
+
337
753
  private receive(id: string, request: TaskRequest, value: unknown): void {
338
754
  const live = this.live.get(id);
339
755
  if (!live || live.terminal || !value || typeof value !== "object") return;
340
756
  const raw = value as Record<string, unknown>;
757
+ const remediation = this.store.get(id)?.sddRemediation;
758
+ if (remediation) observeRemediationTool(remediation, raw);
341
759
  this.armStall(id, live);
342
760
  if (raw.type === "response") {
761
+ if (!live.observationPreparation) this.checkObservationGrant(live);
343
762
  const pending = typeof raw.id === "string" ? live.pending.get(raw.id) : undefined;
344
763
  if (pending) {
345
764
  live.pending.delete(raw.id as string);
@@ -347,10 +766,35 @@ export class AgentRunner {
347
766
  }
348
767
  return;
349
768
  }
350
- for (const event of normalizeRpcEvent(raw)) {
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
+ }
351
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
+ }
352
795
  if (event.type === TASK_EVENT.ASK) void this.answer(id, request, live, event.request, raw);
353
796
  if (event.type === TASK_EVENT.AGENT_SETTLED) {
797
+ if (live.observations) live.observations.agentSettled = true;
354
798
  const terminal = this.store.get(id);
355
799
  if (terminal?.error) this.requestStop(id, TASK_STATUS.FAILED, terminal.error);
356
800
  else if (terminal?.result) this.requestStop(id, TASK_STATUS.COMPLETED, null);
@@ -398,8 +842,10 @@ export class AgentRunner {
398
842
  const live = this.live.get(id);
399
843
  if (!live || live.terminal) return;
400
844
  live.terminal = { status, error };
845
+ live.mutationStarts.clear();
401
846
  live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
402
847
  live.permissionBroker?.close();
848
+ this.closeIpc(live);
403
849
  live.cancelStall();
404
850
  if (abort) void this.send(id, { type: "abort" });
405
851
  this.signal(live, "SIGTERM");
@@ -410,29 +856,59 @@ export class AgentRunner {
410
856
  }, TERMINATION_GRACE_MS);
411
857
  }
412
858
 
413
- private groupExists(live: LiveTask): boolean {
414
- if (live.processGroup === undefined) return false;
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";
415
865
  try {
416
866
  this.processControl.kill(-live.processGroup, 0);
417
- return true;
867
+ return "present";
418
868
  } catch (error) {
419
- return (error as NodeJS.ErrnoException).code !== "ESRCH";
869
+ return (error as NodeJS.ErrnoException).code === "ESRCH" ? "gone" : "present";
420
870
  }
421
871
  }
422
872
 
873
+ private groupExists(live: LiveTask): boolean {
874
+ return this.probeGroup(live) === "present";
875
+ }
876
+
423
877
  private confirmGroupExit(id: string, live: LiveTask): void {
424
878
  if (this.live.get(id) !== live) return;
425
- if (this.groupExists(live)) {
879
+ const group = this.probeGroup(live);
880
+ if (group === "present") {
426
881
  if (this.deps.now() >= (live.cleanupDeadlineAt ?? 0)) {
427
882
  live.cancelGrace();
428
883
  live.quarantined = true;
429
- this.finish(id, TASK_STATUS.FAILED, `process cleanup unconfirmed after ${GROUP_CONFIRM_DEADLINE_MS}ms; capacity quarantined`);
884
+ this.finish(id, TASK_STATUS.FAILED, `process cleanup unconfirmed after ${GROUP_CONFIRM_DEADLINE_MS}ms; capacity quarantined`, live);
430
885
  return;
431
886
  }
432
887
  live.cancelGrace = this.deps.schedule(() => this.confirmGroupExit(id, live), GROUP_CONFIRM_MS);
433
888
  return;
434
889
  }
435
- if (live.childExit !== undefined) this.completeExit(id, live);
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);
436
912
  }
437
913
 
438
914
  private childError(id: string, error: Error): void {
@@ -445,10 +921,11 @@ export class AgentRunner {
445
921
  return;
446
922
  }
447
923
  live.permissionBroker?.close();
924
+ this.closeIpc(live);
448
925
  live.cancelStall();
449
926
  live.cancelGrace();
450
927
  this.live.delete(id);
451
- this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error.message}`);
928
+ this.finish(id, TASK_STATUS.FAILED, `could not start pi: ${error.message}`, live);
452
929
  }
453
930
 
454
931
  private exited(id: string, code: number | null): void {
@@ -464,6 +941,7 @@ export class AgentRunner {
464
941
 
465
942
  private completeExit(id: string, live: LiveTask): void {
466
943
  live.permissionBroker?.close();
944
+ this.closeIpc(live);
467
945
  live.cancelStall();
468
946
  live.cancelGrace();
469
947
  this.live.delete(id);
@@ -473,18 +951,62 @@ export class AgentRunner {
473
951
  return;
474
952
  }
475
953
  const terminal = live.terminal;
476
- this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled`);
954
+ this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled`, live);
477
955
  }
478
956
 
479
- private finish(id: string, status: TaskRecord["status"], error: string | null): void {
957
+ private finish(id: string, status: TaskRecord["status"], error: string | null, live?: LiveTask): void {
480
958
  const current = this.store.get(id);
481
- if (!current || isFinished(current.status)) return;
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
+ }
482
977
  const finished = this.store.update(id, { status, endedAt: this.deps.now(), error, lastStep: error ?? "done" });
483
978
  if (finished) {
484
- this.hooks.onFinish?.(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);
485
990
  for (const resolve of this.waiters.get(id) ?? []) resolve(finished);
486
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);
487
995
  }
488
996
  queueMicrotask(() => this.pump());
489
997
  }
490
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
+ }