gentle-pi 2.6.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +25 -7
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +41 -19
  18. package/docs/readme-reference.md +175 -36
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +289 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +123 -97
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +74 -113
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +19 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +88 -0
  47. package/lib/session-changes.ts +147 -0
  48. package/lib/shell-bar.ts +24 -10
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes-view.ts +2 -1
  51. package/lib/shell-changes.ts +5 -2
  52. package/lib/shell-prompt.ts +25 -8
  53. package/lib/shell-sidebar-banner.ts +2 -2
  54. package/lib/shell-sidebar-layout.ts +5 -2
  55. package/lib/windows-session-transport.ts +877 -0
  56. package/package.json +3 -3
  57. package/runtime/native-review-cli.mjs +18 -96
  58. package/runtime/windows-session-transport.ps1 +791 -0
  59. package/scripts/gentle-ai-installer.mjs +10 -10
  60. package/scripts/test-packed-runner.mjs +1668 -20
  61. package/scripts/verify-package-files.mjs +2 -3
  62. package/tests/agent-home.test.ts +52 -0
  63. package/tests/agent-profiles.test.ts +30 -1
  64. package/tests/agents-config.test.ts +44 -0
  65. package/tests/agents-history.test.ts +12 -24
  66. package/tests/agents-runner.test.ts +321 -58
  67. package/tests/agents-session-transport-process.test.ts +249 -0
  68. package/tests/agents-session-transport.test.ts +823 -0
  69. package/tests/artifact-language.test.ts +10 -7
  70. package/tests/command-palette.test.ts +378 -0
  71. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  72. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  73. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  74. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  75. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  76. package/tests/gentle-agents.test.ts +883 -356
  77. package/tests/gentle-ai-binary.test.ts +1 -1
  78. package/tests/gentle-ai-installer.test.ts +47 -47
  79. package/tests/gentle-ai.test.ts +472 -4
  80. package/tests/gentle-shell.test.ts +338 -205
  81. package/tests/native-choice-list.test.ts +13 -0
  82. package/tests/native-review-capability-contract.test.ts +15 -1
  83. package/tests/native-review-cli.test.ts +0 -33
  84. package/tests/odd-routing-contract.test.ts +208 -0
  85. package/tests/orchestrator-budget.test.ts +17 -2
  86. package/tests/package-manifest.test.ts +119 -31
  87. package/tests/persona-single-channel.test.ts +3 -3
  88. package/tests/pi-pretty.test.ts +45 -0
  89. package/tests/profile-pin.test.ts +370 -0
  90. package/tests/quiet-tool-rendering.test.ts +32 -5
  91. package/tests/review-contract-prompt.test.ts +9 -0
  92. package/tests/review-controller.test.ts +0 -44
  93. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  94. package/tests/runtime-harness.mjs +4 -4
  95. package/tests/sdd-agent-tools.test.ts +15 -36
  96. package/tests/sdd-archive-replay.test.ts +82 -0
  97. package/tests/sdd-classical-continuation.test.ts +74 -0
  98. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  99. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  100. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  101. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  102. package/tests/sdd-odd-integration.test.ts +33 -0
  103. package/tests/sdd-optional-research.test.ts +124 -0
  104. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  105. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  106. package/tests/sdd-preflight.test.ts +1 -1
  107. package/tests/sdd-research-capabilities.test.ts +20 -162
  108. package/tests/sdd-selection-transport.test.ts +180 -88
  109. package/tests/sdd-status.test.ts +5 -778
  110. package/tests/sdd-task-truth.test.ts +43 -0
  111. package/tests/session-change-capture.test.ts +86 -0
  112. package/tests/session-changes-shell.test.ts +38 -0
  113. package/tests/session-changes.test.ts +114 -0
  114. package/tests/shell-bar.test.ts +35 -0
  115. package/tests/shell-card.test.ts +8 -6
  116. package/tests/shell-changes.test.ts +8 -0
  117. package/tests/shell-prompt.test.ts +41 -7
  118. package/tests/shell-sidebar-banner.test.ts +4 -4
  119. package/tests/shell-sidebar-layout.test.ts +97 -13
  120. package/tests/startup-banner.test.ts +55 -2
  121. package/tests/windows-hidden-processes.test.ts +303 -0
  122. package/tests/windows-session-bootstrap.test.ts +1772 -0
  123. package/tests/windows-session-compile.test.ts +170 -0
  124. package/tests/windows-session-transport.test.ts +754 -0
  125. package/assets/agents/sdd-sync.md +0 -146
  126. package/lib/openspec-guardrails.ts +0 -99
  127. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  128. package/tests/openspec-guardrails.test.ts +0 -71
@@ -1,11 +1,13 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import { agentsViewKey, agentsCollapseKey, agentsStopKey } from "../lib/agents-keys.ts";
2
3
  import { extractParentConfirmedSddPreflightContext, getPackageAssetOwner, isParentConfirmedSddPreflightContext, SHIPPED_SDD_AGENT_NAMES } from "../lib/sdd-preflight.ts";
3
- import { NativeReviewCliV216, NativeReviewCliError, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli, type NativeSddAcquireRequest, type NativeSddAttemptResult, type NativeSddSettleRequest } from "../lib/native-review-cli.ts";
4
+ import { NativeReviewCliV216, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli } from "../lib/native-review-cli.ts";
4
5
  import { spawn } from "node:child_process";
5
6
  import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
7
+ import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
6
8
  import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
7
9
  import { existsSync, mkdirSync, readFileSync, lstatSync, realpathSync } from "node:fs";
8
- import { createHash, randomUUID } from "node:crypto";
10
+ import { randomUUID } from "node:crypto";
9
11
  import { mkdir, readFile, writeFile } from "node:fs/promises";
10
12
  import os from "node:os";
11
13
  import { join, resolve, isAbsolute, sep } from "node:path";
@@ -14,12 +16,14 @@ import { Text, type TUI } from "@earendil-works/pi-tui";
14
16
  import { sidebarPart } from "../lib/shell-sidebar.ts";
15
17
  import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
16
18
  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";
19
+ import { AGENT_MODE, discoverAgents, parseAgentDefinition, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
18
20
  import { isFinished, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.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";
21
+ import { AgentRunner, piCommand, abortReasonText, plannedCommands, type RemediationPlan, type RemediationScope, REMEDIATION_PLAN_ENV, parseRemediationPlan, type AskAnswer, type RunnerDeps, type SddChangeSelection, type TaskRequest } from "../lib/agents-runner.ts";
20
22
  import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
23
+ import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry, type PresenceRecord, type ReceivedNotification, type SentNotification, type SessionPresenceCandidate } from "../lib/agents-session-transport.ts";
24
+ import { WindowsActiveSessionClient, WindowsActiveSessionListener, WindowsSessionPresenceRegistry, type WindowsSessionRegistryPhaseObserver } from "../lib/windows-session-transport.ts";
21
25
  import { hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentitySync, type ReviewSessionManager } from "../lib/review-session-standing-permission.ts";
22
- import { acquireTaskLock, historyDir, remediationUnresolved, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
26
+ import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
23
27
  import { sessionToMarkdown } from "../lib/agents-transcript.ts";
24
28
  import { AgentsView } from "../lib/agents-view.ts";
25
29
  import { PresencePublisher } from "../lib/orchestrator-presence.ts";
@@ -27,8 +31,9 @@ import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-inte
27
31
  import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts";
28
32
  import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
29
33
  import { openInExternalEditor } from "./gentle-shell.ts";
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";
34
+ import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts";
35
+ import { resolveProfilePin } from "../lib/agent-profile-pin.ts";
36
+ import { canonicalArtifactPath, researchAgent, renderResearchCapabilities, RESEARCH_CHILD_TOOLS_ENV, RESEARCH_SELECTION_ENV } from "../lib/sdd-research-capabilities.ts";
32
37
  import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
33
38
  import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
34
39
 
@@ -41,10 +46,8 @@ export const AGENTS_WIDGET_KEY = "gentle-agents";
41
46
  export const AGENTS_COMMAND_NAME = "gentle:agents";
42
47
  export const AGENTS_RESULT_TYPE = "gentle-agents.result";
43
48
  export const AGENTS_MESSAGE_TYPE = "gentle-agents.message";
49
+ export const AGENTS_ORCHESTRATOR_MESSAGE_TYPE = "gentle-agents.orchestrator-message";
44
50
  export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result";
45
- const COLLAPSE_KEY_DEFAULT = "ctrl+shift+a";
46
- const VIEW_KEY_DEFAULT = "alt+a";
47
- const STOP_KEY_DEFAULT = "alt+s";
48
51
  const RENDER_COALESCE_MS = 400;
49
52
  const CLOCK_TICK_MS = 1000;
50
53
  const TOOL_PREFIX = "subagent_";
@@ -54,7 +57,6 @@ const SDD_PHASE_BY_AGENT = {
54
57
  "sdd-apply": "apply",
55
58
  "sdd-remediate": "remediate",
56
59
  "sdd-verify": "verify",
57
- "sdd-sync": "sync",
58
60
  "sdd-archive": "archive",
59
61
  } as const;
60
62
 
@@ -65,7 +67,7 @@ function sddPhaseForAgent(name: string): SddChangeSelection["phase"] | undefined
65
67
  function parseSddChange(value: unknown, agentName: string): SddChangeSelection | undefined {
66
68
  if (value === undefined) return undefined;
67
69
  const expectedPhase = sddPhaseForAgent(agentName);
68
- if (!expectedPhase) throw new Error("sdd_change is allowed only for SDD apply, verify, sync, or archive agents.");
70
+ if (!expectedPhase) throw new Error("sdd_change is allowed only for SDD apply, verify, or archive agents.");
69
71
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdd_change must be an object with changeName, workspaceRoot, and phase.");
70
72
  const selection = value as Record<string, unknown>;
71
73
  const keys = Object.keys(selection).sort();
@@ -76,22 +78,18 @@ function parseSddChange(value: unknown, agentName: string): SddChangeSelection |
76
78
  throw new Error("sdd_change must contain only a non-empty changeName, workspaceRoot, and the agent's matching phase.");
77
79
  }
78
80
  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 } : {}) };
81
+ return { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot, phase: expectedPhase, ...(expectedPhase === "remediate" ? { failedEvidenceRevision: selection.failedEvidenceRevision as string } : {}) };
80
82
  }
81
83
 
82
84
  const REMEDIATION_SCHEMA = {
83
- type: "object", additionalProperties: false, required: ["plan", "attempt"],
85
+ type: "object", additionalProperties: false, required: ["plan"],
84
86
  properties: {
85
87
  plan: { type: "object", additionalProperties: false, required: ["cwd", "commands", "runtimeHarness", "rollback"], properties: {
86
88
  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
89
  runtimeHarness: { type: "object", additionalProperties: false, description: "Exactly one concrete command or prior concrete naReason containing because.", properties: { command: { type: "string" }, naReason: { type: "string" } } },
88
90
  rollback: { type: "object", additionalProperties: false, required: ["boundary", "command"], properties: { boundary: { type: "string" }, command: { type: "string" } } },
89
91
  } },
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
- } },
92
+
95
93
  },
96
94
  };
97
95
 
@@ -129,175 +127,21 @@ export function remediationToolAllowed(scope: RemediationScope | undefined, cwd:
129
127
  } catch { return false; }
130
128
  }
131
129
 
132
- export interface RemediationReconciliationResult {
133
- acquireState?: NativeSddAttemptResult["state"];
134
- settlementState?: NativeSddAttemptResult["state"];
135
- }
136
-
137
- async function replayUncertainNativeMutation<T>(label: "acquire" | "settlement", invoke: () => Promise<T>): Promise<T> {
138
- try { return await invoke(); }
139
- catch (error) {
140
- if (error instanceof TypeError || error instanceof NativeReviewCliError && error.mutationOutcome === "none") throw error;
141
- try { return await invoke(); }
142
- catch { throw new Error(`Native remediation ${label} remains unresolved; retry only this exact retained task`); }
143
- }
144
- }
145
-
146
- function finishReconciledTask(task: TaskRecord, message: string): void {
147
- task.status = TASK_STATUS.FAILED;
148
- task.error = message;
149
- task.lastStep = "reconciled";
150
- task.endedAt ??= Date.now();
151
- task.lastActivityAt = Date.now();
152
- }
153
-
154
- export async function reconcileManagedRemediation(task: TaskRecord, native: NativeReviewCli, persist: (task: TaskRecord) => Promise<void>): Promise<RemediationReconciliationResult> {
155
- const state = task.sddRemediation;
156
- if (task.agent !== "sdd-remediate" || !state?.acquire || state.acquire.workspaceRoot !== task.cwd) throw new Error("Task is not an exact managed remediation record");
157
- if (state.settlement) throw new Error("Managed remediation task already has a terminal settlement");
158
-
159
- const settleExact = async (settle: NativeSddSettleRequest, acquireState?: NativeSddAttemptResult["state"]): Promise<RemediationReconciliationResult> => {
160
- if (!native.sddAttemptSettle) throw new Error("Native remediation settlement reconciliation is unavailable");
161
- let settlement: NativeSddAttemptResult;
162
- try { settlement = await replayUncertainNativeMutation("settlement", () => native.sddAttemptSettle!(structuredClone(settle))); }
163
- catch (error) {
164
- state.settlementUncertain = true;
165
- await persist(task);
166
- throw error;
167
- }
168
- state.settlement = structuredClone(settlement);
169
- delete state.settlementUncertain;
170
- finishReconciledTask(task, `Managed remediation settlement reconciled as ${settlement.state}${settlement.reason ? `(${settlement.reason})` : ""}; no actor started`);
171
- await persist(task);
172
- return { ...(acquireState === undefined ? {} : { acquireState }), settlementState: settlement.state };
173
- };
174
-
175
- if (state.settle) {
176
- const settle = state.settle, acquire = state.acquire;
177
- const sameUntracked = JSON.stringify({ scope: settle.untrackedScope, inventory: settle.expectedUntrackedInventory, intended: settle.intendedUntracked }) === JSON.stringify({ scope: acquire.untrackedScope, inventory: acquire.expectedUntrackedInventory, intended: acquire.intendedUntracked });
178
- if (state.acquireResult?.state !== "proceed" || !state.token || state.acquireResult.token !== state.token || settle.token !== state.token || settle.workspaceRoot !== acquire.workspaceRoot || settle.changeName !== acquire.changeName || settle.remediatesEvidenceRevision !== acquire.remediatesEvidenceRevision || !sameUntracked) throw new Error("Retained remediation settlement does not match its exact acquired authority");
179
- return settleExact(structuredClone(settle), state.acquireResult.state);
180
- }
181
- if (!state.acquireUncertain) throw new Error("Managed remediation task has no uncertain acquire or settlement to reconcile");
182
- if (state.actorClaimed) throw new Error("Managed remediation actor effects are uncertain; exact settlement or maintainer intervention is required");
183
- if (state.acquireResult || state.token || state.settlementUncertain) throw new Error("Managed remediation acquire history is inconsistent; maintainer intervention is required");
184
- if (!native.sddAttemptAcquire) throw new Error("Native remediation acquire reconciliation is unavailable");
185
-
186
- let acquired: NativeSddAttemptResult;
187
- try { acquired = await replayUncertainNativeMutation("acquire", () => native.sddAttemptAcquire!(structuredClone(state.acquire))); }
188
- catch (error) {
189
- state.acquireUncertain = true;
190
- await persist(task);
191
- throw error;
192
- }
193
- if (acquired.state === "proceed" && !acquired.token) throw new Error("Native remediation acquire reconciliation returned no token");
194
- state.acquireResult = structuredClone(acquired);
195
- delete state.acquireUncertain;
196
- if (acquired.state !== "proceed") {
197
- delete state.token;
198
- finishReconciledTask(task, `Managed remediation acquire reconciled as ${acquired.state}${acquired.reason ? `(${acquired.reason})` : ""}; no actor started`);
199
- await persist(task);
200
- return { acquireState: acquired.state };
201
- }
202
-
203
- state.token = acquired.token;
204
- const acquire = state.acquire;
205
- const settle: NativeSddSettleRequest = {
206
- workspaceRoot: acquire.workspaceRoot,
207
- changeName: acquire.changeName,
208
- token: acquired.token!,
209
- requestId: `reconcile-${createHash("sha256").update(JSON.stringify(acquire)).digest("hex").slice(0, 32)}`,
210
- outcome: "interrupted",
211
- diagnosis: "Acquire outcome was recovered after the actor launch boundary was refused; no managed actor was launched",
212
- harnessDisposition: "invalidated",
213
- cleanupEvidence: "No managed actor was spawned; no child cleanup was required",
214
- processEvidence: "spawned=false; actor_claimed=false; reconciliation=acquire",
215
- remediatesEvidenceRevision: acquire.remediatesEvidenceRevision,
216
- ...(acquire.untrackedScope === undefined ? {} : { untrackedScope: acquire.untrackedScope, expectedUntrackedInventory: acquire.expectedUntrackedInventory, intendedUntracked: acquire.intendedUntracked }),
217
- };
218
- state.settle = structuredClone(settle);
219
- await persist(task); // Recovered token and exact settlement replay input become durable atomically before native mutation.
220
- return settleExact(settle, acquired.state);
221
- }
222
-
223
- 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>> {
130
+ export async function admitManagedRemediation(request: TaskRequest, input: unknown, native: NativeReviewCli, context?: Pick<ExtensionContext, "hasUI" | "ui">): Promise<Partial<TaskRequest>> {
224
131
  const selected = request.sddChange;
225
132
  const asset = fileURLToPath(new URL("../assets/agents/sdd-remediate.md", import.meta.url));
226
- 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");
133
+ if (request.agent.name !== "sdd-remediate" || selected?.phase !== "remediate" || selected.workspaceRoot !== request.cwd || !native.sddStatus || 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");
227
134
  const owned = parseAgentDefinition(readFileSync(asset, "utf8"), asset, "global");
228
135
  const installed = parseAgentDefinition(readFileSync(request.agent.filePath, "utf8"), request.agent.filePath, request.agent.scope);
229
136
  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");
230
- const value = input as { plan?: unknown; attempt?: NativeSddAcquireRequest };
137
+ const value = input as { plan?: unknown };
231
138
  const plan = parseRemediationPlan(value?.plan, request.cwd);
232
139
  const status = decodeNativeSddStatusV2(await native.sddStatus({ workspaceRoot: request.cwd, changeName: selected.changeName }), selected);
233
- if (status.nextRecommended !== "remediate" || !status.phaseInstructions?.remediate || status.remediationState?.failedEvidenceRevision !== selected.failedEvidenceRevision) throw new Error("Stale remediation selection; refresh native status");
234
- if (!value?.attempt || value.attempt.remediatesEvidenceRevision !== undefined && value.attempt.remediatesEvidenceRevision !== selected.failedEvidenceRevision) throw new Error("Invalid remediation attempt intent");
235
- const acquire: NativeSddAcquireRequest = { ...structuredClone(value.attempt), workspaceRoot: request.cwd, changeName: selected.changeName, remediatesEvidenceRevision: selected.failedEvidenceRevision };
140
+ // Legacy remediation status carries the verification failure being repaired;
141
+ // that is not permission to bypass a missing native edit grant.
142
+ if (status.blockedReasons.some(reason => reason.includes("edit_authority_missing")) || status.nextRecommended !== "remediate" || !status.phaseInstructions?.remediate || status.remediationState?.failedEvidenceRevision !== selected.failedEvidenceRevision) throw new Error("Stale remediation selection; refresh native status");
236
143
  const scope = await confirmRemediationScope(plan, status.actionContext, context);
237
- if (!preparedTask || preparedTask.cwd !== request.cwd || preparedTask.agent !== request.agent.name) throw new Error("Durable prepared remediation task required before acquire");
238
- preparedTask.sddRemediation = { scope, failedEvidenceRevision: selected.failedEvidenceRevision!, plan, observations: [], pending: {}, invalid: false, acquire: structuredClone(acquire) };
239
- await persist(preparedTask);
240
- let admitted;
241
- try { admitted = await native.sddAttemptAcquire(structuredClone(acquire)); }
242
- catch (error) {
243
- if (error instanceof TypeError || error instanceof NativeReviewCliError && error.mutationOutcome === "none") {
244
- preparedTask.sddRemediation.acquireResult = { state: "blocked" };
245
- await persist(preparedTask);
246
- throw error;
247
- }
248
- try { admitted = await native.sddAttemptAcquire(structuredClone(acquire)); }
249
- catch { preparedTask.sddRemediation.acquireUncertain = true; await persist(preparedTask); throw new Error("Unknown acquire outcome; reconcile the exact retained request, never rerun an actor"); }
250
- }
251
- preparedTask.sddRemediation.acquireResult = structuredClone(admitted);
252
- if (admitted.state === "proceed" && admitted.token) preparedTask.sddRemediation.token = admitted.token;
253
- await persist(preparedTask); // Token durability precedes any actor dispatch.
254
- if (admitted.state !== "proceed" || !admitted.token) throw new Error(`Managed remediation admission ${admitted.state}; no actor started`);
255
- let finalizationStarted = false;
256
- return {
257
- sddRemediation: preparedTask.sddRemediation,
258
- finalizeRemediation: async (task: TaskRecord, facts: RemediationTerminalFacts) => {
259
- if (finalizationStarted) return;
260
- finalizationStarted = true;
261
- const state = task.sddRemediation!;
262
- const evidence = state.failedEvidenceRevision === acquire.remediatesEvidenceRevision && task.status === TASK_STATUS.COMPLETED && facts.exited && facts.cleanupConfirmed ? remediationEvidence(state) : undefined;
263
- const interrupted = !facts.spawned || !facts.exited || !facts.cleanupConfirmed || task.status === TASK_STATUS.CANCELLED || task.status === TASK_STATUS.TIMED_OUT;
264
- const payload: NativeSddSettleRequest = {
265
- workspaceRoot: acquire.workspaceRoot, changeName: acquire.changeName, token: state.token!, requestId: randomUUID(),
266
- outcome: interrupted ? "interrupted" : evidence ? "passed" : "failed",
267
- 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",
268
- harnessDisposition: facts.cleanupConfirmed ? "reused" : "invalidated",
269
- cleanupEvidence: facts.cleanupConfirmed ? "Runner confirmed process cleanup" : "Runner could not confirm process cleanup; effects remain unknown",
270
- processEvidence: `spawned=${facts.spawned}; exited=${facts.exited}; task=${task.status}; observations=${state.observations.length}`,
271
- remediatesEvidenceRevision: acquire.remediatesEvidenceRevision,
272
- ...(acquire.untrackedScope === undefined ? {} : { untrackedScope: acquire.untrackedScope, expectedUntrackedInventory: acquire.expectedUntrackedInventory, intendedUntracked: acquire.intendedUntracked }),
273
- ...(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")}` }),
274
- };
275
- const actorStatus = task.status;
276
- if (actorStatus === TASK_STATUS.COMPLETED) task.status = payload.outcome === "passed" ? TASK_STATUS.WAITING : TASK_STATUS.FAILED;
277
- state.settle = structuredClone(payload);
278
- await persist(task); // Exact native replay inputs must be durable BEFORE mutation.
279
- try { state.settlement = await native.sddAttemptSettle!(structuredClone(payload)); }
280
- catch (error) {
281
- if (!(error instanceof TypeError) && !(error instanceof NativeReviewCliError && error.mutationOutcome === "none")) {
282
- try { state.settlement = await native.sddAttemptSettle!(structuredClone(payload)); }
283
- catch { state.settlementUncertain = true; }
284
- } else state.settlementUncertain = true;
285
- }
286
- if (payload.outcome === "failed" && actorStatus === TASK_STATUS.COMPLETED) {
287
- task.status = TASK_STATUS.FAILED;
288
- task.error = "Managed remediation lacks complete passing planned-command evidence";
289
- }
290
- if (payload.outcome === "passed" && state.settlement && state.settlement.state !== "blocked") task.status = TASK_STATUS.COMPLETED;
291
- if (!state.settlement) {
292
- task.status = TASK_STATUS.FAILED;
293
- task.error = "Native remediation settlement unresolved; retain exact history for reconciliation";
294
- } else if (state.settlement.state === "blocked") {
295
- task.status = TASK_STATUS.FAILED;
296
- task.error = `Native remediation settlement blocked(${state.settlement.reason ?? "unspecified"}); current native admission decides any later attempt`;
297
- }
298
- await persist(task);
299
- },
300
- };
144
+ return { sddRemediation: { scope, failedEvidenceRevision: selected.failedEvidenceRevision!, plan } };
301
145
  }
302
146
 
303
147
  // Installed only for the admitted remediation child. Stock shell execution,
@@ -327,11 +171,36 @@ export function remediationBash(cwd: string, operations: BashOperations = create
327
171
  };
328
172
  }
329
173
 
174
+ export interface SessionTransportRegistry {
175
+ list(excludeSessionId?: string): Promise<readonly SessionPresenceCandidate[]>;
176
+ listActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]>;
177
+ close?(): Promise<void>;
178
+ }
179
+
180
+ export interface SessionTransportListener {
181
+ readonly registry: SessionTransportRegistry;
182
+ readonly closesRegistry?: boolean;
183
+ start(): Promise<void>;
184
+ close(): Promise<void>;
185
+ }
186
+
187
+ export interface SessionTransportClient {
188
+ close(): void;
189
+ sendNotification(recipientSessionId: string, message: string, options?: { id?: string; expectedActivation?: PresenceRecord; beforeConnect?: () => boolean | Promise<boolean>; signal?: AbortSignal }): Promise<SentNotification>;
190
+ }
191
+
192
+ export interface SessionTransportFactory {
193
+ createRegistry(agentHome: string, observeWindowsPhase?: WindowsSessionRegistryPhaseObserver): Promise<SessionTransportRegistry>;
194
+ createListener(registry: SessionTransportRegistry, sessionId: string, onNotification: (notification: ReceivedNotification) => Promise<void>): SessionTransportListener;
195
+ createClient(registry: SessionTransportRegistry, sessionId: string): SessionTransportClient;
196
+ }
197
+
330
198
  export interface AgentsDeps extends RunnerDeps {
331
199
  nativeSdd?: NativeReviewCli;
332
200
  home: string;
333
201
  agentHome?: string;
334
202
  childIpc?: IpcEndpoint;
203
+ sessionTransport?: SessionTransportFactory;
335
204
  env: NodeJS.ProcessEnv;
336
205
  resolveWorktree: WorktreeResolver;
337
206
  runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps;
@@ -389,23 +258,7 @@ function legacySubagentsInstalledAt(agentHome: string): boolean {
389
258
  }
390
259
  }
391
260
 
392
- export function agentsViewKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
393
- const value = env.GENTLE_PI_AGENTS_VIEW_KEY?.trim();
394
- if (value === undefined) return VIEW_KEY_DEFAULT;
395
- return value === "" || value.toLowerCase() === "off" ? undefined : value;
396
- }
397
-
398
- export function agentsCollapseKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
399
- const value = env.GENTLE_PI_AGENTS_KEY?.trim();
400
- if (value === undefined) return COLLAPSE_KEY_DEFAULT;
401
- return value === "" || value.toLowerCase() === "off" ? undefined : value;
402
- }
403
-
404
- export function agentsStopKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
405
- const value = env.GENTLE_PI_AGENTS_STOP_KEY?.trim();
406
- if (value === undefined) return STOP_KEY_DEFAULT;
407
- return value === "" || value.toLowerCase() === "off" ? undefined : value;
408
- }
261
+ export { agentsViewKey, agentsCollapseKey, agentsStopKey };
409
262
 
410
263
  function sanitizeTerminalText(value: string): string {
411
264
  return value.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, (control) => `\\x${control.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
@@ -443,6 +296,22 @@ function registerChildMessaging(pi: ExtensionAPI, ipc: IpcEndpoint): void {
443
296
  });
444
297
  }
445
298
 
299
+ const posixSessionTransport: SessionTransportFactory = {
300
+ createRegistry(agentHome) { return SessionPresenceRegistry.create(agentHome); },
301
+ createListener(registry: SessionPresenceRegistry, sessionId, onNotification) { return Object.assign(new ActiveSessionListener(registry, sessionId, onNotification), { closesRegistry: false }); },
302
+ createClient(registry: SessionPresenceRegistry, sessionId) { return new ActiveSessionClient(registry, sessionId); },
303
+ };
304
+
305
+ const windowsSessionTransport: SessionTransportFactory = {
306
+ createRegistry(agentHome, observeWindowsPhase) { return WindowsSessionPresenceRegistry.create(agentHome, observeWindowsPhase); },
307
+ createListener(registry: WindowsSessionPresenceRegistry, sessionId, onNotification) { return Object.assign(new WindowsActiveSessionListener(registry, sessionId, onNotification), { closesRegistry: true }); },
308
+ createClient(registry: WindowsSessionPresenceRegistry, sessionId) { return new WindowsActiveSessionClient(registry, sessionId); },
309
+ };
310
+
311
+ export function createDefaultSessionTransport(platform: NodeJS.Platform = process.platform): SessionTransportFactory {
312
+ return platform === "win32" ? windowsSessionTransport : posixSessionTransport;
313
+ }
314
+
446
315
  function text(value: string, details: Record<string, unknown> = {}, terminate = false): ToolText {
447
316
  return { content: [{ type: "text", text: value }], details, ...(terminate ? { terminate: true } : {}) };
448
317
  }
@@ -505,21 +374,6 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as
505
374
  }
506
375
  }
507
376
 
508
- interface ResearchArtifactCallObservation {
509
- index: number;
510
- desired?: ResearchWriteIdentity;
511
- }
512
- const RESEARCH_ARTIFACT_SCHEMA = {
513
- type: "object", description: "Untrusted exact artifact intent, never authorization or readback. Same bounds required on continuation.",
514
- required: ["store", "worktree", "changeName", "retainedIntent", "locators"],
515
- properties: {
516
- store: { type: "string", enum: ["openspec", "engram", "both", "none"] }, worktree: { type: "string" }, changeName: { type: "string" }, retainedIntent: { type: "string" },
517
- locators: { type: "array", maxItems: 3, items: { type: "object", required: ["artifact", "revision", "digest"], properties: {
518
- artifact: { type: "string", enum: ["research", "preproposal", "explore"] }, revision: { type: "integer", minimum: 1 }, digest: { type: "string", pattern: "^[a-f0-9]{64}$" }, path: { type: "string" },
519
- 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 } } },
520
- } } },
521
- },
522
- };
523
377
  const RESEARCH_SELECTION_SCHEMA = {
524
378
  type: "object",
525
379
  additionalProperties: false,
@@ -543,110 +397,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
543
397
  let selection: unknown;
544
398
  try { selection = JSON.parse(env[RESEARCH_SELECTION_ENV] ?? "null"); } catch { /* Missing selection grants no research. */ }
545
399
  const current = () => researchAgent({ tools: allowed, instructions: "" } as AgentDefinition, pi, selection);
546
- pi.on("before_agent_start", (event, ctx) => {
547
- reads.clear(); initialReads.clear(); writes.clear(); calls.clear(); pending.clear(); accepted.clear(); readbackMismatch = false;
548
- try {
549
- const last = [...(ctx.sessionManager?.getEntries?.() ?? [])].reverse().find(entry => entry.type === "custom" && entry.customType === RESEARCH_PERSISTENCE_ENTRY);
550
- if (last?.type === "custom") {
551
- const restored = parseResearchPersistence(last.data, artifactScope(ctx.cwd), ctx.cwd);
552
- for (const [key, value] of Object.entries(restored.accepted)) accepted.set(key, value);
553
- for (const [key, value] of Object.entries(restored.writes)) writes.set(key, value);
554
- }
555
- } catch { readbackMismatch = true; }
556
- 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.` };
557
- });
558
- let readbackMismatch = false;
559
- const reads = new Map<string, string>();
560
- const initialReads = new Set<string>();
561
- const pending = new Set<string>();
562
- const accepted = new Map<string, ReturnType<typeof parseResearchArtifactIntent>["locators"][number]>();
563
- const writes = new Map<string, ResearchWriteIdentity>();
564
- const calls = new Map<string, ResearchArtifactCallObservation>();
565
- const artifactScope = (cwd: string) => parseResearchArtifactIntent(JSON.parse(env[RESEARCH_ARTIFACT_ENV] ?? "null"), cwd);
566
- const checkpoint = (ctx: ExtensionContext, operation: Record<string, unknown>) => {
567
- const file = ctx.sessionManager?.getSessionFile?.();
568
- if (!pi.appendEntry || !ctx.sessionManager?.getEntries || !file || !existsSync(file)) throw new Error("Durable research session history unavailable");
569
- const data = { version: 1, scope: artifactScope(ctx.cwd), accepted: Object.fromEntries(accepted), writes: Object.fromEntries(writes), operation };
570
- if (Buffer.byteLength(JSON.stringify(data)) > 32_768) throw new Error("Research checkpoint exceeds bounded history payload");
571
- pi.appendEntry(RESEARCH_PERSISTENCE_ENTRY, data);
572
- assertResearchCheckpoint(file, data);
573
- };
574
- pi.on("tool_call", (event, ctx) => {
400
+ pi.on("before_agent_start", event => ({
401
+ systemPrompt: `${event.systemPrompt}\n\n${renderResearchCapabilities(current().capabilities)}\n\nResearch is output-only. Use parent-supplied local context; do not read or mutate repository or Engram artifacts. Return useful partial findings and unavailable sources honestly. The parent owns authorized persistence and actual readback.`,
402
+ }));
403
+ pi.on("tool_call", event => {
575
404
  const registered = pi.getAllTools().some(tool => tool.name === event.toolName && tool.sourceInfo?.source !== "sdk");
576
405
  const selected = current().agent.tools.includes(event.toolName) || event.toolName === "subagent_parent_message";
577
406
  if (!registered || !selected || !allowed.includes(event.toolName) || !pi.getActiveTools().includes(event.toolName)) {
578
- return { block: true, reason: "Tool is outside the research child's active launch allowlist." };
407
+ return { block: true, reason: "Tool is outside the output-only research child's active launch allowlist." };
579
408
  }
580
- if (event.toolName === "subagent_parent_message" || ["fetch_content", "web_search", "source_check", "get_search_content"].includes(event.toolName)) return;
581
- try {
582
- const scope = artifactScope(ctx.cwd);
583
- const index = researchArtifactCall(scope, ctx.cwd, event.toolName, event.input);
584
- let desired: ResearchWriteIdentity | undefined;
585
- if (["write", "edit", "mem_save"].includes(event.toolName)) {
586
- if (readbackMismatch || pending.size) throw new Error("Stale/divergent state requires explicit identical-scope re-entry.");
587
- const tools = scope.store === "both" ? ["read", "mem_get_observation"] : [scope.store === "openspec" ? "read" : "mem_get_observation"];
588
- if (!scope.locators.every((_, i) => tools.every(tool => initialReads.has(`${i}:${tool}`)))) throw new Error("Every selected artifact requires matching initial readback before mutation.");
589
- reads.clear();
590
- const content = "content" in event.input ? event.input.content : undefined;
591
- if (event.toolName === "edit" || typeof content !== "string") throw new Error("Use a full bounded write/save for post-write readback.");
592
- const key = `${index}:${event.toolName === "write" ? "read" : "mem_get_observation"}`;
593
- if (!initialReads.has(key) || writes.has(key)) throw new Error("Fresh matching readback required before mutation.");
594
- const revision: unknown = JSON.parse(content).revision;
595
- if (!Number.isSafeInteger(revision) || Number(revision) <= (accepted.get(key) ?? scope.locators[index]).revision) throw new Error("Full write requires a newer positive revision.");
596
- desired = { revision: Number(revision), digest: createHash("sha256").update(content).digest("hex") };
597
- if (scope.store === "both") {
598
- const peerKey = `${index}:${event.toolName === "write" ? "mem_get_observation" : "read"}`;
599
- const peer = writes.get(peerKey) ?? accepted.get(peerKey);
600
- 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");
601
- }
602
- calls.clear(); pending.add(event.toolCallId); writes.set(key, desired);
603
- checkpoint(ctx, { toolCallId: event.toolCallId, tool: event.toolName, index, desired });
604
- }
605
- calls.set(event.toolCallId, { index, desired });
606
- } catch (error) { return { block: true, reason: `Research scope refused: ${String(error)}. Retain intent and uncertainty; no replacement store.` }; }
607
- });
608
- pi.on("tool_result", (event, ctx) => {
609
- const call = calls.get(event.toolCallId);
610
- calls.delete(event.toolCallId);
611
- if (!call) return;
612
- const { index, desired } = call;
613
- if (desired) {
614
- let valid = false;
615
- try {
616
- 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);
617
- } catch { /* Malformed mutation results cannot authorize completion. */ }
618
- if (!valid) readbackMismatch = true;
619
- 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") }); }
620
- catch { readbackMismatch = true; }
621
- pending.delete(event.toolCallId); reads.clear();
622
- return;
623
- }
624
- if (!["read", "mem_get_observation"].includes(event.toolName)) return;
625
- if (pending.size) return { content: [...event.content, { type: "text" as const, text: "Research readback incomplete: proposal_ready=false; mutation pending." }] };
626
- let matched = false, complete = false;
627
- try {
628
- const scope = artifactScope(ctx.cwd);
629
- researchArtifactCall(scope, ctx.cwd, event.toolName, event.input);
630
- const bytes = event.content.map(part => part.type === "text" ? part.text : "").join("");
631
- const returned = event.toolName === "read" ? bytes : JSON.parse(bytes);
632
- const key = `${index}:${event.toolName}`, written = writes.get(key);
633
- const expected = { ...(accepted.get(key) ?? scope.locators[index]), ...written };
634
- matched = !event.isError && researchArtifactReadback(expected, event.toolName, returned, written !== undefined);
635
- if (matched) {
636
- reads.set(key, expected.digest);
637
- initialReads.add(key);
638
- accepted.set(key, event.toolName === "read" ? expected : { ...expected, engram: { ...expected.engram!, revision_count: returned.revision_count } });
639
- writes.delete(key);
640
- checkpoint(ctx, { toolCallId: event.toolCallId, tool: event.toolName, index, matched: true });
641
- }
642
- const tools = scope.store === "both" ? ["read", "mem_get_observation"] : [scope.store === "openspec" ? "read" : "mem_get_observation"];
643
- const divergent = scope.locators.some((_, i) => tools.every(tool => reads.has(`${i}:${tool}`)) && new Set(tools.map(tool => reads.get(`${i}:${tool}`))).size !== 1);
644
- if (divergent) matched = false;
645
- complete = matched && !readbackMismatch && scope.store !== "none" && scope.locators.every((_, i) => tools.every(tool => reads.has(`${i}:${tool}`)));
646
- } catch { matched = false; /* Unsupported or undurable readback is not evidence. */ }
647
- if (!matched) { reads.clear(); readbackMismatch = true; }
648
- 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.";
649
- return { content: [...event.content, { type: "text" as const, text: note }], isError: event.isError || !matched };
650
409
  });
651
410
  }
652
411
  const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined));
@@ -682,6 +441,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
682
441
  : environmentHome && (selectedHome.startsWith("~/") || (process.platform === "win32" && selectedHome.startsWith("~\\"))) ? join(deps.home, selectedHome.slice(2)) : selectedHome;
683
442
  // Freeze the host's root before a child uses a different session cwd.
684
443
  const agentHome = resolve(expandedHome);
444
+ const sessionTransport = deps.sessionTransport ?? createDefaultSessionTransport();
685
445
  if (legacySubagentsInstalledAt(agentHome)) {
686
446
  pi.on("session_start", (_event, ctx) => {
687
447
  if (ctx.hasUI) ctx.ui.notify(`${AGENTS_GLYPH} Gentle Agents is waiting: remove the old package first with "pi remove npm:${LEGACY_SUBAGENTS_PACKAGE}"`, "warning");
@@ -751,6 +511,92 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
751
511
  // its session. Before the first session_start there is nothing to scope by.
752
512
  const activeSessionId = (): string | undefined => (sessions === undefined ? undefined : sessions.getSessionId() ?? "");
753
513
  const visibleTasks = (): TaskRecord[] => store.list(activeSessionId());
514
+ type SessionTransport = { generation: number; sessionId: string; sessionManager: ExtensionContext["sessionManager"]; client: SessionTransportClient; listener: SessionTransportListener; registry: SessionTransportRegistry; close(): Promise<void> };
515
+ let transportGeneration = 0;
516
+ let activeSessionTransport: SessionTransport | undefined;
517
+ const pendingTransportStartups = new Set<Promise<void>>();
518
+ const validTransportSessionId = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value);
519
+ const closeSessionTransport = async (transport: SessionTransport | undefined) => {
520
+ if (!transport) return;
521
+ await transport.close();
522
+ };
523
+ const trackTransportOperation = (operation: Promise<void>) => {
524
+ pendingTransportStartups.add(operation);
525
+ operation.then(() => { pendingTransportStartups.delete(operation); }, () => { pendingTransportStartups.delete(operation); });
526
+ return operation;
527
+ };
528
+ const startSessionTransport = (ctx: ExtensionContext) => {
529
+ const previous = activeSessionTransport;
530
+ const generation = ++transportGeneration;
531
+ const sessionManager = ctx.sessionManager;
532
+ activeSessionTransport = undefined;
533
+ const operation = (async () => {
534
+ let registry: SessionTransportRegistry | undefined;
535
+ let listener: SessionTransportListener | undefined;
536
+ let client: SessionTransportClient | undefined;
537
+ let closePromise: Promise<void> | undefined;
538
+ const closeStartupTransport = () => {
539
+ if (closePromise) return closePromise;
540
+ closePromise = Promise.resolve().then(async () => {
541
+ await Promise.allSettled([
542
+ (async () => { try { client?.close(); } catch {} })(),
543
+ (async () => { try { await listener?.close(); } catch {} })(),
544
+ (async () => { try { if (registry && (!listener || !listener.closesRegistry)) await registry.close?.(); } catch {} })(),
545
+ ]);
546
+ });
547
+ return closePromise;
548
+ };
549
+ try {
550
+ if (previous) trackTransportOperation(closeSessionTransport(previous));
551
+ const sessionId = sessionManager.getSessionId();
552
+ if (!validTransportSessionId(sessionId) || sessions !== sessionManager || generation !== transportGeneration) return;
553
+ registry = await sessionTransport.createRegistry(agentHome);
554
+ if (sessions !== sessionManager || generation !== transportGeneration) {
555
+ await closeStartupTransport();
556
+ return;
557
+ }
558
+ listener = sessionTransport.createListener(registry, sessionId, async (notification) => {
559
+ const active = activeSessionTransport;
560
+ if (!active || active.generation !== generation || active.sessionManager !== sessionManager || active.sessionId !== sessionId || sessions !== sessionManager || activeSessionId() !== sessionId) throw new Error("stale session transport");
561
+ pi.sendMessage({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming" } } }, { deliverAs: "followUp", triggerTurn: true });
562
+ });
563
+ client = sessionTransport.createClient(registry, sessionId);
564
+ if (sessions !== sessionManager || generation !== transportGeneration || activeSessionId() !== sessionId) {
565
+ await closeStartupTransport();
566
+ return;
567
+ }
568
+ const transport = { generation, sessionId, sessionManager, client, listener, registry, close: closeStartupTransport };
569
+ // The listener deliberately accepts while publishing. Bind its callback
570
+ // first so a peer accepted in that interval remains current-session work.
571
+ activeSessionTransport = transport;
572
+ await listener.start();
573
+ if (sessions !== sessionManager || generation !== transportGeneration || activeSessionId() !== sessionId) {
574
+ if (activeSessionTransport === transport) activeSessionTransport = undefined;
575
+ await closeStartupTransport();
576
+ return;
577
+ }
578
+ } catch {
579
+ if (activeSessionTransport?.generation === generation) activeSessionTransport = undefined;
580
+ await closeStartupTransport();
581
+ }
582
+ })();
583
+ trackTransportOperation(operation);
584
+ return operation;
585
+ };
586
+ const shutdownSessionTransport = async () => {
587
+ const active = activeSessionTransport;
588
+ activeSessionTransport = undefined;
589
+ transportGeneration++;
590
+ if (active) await closeSessionTransport(active);
591
+ while (pendingTransportStartups.size > 0) {
592
+ await Promise.allSettled([...pendingTransportStartups]);
593
+ }
594
+ };
595
+ const activeTransportFor = (ctx: ExtensionContext) => {
596
+ const active = activeSessionTransport;
597
+ const sessionId = ctx.sessionManager.getSessionId();
598
+ return active && active.generation === transportGeneration && active.sessionManager === ctx.sessionManager && active.sessionId === sessionId && sessions === ctx.sessionManager ? active : undefined;
599
+ };
754
600
 
755
601
  const requestRender = () => {
756
602
  if (renderQueued) return;
@@ -885,6 +731,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
885
731
  const root = deps.resolveWorktree(tool.path, task.cwd)?.root;
886
732
  const childRoot = deps.resolveWorktree(task.cwd, task.cwd)?.root;
887
733
  if (!root || root !== childRoot || !worktrees.roots().includes(root)) return;
734
+ if (tool.evidence?.root === root) {
735
+ try {
736
+ let path = tool.path.replace(/^@/, "");
737
+ if (path === "~" || path.startsWith("~/")) path = os.homedir() + path.slice(1);
738
+ if (realpathSync(resolve(task.cwd, path)) === resolve(root, tool.evidence.path)) pi.events.emit(SESSION_CHANGE_RELAY, { sessionId: task.parentSessionId, evidence: { ...tool.evidence, id: `${task.id}:${tool.toolCallId}` } });
739
+ } catch { /* Missing or mismatched targets cannot supply session diffs. */ }
740
+ }
888
741
  recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId });
889
742
  },
890
743
  onFinish: (task, observations) => {
@@ -920,6 +773,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
920
773
  return new Text(`${theme.fg("customMessageLabel", heading)}\n${theme.fg("customMessageText", body)}`, options.outputPad, 0);
921
774
  });
922
775
 
776
+ pi.registerMessageRenderer(AGENTS_ORCHESTRATOR_MESSAGE_TYPE, (message, options, theme) => {
777
+ const details = (message.details as { gentleAgents?: { senderSessionId?: unknown } } | undefined)?.gentleAgents;
778
+ const sender = typeof details?.senderSessionId === "string" ? details.senderSessionId : "unknown";
779
+ const heading = `⇄ Orchestrator message · Received · From ${sanitizeTerminalText(sender)}`;
780
+ const body = sanitizeTerminalText(messageText(message.content));
781
+ return new Text(`${theme.fg("customMessageLabel", heading)}\n${theme.fg("customMessageText", body)}`, options.outputPad, 0);
782
+ });
783
+
923
784
  pi.registerMessageRenderer(AGENTS_RESULT_TYPE, (message, options, theme) => {
924
785
  const details = (message.details as { gentleAgents?: { agent?: string; status?: string } } | undefined)?.gentleAgents;
925
786
  const content = message.content as string | Array<{ type: string; text?: string }>;
@@ -1114,7 +975,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1114
975
 
1115
976
  const roots = (ctx: ExtensionContext) => ({ cwd: ctx.sessionManager.getCwd(), home: deps.home, agentHome });
1116
977
 
1117
- 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 => {
978
+ const buildRequest = (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string, workspaceRoot?: string, sddChange?: SddChangeSelection, researchSelection?: unknown, remediationIntent?: unknown): TaskRequest => {
1118
979
  const registry = registryFor(ctx);
1119
980
  const parentCwd = ctx.sessionManager.getCwd();
1120
981
  // An explicit target is validated before any queue or session-dir writes.
@@ -1129,7 +990,25 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1129
990
  const launchSddChange = sddChange === undefined || target === undefined
1130
991
  ? undefined
1131
992
  : { ...sddChange, workspaceRoot: target };
1132
- const config = loadAgentsConfig(roots(ctx));
993
+ // A per-repository profile pin replaces subagent routing for this launch without
994
+ // materialising anything: the global stores stay untouched, so two repositories
995
+ // worked in parallel stop fighting over one global routing. The child's own
996
+ // worktree is the repository in question, and the local pin sits in the shared
997
+ // Git common directory, so it survives every worktree of the clone. When the
998
+ // launch stays in the session's own worktree the identity already resolved above
999
+ // is reused instead of asking Git a second time. The orchestrator is out of
1000
+ // scope: only `modelProfiles` is replaced.
1001
+ const pinIdentity: WorktreeResolver = target !== undefined && parentIdentity !== undefined && target === parentIdentity.root
1002
+ ? () => parentIdentity
1003
+ : deps.resolveWorktree;
1004
+ const config = withPinnedModelProfiles(
1005
+ loadAgentsConfig(roots(ctx)),
1006
+ resolveProfilePin({
1007
+ cwd: target ?? parentCwd,
1008
+ configHome: gentlePiConfigHome(deps.env),
1009
+ resolveWorktree: pinIdentity,
1010
+ })?.modelProfiles,
1011
+ );
1133
1012
  const profile = resolveAgentProfile(agent, config);
1134
1013
  const research = agent.name === "sdd-research" ? researchAgent(agent, pi, researchSelection) : undefined;
1135
1014
  const sessionDir = agentRuntimePaths(deps.home, agentHome).sessions;
@@ -1157,7 +1036,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1157
1036
  sessionDir,
1158
1037
  resumeSessionPath: resume,
1159
1038
  env: research ? { ...deps.env, [RESEARCH_CHILD_TOOLS_ENV]: JSON.stringify([...research.agent.tools, "subagent_parent_message"]) } : deps.env,
1160
- ...(research ? { researchSelection, extensionPaths: research.extensionPaths, researchArtifact: researchArtifact === undefined ? undefined : parseResearchArtifactIntent(researchArtifact, target ?? parentCwd) } : {}),
1039
+ ...(research ? { researchSelection, extensionPaths: research.extensionPaths } : {}),
1161
1040
  ...(launchSddChange === undefined ? {} : { sddChange: launchSddChange }),
1162
1041
  ...(parentRepositoryIdentity === undefined ? {} : {
1163
1042
  authorizeParentStandingReviewPermission: (repositoryIdentity: string) => {
@@ -1180,26 +1059,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1180
1059
  };
1181
1060
 
1182
1061
  const launch = async (ctx: ExtensionContext, request: TaskRequest, signal?: AbortSignal): Promise<ToolText> => {
1062
+ if (ctx.mode === "print" && request.mode === AGENT_MODE.BACKGROUND) {
1063
+ throw new Error("Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.");
1064
+ }
1183
1065
  // This is the process-spawn boundary. A child receives its task context only
1184
1066
  // after its RPC process starts, so validate the single parent transport here
1185
1067
  // rather than letting a child invent/persist preferences during startup.
1186
1068
  if (SHIPPED_SDD_AGENT_NAME_SET.has(request.agent.name) && !isParentConfirmedSddPreflightContext(request.context)) {
1187
1069
  throw new Error("SDD child dispatch refused: parent-confirmed SDD preflight context is missing or malformed.");
1188
1070
  }
1189
- let prepared: TaskRecord | undefined;
1190
1071
  if (request.agent.name === "sdd-remediate") {
1191
- const previous = await loadHistory(tasksDir);
1192
- 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");
1193
- prepared = runner.prepareRemediation(request);
1194
- const persist = (task: TaskRecord) => saveTask(tasksDir, task, store.thread(task.id));
1195
- try {
1196
- const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
1197
- Object.assign(request, await admitManagedRemediation(request, request.remediationIntent, native, persist, ctx, prepared));
1198
- if (activeSessionId() !== request.parentSessionId) throw new Error("Parent session changed; retain admission and refuse actor replay");
1199
- prepared.sddRemediation!.actorClaimed = true;
1200
- await persist(prepared); // A crash beyond here is an unknown actor effect, not rerun permission.
1201
- } catch (error) { store.update(prepared.id, { status: TASK_STATUS.FAILED, error: "Remediation admission/dispatch refused; reconcile retained history" }); throw error; }
1072
+ const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
1073
+ Object.assign(request, await admitManagedRemediation(request, request.remediationIntent, native, ctx));
1074
+ if (activeSessionId() !== request.parentSessionId) throw new Error("Parent session changed; request fresh authorization before dispatch");
1202
1075
  }
1076
+
1203
1077
  // Bounded live observation only. Native send owns the fresh policy decision;
1204
1078
  // child execution never starts a telemetry policy process or renewal timer.
1205
1079
  const owner = metricsOwner;
@@ -1217,7 +1091,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1217
1091
  metrics.started = metricsNow();
1218
1092
  return metrics.valid();
1219
1093
  } } : {}),
1220
- }, prepared);
1094
+ });
1221
1095
  if (observe) metricTasks.set(task.id, metrics);
1222
1096
  ownedTaskIds.add(task.id);
1223
1097
  store.subscribe(task.id, () => { publishActivity(); requestRender(); });
@@ -1271,6 +1145,82 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1271
1145
  });
1272
1146
  };
1273
1147
 
1148
+ pi.registerTool({
1149
+ name: "orchestrator_session_id",
1150
+ label: "Orchestrator session ID",
1151
+ description: "Return this host session's active ID.",
1152
+ parameters: { type: "object", additionalProperties: false, properties: {} } as never,
1153
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
1154
+ const transport = activeTransportFor(ctx);
1155
+ return transport ? text(`Active session ID: ${transport.sessionId}`, { gentleAgents: { senderSessionId: transport.sessionId } }) : text("Error: session messaging is not ready.", { error: "not ready" });
1156
+ },
1157
+ });
1158
+ pi.registerTool({
1159
+ name: "orchestrator_list",
1160
+ label: "List orchestrators",
1161
+ description: "List other sessions advertised by the trusted local profile. Advertised reachability is unknown and does not prove a session is live.",
1162
+ parameters: { type: "object", additionalProperties: false, properties: {} } as never,
1163
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
1164
+ const transport = activeTransportFor(ctx);
1165
+ if (!transport) return text("Error: session discovery is not ready.", { error: "not ready" });
1166
+ try {
1167
+ const peers = await transport.listener.registry.list(transport.sessionId);
1168
+ if (activeTransportFor(ctx) !== transport) return text("Error: session discovery became unavailable before results were confirmed.", { error: "stale" });
1169
+ return peers.length === 0 ? text("No other sessions are currently advertised. Advertisements have unknown reachability and do not guarantee a live session.") : text(`Advertised sessions (reachability is unknown):\n${peers.map((peer) => `- ${peer.sessionId}`).join("\n")}`, { gentleAgents: { candidates: peers } });
1170
+ } catch {
1171
+ return text("Error: session discovery is unavailable.", { error: "unavailable" });
1172
+ }
1173
+ },
1174
+ });
1175
+ pi.registerTool({
1176
+ name: "orchestrator_send_message",
1177
+ label: "Send orchestrator message",
1178
+ description: "Send a notification to another active session in this trusted local profile. If recipient_session_id is omitted, the sole peer is selected or the user selects one. Acceptance means enqueued, not read or completed.",
1179
+ parameters: { type: "object", additionalProperties: false, required: ["message"], properties: { recipient_session_id: { type: "string" }, message: { type: "string" } } } as never,
1180
+ async execute(_id, params, signal, _onUpdate, ctx) {
1181
+ const transport = activeTransportFor(ctx);
1182
+ const input = params as { recipient_session_id?: unknown; message?: unknown };
1183
+ if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" });
1184
+ if (typeof input.message !== "string" || Buffer.byteLength(input.message, "utf8") > 8192) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
1185
+ let recipient: string | undefined;
1186
+ let activation: PresenceRecord | undefined;
1187
+ if (input.recipient_session_id !== undefined) {
1188
+ if (!validTransportSessionId(input.recipient_session_id)) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
1189
+ recipient = input.recipient_session_id;
1190
+ }
1191
+ if (recipient === undefined) {
1192
+ try {
1193
+ const candidates = (await transport.listener.registry.listActivations(transport.sessionId)).filter((candidate) => candidate.sessionId !== transport.sessionId);
1194
+ if (activeTransportFor(ctx) !== transport || signal?.aborted) return text("Message selection was cancelled.", { error: "cancelled" });
1195
+ if (candidates.length === 0) return text("No other sessions are currently advertised; message delivery is unavailable.", { error: "unavailable" });
1196
+ if (candidates.length === 1) {
1197
+ recipient = candidates[0].sessionId;
1198
+ activation = candidates[0];
1199
+ } else if (!ctx.hasUI) return text("Several recipient orchestrators are available. Ask the user to choose a recipient label; do not ask for a session ID.", { gentleAgents: { candidates: candidates.map((candidate) => ({ label: `Orchestrator ${candidate.sessionId}`, sessionId: candidate.sessionId })) } });
1200
+ else {
1201
+ const labels = candidates.map((candidate) => `Orchestrator ${candidate.sessionId}`);
1202
+ const selected = await ctx.ui.select("Select recipient orchestrator", labels, { signal });
1203
+ if (selected === undefined || activeTransportFor(ctx) !== transport || signal?.aborted) return text("Message selection was cancelled.", { error: "cancelled" });
1204
+ const index = labels.indexOf(selected);
1205
+ if (index < 0) return text("Message selection was cancelled.", { error: "cancelled" });
1206
+ recipient = candidates[index].sessionId;
1207
+ activation = candidates[index];
1208
+ }
1209
+ } catch {
1210
+ return text("Error: session discovery is unavailable.", { error: "unavailable" });
1211
+ }
1212
+ }
1213
+ if (recipient === undefined || !validTransportSessionId(recipient)) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
1214
+ if (recipient === transport.sessionId) return text("Error: cannot send a message to the active session.", { error: "self" });
1215
+ try {
1216
+ const accepted = await transport.client.sendNotification(recipient, input.message, { signal, expectedActivation: activation, beforeConnect: () => activeTransportFor(ctx) === transport });
1217
+ return activeTransportFor(ctx) === transport ? text(`Message ${accepted.id} from ${transport.sessionId} to ${recipient} accepted for delivery; it is not a delivery or read receipt.`, { gentleAgents: { messageId: accepted.id, senderSessionId: transport.sessionId, recipientSessionId: recipient, state: "accepted" } }) : text("Error: session messaging is not ready.", { error: "stale" });
1218
+ } catch {
1219
+ return text("Error: session message was not accepted.", { error: "not accepted" });
1220
+ }
1221
+ },
1222
+ });
1223
+
1274
1224
  tool("list_agents", "List the subagents defined for this project and user, with their descriptions.", { properties: {} }, async (_params, ctx) => {
1275
1225
  const { agents, errors } = discoverAgents(roots(ctx));
1276
1226
  const lines = agents.map((agent) => `- ${agent.name} (${agent.scope}): ${agent.description || "no description"}`);
@@ -1289,8 +1239,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1289
1239
  label: { type: "string", description: "Three to six words naming the work, shown on the agents card, e.g. 'map footer data sources'." },
1290
1240
  context: { type: "string", description: "Optional extra context appended to the task." },
1291
1241
  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." },
1292
- research_artifact: RESEARCH_ARTIFACT_SCHEMA, research_selection: RESEARCH_SELECTION_SCHEMA,
1293
- 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." },
1242
+ research_selection: RESEARCH_SELECTION_SCHEMA,
1243
+ 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", "archive", "remediate"] } }, description: "Launch-local selected SDD identity, accepted only by matching SDD phase agents." },
1294
1244
  mode: { type: "string", enum: ["task", "background"], description: "task waits for the result (default); background returns immediately." },
1295
1245
  },
1296
1246
  },
@@ -1302,7 +1252,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1302
1252
  let sddChange: SddChangeSelection | undefined;
1303
1253
  try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1304
1254
  catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1305
- 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);
1255
+ 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.remediation), signal);
1306
1256
  },
1307
1257
  );
1308
1258
 
@@ -1311,25 +1261,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1311
1261
  return task ? text(describeTask(task), taskDetails(task)) : text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
1312
1262
  });
1313
1263
 
1314
- tool("reconcile", "Reconcile one retained managed remediation mutation without launching an actor.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => {
1315
- const id = String(params.task_id);
1316
- const lock = acquireTaskLock(tasksDir, id);
1317
- try {
1318
- const stored = await loadStoredTask(tasksDir, id);
1319
- if (!stored) return text(`Error: no task ${id}`, { error: "unknown task" });
1320
- const task = stored.task, retainedThread = stored.thread;
1321
- const target = registryFor(ctx).validate(task.cwd);
1322
- if (target !== resolve(task.cwd) || task.sddRemediation?.acquire.workspaceRoot !== target) throw new Error("Retained remediation task must resolve to its exact worktree in the same Git clone as this session");
1323
- const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
1324
- const persist = async (current: TaskRecord) => {
1325
- await saveTask(tasksDir, current, retainedThread);
1326
- store.update(current.id, { status: current.status, error: current.error, lastStep: current.lastStep, endedAt: current.endedAt, lastActivityAt: current.lastActivityAt, sddRemediation: current.sddRemediation });
1327
- };
1328
- const result = await reconcileManagedRemediation(task, native, persist);
1329
- return text(`Managed remediation task ${task.id} reconciled; no actor started. Use fresh native status and admission for later work.`, { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode }, reconciliation: { ...result, actorStarted: false } });
1330
- } finally { lock.release(); }
1331
- });
1332
-
1333
1264
  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) => {
1334
1265
  const task = await resolveTask(String(params.task_id));
1335
1266
  if (!task) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
@@ -1362,7 +1293,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1362
1293
  tool(
1363
1294
  "continue",
1364
1295
  "Resume a finished subagent task in its own session with a follow-up prompt.",
1365
- { 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"] } } },
1296
+ { required: ["task_id", "prompt"], properties: { 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", "archive", "remediate"] } }, description: "Fresh launch-local selected SDD identity, required when continuing an SDD phase agent." }, mode: { type: "string", enum: ["task", "background"] } } },
1366
1297
  async (params, ctx, signal) => {
1367
1298
  const previous = await resolveTask(String(params.task_id));
1368
1299
  if (!previous) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
@@ -1377,14 +1308,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1377
1308
  try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1378
1309
  catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1379
1310
  if (sddPhaseForAgent(agent.name) && !sddChange) return text("Error: continuing an SDD phase agent requires a fresh sdd_change selection.", { error: "missing sdd_change" });
1380
- let artifact: ResearchArtifactIntent | undefined;
1381
- if (agent.name === "sdd-research") {
1382
- try {
1383
- const prior = parseResearchArtifactIntent("researchArtifact" in previous ? previous.researchArtifact : undefined, previous.cwd);
1384
- artifact = parseResearchArtifactIntent(params.research_artifact ?? prior, previous.cwd, prior);
1385
- } catch (error) { return text(`Error: research continuation scope refused: ${String(error)}`, { error: "research scope" }); }
1386
- }
1387
- 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);
1311
+
1312
+ 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, params.remediation), signal);
1388
1313
  },
1389
1314
  );
1390
1315
 
@@ -1416,7 +1341,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1416
1341
  });
1417
1342
  }
1418
1343
 
1419
- pi.on("session_start", (_event, ctx) => {
1344
+ pi.on("session_start", async (_event, ctx) => {
1420
1345
  // A resumed, reloaded, or replaced session starts with an empty completion
1421
1346
  // queue so nothing pending from another session can replay here.
1422
1347
  completions.dropAll();
@@ -1428,8 +1353,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1428
1353
  label: ctx.sessionManager.getSessionName?.() || ctx.sessionManager.getCwd().split(/[\\/]/).pop() || "Orchestrator", activity: [] });
1429
1354
  publishActivity();
1430
1355
  } catch { presence = undefined; }
1356
+ void startSessionTransport(ctx);
1431
1357
  });
1432
- pi.on("session_shutdown", () => {
1358
+ pi.on("session_shutdown", async () => {
1433
1359
  completions.dropAll();
1434
1360
  activeAgentRuns = 0;
1435
1361
  presence?.dispose();
@@ -1441,6 +1367,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1441
1367
  sidebarTui = undefined;
1442
1368
  worktrees?.close();
1443
1369
  worktrees = undefined;
1370
+ const stopped = shutdownSessionTransport();
1444
1371
  runner.cancelAll("cancelled: parent session shut down");
1372
+ await stopped;
1445
1373
  });
1446
1374
  }