gentle-pi 2.5.0 → 2.6.1

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