gentle-pi 2.6.4 → 3.0.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 (128) hide show
  1. package/README.md +25 -7
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +41 -19
  18. package/docs/readme-reference.md +175 -36
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +289 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +123 -97
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +74 -113
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +19 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +88 -0
  47. package/lib/session-changes.ts +147 -0
  48. package/lib/shell-bar.ts +24 -10
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes-view.ts +2 -1
  51. package/lib/shell-changes.ts +5 -2
  52. package/lib/shell-prompt.ts +25 -8
  53. package/lib/shell-sidebar-banner.ts +2 -2
  54. package/lib/shell-sidebar-layout.ts +5 -2
  55. package/lib/windows-session-transport.ts +877 -0
  56. package/package.json +3 -3
  57. package/runtime/native-review-cli.mjs +18 -96
  58. package/runtime/windows-session-transport.ps1 +791 -0
  59. package/scripts/gentle-ai-installer.mjs +10 -10
  60. package/scripts/test-packed-runner.mjs +1668 -20
  61. package/scripts/verify-package-files.mjs +2 -3
  62. package/tests/agent-home.test.ts +52 -0
  63. package/tests/agent-profiles.test.ts +30 -1
  64. package/tests/agents-config.test.ts +44 -0
  65. package/tests/agents-history.test.ts +12 -24
  66. package/tests/agents-runner.test.ts +321 -58
  67. package/tests/agents-session-transport-process.test.ts +249 -0
  68. package/tests/agents-session-transport.test.ts +823 -0
  69. package/tests/artifact-language.test.ts +10 -7
  70. package/tests/command-palette.test.ts +378 -0
  71. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  72. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  73. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  74. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  75. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  76. package/tests/gentle-agents.test.ts +883 -356
  77. package/tests/gentle-ai-binary.test.ts +1 -1
  78. package/tests/gentle-ai-installer.test.ts +47 -47
  79. package/tests/gentle-ai.test.ts +472 -4
  80. package/tests/gentle-shell.test.ts +338 -205
  81. package/tests/native-choice-list.test.ts +13 -0
  82. package/tests/native-review-capability-contract.test.ts +15 -1
  83. package/tests/native-review-cli.test.ts +0 -33
  84. package/tests/odd-routing-contract.test.ts +208 -0
  85. package/tests/orchestrator-budget.test.ts +17 -2
  86. package/tests/package-manifest.test.ts +119 -31
  87. package/tests/persona-single-channel.test.ts +3 -3
  88. package/tests/pi-pretty.test.ts +45 -0
  89. package/tests/profile-pin.test.ts +370 -0
  90. package/tests/quiet-tool-rendering.test.ts +32 -5
  91. package/tests/review-contract-prompt.test.ts +9 -0
  92. package/tests/review-controller.test.ts +0 -44
  93. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  94. package/tests/runtime-harness.mjs +4 -4
  95. package/tests/sdd-agent-tools.test.ts +15 -36
  96. package/tests/sdd-archive-replay.test.ts +82 -0
  97. package/tests/sdd-classical-continuation.test.ts +74 -0
  98. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  99. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  100. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  101. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  102. package/tests/sdd-odd-integration.test.ts +33 -0
  103. package/tests/sdd-optional-research.test.ts +124 -0
  104. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  105. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  106. package/tests/sdd-preflight.test.ts +1 -1
  107. package/tests/sdd-research-capabilities.test.ts +20 -162
  108. package/tests/sdd-selection-transport.test.ts +180 -88
  109. package/tests/sdd-status.test.ts +5 -778
  110. package/tests/sdd-task-truth.test.ts +43 -0
  111. package/tests/session-change-capture.test.ts +86 -0
  112. package/tests/session-changes-shell.test.ts +38 -0
  113. package/tests/session-changes.test.ts +114 -0
  114. package/tests/shell-bar.test.ts +35 -0
  115. package/tests/shell-card.test.ts +8 -6
  116. package/tests/shell-changes.test.ts +8 -0
  117. package/tests/shell-prompt.test.ts +41 -7
  118. package/tests/shell-sidebar-banner.test.ts +4 -4
  119. package/tests/shell-sidebar-layout.test.ts +97 -13
  120. package/tests/startup-banner.test.ts +55 -2
  121. package/tests/windows-hidden-processes.test.ts +303 -0
  122. package/tests/windows-session-bootstrap.test.ts +1772 -0
  123. package/tests/windows-session-compile.test.ts +170 -0
  124. package/tests/windows-session-transport.test.ts +754 -0
  125. package/assets/agents/sdd-sync.md +0 -146
  126. package/lib/openspec-guardrails.ts +0 -99
  127. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  128. package/tests/openspec-guardrails.test.ts +0 -71
@@ -1,11 +1,11 @@
1
- import { createHash } from "node:crypto";
1
+ import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-changes.ts";
2
2
  import type { Duplex, Readable, Writable } from "node:stream";
3
3
  import { stripVTControlCharacters } from "node:util";
4
- import { RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, type ResearchArtifactIntent } from "./sdd-research-capabilities.ts";
4
+ import { RESEARCH_SELECTION_ENV } from "./sdd-research-capabilities.ts";
5
5
  import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
6
6
  import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
7
7
  import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
8
- import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type RemediationTaskState, type TaskStore } from "./agents-protocol.ts";
8
+ import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type AskRequest, type ChildResponseObservation, type TaskRecord, type TaskStore } from "./agents-protocol.ts";
9
9
 
10
10
  // Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
11
11
  // the host never runs subagent work on the TUI thread. It writes JSON
@@ -14,6 +14,7 @@ import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type
14
14
 
15
15
  export interface ChildLike {
16
16
  pid: number | undefined;
17
+ connected?: boolean;
17
18
  stdin: Writable;
18
19
  stdout: Readable;
19
20
  stderr: Readable | null | undefined;
@@ -31,7 +32,7 @@ export interface SpawnOptions {
31
32
  cwd: string;
32
33
  env: NodeJS.ProcessEnv;
33
34
  detached?: boolean;
34
- stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc">;
35
+ stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc" | "overlapped">;
35
36
  }
36
37
 
37
38
  export type Spawn = (command: string, args: string[], options: SpawnOptions) => ChildLike;
@@ -57,6 +58,9 @@ export interface RunnerDeps {
57
58
  export interface RunnerLimits {
58
59
  maxConcurrency: number;
59
60
  stallTimeoutMs: number;
61
+ // Longer ceiling used while an announced tool call is in flight. Optional so
62
+ // callers that only bound silence keep the idle budget as the tool ceiling.
63
+ toolStallTimeoutMs?: number;
60
64
  }
61
65
 
62
66
  export interface AskAnswer {
@@ -100,7 +104,7 @@ export interface RunnerHooks {
100
104
  onNotification?(task: TaskRecord, message: string): boolean | void;
101
105
  onQuery?(task: TaskRecord, requestId: string, message: string): boolean | void;
102
106
  // Parent-only observation of a paired successful filesystem tool, not prose.
103
- onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string }): void | Promise<void>;
107
+ onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string; evidence?: SessionChangeEvidence }): void | Promise<void>;
104
108
  }
105
109
 
106
110
  export interface RemediationHarnessPlan { command?: string; naReason?: string }
@@ -113,21 +117,6 @@ export interface RemediationPlan {
113
117
  runtimeHarness: RemediationHarnessPlan;
114
118
  rollback: RemediationRollbackPlan;
115
119
  }
116
- export interface RemediationObservation {
117
- slot: number;
118
- toolCallId: string;
119
- command: string;
120
- cwd: string;
121
- exitCode: number | null;
122
- result: string;
123
- }
124
- export interface RemediationObservations {
125
- failedEvidenceRevision: string;
126
- plan: RemediationPlan;
127
- observations: RemediationObservation[];
128
- pending: Record<string, number>;
129
- invalid: boolean;
130
- }
131
120
  const concrete = (value: unknown): value is string => typeof value === "string" && value.trim() === value && value.length > 3 && value.length <= 4096 && !/[\0\r\n]/.test(value);
132
121
  export function parseRemediationPlan(value: unknown, cwd: string): RemediationPlan {
133
122
  const plan = value as RemediationPlan;
@@ -137,65 +126,28 @@ export function parseRemediationPlan(value: unknown, cwd: string): RemediationPl
137
126
  return structuredClone(plan);
138
127
  }
139
128
  export const plannedCommands = (plan: RemediationPlan) => [...plan.commands, ...(plan.runtimeHarness.command ? [plan.runtimeHarness.command] : []), plan.rollback.command];
140
- const evidenceDigest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
141
-
142
- // Only paired stock-shell observations may fill this remediation-only plan.
143
- export function observeRemediationTool(state: RemediationObservations, raw: Record<string, unknown>): void {
144
- if (raw.toolName !== "bash" || typeof raw.toolCallId !== "string") return;
145
- const id = raw.toolCallId;
146
- if (raw.type === "tool_execution_start") {
147
- const command = (raw.args as { command?: unknown } | undefined)?.command;
148
- if (typeof command !== "string" || !plannedCommands(state.plan).includes(command)) return;
149
- if (Object.hasOwn(state.pending, id) || state.observations.some(item => item.toolCallId === id) || Object.keys(state.pending).length >= 32) { state.invalid = true; return; }
150
- const slot = plannedCommands(state.plan).findIndex((item, index) => item === command && !state.observations.some(observation => observation.slot === index) && !Object.values(state.pending).includes(index));
151
- if (slot < 0) { state.invalid = true; return; }
152
- state.pending[id] = slot;
153
- }
154
- if (raw.type !== "tool_execution_end" || !Object.hasOwn(state.pending, id)) return;
155
- const slot = state.pending[id];
156
- const command = plannedCommands(state.plan)[slot];
157
- delete state.pending[id];
158
- const result = raw.result as { content?: unknown; details?: { truncation?: unknown; fullOutputPath?: unknown; remediationCommand?: RemediationObservation & { truncated?: boolean } } } | undefined;
159
- const observed = result?.details?.remediationCommand;
160
- const output = JSON.stringify(result?.content ?? null);
161
- if (!observed || observed.toolCallId !== id || observed.command !== command || observed.cwd !== state.plan.cwd ||
162
- !(observed.exitCode === null || Number.isInteger(observed.exitCode)) || state.observations.length >= 32) { state.invalid = true; return; }
163
- const contentValid = Array.isArray(result?.content) && result.content.length > 0 && result.content.every(part =>
164
- part && typeof part === "object" && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string");
165
- if (raw.isError !== false || observed.exitCode !== 0 || observed.truncated || result?.details?.truncation || result?.details?.fullOutputPath || output.length > 16_000 || !contentValid) state.invalid = true;
166
- state.observations.push({ slot, toolCallId: id, command, cwd: observed.cwd, exitCode: observed.exitCode, result: `Observed command output ${evidenceDigest(output)}: ${output.slice(0, 400)}` });
167
- }
168
- export function remediationEvidence(state: RemediationObservations) {
169
- 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);
170
- 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;
171
- const result = (slot: number) => `cwd ${state.plan.cwd}; retained command observation ${evidenceDigest(JSON.stringify(find(slot)))}`;
172
- return {
173
- schema: "gentle-ai.remediation-evidence/v1",
174
- failed_evidence_revision: state.failedEvidenceRevision,
175
- commands: state.plan.commands.map((command, slot) => ({ command, exit_code: 0, result: result(slot) })),
176
- 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 },
177
- rollback: { boundary: state.plan.rollback.boundary, evidence: result(plannedCommands(state.plan).length - 1) },
178
- };
129
+ // Launch-local scope only; task history is not an attempt authority.
130
+ export interface RemediationContext {
131
+ failedEvidenceRevision: string;
132
+ plan: RemediationPlan;
133
+ scope: RemediationScope;
179
134
  }
180
135
 
181
136
  export interface SddChangeSelection {
182
137
  changeName: string;
183
138
  workspaceRoot: string;
184
- phase: "apply" | "verify" | "sync" | "archive" | "remediate";
139
+ phase: "apply" | "verify" | "archive" | "remediate";
185
140
  failedEvidenceRevision?: string;
186
141
  }
187
142
 
188
143
  export const SDD_CHANGE_FLAG = "--gentle-sdd-change";
189
144
 
190
- export interface RemediationTerminalFacts { spawned: boolean; exited: boolean; cleanupConfirmed: boolean }
191
-
192
145
  export const REMEDIATION_PLAN_ENV = "GENTLE_PI_SDD_REMEDIATION_PLAN";
193
146
 
194
147
  export interface TaskRequest {
195
148
  remediationIntent?: unknown;
196
- sddRemediation?: RemediationTaskState;
149
+ sddRemediation?: RemediationContext;
197
150
  sddPreflightContext?: string;
198
- finalizeRemediation?: (task: TaskRecord, facts: RemediationTerminalFacts) => Promise<void>;
199
151
  agent: AgentDefinition;
200
152
  prompt: string;
201
153
  label: string | undefined;
@@ -212,7 +164,6 @@ export interface TaskRequest {
212
164
  sddChange?: SddChangeSelection;
213
165
  // Untrusted narrowing intent; paths come only from matching host provenance.
214
166
  researchSelection?: unknown;
215
- researchArtifact?: ResearchArtifactIntent;
216
167
  extensionPaths?: string[];
217
168
  // Captures the originating session; invoked only after successful OS spawn.
218
169
  onLaunch?: () => void;
@@ -257,6 +208,7 @@ interface PendingReply {
257
208
 
258
209
  interface LiveTask {
259
210
  child: ChildLike;
211
+ sawRunEvent: boolean;
260
212
  observations?: ChildObservationBuffer;
261
213
  observationGuard?: () => boolean;
262
214
  observationPreparation?: () => boolean;
@@ -276,6 +228,10 @@ interface LiveTask {
276
228
  acknowledgedIpcIds: Set<string>;
277
229
  acknowledgedIpcOrder: string[];
278
230
  mutationStarts: Map<string, { toolName: "write" | "edit"; toolCallId: string; path: string }>;
231
+ // Tool calls the child announced and has not ended yet. A call in flight is
232
+ // live work, so the watchdog gives it the tool ceiling instead of the idle
233
+ // silence budget. Keyed by call id, holding the announced tool name.
234
+ inFlightTools: Map<string, string>;
279
235
  // Bounded ring buffer of the child's raw stderr output, capped to the last
280
236
  // STDERR_TAIL_MAX characters. Only surfaced on the stall and pre-settle exit
281
237
  // terminal paths, never on completed, cancelled, or other failure reasons.
@@ -326,7 +282,7 @@ export function childArguments(request: TaskRequest): string[] {
326
282
  if (request.resumeSessionPath) args.push("--session", request.resumeSessionPath);
327
283
  if (request.model) args.push("--model", request.thinking ? `${formatModelRef(request.model)}:${request.thinking}` : formatModelRef(request.model));
328
284
  else if (request.thinking) args.push("--thinking", request.thinking);
329
- const tools = request.agent.tools.length > 0 ? [...new Set([...request.agent.tools, PARENT_NOTIFICATION_TOOL])] : DEFAULT_TOOLS;
285
+ const tools = request.agent.tools.length > 0 || request.agent.name === "sdd-research" ? [...new Set([...request.agent.tools, PARENT_NOTIFICATION_TOOL])] : DEFAULT_TOOLS;
330
286
  if (tools.length > 0) args.push("--tools", tools.join(","));
331
287
  if (request.agent.instructions.length > 0) args.push("--append-system-prompt", request.agent.instructions);
332
288
  return args;
@@ -373,7 +329,7 @@ export class JsonLines {
373
329
 
374
330
  export function promptText(request: TaskRequest): string {
375
331
  const prompt = request.context ? `${request.prompt}\n\n## Context\n${request.context}` : request.prompt;
376
- 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;
332
+ return request.sddRemediation ? `${prompt}\n\n## Human-authorized remediation plan\nExecute only these exact commands in the selected cwd; report actual results without claiming native verification.\n${JSON.stringify(request.sddRemediation.plan)}\nFailed evidence: ${request.sddRemediation.failedEvidenceRevision}` : prompt;
377
333
  }
378
334
 
379
335
  export class AgentRunner {
@@ -384,8 +340,6 @@ export class AgentRunner {
384
340
  private readonly processControl: ProcessControl;
385
341
  private readonly queue: Array<{ task: TaskRecord; request: TaskRequest }> = [];
386
342
  private readonly live = new Map<string, LiveTask>();
387
- private readonly remediationFinalizers = new Map<string, NonNullable<TaskRequest["finalizeRemediation"]>>();
388
- private readonly finalizingRemediation = new Set<string>();
389
343
  private readonly waiters = new Map<string, Array<(task: TaskRecord) => void>>();
390
344
  private readonly queryWaiters = new Map<string, Array<(query: TaskQuery | undefined) => void>>();
391
345
  private readonly firstQueries = new Map<string, TaskQuery>();
@@ -399,22 +353,15 @@ export class AgentRunner {
399
353
  this.processControl = deps.process ?? hostProcess;
400
354
  }
401
355
 
402
- prepareRemediation(request: TaskRequest): TaskRecord {
403
- 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");
404
- return this.createTask(request);
405
- }
406
-
407
356
  private createTask(request: TaskRequest): TaskRecord {
408
357
  const now = this.deps.now();
409
358
  this.counter += 1;
410
359
  const task: TaskRecord = {
411
360
  id: `${now.toString(36)}-${this.counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
412
361
  agent: request.agent.name,
413
- ...(request.sddRemediation ? { sddRemediation: structuredClone(request.sddRemediation) } : {}),
414
362
  ...(request.sddPreflightContext ? { sddPreflightContext: request.sddPreflightContext } : {}),
415
363
  mode: request.mode,
416
364
  prompt: request.prompt,
417
- ...(request.researchArtifact ? { researchArtifact: structuredClone(request.researchArtifact) } : {}),
418
365
  label: taskLabel(request.prompt, request.label),
419
366
  cwd: request.cwd,
420
367
  parentSessionId: request.parentSessionId,
@@ -438,17 +385,22 @@ export class AgentRunner {
438
385
  return task;
439
386
  }
440
387
 
441
- run(request: TaskRequest, preparedRemediation?: TaskRecord): TaskRecord {
442
- 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");
443
- const task = preparedRemediation ?? this.createTask(request);
444
- if (request.sddRemediation) task.sddRemediation = structuredClone(request.sddRemediation);
445
- if (request.finalizeRemediation) this.remediationFinalizers.set(task.id, request.finalizeRemediation);
388
+ run(request: TaskRequest): TaskRecord {
389
+ // Admission already confirmed the canonical cwd and human edit scope.
390
+ // Check this runner's queue/live slots before scheduling any launch: history
391
+ // is not a lock, and quarantined children still own their live slot.
392
+ if (request.sddRemediation) {
393
+ const active = [...this.queue.map(entry => entry.task), ...[...this.live.keys()].map(id => this.store.get(id))];
394
+ if (active.some(task => task?.agent === "sdd-remediate" && task.cwd === request.cwd)) {
395
+ throw new Error("Remediation already queued or running in this worktree; wait for confirmed cleanup or cancel the active task before requesting fresh authorization");
396
+ }
397
+ }
398
+ const task = this.createTask(request);
446
399
  // A caller can retain and mutate its request after dispatch. Preserve only
447
400
  // the identity selected at construction for this child launch.
448
401
  const launchRequest = {
449
402
  ...request,
450
403
  sddChange: request.sddChange && { ...request.sddChange },
451
- researchArtifact: request.researchArtifact && structuredClone(request.researchArtifact),
452
404
  };
453
405
  this.queue.push({ task, request: launchRequest });
454
406
  queueMicrotask(() => this.pump());
@@ -538,9 +490,10 @@ export class AgentRunner {
538
490
  private launch(id: string, request: TaskRequest): void {
539
491
  const detached = this.processControl.platform !== "win32";
540
492
  const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
493
+ const permissionChannelStdio = this.processControl.platform === "win32" ? "overlapped" : "pipe";
541
494
  const env = {
542
495
  ...request.env,
543
- ...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null), [RESEARCH_ARTIFACT_ENV]: JSON.stringify(request.researchArtifact ?? null) } : {}),
496
+ ...(request.extensionPaths ? { [RESEARCH_SELECTION_ENV]: JSON.stringify(request.researchSelection ?? null) } : {}),
544
497
  [CHILD_MARKER]: "1",
545
498
  [IPC_MARKER]: `${this.deps.now()}-${Math.random().toString(36).slice(2)}`,
546
499
  ...(hasParentPermissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}),
@@ -553,7 +506,7 @@ export class AgentRunner {
553
506
  cwd: request.cwd,
554
507
  env,
555
508
  detached,
556
- stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
509
+ stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
557
510
  });
558
511
  } catch (error) {
559
512
  this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
@@ -561,7 +514,7 @@ export class AgentRunner {
561
514
  return;
562
515
  }
563
516
  const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
564
- 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: [], stderrTail: "" };
517
+ const live: LiveTask = { child, sawRunEvent: false, mutationStarts: new Map(), inFlightTools: 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: [], stderrTail: "" };
565
518
  if (request.prepareResponseObservations) {
566
519
  let ready = false;
567
520
  live.observationPreparation = () => ready;
@@ -644,13 +597,23 @@ export class AgentRunner {
644
597
  return cleaned ? `; stderr: ${cleaned}` : "";
645
598
  }
646
599
 
600
+ // An idle child is bounded by the silence budget; a child whose announced
601
+ // tool call is still running is live work and bounded by the longer tool
602
+ // ceiling. The budget is chosen from the state at arm time, and every RPC
603
+ // object re-arms, so a finished tool call returns the task to idle silence.
647
604
  private armStall(id: string, live: LiveTask): void {
648
605
  live.cancelStall();
606
+ const tool = live.inFlightTools.values().next().value;
607
+ const budget = tool === undefined ? this.limits.stallTimeoutMs : Math.max(this.limits.toolStallTimeoutMs ?? this.limits.stallTimeoutMs, this.limits.stallTimeoutMs);
649
608
  live.cancelStall = this.deps.schedule(() => {
650
609
  const lastStep = this.store.get(id)?.lastStep ?? "starting";
651
- const minutes = Math.round(this.limits.stallTimeoutMs / 60_000);
652
- this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${minutes} min after: ${lastStep}${this.stderrSuffix(live)}`);
653
- }, this.limits.stallTimeoutMs);
610
+ const minutes = Math.round(budget / 60_000);
611
+ if (tool === undefined) {
612
+ const boundary = lastStep === "prompt accepted" && !live.sawRunEvent ? `; no first run event received for model: ${this.store.get(id)?.model}` : "";
613
+ this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${minutes} min after: ${lastStep}${boundary}${this.stderrSuffix(live)}`);
614
+ }
615
+ else this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${minutes} min with tool "${tool}" still running after: ${lastStep}${this.stderrSuffix(live)}`);
616
+ }, budget);
654
617
  }
655
618
 
656
619
  private send(id: string, command: Record<string, unknown>): Promise<Record<string, unknown>> {
@@ -758,8 +721,10 @@ export class AgentRunner {
758
721
  for (const pending of live.replies.values()) pending.resolve(false);
759
722
  live.replies.clear();
760
723
  live.child.channel?.unref?.();
761
- try { live.child.disconnect?.(); }
762
- catch { /* Channel may already be disconnected. */ }
724
+ if (live.child.connected !== false) {
725
+ try { live.child.disconnect?.(); }
726
+ catch { /* Channel may already be disconnected. */ }
727
+ }
763
728
  }
764
729
 
765
730
  private write(live: LiveTask, payload: Record<string, unknown>): void {
@@ -787,10 +752,8 @@ export class AgentRunner {
787
752
  const live = this.live.get(id);
788
753
  if (!live || live.terminal || !value || typeof value !== "object") return;
789
754
  const raw = value as Record<string, unknown>;
790
- const remediation = this.store.get(id)?.sddRemediation;
791
- if (remediation) observeRemediationTool(remediation, raw);
792
- this.armStall(id, live);
793
755
  if (raw.type === "response") {
756
+ this.armStall(id, live);
794
757
  if (!live.observationPreparation) this.checkObservationGrant(live);
795
758
  const pending = typeof raw.id === "string" ? live.pending.get(raw.id) : undefined;
796
759
  if (pending) {
@@ -800,7 +763,13 @@ export class AgentRunner {
800
763
  return;
801
764
  }
802
765
  this.checkObservationGrant(live);
803
- for (const event of normalizeRpcEvent(raw, { observeResponses: live.observations !== undefined })) {
766
+ const events = normalizeRpcEvent(raw, { observeResponses: live.observations !== undefined });
767
+ // A parsed object is not progress by itself. Only a recognized run event
768
+ // renews the watchdog here, so fire-and-forget UI traffic that normalizes
769
+ // to nothing cannot keep a child that never started its run alive forever
770
+ // (#1034); the pre-existing timer stays armed until real progress arrives.
771
+ const progress = events.length > 0;
772
+ for (const event of events) {
804
773
  if (event.type === TASK_EVENT.RESPONSE_OBSERVATION) {
805
774
  const buffer = live.observations;
806
775
  if (buffer) {
@@ -809,19 +778,26 @@ export class AgentRunner {
809
778
  }
810
779
  continue; // Separate from store persistence, UI totals and notifications.
811
780
  }
781
+ live.sawRunEvent = true;
812
782
  this.store.apply(id, event, this.deps.now());
813
783
  if (event.type === TASK_EVENT.TOOL_START && event.callId) {
784
+ live.inFlightTools.set(event.callId, event.name);
814
785
  live.mutationStarts.delete(event.callId);
815
786
  if ((event.name === "write" || event.name === "edit") && typeof event.args.path === "string" && event.args.path.trim()) {
816
787
  live.mutationStarts.set(event.callId, { toolName: event.name, toolCallId: event.callId, path: event.args.path });
817
788
  }
818
789
  }
819
790
  if (event.type === TASK_EVENT.TOOL_END) {
791
+ live.inFlightTools.delete(event.callId);
820
792
  const mutation = live.mutationStarts.get(event.callId);
821
793
  live.mutationStarts.delete(event.callId);
822
794
  const task = this.store.get(id);
823
795
  if (mutation && task && raw.isError === false && !event.isError) {
824
- try { void Promise.resolve(this.hooks.onSuccessfulMutation?.(task, mutation)).catch(() => {}); }
796
+ try {
797
+ const evidence = (raw.result as { details?: { gentleSessionChange?: unknown } } | undefined)?.details?.gentleSessionChange;
798
+ const observed = isSessionChangeEvidence(evidence) && evidence.id === mutation.toolCallId ? { ...mutation, evidence: structuredClone(evidence) } : mutation;
799
+ void Promise.resolve(this.hooks.onSuccessfulMutation?.(task, observed)).catch(() => {});
800
+ }
825
801
  catch { /* Bookkeeping failure must not rewrite a successful tool or stop the child. */ }
826
802
  }
827
803
  }
@@ -834,6 +810,7 @@ export class AgentRunner {
834
810
  else this.requestStop(id, TASK_STATUS.FAILED, "assistant settled without a final report");
835
811
  }
836
812
  }
813
+ if (!live.terminal && progress) this.armStall(id, live);
837
814
  }
838
815
 
839
816
  // Task-mode subagents may ask the human through the host; background ones
@@ -876,6 +853,7 @@ export class AgentRunner {
876
853
  if (!live || live.terminal) return;
877
854
  live.terminal = { status, error };
878
855
  live.mutationStarts.clear();
856
+ live.inFlightTools.clear();
879
857
  live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
880
858
  live.permissionBroker?.close();
881
859
  this.closeIpc(live);
@@ -989,24 +967,7 @@ export class AgentRunner {
989
967
 
990
968
  private finish(id: string, status: TaskRecord["status"], error: string | null, live?: LiveTask): void {
991
969
  const current = this.store.get(id);
992
- if (!current || isFinished(current.status) || this.finalizingRemediation.has(id)) return;
993
- const finalize = this.remediationFinalizers.get(id);
994
- if (finalize) {
995
- this.finalizingRemediation.add(id);
996
- const terminal = { ...current, status, error };
997
- void finalize(terminal, { spawned: typeof live?.child.pid === "number", exited: live?.childExit !== undefined, cleanupConfirmed: !live || !live.quarantined && live.childExit !== undefined }).then(() => {
998
- status = terminal.status;
999
- error = terminal.error;
1000
- }).catch(() => {
1001
- status = TASK_STATUS.FAILED;
1002
- error = "Native remediation settlement could not be durably finalized; retain task history and reconcile before further execution";
1003
- }).finally(() => {
1004
- this.remediationFinalizers.delete(id);
1005
- this.finalizingRemediation.delete(id);
1006
- this.finish(id, status, error, live);
1007
- });
1008
- return;
1009
- }
970
+ if (!current || isFinished(current.status)) return;
1010
971
  const finished = this.store.update(id, { status, endedAt: this.deps.now(), error, lastStep: error ?? "done" });
1011
972
  if (finished) {
1012
973
  if (live) this.checkObservationGrant(live);