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,22 +1,37 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { extractParentConfirmedSddPreflightContext, getPackageAssetOwner, isParentConfirmedSddPreflightContext, SHIPPED_SDD_AGENT_NAMES } from "../lib/sdd-preflight.ts";
3
+ import { NativeReviewCliV216, NativeReviewCliError, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli, type NativeSddAcquireRequest, type NativeSddSettleRequest } from "../lib/native-review-cli.ts";
1
4
  import { spawn } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
5
+ import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
6
+ import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
7
+ import { existsSync, mkdirSync, readFileSync, lstatSync, realpathSync } from "node:fs";
8
+ import { createHash, randomUUID } from "node:crypto";
3
9
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
10
  import os from "node:os";
5
- import { join, resolve } from "node:path";
6
- import { keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import { Text } from "@earendil-works/pi-tui";
8
- import { AGENT_MODE, discoverAgents, loadAgentsConfig, resolveAgentProfile, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
11
+ import { join, resolve, isAbsolute, sep } from "node:path";
12
+ import { createBashToolDefinition, createLocalBashOperations, type BashOperations, keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { Text, type TUI } from "@earendil-works/pi-tui";
14
+ import { sidebarPart } from "../lib/shell-sidebar.ts";
15
+ import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
16
+ import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
17
+ import { AGENT_MODE, discoverAgents, parseAgentDefinition, loadAgentsConfig, resolveAgentProfile, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
9
18
  import { isFinished, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
10
- import { AgentRunner, piCommand, type AskAnswer, type RunnerDeps, type TaskRequest } from "../lib/agents-runner.ts";
19
+ import { AgentRunner, piCommand, abortReasonText, plannedCommands, type RemediationPlan, type RemediationScope, REMEDIATION_PLAN_ENV, parseRemediationPlan, remediationEvidence, type AskAnswer, type RunnerDeps, type SddChangeSelection, type TaskRequest, type RemediationTerminalFacts } from "../lib/agents-runner.ts";
20
+ import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
11
21
  import { hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentitySync, type ReviewSessionManager } from "../lib/review-session-standing-permission.ts";
12
- import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
22
+ import { historyDir, remediationUnresolved, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
13
23
  import { sessionToMarkdown } from "../lib/agents-transcript.ts";
14
24
  import { AgentsView } from "../lib/agents-view.ts";
25
+ import { PresencePublisher } from "../lib/orchestrator-presence.ts";
15
26
  import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
16
27
  import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts";
17
28
  import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
18
29
  import { openInExternalEditor } from "./gentle-shell.ts";
19
30
  import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
31
+ import { assertResearchCheckpoint, parseResearchPersistence, RESEARCH_PERSISTENCE_ENTRY, canonicalArtifactPath, researchAgent, renderResearchCapabilities, RESEARCH_CHILD_TOOLS_ENV, RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, parseResearchArtifactIntent, researchArtifactCall, researchArtifactReadback, type ResearchArtifactIntent, type ResearchWriteIdentity } from "../lib/sdd-research-capabilities.ts";
32
+ import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
33
+ import { lookupPiCatalogName } from "../lib/runtime-metrics-pi-identity.ts";
34
+ import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
20
35
 
21
36
  // Gentle Agents: subagents as isolated `pi --mode rpc` children, a task
22
37
  // store that notifies per task, and a Gentle Shell card above the editor.
@@ -26,19 +41,210 @@ import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
26
41
  export const AGENTS_WIDGET_KEY = "gentle-agents";
27
42
  export const AGENTS_COMMAND_NAME = "gentle:agents";
28
43
  export const AGENTS_RESULT_TYPE = "gentle-agents.result";
44
+ export const AGENTS_MESSAGE_TYPE = "gentle-agents.message";
45
+ export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result";
29
46
  const COLLAPSE_KEY_DEFAULT = "ctrl+shift+a";
30
47
  const VIEW_KEY_DEFAULT = "alt+a";
31
48
  const STOP_KEY_DEFAULT = "alt+s";
32
- const OVERLAY_HEIGHT_RATIO = 0.8;
33
- const OVERLAY_MIN_ROWS = 12;
34
49
  const RENDER_COALESCE_MS = 400;
35
50
  const CLOCK_TICK_MS = 1000;
36
51
  const TOOL_PREFIX = "subagent_";
52
+ const SHIPPED_SDD_AGENT_NAME_SET = new Set(SHIPPED_SDD_AGENT_NAMES);
53
+
54
+ const SDD_PHASE_BY_AGENT = {
55
+ "sdd-apply": "apply",
56
+ "sdd-remediate": "remediate",
57
+ "sdd-verify": "verify",
58
+ "sdd-sync": "sync",
59
+ "sdd-archive": "archive",
60
+ } as const;
61
+
62
+ function sddPhaseForAgent(name: string): SddChangeSelection["phase"] | undefined {
63
+ return Object.hasOwn(SDD_PHASE_BY_AGENT, name) ? SDD_PHASE_BY_AGENT[name as keyof typeof SDD_PHASE_BY_AGENT] : undefined;
64
+ }
65
+
66
+ function parseSddChange(value: unknown, agentName: string): SddChangeSelection | undefined {
67
+ if (value === undefined) return undefined;
68
+ const expectedPhase = sddPhaseForAgent(agentName);
69
+ if (!expectedPhase) throw new Error("sdd_change is allowed only for SDD apply, verify, sync, or archive agents.");
70
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdd_change must be an object with changeName, workspaceRoot, and phase.");
71
+ const selection = value as Record<string, unknown>;
72
+ const keys = Object.keys(selection).sort();
73
+ if (keys.join(",") !== (expectedPhase === "remediate" ? "changeName,failedEvidenceRevision,phase,workspaceRoot" : "changeName,phase,workspaceRoot") ||
74
+ typeof selection.changeName !== "string" || selection.changeName.length === 0 ||
75
+ typeof selection.workspaceRoot !== "string" || selection.workspaceRoot.length === 0 ||
76
+ selection.phase !== expectedPhase) {
77
+ throw new Error("sdd_change must contain only a non-empty changeName, workspaceRoot, and the agent's matching phase.");
78
+ }
79
+ if (expectedPhase === "remediate" && (typeof selection.failedEvidenceRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(selection.failedEvidenceRevision))) throw new Error("Invalid remediation revision");
80
+ return { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot, phase: selection.phase, ...(expectedPhase === "remediate" ? { failedEvidenceRevision: selection.failedEvidenceRevision as string } : {}) };
81
+ }
82
+
83
+ const REMEDIATION_SCHEMA = {
84
+ type: "object", additionalProperties: false, required: ["plan", "attempt"],
85
+ properties: {
86
+ plan: { type: "object", additionalProperties: false, required: ["cwd", "commands", "runtimeHarness", "rollback"], properties: {
87
+ editPaths: { type: "array", maxItems: 32, items: { type: "string" }, description: "Exact canonical files requested for this launch; no entry means no edit/write authority." }, cwd: { type: "string" }, commands: { type: "array", minItems: 1, maxItems: 16, items: { type: "string" } },
88
+ runtimeHarness: { type: "object", additionalProperties: false, description: "Exactly one concrete command or prior concrete naReason containing because.", properties: { command: { type: "string" }, naReason: { type: "string" } } },
89
+ rollback: { type: "object", additionalProperties: false, required: ["boundary", "command"], properties: { boundary: { type: "string" }, command: { type: "string" } } },
90
+ } },
91
+ attempt: { type: "object", additionalProperties: false, required: ["requestId", "workUnit", "evidenceGoal"], properties: {
92
+ requestId: { type: "string" }, workUnit: { type: "string" }, evidenceGoal: { type: "string" }, token: { type: "string" }, expectedRevision: { type: "string" },
93
+ maxAttempts: { type: "integer", minimum: 1, maximum: 100 }, maxChangedLines: { type: "integer", minimum: 1, maximum: 1000000 },
94
+ untrackedScope: { type: "string", enum: ["select", "exclude"] }, expectedUntrackedInventory: { type: "string" }, intendedUntracked: { type: "array", items: { type: "string" } },
95
+ } },
96
+ },
97
+ };
98
+
99
+ export async function confirmRemediationScope(plan: RemediationPlan, action: Record<string, unknown>, context?: Pick<ExtensionContext, "hasUI" | "ui">): Promise<RemediationScope> {
100
+ const cwd = plan.cwd, roots = action.allowedEditRoots;
101
+ if (realpathSync(cwd) !== cwd || action.workspaceRoot !== cwd || !Array.isArray(roots) || !roots.every(root => typeof root === "string" && isAbsolute(root) && canonicalArtifactPath(root) === root)) throw new Error("Remediation native scope mismatch");
102
+ const inside = (path: string, root: string) => path === root || path.startsWith(`${root}${sep}`);
103
+ const editPaths = plan.editPaths ?? [];
104
+ if (!Array.isArray(editPaths) || editPaths.length > 32 || new Set(editPaths).size !== editPaths.length) throw new Error("Ambiguous remediation paths");
105
+ for (const path of editPaths) {
106
+ if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path || /[*?\[\]{}]/.test(path) || canonicalArtifactPath(path) !== path || !inside(path, cwd) || !roots.some(root => inside(path, root))) throw new Error("Remediation path outside canonical native scope");
107
+ if (existsSync(path) && !lstatSync(path).isFile()) throw new Error("Remediation requires exact file paths, not directories");
108
+ }
109
+ const scope: RemediationScope = { cwd, editPaths: [...editPaths], commands: plannedCommands(plan), allowedEditRoots: [...roots] };
110
+ if (!context?.hasUI || !context.ui?.confirm || await context.ui.confirm("Authorize one remediation launch", `Canonical worktree: ${cwd}\nNative allowed roots: ${JSON.stringify(roots)}\nExact edit/write files: ${JSON.stringify(editPaths)}\nExact invocations (not a shell sandbox):\n${scope.commands.map((command, slot) => `${slot}: cwd=${JSON.stringify(cwd)} command=${JSON.stringify(command)}`).join("\n")}`) !== true) throw new Error("Remediation requires fresh human authorization; no actor started");
111
+ if (realpathSync(cwd) !== cwd || editPaths.some(path => canonicalArtifactPath(path) !== path)) throw new Error("Remediation scope changed during authorization");
112
+ return scope;
113
+ }
114
+ export function remediationToolAllowed(scope: RemediationScope | undefined, cwd: string, tool: string, input: Record<string, unknown>): boolean {
115
+ try {
116
+ if (!scope || scope.cwd !== cwd || canonicalArtifactPath(cwd) !== cwd) return false;
117
+ if (tool === "bash") return typeof input.command === "string" && scope.commands.includes(input.command);
118
+ if (["edit", "write"].includes(tool)) {
119
+ if (typeof input.path !== "string") return false;
120
+ const path = resolve(cwd, input.path);
121
+ return scope.editPaths.includes(path) && canonicalArtifactPath(path) === path && scope.allowedEditRoots.some(root => path === root || path.startsWith(`${root}${sep}`));
122
+ }
123
+ if (["read", "grep", "find"].includes(tool)) {
124
+ if (input.path === undefined) return true;
125
+ if (typeof input.path !== "string" || !input.path || isAbsolute(input.path)) return false;
126
+ const path = resolve(cwd, input.path);
127
+ return canonicalArtifactPath(path) === path && (path === cwd || path.startsWith(`${cwd}${sep}`));
128
+ }
129
+ return tool === "subagent_parent_message";
130
+ } catch { return false; }
131
+ }
132
+
133
+ export async function admitManagedRemediation(request: TaskRequest, input: unknown, native: NativeReviewCli, persist: (task: TaskRecord) => Promise<void>, context?: Pick<ExtensionContext, "hasUI" | "ui">, preparedTask?: TaskRecord): Promise<Partial<TaskRequest>> {
134
+ const selected = request.sddChange;
135
+ const asset = fileURLToPath(new URL("../assets/agents/sdd-remediate.md", import.meta.url));
136
+ if (request.agent.name !== "sdd-remediate" || selected?.phase !== "remediate" || selected.workspaceRoot !== request.cwd || !native.sddStatus || !native.sddAttemptAcquire || !native.sddAttemptSettle || getPackageAssetOwner("agents/sdd-remediate.md") !== "sdd" || !existsSync(request.agent.filePath) ) throw new Error("Unsupported managed remediation owner/asset or native capability; install matching package assets before retrying");
137
+ const owned = parseAgentDefinition(readFileSync(asset, "utf8"), asset, "global");
138
+ const installed = parseAgentDefinition(readFileSync(request.agent.filePath, "utf8"), request.agent.filePath, request.agent.scope);
139
+ if (!("instructions" in owned) || !("instructions" in installed) || [request.agent, installed].some(agent => agent.instructions !== owned.instructions || JSON.stringify(agent.tools) !== JSON.stringify(owned.tools))) throw new Error("Unsupported remediation actor content; install matching managed assets");
140
+ const value = input as { plan?: unknown; attempt?: NativeSddAcquireRequest };
141
+ const plan = parseRemediationPlan(value?.plan, request.cwd);
142
+ const status = decodeNativeSddStatusV2(await native.sddStatus({ workspaceRoot: request.cwd, changeName: selected.changeName }), selected);
143
+ if (status.nextRecommended !== "remediate" || !status.phaseInstructions?.remediate || status.remediationState?.failedEvidenceRevision !== selected.failedEvidenceRevision) throw new Error("Stale remediation selection; refresh native status");
144
+ if (!value?.attempt || value.attempt.remediatesEvidenceRevision !== undefined && value.attempt.remediatesEvidenceRevision !== selected.failedEvidenceRevision) throw new Error("Invalid remediation attempt intent");
145
+ const acquire: NativeSddAcquireRequest = { ...structuredClone(value.attempt), workspaceRoot: request.cwd, changeName: selected.changeName, remediatesEvidenceRevision: selected.failedEvidenceRevision };
146
+ const scope = await confirmRemediationScope(plan, status.actionContext, context);
147
+ if (!preparedTask || preparedTask.cwd !== request.cwd || preparedTask.agent !== request.agent.name) throw new Error("Durable prepared remediation task required before acquire");
148
+ preparedTask.sddRemediation = { scope, failedEvidenceRevision: selected.failedEvidenceRevision!, plan, observations: [], pending: {}, invalid: false, acquire: structuredClone(acquire) };
149
+ await persist(preparedTask);
150
+ let admitted;
151
+ try { admitted = await native.sddAttemptAcquire(structuredClone(acquire)); }
152
+ catch (error) {
153
+ if (error instanceof TypeError || error instanceof NativeReviewCliError && error.mutationOutcome === "none") {
154
+ preparedTask.sddRemediation.acquireResult = { state: "blocked" };
155
+ await persist(preparedTask);
156
+ throw error;
157
+ }
158
+ try { admitted = await native.sddAttemptAcquire(structuredClone(acquire)); }
159
+ catch { preparedTask.sddRemediation.acquireUncertain = true; await persist(preparedTask); throw new Error("Unknown acquire outcome; reconcile the exact retained request, never rerun an actor"); }
160
+ }
161
+ preparedTask.sddRemediation.acquireResult = structuredClone(admitted);
162
+ if (admitted.state === "proceed" && admitted.token) preparedTask.sddRemediation.token = admitted.token;
163
+ await persist(preparedTask); // Token durability precedes any actor dispatch.
164
+ if (admitted.state !== "proceed" || !admitted.token) throw new Error(`Managed remediation admission ${admitted.state}; no actor started`);
165
+ let finalizationStarted = false;
166
+ return {
167
+ sddRemediation: preparedTask.sddRemediation,
168
+ finalizeRemediation: async (task: TaskRecord, facts: RemediationTerminalFacts) => {
169
+ if (finalizationStarted) return;
170
+ finalizationStarted = true;
171
+ const state = task.sddRemediation!;
172
+ const evidence = state.failedEvidenceRevision === acquire.remediatesEvidenceRevision && task.status === TASK_STATUS.COMPLETED && facts.exited && facts.cleanupConfirmed ? remediationEvidence(state) : undefined;
173
+ const interrupted = !facts.spawned || !facts.exited || !facts.cleanupConfirmed || task.status === TASK_STATUS.CANCELLED || task.status === TASK_STATUS.TIMED_OUT;
174
+ const payload: NativeSddSettleRequest = {
175
+ workspaceRoot: acquire.workspaceRoot, changeName: acquire.changeName, token: state.token!, requestId: randomUUID(),
176
+ outcome: interrupted ? "interrupted" : evidence ? "passed" : "failed",
177
+ diagnosis: interrupted ? "Managed remediation interrupted; retain observed uncertainty" : evidence ? "All planned remediation commands observed exit zero; independent verification remains required" : "Managed remediation failed or planned command evidence is incomplete",
178
+ harnessDisposition: facts.cleanupConfirmed ? "reused" : "invalidated",
179
+ cleanupEvidence: facts.cleanupConfirmed ? "Runner confirmed process cleanup" : "Runner could not confirm process cleanup; effects remain unknown",
180
+ processEvidence: `spawned=${facts.spawned}; exited=${facts.exited}; task=${task.status}; observations=${state.observations.length}`,
181
+ remediatesEvidenceRevision: acquire.remediatesEvidenceRevision,
182
+ ...(acquire.untrackedScope === undefined ? {} : { untrackedScope: acquire.untrackedScope, expectedUntrackedInventory: acquire.expectedUntrackedInventory, intendedUntracked: acquire.intendedUntracked }),
183
+ ...(interrupted ? {} : evidence ? { remediationEvidence: JSON.stringify(evidence) } : { evidenceRevision: `sha256:${createHash("sha256").update(JSON.stringify({ facts, observations: state.observations, invalid: state.invalid, pending: state.pending })).digest("hex")}` }),
184
+ };
185
+ const actorStatus = task.status;
186
+ if (actorStatus === TASK_STATUS.COMPLETED) task.status = payload.outcome === "passed" ? TASK_STATUS.WAITING : TASK_STATUS.FAILED;
187
+ state.settle = structuredClone(payload);
188
+ await persist(task); // Exact native replay inputs must be durable BEFORE mutation.
189
+ try { state.settlement = await native.sddAttemptSettle!(structuredClone(payload)); }
190
+ catch (error) {
191
+ if (!(error instanceof TypeError) && !(error instanceof NativeReviewCliError && error.mutationOutcome === "none")) {
192
+ try { state.settlement = await native.sddAttemptSettle!(structuredClone(payload)); }
193
+ catch { state.settlementUncertain = true; }
194
+ } else state.settlementUncertain = true;
195
+ }
196
+ if (payload.outcome === "failed" && actorStatus === TASK_STATUS.COMPLETED) {
197
+ task.status = TASK_STATUS.FAILED;
198
+ task.error = "Managed remediation lacks complete passing planned-command evidence";
199
+ }
200
+ if (payload.outcome === "passed" && state.settlement && state.settlement.state !== "blocked") task.status = TASK_STATUS.COMPLETED;
201
+ if (!state.settlement || state.settlement.state === "blocked") {
202
+ task.status = TASK_STATUS.FAILED;
203
+ task.error = "Native remediation settlement unresolved; retain exact history for reconciliation";
204
+ }
205
+ await persist(task);
206
+ },
207
+ };
208
+ }
209
+
210
+ // Installed only for the admitted remediation child. Stock shell execution,
211
+ // cancellation, truncation and rendering remain owned by the SDK definition.
212
+ export function remediationBash(cwd: string, operations: BashOperations = createLocalBashOperations(), scope?: RemediationScope) {
213
+ const captured = new Map<string, { toolCallId: string; command: string; cwd: string; exitCode: number | null }>();
214
+ const remaining = [...(scope?.commands ?? [])], used = new Set<string>();
215
+ const stock = createBashToolDefinition(cwd);
216
+ return {
217
+ definition: { ...stock, async execute(...input: Parameters<typeof stock.execute>) {
218
+ const [id, args, signal, onUpdate, ctx] = input;
219
+ const slot = remaining.indexOf(args.command);
220
+ if (!remediationToolAllowed(scope, ctx?.cwd ?? cwd, "bash", args) || slot < 0 || used.has(id)) throw new Error("Bash invocation is outside this launch human authorization");
221
+ remaining.splice(slot, 1); used.add(id);
222
+ const definition = createBashToolDefinition(cwd, { operations: { exec: async (command, directory, options) => {
223
+ const result = await operations.exec(command, directory, options);
224
+ if (captured.size < 32) captured.set(id, { toolCallId: id, command, cwd: directory, exitCode: result.exitCode });
225
+ return result;
226
+ } } });
227
+ return definition.execute(id, args, signal, onUpdate, ctx);
228
+ } },
229
+ result(event: { toolCallId: string; details?: unknown }) {
230
+ const observation = captured.get(event.toolCallId);
231
+ captured.delete(event.toolCallId);
232
+ return observation ? { details: { ...(event.details as object ?? {}), remediationCommand: observation } } : undefined;
233
+ },
234
+ };
235
+ }
37
236
 
38
237
  export interface AgentsDeps extends RunnerDeps {
238
+ nativeSdd?: NativeReviewCli;
39
239
  home: string;
40
240
  agentHome?: string;
241
+ childIpc?: IpcEndpoint;
41
242
  env: NodeJS.ProcessEnv;
243
+ resolveWorktree: WorktreeResolver;
244
+ runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps;
245
+ metricsNow?: () => number;
246
+ metricsSchedule?: RunnerDeps["schedule"];
247
+ lookupPiCatalogName?: typeof lookupPiCatalogName;
42
248
  }
43
249
 
44
250
  export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "agent")): { sessions: string; transcripts: string } {
@@ -49,10 +255,11 @@ export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "a
49
255
  interface ToolText {
50
256
  content: Array<{ type: "text"; text: string }>;
51
257
  details: Record<string, unknown>;
258
+ terminate?: boolean;
52
259
  }
53
260
 
54
261
  const defaultDeps = (env: NodeJS.ProcessEnv): AgentsDeps => ({
55
- spawn: (command, args, options) => spawn(command, args, { cwd: options.cwd, env: options.env, stdio: options.stdio ?? ["pipe", "pipe", "pipe"] }),
262
+ spawn: (command, args, options) => spawn(command, args, { cwd: options.cwd, env: options.env, stdio: options.stdio ?? ["pipe", "pipe", "pipe"], windowsHide: true, detached: options.detached }),
56
263
  now: () => Date.now(),
57
264
  schedule: (fn, ms) => {
58
265
  const timer = setTimeout(fn, ms);
@@ -61,6 +268,7 @@ const defaultDeps = (env: NodeJS.ProcessEnv): AgentsDeps => ({
61
268
  },
62
269
  pi: piCommand(),
63
270
  home: os.homedir(),
271
+ resolveWorktree: resolveSessionWorktree,
64
272
  env,
65
273
  });
66
274
 
@@ -107,18 +315,54 @@ export function agentsStopKey(env: NodeJS.ProcessEnv = process.env): string | un
107
315
  return value === "" || value.toLowerCase() === "off" ? undefined : value;
108
316
  }
109
317
 
110
- function text(value: string, details: Record<string, unknown> = {}): ToolText {
111
- return { content: [{ type: "text", text: value }], details };
318
+ function sanitizeTerminalText(value: string): string {
319
+ return value.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, (control) => `\\x${control.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
320
+ }
321
+
322
+ function messageText(content: unknown): string {
323
+ if (typeof content === "string") return content;
324
+ if (!Array.isArray(content)) return "";
325
+ return content.map((part) => part && typeof part === "object" && (part as { type?: unknown }).type === "text" && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "").join("\n");
326
+ }
327
+
328
+ function ownedChildIpc(env: NodeJS.ProcessEnv, candidate: IpcEndpoint | undefined): IpcEndpoint | undefined {
329
+ if (env.GENTLE_PI_AGENTS_CHILD !== "1" || !env.GENTLE_PI_AGENTS_OWNED_IPC || !candidate || typeof candidate.send !== "function" || typeof candidate.on !== "function") return undefined;
330
+ return candidate;
331
+ }
332
+
333
+ function registerChildMessaging(pi: ExtensionAPI, ipc: IpcEndpoint): void {
334
+ const messenger = new ChildMessenger(ipc);
335
+ pi.registerTool({
336
+ name: "subagent_parent_message",
337
+ label: "Agent parent message",
338
+ description: "Send a bounded notification or correlated query to this subagent's parent.",
339
+ parameters: { type: "object", additionalProperties: false, required: ["message"], properties: { kind: { type: "string", enum: ["notification", "query"] }, message: { type: "string" } } } as never,
340
+ async execute(_id, params) {
341
+ const input = params as { kind?: unknown; message?: unknown };
342
+ if (typeof input.message !== "string") throw new Error("parent messages require text");
343
+ if (input.kind === undefined || input.kind === "notification") {
344
+ await messenger.notify(input.message);
345
+ return { content: [{ type: "text", text: "Notification accepted by the parent." }], details: {} };
346
+ }
347
+ if (input.kind !== "query") throw new Error("parent messages require notification or query kind");
348
+ const reply = await messenger.query(input.message);
349
+ return { content: [{ type: "text", text: reply }], details: { reply } };
350
+ },
351
+ });
352
+ }
353
+
354
+ function text(value: string, details: Record<string, unknown> = {}, terminate = false): ToolText {
355
+ return { content: [{ type: "text", text: value }], details, ...(terminate ? { terminate: true } : {}) };
112
356
  }
113
357
 
114
358
  function taskDetails(task: TaskRecord): Record<string, unknown> {
115
- return { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode } };
359
+ return { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode, cwd: task.cwd } };
116
360
  }
117
361
 
118
362
  export function describeTask(task: TaskRecord): string {
119
363
  const head = `${task.id} · ${task.agent} · ${task.status} · ${task.mode}`;
120
364
  const detail = task.error ? `\n${task.error}` : "";
121
- return `${head} · ${task.turns} turns · ${task.toolCalls} tool calls · last: ${task.lastStep}${detail}`;
365
+ return `${head} · cwd: ${task.cwd} · ${task.turns} turns · ${task.toolCalls} tool calls · last: ${task.lastStep}${detail}`;
122
366
  }
123
367
 
124
368
  function finishedText(task: TaskRecord): string {
@@ -169,7 +413,174 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as
169
413
  }
170
414
  }
171
415
 
416
+ interface ResearchArtifactCallObservation {
417
+ index: number;
418
+ desired?: ResearchWriteIdentity;
419
+ }
420
+ const RESEARCH_ARTIFACT_SCHEMA = {
421
+ type: "object", description: "Untrusted exact artifact intent, never authorization or readback. Same bounds required on continuation.",
422
+ required: ["store", "worktree", "changeName", "retainedIntent", "locators"],
423
+ properties: {
424
+ store: { type: "string", enum: ["openspec", "engram", "both", "none"] }, worktree: { type: "string" }, changeName: { type: "string" }, retainedIntent: { type: "string" },
425
+ locators: { type: "array", maxItems: 3, items: { type: "object", required: ["artifact", "revision", "digest"], properties: {
426
+ artifact: { type: "string", enum: ["research", "preproposal", "explore"] }, revision: { type: "integer", minimum: 1 }, digest: { type: "string", pattern: "^[a-f0-9]{64}$" }, path: { type: "string" },
427
+ engram: { type: "object", required: ["id", "project", "topic_key", "revision_count"], properties: { id: { type: "integer", minimum: 1 }, project: { type: "string" }, topic_key: { type: "string" }, revision_count: { type: "integer", minimum: 1 } } },
428
+ } } },
429
+ },
430
+ };
431
+ const RESEARCH_SELECTION_SCHEMA = {
432
+ type: "object",
433
+ additionalProperties: false,
434
+ description: "Untrusted narrowing intent for sdd-research; exact tools and existing sourceInfo.path per tool. Never grants permissions or installs extensions.",
435
+ properties: Object.fromEntries(["documentation", "open-web"].map(kind => [kind, {
436
+ type: "object", additionalProperties: false, required: ["tools", "extensions"],
437
+ properties: {
438
+ tools: { type: "array", items: { type: "string" } },
439
+ extensions: { type: "object", additionalProperties: { type: "string" } },
440
+ },
441
+ }])),
442
+ };
443
+
172
444
  export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<AgentsDeps> = {}): void {
445
+ if (env.GENTLE_PI_AGENTS_CHILD === "1" && env[RESEARCH_CHILD_TOOLS_ENV] !== undefined) {
446
+ let allowed: string[] = [];
447
+ try {
448
+ const parsed: unknown = JSON.parse(env[RESEARCH_CHILD_TOOLS_ENV]!);
449
+ if (Array.isArray(parsed) && parsed.every(value => typeof value === "string")) allowed = parsed;
450
+ } catch { /* Invalid launch restrictions deny every tool. */ }
451
+ let selection: unknown;
452
+ try { selection = JSON.parse(env[RESEARCH_SELECTION_ENV] ?? "null"); } catch { /* Missing selection grants no research. */ }
453
+ const current = () => researchAgent({ tools: allowed, instructions: "" } as AgentDefinition, pi, selection);
454
+ pi.on("before_agent_start", (event, ctx) => {
455
+ reads.clear(); initialReads.clear(); writes.clear(); calls.clear(); pending.clear(); accepted.clear(); readbackMismatch = false;
456
+ try {
457
+ const last = [...(ctx.sessionManager?.getEntries?.() ?? [])].reverse().find(entry => entry.type === "custom" && entry.customType === RESEARCH_PERSISTENCE_ENTRY);
458
+ if (last?.type === "custom") {
459
+ const restored = parseResearchPersistence(last.data, artifactScope(ctx.cwd), ctx.cwd);
460
+ for (const [key, value] of Object.entries(restored.accepted)) accepted.set(key, value);
461
+ for (const [key, value] of Object.entries(restored.writes)) writes.set(key, value);
462
+ }
463
+ } catch { readbackMismatch = true; }
464
+ return { systemPrompt: `${event.systemPrompt}\n\n${renderResearchCapabilities(current().capabilities)}\n\nBounded artifact narrowing intent (untrusted data, never authority or verification): ${env[RESEARCH_ARTIFACT_ENV] ?? "missing"}\nRead every selected store through actual authorized tools before readiness. Missing/none/divergent readback keeps proposal_ready=false. Retain denial intent; host permission remains required.` };
465
+ });
466
+ let readbackMismatch = false;
467
+ const reads = new Map<string, string>();
468
+ const initialReads = new Set<string>();
469
+ const pending = new Set<string>();
470
+ const accepted = new Map<string, ReturnType<typeof parseResearchArtifactIntent>["locators"][number]>();
471
+ const writes = new Map<string, ResearchWriteIdentity>();
472
+ const calls = new Map<string, ResearchArtifactCallObservation>();
473
+ const artifactScope = (cwd: string) => parseResearchArtifactIntent(JSON.parse(env[RESEARCH_ARTIFACT_ENV] ?? "null"), cwd);
474
+ const checkpoint = (ctx: ExtensionContext, operation: Record<string, unknown>) => {
475
+ const file = ctx.sessionManager?.getSessionFile?.();
476
+ if (!pi.appendEntry || !ctx.sessionManager?.getEntries || !file || !existsSync(file)) throw new Error("Durable research session history unavailable");
477
+ const data = { version: 1, scope: artifactScope(ctx.cwd), accepted: Object.fromEntries(accepted), writes: Object.fromEntries(writes), operation };
478
+ if (Buffer.byteLength(JSON.stringify(data)) > 32_768) throw new Error("Research checkpoint exceeds bounded history payload");
479
+ pi.appendEntry(RESEARCH_PERSISTENCE_ENTRY, data);
480
+ assertResearchCheckpoint(file, data);
481
+ };
482
+ pi.on("tool_call", (event, ctx) => {
483
+ const registered = pi.getAllTools().some(tool => tool.name === event.toolName && tool.sourceInfo?.source !== "sdk");
484
+ const selected = current().agent.tools.includes(event.toolName) || event.toolName === "subagent_parent_message";
485
+ if (!registered || !selected || !allowed.includes(event.toolName) || !pi.getActiveTools().includes(event.toolName)) {
486
+ return { block: true, reason: "Tool is outside the research child's active launch allowlist." };
487
+ }
488
+ if (event.toolName === "subagent_parent_message" || ["fetch_content", "web_search", "source_check", "get_search_content"].includes(event.toolName)) return;
489
+ try {
490
+ const scope = artifactScope(ctx.cwd);
491
+ const index = researchArtifactCall(scope, ctx.cwd, event.toolName, event.input);
492
+ let desired: ResearchWriteIdentity | undefined;
493
+ if (["write", "edit", "mem_save"].includes(event.toolName)) {
494
+ if (readbackMismatch || pending.size) throw new Error("Stale/divergent state requires explicit identical-scope re-entry.");
495
+ const tools = scope.store === "both" ? ["read", "mem_get_observation"] : [scope.store === "openspec" ? "read" : "mem_get_observation"];
496
+ if (!scope.locators.every((_, i) => tools.every(tool => initialReads.has(`${i}:${tool}`)))) throw new Error("Every selected artifact requires matching initial readback before mutation.");
497
+ reads.clear();
498
+ const content = "content" in event.input ? event.input.content : undefined;
499
+ if (event.toolName === "edit" || typeof content !== "string") throw new Error("Use a full bounded write/save for post-write readback.");
500
+ const key = `${index}:${event.toolName === "write" ? "read" : "mem_get_observation"}`;
501
+ if (!initialReads.has(key) || writes.has(key)) throw new Error("Fresh matching readback required before mutation.");
502
+ const revision: unknown = JSON.parse(content).revision;
503
+ if (!Number.isSafeInteger(revision) || Number(revision) <= (accepted.get(key) ?? scope.locators[index]).revision) throw new Error("Full write requires a newer positive revision.");
504
+ desired = { revision: Number(revision), digest: createHash("sha256").update(content).digest("hex") };
505
+ if (scope.store === "both") {
506
+ const peerKey = `${index}:${event.toolName === "write" ? "mem_get_observation" : "read"}`;
507
+ const peer = writes.get(peerKey) ?? accepted.get(peerKey);
508
+ if (peer && peer.revision > (accepted.get(key) ?? scope.locators[index]).revision && (peer.revision !== desired.revision || peer.digest !== desired.digest)) throw new Error("Hybrid desired identity divergence refused before mutation");
509
+ }
510
+ calls.clear(); pending.add(event.toolCallId); writes.set(key, desired);
511
+ checkpoint(ctx, { toolCallId: event.toolCallId, tool: event.toolName, index, desired });
512
+ }
513
+ calls.set(event.toolCallId, { index, desired });
514
+ } catch (error) { return { block: true, reason: `Research scope refused: ${String(error)}. Retain intent and uncertainty; no replacement store.` }; }
515
+ });
516
+ pi.on("tool_result", (event, ctx) => {
517
+ const call = calls.get(event.toolCallId);
518
+ calls.delete(event.toolCallId);
519
+ if (!call) return;
520
+ const { index, desired } = call;
521
+ if (desired) {
522
+ let valid = false;
523
+ try {
524
+ valid = event.isError === false && Array.isArray(event.content) && event.content.length > 0 && Array.from(event.content).every(part => part !== null && typeof part === "object" && part.type === "text" && typeof part.text === "string" && part.text.trim().length > 0);
525
+ } catch { /* Malformed mutation results cannot authorize completion. */ }
526
+ if (!valid) readbackMismatch = true;
527
+ try { checkpoint(ctx, { toolCallId: event.toolCallId, tool: event.toolName, index, valid, isError: event.isError, resultDigest: createHash("sha256").update(JSON.stringify(event.content) ?? "undefined").digest("hex") }); }
528
+ catch { readbackMismatch = true; }
529
+ pending.delete(event.toolCallId); reads.clear();
530
+ return;
531
+ }
532
+ if (!["read", "mem_get_observation"].includes(event.toolName)) return;
533
+ if (pending.size) return { content: [...event.content, { type: "text" as const, text: "Research readback incomplete: proposal_ready=false; mutation pending." }] };
534
+ let matched = false, complete = false;
535
+ try {
536
+ const scope = artifactScope(ctx.cwd);
537
+ researchArtifactCall(scope, ctx.cwd, event.toolName, event.input);
538
+ const bytes = event.content.map(part => part.type === "text" ? part.text : "").join("");
539
+ const returned = event.toolName === "read" ? bytes : JSON.parse(bytes);
540
+ const key = `${index}:${event.toolName}`, written = writes.get(key);
541
+ const expected = { ...(accepted.get(key) ?? scope.locators[index]), ...written };
542
+ matched = !event.isError && researchArtifactReadback(expected, event.toolName, returned, written !== undefined);
543
+ if (matched) {
544
+ reads.set(key, expected.digest);
545
+ initialReads.add(key);
546
+ accepted.set(key, event.toolName === "read" ? expected : { ...expected, engram: { ...expected.engram!, revision_count: returned.revision_count } });
547
+ writes.delete(key);
548
+ checkpoint(ctx, { toolCallId: event.toolCallId, tool: event.toolName, index, matched: true });
549
+ }
550
+ const tools = scope.store === "both" ? ["read", "mem_get_observation"] : [scope.store === "openspec" ? "read" : "mem_get_observation"];
551
+ const divergent = scope.locators.some((_, i) => tools.every(tool => reads.has(`${i}:${tool}`)) && new Set(tools.map(tool => reads.get(`${i}:${tool}`))).size !== 1);
552
+ if (divergent) matched = false;
553
+ complete = matched && !readbackMismatch && scope.store !== "none" && scope.locators.every((_, i) => tools.every(tool => reads.has(`${i}:${tool}`)));
554
+ } catch { matched = false; /* Unsupported or undurable readback is not evidence. */ }
555
+ if (!matched) { reads.clear(); readbackMismatch = true; }
556
+ const note = !matched ? "Research readback mismatch: proposal_ready=false; retain intent and uncertainty." : complete ? "Readback identity matched in all selected stores; not evidence validation or proposal admission." : "Research readback incomplete: proposal_ready=false; read every selected store.";
557
+ return { content: [...event.content, { type: "text" as const, text: note }], isError: event.isError || !matched };
558
+ });
559
+ }
560
+ const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined));
561
+ if (env.GENTLE_PI_AGENTS_CHILD === "1") {
562
+ if (env[REMEDIATION_PLAN_ENV] !== undefined) {
563
+ let granted: RemediationScope | undefined;
564
+ pi.on("tool_call", (event, current) => remediationToolAllowed(granted, current.cwd, event.toolName, event.input) ? undefined : { block: true, reason: "Outside exact remediation human authorization" });
565
+ pi.on("session_start", (_event, ctx) => {
566
+ granted = undefined;
567
+ try {
568
+ const retained = JSON.parse(env[REMEDIATION_PLAN_ENV]!);
569
+ // SDK flags are owner-local; the runner transports the same selected context.
570
+ const selection = Object.hasOwn(retained, "selection") ? retained.selection : JSON.parse(String(pi.getFlag("gentle-sdd-change")));
571
+ parseSddChange(selection, "sdd-remediate");
572
+ const plan = parseRemediationPlan(retained.plan, ctx.cwd);
573
+ if (selection.workspaceRoot !== ctx.cwd || JSON.stringify(plannedCommands(plan)) !== JSON.stringify(retained.scope?.commands) || JSON.stringify(plan.editPaths ?? []) !== JSON.stringify(retained.scope?.editPaths)) throw new Error("Remediation grant/plan mismatch");
574
+ const shell = remediationBash(ctx.cwd, undefined, retained.scope);
575
+ pi.registerTool(shell.definition);
576
+ pi.on("tool_result", event => event.toolName === "bash" ? shell.result(event) : undefined);
577
+ granted = retained.scope;
578
+ } catch { /* No valid host grant: deny every tool, even if Pi continues initialization. */ }
579
+ });
580
+ }
581
+ if (childIpc) registerChildMessaging(pi, childIpc);
582
+ return;
583
+ }
173
584
  if (!agentsEnabled(env)) return;
174
585
  const deps: AgentsDeps = { ...defaultDeps(env), ...overrides };
175
586
  const selectedHome = overrides.agentHome ?? (overrides.home === undefined ? resolveGentlePiAgentHome(deps.env) : join(deps.home, ".pi", "agent"));
@@ -189,15 +600,59 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
189
600
  const viewKey = agentsViewKey(env);
190
601
  const stopKey = agentsStopKey(env);
191
602
  const store = new TaskStore();
603
+ const restoredTaskIds = new Set<string>();
192
604
  const tasksDir = historyDir(deps.home, agentHome);
193
605
  let ui: ExtensionContext["ui"] | undefined;
194
606
  let host: { requestRender(): void } | undefined;
607
+ let sidebarTui: TUI | undefined;
195
608
  let sessions: ExtensionContext["sessionManager"] | undefined;
609
+ let presence: PresencePublisher | undefined;
610
+ const overlays = new Set<AgentsView>();
611
+ const publishActivity = () => {
612
+ if (!sessions) return;
613
+ try {
614
+ if (!presence || presence.error) {
615
+ presence = PresencePublisher.start({ profile: agentHome, sessionId: activeSessionId() ?? "",
616
+ label: sessions.getSessionName?.() || sessions.getCwd().split(/[\\/]/).pop() || "Orchestrator", activity: [] });
617
+ }
618
+ presence?.update(store.list(activeSessionId()).filter((task) => !isFinished(task.status) && !restoredTaskIds.has(task.id)).map((task) => ({ task, thread: store.thread(task.id) })));
619
+ } catch { presence?.dispose(); presence = undefined; }
620
+ };
621
+ let worktrees: SessionWorktreeRegistry | undefined;
622
+ const registryFor = (ctx: ExtensionContext) => {
623
+ if (!worktrees || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
624
+ worktrees?.close();
625
+ worktrees = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.sessionManager.getCwd(), deps.resolveWorktree);
626
+ }
627
+ return worktrees;
628
+ };
196
629
  let collapsed = false;
197
630
  let renderQueued = false;
198
631
  let cancelClock: (() => void) | undefined;
199
632
  const ownedTaskIds = new Set<string>();
200
633
  const stoppingTaskIds = new Set<string>();
634
+ const yieldedTaskIds = new Set<string>();
635
+ const metricsNow = deps.metricsNow ?? (() => performance.now());
636
+ const catalogLookup = deps.lookupPiCatalogName ?? lookupPiCatalogName;
637
+ let metricsOwner = {};
638
+ const metricTasks = new Map<string, { selection?: LaunchSelection; started: number; launched: boolean; finished: boolean; current(): boolean; valid(): boolean }>();
639
+ const unsubscribeMetrics = pi.events.on(CHILD_METRICS_REVOKED, id => {
640
+ if (id === activeSessionId()) {
641
+ metricsOwner = {};
642
+ for (const taskId of metricTasks.keys()) runner.discardResponseObservations(taskId);
643
+ metricTasks.clear();
644
+ }
645
+ });
646
+ const clearTaskMetrics = () => {
647
+ metricsOwner = {};
648
+ for (const taskId of metricTasks.keys()) runner.discardResponseObservations(taskId);
649
+ metricTasks.clear();
650
+ };
651
+ pi.on("session_start", clearTaskMetrics);
652
+ pi.on("session_shutdown", () => {
653
+ clearTaskMetrics();
654
+ unsubscribeMetrics();
655
+ });
201
656
  let stopAllConfirmation: Promise<void> | undefined;
202
657
 
203
658
  // The card and its clock follow the session pi has open right now; a task
@@ -211,6 +666,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
211
666
  renderQueued = true;
212
667
  deps.schedule(() => {
213
668
  renderQueued = false;
669
+ if (sidebarTui) invalidateSidebar(sidebarTui);
214
670
  host?.requestRender();
215
671
  }, RENDER_COALESCE_MS);
216
672
  };
@@ -221,6 +677,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
221
677
  const tickClock = () => {
222
678
  cancelClock?.();
223
679
  cancelClock = undefined;
680
+ if (!sessions) return;
224
681
  const tasks = visibleTasks();
225
682
  if (tasks.some((task) => !isFinished(task.status))) {
226
683
  cancelClock = deps.schedule(() => {
@@ -232,6 +689,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
232
689
  const expiry = widgetExpiryMs(tasks, deps.now());
233
690
  if (expiry === undefined) return;
234
691
  cancelClock = deps.schedule(() => {
692
+ if (sidebarTui) invalidateSidebar(sidebarTui);
235
693
  host?.requestRender();
236
694
  tickClock();
237
695
  }, expiry);
@@ -245,20 +703,130 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
245
703
  .catch(() => {});
246
704
  };
247
705
 
248
- // A background result is delivered as a message: queued behind the current
249
- // turn if the model is busy, or starting a turn right away if it is idle.
706
+ // A background result used to be handed straight to the host as a followUp
707
+ // message, but the host only drains that queue when the parent agent stops
708
+ // calling tools entirely, so in a long orchestrator run the notification
709
+ // could land nearly an hour after the parent pulled the same result (#867).
710
+ // Gentle Agents now owns the pending completions: they settle here, are
711
+ // flushed at the next turn boundary, and a stale one never re-enters the
712
+ // conversation.
713
+ const completions = createCompletionQueue<TaskRecord>();
714
+ let activeAgentRuns = 0;
715
+
250
716
  const deliver = (task: TaskRecord) => {
251
- pi.sendMessage({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "followUp", triggerTurn: true });
717
+ // Ownership is consulted at delivery time, matching onNotification and
718
+ // onQuery: a completion owned by another session is dropped, not delivered.
719
+ if (activeSessionId() !== task.parentSessionId) return;
720
+ // "steer" + triggerTurn keeps delivery bounded to the current turn. While
721
+ // the parent streams, the host polls steering each turn and injects the
722
+ // message before the next LLM call; "followUp" is NOT acceptable here
723
+ // because the host drains the follow-up queue only in the run loop's stop
724
+ // branch, so a parent that keeps calling tools would see the completion
725
+ // only when the whole run ends — the original #867 delay. When the parent
726
+ // is idle, triggerTurn runs the prompt immediately, preserving wake-up.
727
+ pi.sendMessage({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true });
728
+ };
729
+
730
+ // A stale completion must not re-enter the LLM conversation, so it is
731
+ // delivered as durable TUI-only content and the human still sees it.
732
+ const deliverStale = (task: TaskRecord, settledAt: number) => {
733
+ if (activeSessionId() !== task.parentSessionId) return;
734
+ const ageSeconds = Math.max(0, Math.round((deps.now() - settledAt) / 1000));
735
+ pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds });
736
+ };
737
+
738
+ const flushCompletions = () => {
739
+ for (const { task, settledAt, stale } of completions.takeDeliverable(deps.now())) {
740
+ try {
741
+ if (stale) deliverStale(task, settledAt);
742
+ else deliver(task);
743
+ } catch { /* Best-effort delivery: at most once, even if forwarding fails. */ }
744
+ }
745
+ };
746
+
747
+ // A completion settles into our queue. An idle parent flushes right away so
748
+ // the wake-up behavior is unchanged; a busy parent flushes at the next turn
749
+ // boundary, and the steer mode injects it before that turn's next LLM call
750
+ // instead of parking it behind the whole run.
751
+ const settleCompletion = (task: TaskRecord) => {
752
+ completions.enqueue(task, deps.now());
753
+ if (activeAgentRuns === 0) flushCompletions();
252
754
  };
253
755
 
756
+ // `agent_start`/`agent_end` bracket a parent agent run; `turn_end` fires at
757
+ // every turn boundary inside one, so with steering delivery a held
758
+ // completion is injected before the next LLM call and never outlives the
759
+ // current turn. `agent_end` stays a flush trigger for runs that end without
760
+ // a final `turn_end` (an aborted run, or the host's early post-run return
761
+ // when a run produced no assistant message; the host compensates via
762
+ // hasQueuedMessages() + continue(), so steering there is still bounded).
763
+ // `agent_settled` is the final idle boundary after retries — normally a
764
+ // no-op safety net, since anything enqueued while idle flushes right away.
765
+ pi.on("agent_start", () => { activeAgentRuns += 1; });
766
+ pi.on("agent_end", () => {
767
+ activeAgentRuns = Math.max(0, activeAgentRuns - 1);
768
+ flushCompletions();
769
+ });
770
+ pi.on("agent_settled", () => flushCompletions());
771
+ pi.on("turn_end", () => flushCompletions());
772
+
254
773
  const runner = new AgentRunner(store, loadAgentsConfig({ cwd: process.cwd(), home: deps.home, agentHome }), deps, {
255
774
  askUser: (_taskId, ask, raw) => answerThroughUi(ui, ask, raw),
256
- onFinish: (task) => {
257
- ownedTaskIds.delete(task.id);
258
- requestRender();
259
- persist(task);
260
- if (task.mode === AGENT_MODE.BACKGROUND && task.status !== TASK_STATUS.CANCELLED) deliver(task);
775
+ onNotification: (task, message) => {
776
+ if (activeSessionId() !== task.parentSessionId) return false;
777
+ pi.sendMessage({ customType: AGENTS_MESSAGE_TYPE, content: message, display: false, details: { gentleAgents: { taskId: task.id, agent: task.agent, parentSessionId: task.parentSessionId, kind: "notification" } } }, { deliverAs: "followUp", triggerTurn: true });
778
+ return true;
261
779
  },
780
+ onQuery: (task, requestId, message) => {
781
+ if (activeSessionId() !== task.parentSessionId) return false;
782
+ const hadYield = yieldedTaskIds.has(task.id);
783
+ if (task.mode === AGENT_MODE.TASK) yieldedTaskIds.add(task.id);
784
+ try {
785
+ pi.sendMessage({ customType: AGENTS_MESSAGE_TYPE, content: `Subagent ${task.agent} asks:\nTask ID: ${task.id}\nRequest ID: ${requestId}\nQuestion: ${message}`, display: true, details: { gentleAgents: { taskId: task.id, agent: task.agent, parentSessionId: task.parentSessionId, requestId, kind: "query" } } }, { deliverAs: "followUp", triggerTurn: true });
786
+ return true;
787
+ } catch (error) {
788
+ if (task.mode === AGENT_MODE.TASK && !hadYield) yieldedTaskIds.delete(task.id);
789
+ throw error;
790
+ }
791
+ },
792
+ onSuccessfulMutation: (task, tool) => {
793
+ if (!sessions || !worktrees || task.parentSessionId !== activeSessionId() || !ownedTaskIds.has(task.id)) return;
794
+ const root = deps.resolveWorktree(tool.path, task.cwd)?.root;
795
+ const childRoot = deps.resolveWorktree(task.cwd, task.cwd)?.root;
796
+ if (!root || root !== childRoot || !worktrees.roots().includes(root)) return;
797
+ recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId });
798
+ },
799
+ onFinish: (task, observations) => {
800
+ // Completion is the only forwarding opportunity. No pending event, policy
801
+ // query or promise survives this callback; the receiver drops when busy.
802
+ const { id, parentSessionId, status } = task;
803
+ const metrics = metricTasks.get(id);
804
+ metricTasks.delete(id); // Deliver at most once, even if forwarding fails.
805
+ try {
806
+ const authorized = metrics?.valid();
807
+ if (metrics) metrics.finished = true;
808
+ if (authorized && metrics?.launched && metrics.selection && observations) {
809
+ const event = childEvent(parentSessionId, id, metrics.selection, status, observations, metrics.started);
810
+ if (event && metrics.current()) pi.events.emit(CHILD_METRICS_EVENT, event);
811
+ }
812
+ } catch { /* Metrics must never interrupt task finalization. */ }
813
+ try {
814
+ ownedTaskIds.delete(task.id);
815
+ requestRender();
816
+ persist(task);
817
+ const yielded = yieldedTaskIds.delete(task.id);
818
+ if ((task.mode === AGENT_MODE.BACKGROUND && task.status !== TASK_STATUS.CANCELLED) || (yielded && task.status !== TASK_STATUS.CANCELLED && activeSessionId() === task.parentSessionId)) settleCompletion(task);
819
+ } catch { /* Best-effort completion bookkeeping cannot strand runner waiters. */ }
820
+ },
821
+ });
822
+
823
+ pi.registerMessageRenderer(AGENTS_MESSAGE_TYPE, (message, options, theme) => {
824
+ const details = (message.details as { gentleAgents?: { taskId?: unknown; agent?: unknown } } | undefined)?.gentleAgents;
825
+ const taskId = typeof details?.taskId === "string" ? details.taskId : "unknown";
826
+ const agent = typeof details?.agent === "string" ? details.agent : "Subagent";
827
+ const heading = `${sanitizeTerminalText(agent)} message · Task ${sanitizeTerminalText(taskId)}`;
828
+ const body = sanitizeTerminalText(messageText(message.content));
829
+ return new Text(`${theme.fg("customMessageLabel", heading)}\n${theme.fg("customMessageText", body)}`, options.outputPad, 0);
262
830
  });
263
831
 
264
832
  pi.registerMessageRenderer(AGENTS_RESULT_TYPE, (message, options, theme) => {
@@ -275,6 +843,28 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
275
843
  };
276
844
  });
277
845
 
846
+ // A stale completion is appended as a custom entry: durable transcript
847
+ // content for the human that never participates in the LLM context.
848
+ pi.registerEntryRenderer(AGENTS_STALE_RESULT_TYPE, (entry, options, theme) => {
849
+ const data = (entry.data ?? {}) as { taskId?: unknown; agent?: unknown; label?: unknown; status?: unknown; ageSeconds?: unknown };
850
+ const taskId = typeof data.taskId === "string" ? data.taskId : "unknown";
851
+ const agent = typeof data.agent === "string" ? data.agent : "Subagent";
852
+ const label = typeof data.label === "string" ? data.label : "";
853
+ const status = typeof data.status === "string" ? data.status.replace("_", " ") : "unknown";
854
+ const ageSeconds = typeof data.ageSeconds === "number" && Number.isFinite(data.ageSeconds) ? Math.max(0, Math.round(data.ageSeconds)) : 0;
855
+ const age = ageSeconds < 90 ? `${ageSeconds}s` : ageSeconds < 3600 ? `${Math.round(ageSeconds / 60)}m` : `${Math.round(ageSeconds / 3600)}h`;
856
+ const body = [
857
+ `Subagent ${sanitizeTerminalText(agent)} (task ${sanitizeTerminalText(taskId)}, "${sanitizeTerminalText(label)}") ${sanitizeTerminalText(status)} about ${age} ago, while the orchestrator was still busy.`,
858
+ "Marked stale: the result was not replayed into the conversation. It stays available through subagent_status and subagent_result.",
859
+ ];
860
+ return {
861
+ render(width: number) {
862
+ return renderCard({ title: "Stale agent result", subtitle: `${agent} · task ${taskId}`, body, tone: CARD_TONE.WARNING, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, hint: expandHint(options.expanded) });
863
+ },
864
+ invalidate() {},
865
+ };
866
+ });
867
+
278
868
  const isOwnedActive = (task: TaskRecord | undefined): task is TaskRecord => task !== undefined && ownedTaskIds.has(task.id) && !isFinished(task.status);
279
869
 
280
870
  const stopSelected = async (task: TaskRecord, ctx: ExtensionContext): Promise<void> => {
@@ -329,13 +919,19 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
329
919
  const live = store.get(id);
330
920
  if (live) return live;
331
921
  const stored = await loadStoredTask(tasksDir, id);
332
- if (stored) store.restore(stored.task, stored.thread);
922
+ if (stored) {
923
+ restoredTaskIds.add(stored.task.id);
924
+ store.restore(stored.task, stored.thread);
925
+ }
333
926
  return stored?.task;
334
927
  };
335
928
 
336
929
  const openOverlay = async (ctx: ExtensionContext) => {
337
930
  if (!ctx.hasUI) return;
338
- for (const stored of await loadHistory(tasksDir)) store.restore(stored.task, stored.thread);
931
+ if (ctx.mode !== "tui") {
932
+ ctx.ui.notify("The agents overlay requires TUI mode.", "warning");
933
+ return;
934
+ }
339
935
  let view: AgentsView | undefined;
340
936
  let overlayHost: { requestRender(force?: boolean): void; stop(): void; start(): void } | undefined;
341
937
  const chosen = await ctx.ui.custom<TaskRecord | null>(
@@ -343,16 +939,22 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
343
939
  overlayHost = tui;
344
940
  view = new AgentsView({
345
941
  theme,
346
- rows: Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
942
+ rows: () => Math.max(0, tui.terminal.rows),
347
943
  store,
348
944
  sessionId: ctx.sessionManager.getSessionId() ?? "",
945
+ presence: {
946
+ profile: agentHome,
947
+ get target() { return presence?.target; },
948
+ },
349
949
  now: () => deps.now(),
350
950
  onCancel: (task) => void stopSelected(task, ctx),
351
951
  canCancel: isOwnedActive,
952
+ isLocalTask: (task) => !restoredTaskIds.has(task.id),
352
953
  onOpen: (task) => done(task),
353
954
  onClose: () => done(null),
354
955
  requestRender: () => tui.requestRender(),
355
956
  });
957
+ overlays.add(view);
356
958
  const interaction = createNativeFullscreenInteraction({
357
959
  keyboardTarget: view,
358
960
  requestRender: () => tui.requestRender(),
@@ -361,9 +963,11 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
361
963
  interaction.addChild(view);
362
964
  return interaction;
363
965
  },
364
- { overlay: true, overlayOptions: { width: "92%", anchor: "center" } },
365
- );
366
- view?.dispose();
966
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } },
967
+ ).finally(() => {
968
+ view?.dispose();
969
+ if (view) overlays.delete(view);
970
+ });
367
971
  if (!chosen || !overlayHost) return;
368
972
  if (!chosen.sessionPath) {
369
973
  ctx.ui.notify("This task has no session file yet.", "warning");
@@ -391,6 +995,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
391
995
  // A status change is worth a frame right away; deltas inside a task are
392
996
  // coalesced so a chatty child cannot flood the terminal.
393
997
  store.subscribeSummary(() => {
998
+ publishActivity();
999
+ if (sidebarTui) invalidateSidebar(sidebarTui);
394
1000
  host?.requestRender();
395
1001
  tickClock();
396
1002
  });
@@ -401,40 +1007,67 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
401
1007
  tickClock();
402
1008
  ui?.setWidget(AGENTS_WIDGET_KEY, (tui, theme) => {
403
1009
  host = tui;
404
- return {
1010
+ sidebarTui = tui;
1011
+ return sidebarPart(tui, "agents", {
405
1012
  render(width: number) {
406
1013
  const lines = renderAgentsCard(visibleTasks(), theme, width, deps.now(), { collapsed, collapseKey, maxRows: widgetRows(tui.terminal?.rows), viewKey });
407
1014
  return lines.length === 0 ? [] : [...lines, ""];
408
1015
  },
409
1016
  invalidate() {},
410
- };
1017
+ }, {
1018
+ render: (width) => renderAgentsCard(visibleTasks(), theme, width, deps.now(), { collapsed, collapseKey, viewKey }),
1019
+ invalidate() {},
1020
+ });
411
1021
  });
412
1022
  };
413
1023
 
414
1024
  const roots = (ctx: ExtensionContext) => ({ cwd: ctx.sessionManager.getCwd(), home: deps.home, agentHome });
415
1025
 
416
- const buildRequest = (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string): TaskRequest => {
1026
+ const buildRequest = (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string, workspaceRoot?: string, sddChange?: SddChangeSelection, researchSelection?: unknown, researchArtifact?: unknown, remediationIntent?: unknown): TaskRequest => {
1027
+ const registry = registryFor(ctx);
1028
+ const parentCwd = ctx.sessionManager.getCwd();
1029
+ // An explicit target is validated before any queue or session-dir writes.
1030
+ const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd);
1031
+ const selectedRoot = workspaceRoot ?? sddChange?.workspaceRoot;
1032
+ // Preserve ordinary non-Git continuation, without admitting any new root.
1033
+ const sameNonGitContinuation = resume !== undefined && selectedRoot === parentCwd && !parentIdentity;
1034
+ const target = selectedRoot !== undefined && !sameNonGitContinuation ? registry.validate(selectedRoot) : parentIdentity?.root;
1035
+ if (sddChange && target !== sddChange.workspaceRoot && target !== resolve(sddChange.workspaceRoot)) {
1036
+ throw new Error("sdd_change workspaceRoot must resolve to the selected child worktree.");
1037
+ }
1038
+ const launchSddChange = sddChange === undefined || target === undefined
1039
+ ? undefined
1040
+ : { ...sddChange, workspaceRoot: target };
417
1041
  const config = loadAgentsConfig(roots(ctx));
418
1042
  const profile = resolveAgentProfile(agent, config);
1043
+ const research = agent.name === "sdd-research" ? researchAgent(agent, pi, researchSelection) : undefined;
419
1044
  const sessionDir = agentRuntimePaths(deps.home, agentHome).sessions;
420
1045
  mkdirSync(sessionDir, { recursive: true });
421
1046
  const parentSessionManager = ctx.sessionManager as unknown as ReviewSessionManager;
422
1047
  const parentSessionId = ctx.sessionManager.getSessionId() ?? "";
423
1048
  const parentWorktreeRoot = ctx.sessionManager.getCwd();
424
1049
  const parentRepositoryIdentity = resolveCanonicalGitRepositoryIdentitySync(parentWorktreeRoot);
1050
+ const sddPreflightContext = SHIPPED_SDD_AGENT_NAME_SET.has(agent.name)
1051
+ ? extractParentConfirmedSddPreflightContext(context)
1052
+ : undefined;
425
1053
  return {
426
- agent,
1054
+ agent: research?.agent ?? agent,
1055
+ remediationIntent,
427
1056
  prompt,
428
1057
  label,
429
1058
  context,
1059
+ ...(sddPreflightContext === undefined ? {} : { sddPreflightContext }),
430
1060
  mode,
431
- cwd: parentWorktreeRoot,
1061
+ cwd: target ?? parentWorktreeRoot,
432
1062
  parentSessionId,
1063
+ ...(target === undefined ? {} : { onLaunch: () => { registry.register(target, "subagent:spawn"); } }),
433
1064
  model: profile.model,
434
1065
  thinking: profile.thinking,
435
1066
  sessionDir,
436
1067
  resumeSessionPath: resume,
437
- env: deps.env,
1068
+ env: research ? { ...deps.env, [RESEARCH_CHILD_TOOLS_ENV]: JSON.stringify([...research.agent.tools, "subagent_parent_message"]) } : deps.env,
1069
+ ...(research ? { researchSelection, extensionPaths: research.extensionPaths, researchArtifact: researchArtifact === undefined ? undefined : parseResearchArtifactIntent(researchArtifact, target ?? parentCwd) } : {}),
1070
+ ...(launchSddChange === undefined ? {} : { sddChange: launchSddChange }),
438
1071
  ...(parentRepositoryIdentity === undefined ? {} : {
439
1072
  authorizeParentStandingReviewPermission: (repositoryIdentity: string) => {
440
1073
  try {
@@ -455,18 +1088,82 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
455
1088
  };
456
1089
  };
457
1090
 
458
- const launch = async (ctx: ExtensionContext, request: TaskRequest): Promise<ToolText> => {
459
- const task = runner.run(request);
1091
+ const launch = async (ctx: ExtensionContext, request: TaskRequest, signal?: AbortSignal): Promise<ToolText> => {
1092
+ // This is the process-spawn boundary. A child receives its task context only
1093
+ // after its RPC process starts, so validate the single parent transport here
1094
+ // rather than letting a child invent/persist preferences during startup.
1095
+ if (SHIPPED_SDD_AGENT_NAME_SET.has(request.agent.name) && !isParentConfirmedSddPreflightContext(request.context)) {
1096
+ throw new Error("SDD child dispatch refused: parent-confirmed SDD preflight context is missing or malformed.");
1097
+ }
1098
+ let prepared: TaskRecord | undefined;
1099
+ if (request.agent.name === "sdd-remediate") {
1100
+ const previous = await loadHistory(tasksDir);
1101
+ if (previous.some(({ task }) => task.cwd === request.cwd && task.sddRemediation?.acquire.changeName === request.sddChange?.changeName && remediationUnresolved(task))) throw new Error("Retained remediation operation unresolved; reconcile exact history without actor replay");
1102
+ prepared = runner.prepareRemediation(request);
1103
+ const persist = (task: TaskRecord) => saveTask(tasksDir, task, store.thread(task.id));
1104
+ try {
1105
+ const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
1106
+ Object.assign(request, await admitManagedRemediation(request, request.remediationIntent, native, persist, ctx, prepared));
1107
+ if (activeSessionId() !== request.parentSessionId) throw new Error("Parent session changed; retain admission and refuse actor replay");
1108
+ prepared.sddRemediation!.actorClaimed = true;
1109
+ await persist(prepared); // A crash beyond here is an unknown actor effect, not rerun permission.
1110
+ } catch (error) { store.update(prepared.id, { status: TASK_STATUS.FAILED, error: "Remediation admission/dispatch refused; reconcile retained history" }); throw error; }
1111
+ }
1112
+ // Bounded live observation only. Native send owns the fresh policy decision;
1113
+ // child execution never starts a telemetry policy process or renewal timer.
1114
+ const owner = metricsOwner;
1115
+ const metrics = { selection: undefined as LaunchSelection | undefined,
1116
+ started: 0, launched: false, finished: false,
1117
+ current: () => owner === metricsOwner && request.parentSessionId === activeSessionId() && runtimeMetricsEnvAllows(deps.env),
1118
+ valid: () => !metrics.finished && metrics.current() };
1119
+ const observe = runtimeMetricsEnvAllows(deps.env) && metricTasks.size < 256;
1120
+ const task = runner.run({ ...request, collectResponseObservations: false,
1121
+ onLaunch: () => { metrics.launched = true; request.onLaunch?.(); },
1122
+ ...(observe ? { canCollectResponseObservations: metrics.valid, prepareResponseObservations: async () => {
1123
+ if (metrics.finished || owner !== metricsOwner || request.parentSessionId !== activeSessionId() || !runtimeMetricsEnvAllows(deps.env)) return false;
1124
+ void catalogLookup({ provider: "openai", modelId: "gpt-4o" }).catch(() => {});
1125
+ if (!metrics.valid()) return false;
1126
+ metrics.selection = launchSelection(request.agent, request.model, request.thinking);
1127
+ metrics.started = metricsNow();
1128
+ return metrics.valid();
1129
+ } } : {}),
1130
+ }, prepared);
1131
+ if (observe) metricTasks.set(task.id, metrics);
460
1132
  ownedTaskIds.add(task.id);
461
- store.subscribe(task.id, () => requestRender());
1133
+ store.subscribe(task.id, () => { publishActivity(); requestRender(); });
462
1134
  if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Use subagent_status or subagent_result with that id.`, taskDetails(task));
463
- const finished = await runner.waitFor(task.id);
464
- return text(finishedText(finished), taskDetails(finished));
1135
+ // A tool call aborted by the host (a human interrupting the turn, a timeout)
1136
+ // would otherwise leave the child running and end the call with no result and
1137
+ // no recorded reason. Cancel through the runner so the lifecycle runs and the
1138
+ // record is persisted, and tell the user why.
1139
+ const onAbort = (): void => {
1140
+ if (runner.cancel(task.id)) {
1141
+ ctx.ui.notify(
1142
+ `Subagent ${task.agent} cancelled: the tool call was aborted${abortReasonText(signal?.reason)}. The run is recorded as cancelled.`,
1143
+ "warning",
1144
+ );
1145
+ }
1146
+ };
1147
+ if (signal?.aborted) onAbort();
1148
+ else signal?.addEventListener("abort", onAbort, { once: true });
1149
+ try {
1150
+ const query = await runner.waitForQuery(task.id);
1151
+ if (query) {
1152
+ const live = store.get(task.id) ?? task;
1153
+ return text(`Subagent ${live.agent} is waiting for your reply to request ${query.requestId}.`, { gentleAgents: { taskId: live.id, agent: live.agent, status: live.status, mode: live.mode, requestId: query.requestId } }, true);
1154
+ }
1155
+ const finished = await runner.waitFor(task.id);
1156
+ completions.consume(finished.id);
1157
+ return text(finishedText(finished), taskDetails(finished));
1158
+ } finally {
1159
+ signal?.removeEventListener("abort", onAbort);
1160
+ }
465
1161
  };
466
1162
 
467
- const tool = (name: string, description: string, parameters: Record<string, unknown>, execute: (params: Record<string, unknown>, ctx: ExtensionContext) => Promise<ToolText>) => {
1163
+ const tool = (name: string, description: string, parameters: Record<string, unknown>, execute: (params: Record<string, unknown>, ctx: ExtensionContext, signal?: AbortSignal) => Promise<ToolText>) => {
468
1164
  pi.registerTool({
469
1165
  name: `${TOOL_PREFIX}${name}`,
1166
+ renderShell: "self",
470
1167
  label: `Agent ${name.replace(/_/g, " ")}`,
471
1168
  description,
472
1169
  parameters: { type: "object", additionalProperties: false, ...parameters } as never,
@@ -478,8 +1175,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
478
1175
  const body = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
479
1176
  return new Text(options.expanded ? body : theme.fg("muted", body.split("\n")[0] ?? ""), 0, 0);
480
1177
  },
481
- async execute(_id, params, _signal, _onUpdate, ctx) {
482
- return execute(params as Record<string, unknown>, ctx);
1178
+ async execute(_id, params, signal, _onUpdate, ctx) {
1179
+ return execute(params as Record<string, unknown>, ctx, signal);
483
1180
  },
484
1181
  });
485
1182
  };
@@ -501,15 +1198,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
501
1198
  task: { type: "string", description: "What the subagent must do, self-contained." },
502
1199
  label: { type: "string", description: "Three to six words naming the work, shown on the agents card, e.g. 'map footer data sources'." },
503
1200
  context: { type: "string", description: "Optional extra context appended to the task." },
1201
+ workspace_root: { type: "string", description: "Optional worktree in the same Git clone. Validated before queueing; the child runs at its canonical root and registers it on actual launch." },
1202
+ research_artifact: RESEARCH_ARTIFACT_SCHEMA, research_selection: RESEARCH_SELECTION_SCHEMA,
1203
+ remediation: REMEDIATION_SCHEMA, sdd_change: { type: "object", additionalProperties: false, required: ["changeName", "workspaceRoot", "phase"], properties: { changeName: { type: "string" }, workspaceRoot: { type: "string" }, failedEvidenceRevision: { type: "string" }, phase: { type: "string", enum: ["apply", "verify", "sync", "archive", "remediate"] } }, description: "Launch-local selected SDD identity, accepted only by matching SDD phase agents." },
504
1204
  mode: { type: "string", enum: ["task", "background"], description: "task waits for the result (default); background returns immediately." },
505
1205
  },
506
1206
  },
507
- async (params, ctx) => {
1207
+ async (params, ctx, signal) => {
508
1208
  const { agents } = discoverAgents(roots(ctx));
509
1209
  const agent = agents.find((candidate) => candidate.name === params.agent);
510
1210
  if (!agent) return text(`Error: no subagent named "${String(params.agent)}". Known: ${agents.map((candidate) => candidate.name).join(", ") || "none"}`, { error: "unknown agent" });
511
1211
  const mode = (params.mode as AgentMode | undefined) ?? agent.mode ?? loadAgentsConfig(roots(ctx)).defaultMode;
512
- return launch(ctx, buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode));
1212
+ let sddChange: SddChangeSelection | undefined;
1213
+ try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1214
+ catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1215
+ return launch(ctx, buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, typeof params.workspace_root === "string" ? params.workspace_root : undefined, sddChange, params.research_selection, params.research_artifact, params.remediation), signal);
513
1216
  },
514
1217
  );
515
1218
 
@@ -521,6 +1224,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
521
1224
  tool("result", "Return the final answer of a finished subagent task, or its current state if it is still running.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
522
1225
  const task = await resolveTask(String(params.task_id));
523
1226
  if (!task) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
1227
+ // The parent just pulled a finished result; its pending completion must
1228
+ // never be replayed on top of it.
1229
+ if (isFinished(task.status)) completions.consume(task.id);
524
1230
  return text(isFinished(task.status) ? finishedText(task) : `Task ${task.id} is still ${task.status} (last: ${task.lastStep}).`, taskDetails(task));
525
1231
  });
526
1232
 
@@ -529,7 +1235,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
529
1235
  return text(tasks.length === 0 ? "No subagent tasks in this session." : tasks.map(describeTask).join("\n"));
530
1236
  });
531
1237
 
532
- tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
1238
+ tool("reply", "Reply once to a live query from a child of the current parent session.", { required: ["task_id", "request_id", "message"], properties: { task_id: { type: "string" }, request_id: { type: "string" }, message: { type: "string" } } }, async (params, ctx) => {
1239
+ const accepted = await runner.reply(String(params.task_id), String(params.request_id), typeof params.message === "string" ? params.message : "", ctx.sessionManager.getSessionId() ?? "");
1240
+ return accepted ? text("Reply accepted for delivery.") : text("Error: query is unavailable.", { error: "query unavailable" });
1241
+ });
1242
+
1243
+ tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
533
1244
  const id = String(params.task_id);
534
1245
  return runner.cancel(id) ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
535
1246
  });
@@ -542,15 +1253,29 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
542
1253
  tool(
543
1254
  "continue",
544
1255
  "Resume a finished subagent task in its own session with a follow-up prompt.",
545
- { required: ["task_id", "prompt"], properties: { task_id: { type: "string" }, prompt: { type: "string" }, label: { type: "string", description: "Three to six words naming the follow-up." }, mode: { type: "string", enum: ["task", "background"] } } },
546
- async (params, ctx) => {
1256
+ { required: ["task_id", "prompt"], properties: { research_artifact: RESEARCH_ARTIFACT_SCHEMA, research_selection: RESEARCH_SELECTION_SCHEMA, task_id: { type: "string" }, prompt: { type: "string" }, label: { type: "string", description: "Three to six words naming the follow-up." }, remediation: REMEDIATION_SCHEMA, sdd_change: { type: "object", additionalProperties: false, required: ["changeName", "workspaceRoot", "phase"], properties: { changeName: { type: "string" }, workspaceRoot: { type: "string" }, failedEvidenceRevision: { type: "string" }, phase: { type: "string", enum: ["apply", "verify", "sync", "archive", "remediate"] } }, description: "Fresh launch-local selected SDD identity, required when continuing an SDD phase agent." }, mode: { type: "string", enum: ["task", "background"] } } },
1257
+ async (params, ctx, signal) => {
547
1258
  const previous = await resolveTask(String(params.task_id));
548
1259
  if (!previous) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
549
1260
  if (!isFinished(previous.status) || !previous.sessionPath) return text(`Error: task ${previous.id} cannot be continued yet (${previous.status}).`, { error: "not continuable" });
1261
+ // Continuing acts on the previous result, so any pending completion for
1262
+ // it is already consumed by the parent.
1263
+ completions.consume(previous.id);
550
1264
  const agent = discoverAgents(roots(ctx)).agents.find((candidate) => candidate.name === previous.agent);
551
1265
  if (!agent) return text(`Error: subagent "${previous.agent}" is no longer defined.`, { error: "unknown agent" });
552
1266
  const mode = (params.mode as AgentMode | undefined) ?? (previous.mode as AgentMode);
553
- return launch(ctx, buildRequest(ctx, agent, String(params.prompt ?? ""), typeof params.label === "string" ? params.label : undefined, undefined, mode, previous.sessionPath));
1267
+ let sddChange: SddChangeSelection | undefined;
1268
+ try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1269
+ catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1270
+ if (sddPhaseForAgent(agent.name) && !sddChange) return text("Error: continuing an SDD phase agent requires a fresh sdd_change selection.", { error: "missing sdd_change" });
1271
+ let artifact: ResearchArtifactIntent | undefined;
1272
+ if (agent.name === "sdd-research") {
1273
+ try {
1274
+ const prior = parseResearchArtifactIntent("researchArtifact" in previous ? previous.researchArtifact : undefined, previous.cwd);
1275
+ artifact = parseResearchArtifactIntent(params.research_artifact ?? prior, previous.cwd, prior);
1276
+ } catch (error) { return text(`Error: research continuation scope refused: ${String(error)}`, { error: "research scope" }); }
1277
+ }
1278
+ return launch(ctx, buildRequest(ctx, agent, String(params.prompt ?? ""), typeof params.label === "string" ? params.label : undefined, previous.sddPreflightContext, mode, previous.sessionPath, sddChange?.workspaceRoot ?? previous.cwd, sddChange, params.research_selection, artifact, params.remediation), signal);
554
1279
  },
555
1280
  );
556
1281
 
@@ -559,13 +1284,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
559
1284
  description: "Collapse or expand the agents card",
560
1285
  handler: async () => {
561
1286
  collapsed = !collapsed;
1287
+ if (sidebarTui) invalidateSidebar(sidebarTui);
562
1288
  host?.requestRender();
563
1289
  },
564
1290
  });
565
1291
  }
566
1292
 
567
1293
  pi.registerCommand(AGENTS_COMMAND_NAME, {
568
- description: "Show this session's subagents with their threads; a widens the list to every session. Press o to open a task's session in $EDITOR.",
1294
+ description: "Show this session's active subagents; a lists open orchestrators in this profile. Peer threads are read-only; o opens a local task's transcript in $EDITOR.",
569
1295
  handler: async (_args, ctx) => openOverlay(ctx),
570
1296
  });
571
1297
  if (viewKey) {
@@ -581,8 +1307,31 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
581
1307
  });
582
1308
  }
583
1309
 
584
- pi.on("session_start", (_event, ctx) => showWidget(ctx));
1310
+ pi.on("session_start", (_event, ctx) => {
1311
+ // A resumed, reloaded, or replaced session starts with an empty completion
1312
+ // queue so nothing pending from another session can replay here.
1313
+ completions.dropAll();
1314
+ presence?.dispose();
1315
+ registryFor(ctx);
1316
+ showWidget(ctx);
1317
+ try {
1318
+ presence = PresencePublisher.start({ profile: agentHome, sessionId: activeSessionId() ?? "",
1319
+ label: ctx.sessionManager.getSessionName?.() || ctx.sessionManager.getCwd().split(/[\\/]/).pop() || "Orchestrator", activity: [] });
1320
+ publishActivity();
1321
+ } catch { presence = undefined; }
1322
+ });
585
1323
  pi.on("session_shutdown", () => {
1324
+ completions.dropAll();
1325
+ activeAgentRuns = 0;
1326
+ presence?.dispose();
1327
+ presence = undefined;
1328
+ cancelClock?.();
1329
+ for (const view of overlays) { view.handleInput("q"); view.dispose(); }
1330
+ overlays.clear();
1331
+ sessions = undefined;
1332
+ sidebarTui = undefined;
1333
+ worktrees?.close();
1334
+ worktrees = undefined;
586
1335
  runner.cancelAll();
587
1336
  });
588
1337
  }