gentle-pi 2.7.0 → 3.1.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 (126) hide show
  1. package/README.md +24 -6
  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 +14 -4
  18. package/docs/readme-reference.md +175 -36
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  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 +67 -111
  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 +28 -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 +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +27 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/gentle-ai-installer.mjs +12 -12
  59. package/scripts/test-packed-runner.mjs +1668 -20
  60. package/scripts/verify-package-files.mjs +2 -3
  61. package/tests/agent-home.test.ts +52 -0
  62. package/tests/agent-profiles.test.ts +30 -1
  63. package/tests/agents-config.test.ts +44 -0
  64. package/tests/agents-history.test.ts +12 -24
  65. package/tests/agents-runner.test.ts +307 -58
  66. package/tests/agents-session-transport-process.test.ts +249 -0
  67. package/tests/agents-session-transport.test.ts +823 -0
  68. package/tests/artifact-language.test.ts +10 -7
  69. package/tests/command-palette.test.ts +378 -0
  70. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  71. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  72. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  73. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  74. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  75. package/tests/gentle-agents.test.ts +849 -356
  76. package/tests/gentle-ai-binary.test.ts +3 -3
  77. package/tests/gentle-ai-installer.test.ts +51 -51
  78. package/tests/gentle-ai.test.ts +472 -4
  79. package/tests/gentle-shell.test.ts +201 -8
  80. package/tests/native-choice-list.test.ts +13 -0
  81. package/tests/native-review-capability-contract.test.ts +30 -1
  82. package/tests/native-review-cli.test.ts +0 -33
  83. package/tests/odd-routing-contract.test.ts +208 -0
  84. package/tests/orchestrator-budget.test.ts +17 -2
  85. package/tests/package-manifest.test.ts +119 -31
  86. package/tests/persona-single-channel.test.ts +3 -3
  87. package/tests/pi-pretty.test.ts +45 -0
  88. package/tests/profile-pin.test.ts +370 -0
  89. package/tests/quiet-tool-rendering.test.ts +32 -5
  90. package/tests/review-contract-prompt.test.ts +9 -0
  91. package/tests/review-controller.test.ts +0 -44
  92. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  93. package/tests/runtime-harness.mjs +4 -4
  94. package/tests/sdd-agent-tools.test.ts +15 -36
  95. package/tests/sdd-archive-replay.test.ts +82 -0
  96. package/tests/sdd-classical-continuation.test.ts +74 -0
  97. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  98. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  99. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  100. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  101. package/tests/sdd-odd-integration.test.ts +33 -0
  102. package/tests/sdd-optional-research.test.ts +124 -0
  103. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  104. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  105. package/tests/sdd-preflight.test.ts +1 -1
  106. package/tests/sdd-research-capabilities.test.ts +20 -162
  107. package/tests/sdd-selection-transport.test.ts +180 -88
  108. package/tests/sdd-status.test.ts +5 -778
  109. package/tests/sdd-task-truth.test.ts +43 -0
  110. package/tests/session-change-capture.test.ts +20 -2
  111. package/tests/session-changes.test.ts +11 -0
  112. package/tests/shell-bar.test.ts +21 -0
  113. package/tests/shell-card.test.ts +8 -6
  114. package/tests/shell-changes.test.ts +8 -0
  115. package/tests/shell-prompt.test.ts +41 -7
  116. package/tests/shell-sidebar-banner.test.ts +4 -4
  117. package/tests/shell-sidebar-layout.test.ts +97 -13
  118. package/tests/startup-banner.test.ts +55 -2
  119. package/tests/windows-hidden-processes.test.ts +303 -0
  120. package/tests/windows-session-bootstrap.test.ts +1772 -0
  121. package/tests/windows-session-compile.test.ts +170 -0
  122. package/tests/windows-session-transport.test.ts +754 -0
  123. package/assets/agents/sdd-sync.md +0 -146
  124. package/lib/openspec-guardrails.ts +0 -99
  125. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  126. package/tests/openspec-guardrails.test.ts +0 -71
@@ -1,12 +1,11 @@
1
- import { createHash } from "node:crypto";
2
1
  import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-changes.ts";
3
2
  import type { Duplex, Readable, Writable } from "node:stream";
4
3
  import { stripVTControlCharacters } from "node:util";
5
- import { RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, type ResearchArtifactIntent } from "./sdd-research-capabilities.ts";
4
+ import { RESEARCH_SELECTION_ENV } from "./sdd-research-capabilities.ts";
6
5
  import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
7
6
  import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
8
7
  import { ParentStandingReviewPermissionBroker } from "./review-session-standing-permission-ipc.ts";
9
- 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";
10
9
 
11
10
  // Gentle Agents runner. Every subagent is its own `pi --mode rpc` process:
12
11
  // the host never runs subagent work on the TUI thread. It writes JSON
@@ -15,6 +14,7 @@ import { isFinished, normalizeRpcEvent, TASK_EVENT, TASK_STATUS, taskLabel, type
15
14
 
16
15
  export interface ChildLike {
17
16
  pid: number | undefined;
17
+ connected?: boolean;
18
18
  stdin: Writable;
19
19
  stdout: Readable;
20
20
  stderr: Readable | null | undefined;
@@ -32,7 +32,7 @@ export interface SpawnOptions {
32
32
  cwd: string;
33
33
  env: NodeJS.ProcessEnv;
34
34
  detached?: boolean;
35
- stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc">;
35
+ stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc" | "overlapped">;
36
36
  }
37
37
 
38
38
  export type Spawn = (command: string, args: string[], options: SpawnOptions) => ChildLike;
@@ -58,6 +58,9 @@ export interface RunnerDeps {
58
58
  export interface RunnerLimits {
59
59
  maxConcurrency: number;
60
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;
61
64
  }
62
65
 
63
66
  export interface AskAnswer {
@@ -114,21 +117,6 @@ export interface RemediationPlan {
114
117
  runtimeHarness: RemediationHarnessPlan;
115
118
  rollback: RemediationRollbackPlan;
116
119
  }
117
- export interface RemediationObservation {
118
- slot: number;
119
- toolCallId: string;
120
- command: string;
121
- cwd: string;
122
- exitCode: number | null;
123
- result: string;
124
- }
125
- export interface RemediationObservations {
126
- failedEvidenceRevision: string;
127
- plan: RemediationPlan;
128
- observations: RemediationObservation[];
129
- pending: Record<string, number>;
130
- invalid: boolean;
131
- }
132
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);
133
121
  export function parseRemediationPlan(value: unknown, cwd: string): RemediationPlan {
134
122
  const plan = value as RemediationPlan;
@@ -138,65 +126,28 @@ export function parseRemediationPlan(value: unknown, cwd: string): RemediationPl
138
126
  return structuredClone(plan);
139
127
  }
140
128
  export const plannedCommands = (plan: RemediationPlan) => [...plan.commands, ...(plan.runtimeHarness.command ? [plan.runtimeHarness.command] : []), plan.rollback.command];
141
- const evidenceDigest = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
142
-
143
- // Only paired stock-shell observations may fill this remediation-only plan.
144
- export function observeRemediationTool(state: RemediationObservations, raw: Record<string, unknown>): void {
145
- if (raw.toolName !== "bash" || typeof raw.toolCallId !== "string") return;
146
- const id = raw.toolCallId;
147
- if (raw.type === "tool_execution_start") {
148
- const command = (raw.args as { command?: unknown } | undefined)?.command;
149
- if (typeof command !== "string" || !plannedCommands(state.plan).includes(command)) return;
150
- if (Object.hasOwn(state.pending, id) || state.observations.some(item => item.toolCallId === id) || Object.keys(state.pending).length >= 32) { state.invalid = true; return; }
151
- const slot = plannedCommands(state.plan).findIndex((item, index) => item === command && !state.observations.some(observation => observation.slot === index) && !Object.values(state.pending).includes(index));
152
- if (slot < 0) { state.invalid = true; return; }
153
- state.pending[id] = slot;
154
- }
155
- if (raw.type !== "tool_execution_end" || !Object.hasOwn(state.pending, id)) return;
156
- const slot = state.pending[id];
157
- const command = plannedCommands(state.plan)[slot];
158
- delete state.pending[id];
159
- const result = raw.result as { content?: unknown; details?: { truncation?: unknown; fullOutputPath?: unknown; remediationCommand?: RemediationObservation & { truncated?: boolean } } } | undefined;
160
- const observed = result?.details?.remediationCommand;
161
- const output = JSON.stringify(result?.content ?? null);
162
- if (!observed || observed.toolCallId !== id || observed.command !== command || observed.cwd !== state.plan.cwd ||
163
- !(observed.exitCode === null || Number.isInteger(observed.exitCode)) || state.observations.length >= 32) { state.invalid = true; return; }
164
- const contentValid = Array.isArray(result?.content) && result.content.length > 0 && result.content.every(part =>
165
- part && typeof part === "object" && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string");
166
- if (raw.isError !== false || observed.exitCode !== 0 || observed.truncated || result?.details?.truncation || result?.details?.fullOutputPath || output.length > 16_000 || !contentValid) state.invalid = true;
167
- state.observations.push({ slot, toolCallId: id, command, cwd: observed.cwd, exitCode: observed.exitCode, result: `Observed command output ${evidenceDigest(output)}: ${output.slice(0, 400)}` });
168
- }
169
- export function remediationEvidence(state: RemediationObservations) {
170
- 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);
171
- 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;
172
- const result = (slot: number) => `cwd ${state.plan.cwd}; retained command observation ${evidenceDigest(JSON.stringify(find(slot)))}`;
173
- return {
174
- schema: "gentle-ai.remediation-evidence/v1",
175
- failed_evidence_revision: state.failedEvidenceRevision,
176
- commands: state.plan.commands.map((command, slot) => ({ command, exit_code: 0, result: result(slot) })),
177
- 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 },
178
- rollback: { boundary: state.plan.rollback.boundary, evidence: result(plannedCommands(state.plan).length - 1) },
179
- };
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;
180
134
  }
181
135
 
182
136
  export interface SddChangeSelection {
183
137
  changeName: string;
184
138
  workspaceRoot: string;
185
- phase: "apply" | "verify" | "sync" | "archive" | "remediate";
139
+ phase: "apply" | "verify" | "archive" | "remediate";
186
140
  failedEvidenceRevision?: string;
187
141
  }
188
142
 
189
143
  export const SDD_CHANGE_FLAG = "--gentle-sdd-change";
190
144
 
191
- export interface RemediationTerminalFacts { spawned: boolean; exited: boolean; cleanupConfirmed: boolean }
192
-
193
145
  export const REMEDIATION_PLAN_ENV = "GENTLE_PI_SDD_REMEDIATION_PLAN";
194
146
 
195
147
  export interface TaskRequest {
196
148
  remediationIntent?: unknown;
197
- sddRemediation?: RemediationTaskState;
149
+ sddRemediation?: RemediationContext;
198
150
  sddPreflightContext?: string;
199
- finalizeRemediation?: (task: TaskRecord, facts: RemediationTerminalFacts) => Promise<void>;
200
151
  agent: AgentDefinition;
201
152
  prompt: string;
202
153
  label: string | undefined;
@@ -213,7 +164,6 @@ export interface TaskRequest {
213
164
  sddChange?: SddChangeSelection;
214
165
  // Untrusted narrowing intent; paths come only from matching host provenance.
215
166
  researchSelection?: unknown;
216
- researchArtifact?: ResearchArtifactIntent;
217
167
  extensionPaths?: string[];
218
168
  // Captures the originating session; invoked only after successful OS spawn.
219
169
  onLaunch?: () => void;
@@ -258,6 +208,7 @@ interface PendingReply {
258
208
 
259
209
  interface LiveTask {
260
210
  child: ChildLike;
211
+ sawRunEvent: boolean;
261
212
  observations?: ChildObservationBuffer;
262
213
  observationGuard?: () => boolean;
263
214
  observationPreparation?: () => boolean;
@@ -277,6 +228,10 @@ interface LiveTask {
277
228
  acknowledgedIpcIds: Set<string>;
278
229
  acknowledgedIpcOrder: string[];
279
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>;
280
235
  // Bounded ring buffer of the child's raw stderr output, capped to the last
281
236
  // STDERR_TAIL_MAX characters. Only surfaced on the stall and pre-settle exit
282
237
  // terminal paths, never on completed, cancelled, or other failure reasons.
@@ -327,7 +282,7 @@ export function childArguments(request: TaskRequest): string[] {
327
282
  if (request.resumeSessionPath) args.push("--session", request.resumeSessionPath);
328
283
  if (request.model) args.push("--model", request.thinking ? `${formatModelRef(request.model)}:${request.thinking}` : formatModelRef(request.model));
329
284
  else if (request.thinking) args.push("--thinking", request.thinking);
330
- 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;
331
286
  if (tools.length > 0) args.push("--tools", tools.join(","));
332
287
  if (request.agent.instructions.length > 0) args.push("--append-system-prompt", request.agent.instructions);
333
288
  return args;
@@ -374,7 +329,7 @@ export class JsonLines {
374
329
 
375
330
  export function promptText(request: TaskRequest): string {
376
331
  const prompt = request.context ? `${request.prompt}\n\n## Context\n${request.context}` : request.prompt;
377
- 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;
378
333
  }
379
334
 
380
335
  export class AgentRunner {
@@ -385,8 +340,6 @@ export class AgentRunner {
385
340
  private readonly processControl: ProcessControl;
386
341
  private readonly queue: Array<{ task: TaskRecord; request: TaskRequest }> = [];
387
342
  private readonly live = new Map<string, LiveTask>();
388
- private readonly remediationFinalizers = new Map<string, NonNullable<TaskRequest["finalizeRemediation"]>>();
389
- private readonly finalizingRemediation = new Set<string>();
390
343
  private readonly waiters = new Map<string, Array<(task: TaskRecord) => void>>();
391
344
  private readonly queryWaiters = new Map<string, Array<(query: TaskQuery | undefined) => void>>();
392
345
  private readonly firstQueries = new Map<string, TaskQuery>();
@@ -400,22 +353,15 @@ export class AgentRunner {
400
353
  this.processControl = deps.process ?? hostProcess;
401
354
  }
402
355
 
403
- prepareRemediation(request: TaskRequest): TaskRecord {
404
- 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");
405
- return this.createTask(request);
406
- }
407
-
408
356
  private createTask(request: TaskRequest): TaskRecord {
409
357
  const now = this.deps.now();
410
358
  this.counter += 1;
411
359
  const task: TaskRecord = {
412
360
  id: `${now.toString(36)}-${this.counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
413
361
  agent: request.agent.name,
414
- ...(request.sddRemediation ? { sddRemediation: structuredClone(request.sddRemediation) } : {}),
415
362
  ...(request.sddPreflightContext ? { sddPreflightContext: request.sddPreflightContext } : {}),
416
363
  mode: request.mode,
417
364
  prompt: request.prompt,
418
- ...(request.researchArtifact ? { researchArtifact: structuredClone(request.researchArtifact) } : {}),
419
365
  label: taskLabel(request.prompt, request.label),
420
366
  cwd: request.cwd,
421
367
  parentSessionId: request.parentSessionId,
@@ -439,17 +385,22 @@ export class AgentRunner {
439
385
  return task;
440
386
  }
441
387
 
442
- run(request: TaskRequest, preparedRemediation?: TaskRecord): TaskRecord {
443
- 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");
444
- const task = preparedRemediation ?? this.createTask(request);
445
- if (request.sddRemediation) task.sddRemediation = structuredClone(request.sddRemediation);
446
- 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);
447
399
  // A caller can retain and mutate its request after dispatch. Preserve only
448
400
  // the identity selected at construction for this child launch.
449
401
  const launchRequest = {
450
402
  ...request,
451
403
  sddChange: request.sddChange && { ...request.sddChange },
452
- researchArtifact: request.researchArtifact && structuredClone(request.researchArtifact),
453
404
  };
454
405
  this.queue.push({ task, request: launchRequest });
455
406
  queueMicrotask(() => this.pump());
@@ -539,9 +490,10 @@ export class AgentRunner {
539
490
  private launch(id: string, request: TaskRequest): void {
540
491
  const detached = this.processControl.platform !== "win32";
541
492
  const hasParentPermissionChannel = request.authorizeParentStandingReviewPermission !== undefined;
493
+ const permissionChannelStdio = this.processControl.platform === "win32" ? "overlapped" : "pipe";
542
494
  const env = {
543
495
  ...request.env,
544
- ...(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) } : {}),
545
497
  [CHILD_MARKER]: "1",
546
498
  [IPC_MARKER]: `${this.deps.now()}-${Math.random().toString(36).slice(2)}`,
547
499
  ...(hasParentPermissionChannel ? { GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" } : {}),
@@ -554,7 +506,7 @@ export class AgentRunner {
554
506
  cwd: request.cwd,
555
507
  env,
556
508
  detached,
557
- stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
509
+ stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"] : ["pipe", "pipe", "pipe", "ipc"],
558
510
  });
559
511
  } catch (error) {
560
512
  this.store.update(id, { status: TASK_STATUS.RUNNING, startedAt: this.deps.now(), lastStep: "starting" });
@@ -562,7 +514,7 @@ export class AgentRunner {
562
514
  return;
563
515
  }
564
516
  const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
565
- 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: "" };
566
518
  if (request.prepareResponseObservations) {
567
519
  let ready = false;
568
520
  live.observationPreparation = () => ready;
@@ -645,13 +597,23 @@ export class AgentRunner {
645
597
  return cleaned ? `; stderr: ${cleaned}` : "";
646
598
  }
647
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.
648
604
  private armStall(id: string, live: LiveTask): void {
649
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);
650
608
  live.cancelStall = this.deps.schedule(() => {
651
609
  const lastStep = this.store.get(id)?.lastStep ?? "starting";
652
- const minutes = Math.round(this.limits.stallTimeoutMs / 60_000);
653
- this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${minutes} min after: ${lastStep}${this.stderrSuffix(live)}`);
654
- }, 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);
655
617
  }
656
618
 
657
619
  private send(id: string, command: Record<string, unknown>): Promise<Record<string, unknown>> {
@@ -759,8 +721,10 @@ export class AgentRunner {
759
721
  for (const pending of live.replies.values()) pending.resolve(false);
760
722
  live.replies.clear();
761
723
  live.child.channel?.unref?.();
762
- try { live.child.disconnect?.(); }
763
- 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
+ }
764
728
  }
765
729
 
766
730
  private write(live: LiveTask, payload: Record<string, unknown>): void {
@@ -788,10 +752,8 @@ export class AgentRunner {
788
752
  const live = this.live.get(id);
789
753
  if (!live || live.terminal || !value || typeof value !== "object") return;
790
754
  const raw = value as Record<string, unknown>;
791
- const remediation = this.store.get(id)?.sddRemediation;
792
- if (remediation) observeRemediationTool(remediation, raw);
793
- this.armStall(id, live);
794
755
  if (raw.type === "response") {
756
+ this.armStall(id, live);
795
757
  if (!live.observationPreparation) this.checkObservationGrant(live);
796
758
  const pending = typeof raw.id === "string" ? live.pending.get(raw.id) : undefined;
797
759
  if (pending) {
@@ -801,7 +763,13 @@ export class AgentRunner {
801
763
  return;
802
764
  }
803
765
  this.checkObservationGrant(live);
804
- 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) {
805
773
  if (event.type === TASK_EVENT.RESPONSE_OBSERVATION) {
806
774
  const buffer = live.observations;
807
775
  if (buffer) {
@@ -810,14 +778,17 @@ export class AgentRunner {
810
778
  }
811
779
  continue; // Separate from store persistence, UI totals and notifications.
812
780
  }
781
+ live.sawRunEvent = true;
813
782
  this.store.apply(id, event, this.deps.now());
814
783
  if (event.type === TASK_EVENT.TOOL_START && event.callId) {
784
+ live.inFlightTools.set(event.callId, event.name);
815
785
  live.mutationStarts.delete(event.callId);
816
786
  if ((event.name === "write" || event.name === "edit") && typeof event.args.path === "string" && event.args.path.trim()) {
817
787
  live.mutationStarts.set(event.callId, { toolName: event.name, toolCallId: event.callId, path: event.args.path });
818
788
  }
819
789
  }
820
790
  if (event.type === TASK_EVENT.TOOL_END) {
791
+ live.inFlightTools.delete(event.callId);
821
792
  const mutation = live.mutationStarts.get(event.callId);
822
793
  live.mutationStarts.delete(event.callId);
823
794
  const task = this.store.get(id);
@@ -839,6 +810,7 @@ export class AgentRunner {
839
810
  else this.requestStop(id, TASK_STATUS.FAILED, "assistant settled without a final report");
840
811
  }
841
812
  }
813
+ if (!live.terminal && progress) this.armStall(id, live);
842
814
  }
843
815
 
844
816
  // Task-mode subagents may ask the human through the host; background ones
@@ -881,6 +853,7 @@ export class AgentRunner {
881
853
  if (!live || live.terminal) return;
882
854
  live.terminal = { status, error };
883
855
  live.mutationStarts.clear();
856
+ live.inFlightTools.clear();
884
857
  live.cleanupDeadlineAt = this.deps.now() + GROUP_CONFIRM_DEADLINE_MS;
885
858
  live.permissionBroker?.close();
886
859
  this.closeIpc(live);
@@ -994,24 +967,7 @@ export class AgentRunner {
994
967
 
995
968
  private finish(id: string, status: TaskRecord["status"], error: string | null, live?: LiveTask): void {
996
969
  const current = this.store.get(id);
997
- if (!current || isFinished(current.status) || this.finalizingRemediation.has(id)) return;
998
- const finalize = this.remediationFinalizers.get(id);
999
- if (finalize) {
1000
- this.finalizingRemediation.add(id);
1001
- const terminal = { ...current, status, error };
1002
- void finalize(terminal, { spawned: typeof live?.child.pid === "number", exited: live?.childExit !== undefined, cleanupConfirmed: !live || !live.quarantined && live.childExit !== undefined }).then(() => {
1003
- status = terminal.status;
1004
- error = terminal.error;
1005
- }).catch(() => {
1006
- status = TASK_STATUS.FAILED;
1007
- error = "Native remediation settlement could not be durably finalized; retain task history and reconcile before further execution";
1008
- }).finally(() => {
1009
- this.remediationFinalizers.delete(id);
1010
- this.finalizingRemediation.delete(id);
1011
- this.finish(id, status, error, live);
1012
- });
1013
- return;
1014
- }
970
+ if (!current || isFinished(current.status)) return;
1015
971
  const finished = this.store.update(id, { status, endedAt: this.deps.now(), error, lastStep: error ?? "done" });
1016
972
  if (finished) {
1017
973
  if (live) this.checkObservationGrant(live);