gentle-pi 2.7.0 → 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 (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. package/tests/openspec-guardrails.test.ts +0 -71
@@ -1,12 +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";
6
7
  import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
7
8
  import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
8
9
  import { existsSync, mkdirSync, readFileSync, lstatSync, realpathSync } from "node:fs";
9
- import { createHash, randomUUID } from "node:crypto";
10
+ import { randomUUID } from "node:crypto";
10
11
  import { mkdir, readFile, writeFile } from "node:fs/promises";
11
12
  import os from "node:os";
12
13
  import { join, resolve, isAbsolute, sep } from "node:path";
@@ -15,12 +16,14 @@ import { Text, type TUI } from "@earendil-works/pi-tui";
15
16
  import { sidebarPart } from "../lib/shell-sidebar.ts";
16
17
  import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
17
18
  import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
18
- 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";
19
20
  import { isFinished, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
20
- 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";
21
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";
22
25
  import { hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentitySync, type ReviewSessionManager } from "../lib/review-session-standing-permission.ts";
23
- 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";
24
27
  import { sessionToMarkdown } from "../lib/agents-transcript.ts";
25
28
  import { AgentsView } from "../lib/agents-view.ts";
26
29
  import { PresencePublisher } from "../lib/orchestrator-presence.ts";
@@ -28,8 +31,9 @@ import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-inte
28
31
  import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts";
29
32
  import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
30
33
  import { openInExternalEditor } from "./gentle-shell.ts";
31
- import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
32
- 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";
33
37
  import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
34
38
  import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
35
39
 
@@ -42,10 +46,8 @@ export const AGENTS_WIDGET_KEY = "gentle-agents";
42
46
  export const AGENTS_COMMAND_NAME = "gentle:agents";
43
47
  export const AGENTS_RESULT_TYPE = "gentle-agents.result";
44
48
  export const AGENTS_MESSAGE_TYPE = "gentle-agents.message";
49
+ export const AGENTS_ORCHESTRATOR_MESSAGE_TYPE = "gentle-agents.orchestrator-message";
45
50
  export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result";
46
- const COLLAPSE_KEY_DEFAULT = "ctrl+shift+a";
47
- const VIEW_KEY_DEFAULT = "alt+a";
48
- const STOP_KEY_DEFAULT = "alt+s";
49
51
  const RENDER_COALESCE_MS = 400;
50
52
  const CLOCK_TICK_MS = 1000;
51
53
  const TOOL_PREFIX = "subagent_";
@@ -55,7 +57,6 @@ const SDD_PHASE_BY_AGENT = {
55
57
  "sdd-apply": "apply",
56
58
  "sdd-remediate": "remediate",
57
59
  "sdd-verify": "verify",
58
- "sdd-sync": "sync",
59
60
  "sdd-archive": "archive",
60
61
  } as const;
61
62
 
@@ -66,7 +67,7 @@ function sddPhaseForAgent(name: string): SddChangeSelection["phase"] | undefined
66
67
  function parseSddChange(value: unknown, agentName: string): SddChangeSelection | undefined {
67
68
  if (value === undefined) return undefined;
68
69
  const expectedPhase = sddPhaseForAgent(agentName);
69
- if (!expectedPhase) throw new Error("sdd_change is allowed only for SDD apply, verify, sync, or archive agents.");
70
+ if (!expectedPhase) throw new Error("sdd_change is allowed only for SDD apply, verify, or archive agents.");
70
71
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdd_change must be an object with changeName, workspaceRoot, and phase.");
71
72
  const selection = value as Record<string, unknown>;
72
73
  const keys = Object.keys(selection).sort();
@@ -77,22 +78,18 @@ function parseSddChange(value: unknown, agentName: string): SddChangeSelection |
77
78
  throw new Error("sdd_change must contain only a non-empty changeName, workspaceRoot, and the agent's matching phase.");
78
79
  }
79
80
  if (expectedPhase === "remediate" && (typeof selection.failedEvidenceRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(selection.failedEvidenceRevision))) throw new Error("Invalid remediation revision");
80
- return { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot, phase: selection.phase, ...(expectedPhase === "remediate" ? { failedEvidenceRevision: selection.failedEvidenceRevision as string } : {}) };
81
+ return { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot, phase: expectedPhase, ...(expectedPhase === "remediate" ? { failedEvidenceRevision: selection.failedEvidenceRevision as string } : {}) };
81
82
  }
82
83
 
83
84
  const REMEDIATION_SCHEMA = {
84
- type: "object", additionalProperties: false, required: ["plan", "attempt"],
85
+ type: "object", additionalProperties: false, required: ["plan"],
85
86
  properties: {
86
87
  plan: { type: "object", additionalProperties: false, required: ["cwd", "commands", "runtimeHarness", "rollback"], properties: {
87
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" } },
88
89
  runtimeHarness: { type: "object", additionalProperties: false, description: "Exactly one concrete command or prior concrete naReason containing because.", properties: { command: { type: "string" }, naReason: { type: "string" } } },
89
90
  rollback: { type: "object", additionalProperties: false, required: ["boundary", "command"], properties: { boundary: { type: "string" }, command: { type: "string" } } },
90
91
  } },
91
- attempt: { type: "object", additionalProperties: false, required: ["requestId", "workUnit", "evidenceGoal"], properties: {
92
- requestId: { type: "string" }, workUnit: { type: "string" }, evidenceGoal: { type: "string" }, token: { type: "string" }, expectedRevision: { type: "string" },
93
- maxAttempts: { type: "integer", minimum: 1, maximum: 100 }, maxChangedLines: { type: "integer", minimum: 1, maximum: 1000000 },
94
- untrackedScope: { type: "string", enum: ["select", "exclude"] }, expectedUntrackedInventory: { type: "string" }, intendedUntracked: { type: "array", items: { type: "string" } },
95
- } },
92
+
96
93
  },
97
94
  };
98
95
 
@@ -130,175 +127,21 @@ export function remediationToolAllowed(scope: RemediationScope | undefined, cwd:
130
127
  } catch { return false; }
131
128
  }
132
129
 
133
- export interface RemediationReconciliationResult {
134
- acquireState?: NativeSddAttemptResult["state"];
135
- settlementState?: NativeSddAttemptResult["state"];
136
- }
137
-
138
- async function replayUncertainNativeMutation<T>(label: "acquire" | "settlement", invoke: () => Promise<T>): Promise<T> {
139
- try { return await invoke(); }
140
- catch (error) {
141
- if (error instanceof TypeError || error instanceof NativeReviewCliError && error.mutationOutcome === "none") throw error;
142
- try { return await invoke(); }
143
- catch { throw new Error(`Native remediation ${label} remains unresolved; retry only this exact retained task`); }
144
- }
145
- }
146
-
147
- function finishReconciledTask(task: TaskRecord, message: string): void {
148
- task.status = TASK_STATUS.FAILED;
149
- task.error = message;
150
- task.lastStep = "reconciled";
151
- task.endedAt ??= Date.now();
152
- task.lastActivityAt = Date.now();
153
- }
154
-
155
- export async function reconcileManagedRemediation(task: TaskRecord, native: NativeReviewCli, persist: (task: TaskRecord) => Promise<void>): Promise<RemediationReconciliationResult> {
156
- const state = task.sddRemediation;
157
- if (task.agent !== "sdd-remediate" || !state?.acquire || state.acquire.workspaceRoot !== task.cwd) throw new Error("Task is not an exact managed remediation record");
158
- if (state.settlement) throw new Error("Managed remediation task already has a terminal settlement");
159
-
160
- const settleExact = async (settle: NativeSddSettleRequest, acquireState?: NativeSddAttemptResult["state"]): Promise<RemediationReconciliationResult> => {
161
- if (!native.sddAttemptSettle) throw new Error("Native remediation settlement reconciliation is unavailable");
162
- let settlement: NativeSddAttemptResult;
163
- try { settlement = await replayUncertainNativeMutation("settlement", () => native.sddAttemptSettle!(structuredClone(settle))); }
164
- catch (error) {
165
- state.settlementUncertain = true;
166
- await persist(task);
167
- throw error;
168
- }
169
- state.settlement = structuredClone(settlement);
170
- delete state.settlementUncertain;
171
- finishReconciledTask(task, `Managed remediation settlement reconciled as ${settlement.state}${settlement.reason ? `(${settlement.reason})` : ""}; no actor started`);
172
- await persist(task);
173
- return { ...(acquireState === undefined ? {} : { acquireState }), settlementState: settlement.state };
174
- };
175
-
176
- if (state.settle) {
177
- const settle = state.settle, acquire = state.acquire;
178
- const sameUntracked = JSON.stringify({ scope: settle.untrackedScope, inventory: settle.expectedUntrackedInventory, intended: settle.intendedUntracked }) === JSON.stringify({ scope: acquire.untrackedScope, inventory: acquire.expectedUntrackedInventory, intended: acquire.intendedUntracked });
179
- 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");
180
- return settleExact(structuredClone(settle), state.acquireResult.state);
181
- }
182
- if (!state.acquireUncertain) throw new Error("Managed remediation task has no uncertain acquire or settlement to reconcile");
183
- if (state.actorClaimed) throw new Error("Managed remediation actor effects are uncertain; exact settlement or maintainer intervention is required");
184
- if (state.acquireResult || state.token || state.settlementUncertain) throw new Error("Managed remediation acquire history is inconsistent; maintainer intervention is required");
185
- if (!native.sddAttemptAcquire) throw new Error("Native remediation acquire reconciliation is unavailable");
186
-
187
- let acquired: NativeSddAttemptResult;
188
- try { acquired = await replayUncertainNativeMutation("acquire", () => native.sddAttemptAcquire!(structuredClone(state.acquire))); }
189
- catch (error) {
190
- state.acquireUncertain = true;
191
- await persist(task);
192
- throw error;
193
- }
194
- if (acquired.state === "proceed" && !acquired.token) throw new Error("Native remediation acquire reconciliation returned no token");
195
- state.acquireResult = structuredClone(acquired);
196
- delete state.acquireUncertain;
197
- if (acquired.state !== "proceed") {
198
- delete state.token;
199
- finishReconciledTask(task, `Managed remediation acquire reconciled as ${acquired.state}${acquired.reason ? `(${acquired.reason})` : ""}; no actor started`);
200
- await persist(task);
201
- return { acquireState: acquired.state };
202
- }
203
-
204
- state.token = acquired.token;
205
- const acquire = state.acquire;
206
- const settle: NativeSddSettleRequest = {
207
- workspaceRoot: acquire.workspaceRoot,
208
- changeName: acquire.changeName,
209
- token: acquired.token!,
210
- requestId: `reconcile-${createHash("sha256").update(JSON.stringify(acquire)).digest("hex").slice(0, 32)}`,
211
- outcome: "interrupted",
212
- diagnosis: "Acquire outcome was recovered after the actor launch boundary was refused; no managed actor was launched",
213
- harnessDisposition: "invalidated",
214
- cleanupEvidence: "No managed actor was spawned; no child cleanup was required",
215
- processEvidence: "spawned=false; actor_claimed=false; reconciliation=acquire",
216
- remediatesEvidenceRevision: acquire.remediatesEvidenceRevision,
217
- ...(acquire.untrackedScope === undefined ? {} : { untrackedScope: acquire.untrackedScope, expectedUntrackedInventory: acquire.expectedUntrackedInventory, intendedUntracked: acquire.intendedUntracked }),
218
- };
219
- state.settle = structuredClone(settle);
220
- await persist(task); // Recovered token and exact settlement replay input become durable atomically before native mutation.
221
- return settleExact(settle, acquired.state);
222
- }
223
-
224
- 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>> {
225
131
  const selected = request.sddChange;
226
132
  const asset = fileURLToPath(new URL("../assets/agents/sdd-remediate.md", import.meta.url));
227
- 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");
228
134
  const owned = parseAgentDefinition(readFileSync(asset, "utf8"), asset, "global");
229
135
  const installed = parseAgentDefinition(readFileSync(request.agent.filePath, "utf8"), request.agent.filePath, request.agent.scope);
230
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");
231
- const value = input as { plan?: unknown; attempt?: NativeSddAcquireRequest };
137
+ const value = input as { plan?: unknown };
232
138
  const plan = parseRemediationPlan(value?.plan, request.cwd);
233
139
  const status = decodeNativeSddStatusV2(await native.sddStatus({ workspaceRoot: request.cwd, changeName: selected.changeName }), selected);
234
- if (status.nextRecommended !== "remediate" || !status.phaseInstructions?.remediate || status.remediationState?.failedEvidenceRevision !== selected.failedEvidenceRevision) throw new Error("Stale remediation selection; refresh native status");
235
- if (!value?.attempt || value.attempt.remediatesEvidenceRevision !== undefined && value.attempt.remediatesEvidenceRevision !== selected.failedEvidenceRevision) throw new Error("Invalid remediation attempt intent");
236
- 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");
237
143
  const scope = await confirmRemediationScope(plan, status.actionContext, context);
238
- if (!preparedTask || preparedTask.cwd !== request.cwd || preparedTask.agent !== request.agent.name) throw new Error("Durable prepared remediation task required before acquire");
239
- preparedTask.sddRemediation = { scope, failedEvidenceRevision: selected.failedEvidenceRevision!, plan, observations: [], pending: {}, invalid: false, acquire: structuredClone(acquire) };
240
- await persist(preparedTask);
241
- let admitted;
242
- try { admitted = await native.sddAttemptAcquire(structuredClone(acquire)); }
243
- catch (error) {
244
- if (error instanceof TypeError || error instanceof NativeReviewCliError && error.mutationOutcome === "none") {
245
- preparedTask.sddRemediation.acquireResult = { state: "blocked" };
246
- await persist(preparedTask);
247
- throw error;
248
- }
249
- try { admitted = await native.sddAttemptAcquire(structuredClone(acquire)); }
250
- catch { preparedTask.sddRemediation.acquireUncertain = true; await persist(preparedTask); throw new Error("Unknown acquire outcome; reconcile the exact retained request, never rerun an actor"); }
251
- }
252
- preparedTask.sddRemediation.acquireResult = structuredClone(admitted);
253
- if (admitted.state === "proceed" && admitted.token) preparedTask.sddRemediation.token = admitted.token;
254
- await persist(preparedTask); // Token durability precedes any actor dispatch.
255
- if (admitted.state !== "proceed" || !admitted.token) throw new Error(`Managed remediation admission ${admitted.state}; no actor started`);
256
- let finalizationStarted = false;
257
- return {
258
- sddRemediation: preparedTask.sddRemediation,
259
- finalizeRemediation: async (task: TaskRecord, facts: RemediationTerminalFacts) => {
260
- if (finalizationStarted) return;
261
- finalizationStarted = true;
262
- const state = task.sddRemediation!;
263
- const evidence = state.failedEvidenceRevision === acquire.remediatesEvidenceRevision && task.status === TASK_STATUS.COMPLETED && facts.exited && facts.cleanupConfirmed ? remediationEvidence(state) : undefined;
264
- const interrupted = !facts.spawned || !facts.exited || !facts.cleanupConfirmed || task.status === TASK_STATUS.CANCELLED || task.status === TASK_STATUS.TIMED_OUT;
265
- const payload: NativeSddSettleRequest = {
266
- workspaceRoot: acquire.workspaceRoot, changeName: acquire.changeName, token: state.token!, requestId: randomUUID(),
267
- outcome: interrupted ? "interrupted" : evidence ? "passed" : "failed",
268
- 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",
269
- harnessDisposition: facts.cleanupConfirmed ? "reused" : "invalidated",
270
- cleanupEvidence: facts.cleanupConfirmed ? "Runner confirmed process cleanup" : "Runner could not confirm process cleanup; effects remain unknown",
271
- processEvidence: `spawned=${facts.spawned}; exited=${facts.exited}; task=${task.status}; observations=${state.observations.length}`,
272
- remediatesEvidenceRevision: acquire.remediatesEvidenceRevision,
273
- ...(acquire.untrackedScope === undefined ? {} : { untrackedScope: acquire.untrackedScope, expectedUntrackedInventory: acquire.expectedUntrackedInventory, intendedUntracked: acquire.intendedUntracked }),
274
- ...(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")}` }),
275
- };
276
- const actorStatus = task.status;
277
- if (actorStatus === TASK_STATUS.COMPLETED) task.status = payload.outcome === "passed" ? TASK_STATUS.WAITING : TASK_STATUS.FAILED;
278
- state.settle = structuredClone(payload);
279
- await persist(task); // Exact native replay inputs must be durable BEFORE mutation.
280
- try { state.settlement = await native.sddAttemptSettle!(structuredClone(payload)); }
281
- catch (error) {
282
- if (!(error instanceof TypeError) && !(error instanceof NativeReviewCliError && error.mutationOutcome === "none")) {
283
- try { state.settlement = await native.sddAttemptSettle!(structuredClone(payload)); }
284
- catch { state.settlementUncertain = true; }
285
- } else state.settlementUncertain = true;
286
- }
287
- if (payload.outcome === "failed" && actorStatus === TASK_STATUS.COMPLETED) {
288
- task.status = TASK_STATUS.FAILED;
289
- task.error = "Managed remediation lacks complete passing planned-command evidence";
290
- }
291
- if (payload.outcome === "passed" && state.settlement && state.settlement.state !== "blocked") task.status = TASK_STATUS.COMPLETED;
292
- if (!state.settlement) {
293
- task.status = TASK_STATUS.FAILED;
294
- task.error = "Native remediation settlement unresolved; retain exact history for reconciliation";
295
- } else if (state.settlement.state === "blocked") {
296
- task.status = TASK_STATUS.FAILED;
297
- task.error = `Native remediation settlement blocked(${state.settlement.reason ?? "unspecified"}); current native admission decides any later attempt`;
298
- }
299
- await persist(task);
300
- },
301
- };
144
+ return { sddRemediation: { scope, failedEvidenceRevision: selected.failedEvidenceRevision!, plan } };
302
145
  }
303
146
 
304
147
  // Installed only for the admitted remediation child. Stock shell execution,
@@ -328,11 +171,36 @@ export function remediationBash(cwd: string, operations: BashOperations = create
328
171
  };
329
172
  }
330
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
+
331
198
  export interface AgentsDeps extends RunnerDeps {
332
199
  nativeSdd?: NativeReviewCli;
333
200
  home: string;
334
201
  agentHome?: string;
335
202
  childIpc?: IpcEndpoint;
203
+ sessionTransport?: SessionTransportFactory;
336
204
  env: NodeJS.ProcessEnv;
337
205
  resolveWorktree: WorktreeResolver;
338
206
  runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps;
@@ -390,23 +258,7 @@ function legacySubagentsInstalledAt(agentHome: string): boolean {
390
258
  }
391
259
  }
392
260
 
393
- export function agentsViewKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
394
- const value = env.GENTLE_PI_AGENTS_VIEW_KEY?.trim();
395
- if (value === undefined) return VIEW_KEY_DEFAULT;
396
- return value === "" || value.toLowerCase() === "off" ? undefined : value;
397
- }
398
-
399
- export function agentsCollapseKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
400
- const value = env.GENTLE_PI_AGENTS_KEY?.trim();
401
- if (value === undefined) return COLLAPSE_KEY_DEFAULT;
402
- return value === "" || value.toLowerCase() === "off" ? undefined : value;
403
- }
404
-
405
- export function agentsStopKey(env: NodeJS.ProcessEnv = process.env): string | undefined {
406
- const value = env.GENTLE_PI_AGENTS_STOP_KEY?.trim();
407
- if (value === undefined) return STOP_KEY_DEFAULT;
408
- return value === "" || value.toLowerCase() === "off" ? undefined : value;
409
- }
261
+ export { agentsViewKey, agentsCollapseKey, agentsStopKey };
410
262
 
411
263
  function sanitizeTerminalText(value: string): string {
412
264
  return value.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, (control) => `\\x${control.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
@@ -444,6 +296,22 @@ function registerChildMessaging(pi: ExtensionAPI, ipc: IpcEndpoint): void {
444
296
  });
445
297
  }
446
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
+
447
315
  function text(value: string, details: Record<string, unknown> = {}, terminate = false): ToolText {
448
316
  return { content: [{ type: "text", text: value }], details, ...(terminate ? { terminate: true } : {}) };
449
317
  }
@@ -506,21 +374,6 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as
506
374
  }
507
375
  }
508
376
 
509
- interface ResearchArtifactCallObservation {
510
- index: number;
511
- desired?: ResearchWriteIdentity;
512
- }
513
- const RESEARCH_ARTIFACT_SCHEMA = {
514
- type: "object", description: "Untrusted exact artifact intent, never authorization or readback. Same bounds required on continuation.",
515
- required: ["store", "worktree", "changeName", "retainedIntent", "locators"],
516
- properties: {
517
- store: { type: "string", enum: ["openspec", "engram", "both", "none"] }, worktree: { type: "string" }, changeName: { type: "string" }, retainedIntent: { type: "string" },
518
- locators: { type: "array", maxItems: 3, items: { type: "object", required: ["artifact", "revision", "digest"], properties: {
519
- artifact: { type: "string", enum: ["research", "preproposal", "explore"] }, revision: { type: "integer", minimum: 1 }, digest: { type: "string", pattern: "^[a-f0-9]{64}$" }, path: { type: "string" },
520
- 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 } } },
521
- } } },
522
- },
523
- };
524
377
  const RESEARCH_SELECTION_SCHEMA = {
525
378
  type: "object",
526
379
  additionalProperties: false,
@@ -544,110 +397,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
544
397
  let selection: unknown;
545
398
  try { selection = JSON.parse(env[RESEARCH_SELECTION_ENV] ?? "null"); } catch { /* Missing selection grants no research. */ }
546
399
  const current = () => researchAgent({ tools: allowed, instructions: "" } as AgentDefinition, pi, selection);
547
- pi.on("before_agent_start", (event, ctx) => {
548
- reads.clear(); initialReads.clear(); writes.clear(); calls.clear(); pending.clear(); accepted.clear(); readbackMismatch = false;
549
- try {
550
- const last = [...(ctx.sessionManager?.getEntries?.() ?? [])].reverse().find(entry => entry.type === "custom" && entry.customType === RESEARCH_PERSISTENCE_ENTRY);
551
- if (last?.type === "custom") {
552
- const restored = parseResearchPersistence(last.data, artifactScope(ctx.cwd), ctx.cwd);
553
- for (const [key, value] of Object.entries(restored.accepted)) accepted.set(key, value);
554
- for (const [key, value] of Object.entries(restored.writes)) writes.set(key, value);
555
- }
556
- } catch { readbackMismatch = true; }
557
- 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.` };
558
- });
559
- let readbackMismatch = false;
560
- const reads = new Map<string, string>();
561
- const initialReads = new Set<string>();
562
- const pending = new Set<string>();
563
- const accepted = new Map<string, ReturnType<typeof parseResearchArtifactIntent>["locators"][number]>();
564
- const writes = new Map<string, ResearchWriteIdentity>();
565
- const calls = new Map<string, ResearchArtifactCallObservation>();
566
- const artifactScope = (cwd: string) => parseResearchArtifactIntent(JSON.parse(env[RESEARCH_ARTIFACT_ENV] ?? "null"), cwd);
567
- const checkpoint = (ctx: ExtensionContext, operation: Record<string, unknown>) => {
568
- const file = ctx.sessionManager?.getSessionFile?.();
569
- if (!pi.appendEntry || !ctx.sessionManager?.getEntries || !file || !existsSync(file)) throw new Error("Durable research session history unavailable");
570
- const data = { version: 1, scope: artifactScope(ctx.cwd), accepted: Object.fromEntries(accepted), writes: Object.fromEntries(writes), operation };
571
- if (Buffer.byteLength(JSON.stringify(data)) > 32_768) throw new Error("Research checkpoint exceeds bounded history payload");
572
- pi.appendEntry(RESEARCH_PERSISTENCE_ENTRY, data);
573
- assertResearchCheckpoint(file, data);
574
- };
575
- 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 => {
576
404
  const registered = pi.getAllTools().some(tool => tool.name === event.toolName && tool.sourceInfo?.source !== "sdk");
577
405
  const selected = current().agent.tools.includes(event.toolName) || event.toolName === "subagent_parent_message";
578
406
  if (!registered || !selected || !allowed.includes(event.toolName) || !pi.getActiveTools().includes(event.toolName)) {
579
- 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." };
580
408
  }
581
- if (event.toolName === "subagent_parent_message" || ["fetch_content", "web_search", "source_check", "get_search_content"].includes(event.toolName)) return;
582
- try {
583
- const scope = artifactScope(ctx.cwd);
584
- const index = researchArtifactCall(scope, ctx.cwd, event.toolName, event.input);
585
- let desired: ResearchWriteIdentity | undefined;
586
- if (["write", "edit", "mem_save"].includes(event.toolName)) {
587
- if (readbackMismatch || pending.size) throw new Error("Stale/divergent state requires explicit identical-scope re-entry.");
588
- const tools = scope.store === "both" ? ["read", "mem_get_observation"] : [scope.store === "openspec" ? "read" : "mem_get_observation"];
589
- if (!scope.locators.every((_, i) => tools.every(tool => initialReads.has(`${i}:${tool}`)))) throw new Error("Every selected artifact requires matching initial readback before mutation.");
590
- reads.clear();
591
- const content = "content" in event.input ? event.input.content : undefined;
592
- if (event.toolName === "edit" || typeof content !== "string") throw new Error("Use a full bounded write/save for post-write readback.");
593
- const key = `${index}:${event.toolName === "write" ? "read" : "mem_get_observation"}`;
594
- if (!initialReads.has(key) || writes.has(key)) throw new Error("Fresh matching readback required before mutation.");
595
- const revision: unknown = JSON.parse(content).revision;
596
- if (!Number.isSafeInteger(revision) || Number(revision) <= (accepted.get(key) ?? scope.locators[index]).revision) throw new Error("Full write requires a newer positive revision.");
597
- desired = { revision: Number(revision), digest: createHash("sha256").update(content).digest("hex") };
598
- if (scope.store === "both") {
599
- const peerKey = `${index}:${event.toolName === "write" ? "mem_get_observation" : "read"}`;
600
- const peer = writes.get(peerKey) ?? accepted.get(peerKey);
601
- 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");
602
- }
603
- calls.clear(); pending.add(event.toolCallId); writes.set(key, desired);
604
- checkpoint(ctx, { toolCallId: event.toolCallId, tool: event.toolName, index, desired });
605
- }
606
- calls.set(event.toolCallId, { index, desired });
607
- } catch (error) { return { block: true, reason: `Research scope refused: ${String(error)}. Retain intent and uncertainty; no replacement store.` }; }
608
- });
609
- pi.on("tool_result", (event, ctx) => {
610
- const call = calls.get(event.toolCallId);
611
- calls.delete(event.toolCallId);
612
- if (!call) return;
613
- const { index, desired } = call;
614
- if (desired) {
615
- let valid = false;
616
- try {
617
- 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);
618
- } catch { /* Malformed mutation results cannot authorize completion. */ }
619
- if (!valid) readbackMismatch = true;
620
- 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") }); }
621
- catch { readbackMismatch = true; }
622
- pending.delete(event.toolCallId); reads.clear();
623
- return;
624
- }
625
- if (!["read", "mem_get_observation"].includes(event.toolName)) return;
626
- if (pending.size) return { content: [...event.content, { type: "text" as const, text: "Research readback incomplete: proposal_ready=false; mutation pending." }] };
627
- let matched = false, complete = false;
628
- try {
629
- const scope = artifactScope(ctx.cwd);
630
- researchArtifactCall(scope, ctx.cwd, event.toolName, event.input);
631
- const bytes = event.content.map(part => part.type === "text" ? part.text : "").join("");
632
- const returned = event.toolName === "read" ? bytes : JSON.parse(bytes);
633
- const key = `${index}:${event.toolName}`, written = writes.get(key);
634
- const expected = { ...(accepted.get(key) ?? scope.locators[index]), ...written };
635
- matched = !event.isError && researchArtifactReadback(expected, event.toolName, returned, written !== undefined);
636
- if (matched) {
637
- reads.set(key, expected.digest);
638
- initialReads.add(key);
639
- accepted.set(key, event.toolName === "read" ? expected : { ...expected, engram: { ...expected.engram!, revision_count: returned.revision_count } });
640
- writes.delete(key);
641
- checkpoint(ctx, { toolCallId: event.toolCallId, tool: event.toolName, index, matched: true });
642
- }
643
- const tools = scope.store === "both" ? ["read", "mem_get_observation"] : [scope.store === "openspec" ? "read" : "mem_get_observation"];
644
- const divergent = scope.locators.some((_, i) => tools.every(tool => reads.has(`${i}:${tool}`)) && new Set(tools.map(tool => reads.get(`${i}:${tool}`))).size !== 1);
645
- if (divergent) matched = false;
646
- complete = matched && !readbackMismatch && scope.store !== "none" && scope.locators.every((_, i) => tools.every(tool => reads.has(`${i}:${tool}`)));
647
- } catch { matched = false; /* Unsupported or undurable readback is not evidence. */ }
648
- if (!matched) { reads.clear(); readbackMismatch = true; }
649
- 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.";
650
- return { content: [...event.content, { type: "text" as const, text: note }], isError: event.isError || !matched };
651
409
  });
652
410
  }
653
411
  const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined));
@@ -683,6 +441,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
683
441
  : environmentHome && (selectedHome.startsWith("~/") || (process.platform === "win32" && selectedHome.startsWith("~\\"))) ? join(deps.home, selectedHome.slice(2)) : selectedHome;
684
442
  // Freeze the host's root before a child uses a different session cwd.
685
443
  const agentHome = resolve(expandedHome);
444
+ const sessionTransport = deps.sessionTransport ?? createDefaultSessionTransport();
686
445
  if (legacySubagentsInstalledAt(agentHome)) {
687
446
  pi.on("session_start", (_event, ctx) => {
688
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");
@@ -752,6 +511,92 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
752
511
  // its session. Before the first session_start there is nothing to scope by.
753
512
  const activeSessionId = (): string | undefined => (sessions === undefined ? undefined : sessions.getSessionId() ?? "");
754
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
+ };
755
600
 
756
601
  const requestRender = () => {
757
602
  if (renderQueued) return;
@@ -928,6 +773,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
928
773
  return new Text(`${theme.fg("customMessageLabel", heading)}\n${theme.fg("customMessageText", body)}`, options.outputPad, 0);
929
774
  });
930
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
+
931
784
  pi.registerMessageRenderer(AGENTS_RESULT_TYPE, (message, options, theme) => {
932
785
  const details = (message.details as { gentleAgents?: { agent?: string; status?: string } } | undefined)?.gentleAgents;
933
786
  const content = message.content as string | Array<{ type: string; text?: string }>;
@@ -1122,7 +975,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1122
975
 
1123
976
  const roots = (ctx: ExtensionContext) => ({ cwd: ctx.sessionManager.getCwd(), home: deps.home, agentHome });
1124
977
 
1125
- 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 => {
1126
979
  const registry = registryFor(ctx);
1127
980
  const parentCwd = ctx.sessionManager.getCwd();
1128
981
  // An explicit target is validated before any queue or session-dir writes.
@@ -1137,7 +990,25 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1137
990
  const launchSddChange = sddChange === undefined || target === undefined
1138
991
  ? undefined
1139
992
  : { ...sddChange, workspaceRoot: target };
1140
- 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
+ );
1141
1012
  const profile = resolveAgentProfile(agent, config);
1142
1013
  const research = agent.name === "sdd-research" ? researchAgent(agent, pi, researchSelection) : undefined;
1143
1014
  const sessionDir = agentRuntimePaths(deps.home, agentHome).sessions;
@@ -1165,7 +1036,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1165
1036
  sessionDir,
1166
1037
  resumeSessionPath: resume,
1167
1038
  env: research ? { ...deps.env, [RESEARCH_CHILD_TOOLS_ENV]: JSON.stringify([...research.agent.tools, "subagent_parent_message"]) } : deps.env,
1168
- ...(research ? { researchSelection, extensionPaths: research.extensionPaths, researchArtifact: researchArtifact === undefined ? undefined : parseResearchArtifactIntent(researchArtifact, target ?? parentCwd) } : {}),
1039
+ ...(research ? { researchSelection, extensionPaths: research.extensionPaths } : {}),
1169
1040
  ...(launchSddChange === undefined ? {} : { sddChange: launchSddChange }),
1170
1041
  ...(parentRepositoryIdentity === undefined ? {} : {
1171
1042
  authorizeParentStandingReviewPermission: (repositoryIdentity: string) => {
@@ -1188,26 +1059,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1188
1059
  };
1189
1060
 
1190
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
+ }
1191
1065
  // This is the process-spawn boundary. A child receives its task context only
1192
1066
  // after its RPC process starts, so validate the single parent transport here
1193
1067
  // rather than letting a child invent/persist preferences during startup.
1194
1068
  if (SHIPPED_SDD_AGENT_NAME_SET.has(request.agent.name) && !isParentConfirmedSddPreflightContext(request.context)) {
1195
1069
  throw new Error("SDD child dispatch refused: parent-confirmed SDD preflight context is missing or malformed.");
1196
1070
  }
1197
- let prepared: TaskRecord | undefined;
1198
1071
  if (request.agent.name === "sdd-remediate") {
1199
- const previous = await loadHistory(tasksDir);
1200
- 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");
1201
- prepared = runner.prepareRemediation(request);
1202
- const persist = (task: TaskRecord) => saveTask(tasksDir, task, store.thread(task.id));
1203
- try {
1204
- const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
1205
- Object.assign(request, await admitManagedRemediation(request, request.remediationIntent, native, persist, ctx, prepared));
1206
- if (activeSessionId() !== request.parentSessionId) throw new Error("Parent session changed; retain admission and refuse actor replay");
1207
- prepared.sddRemediation!.actorClaimed = true;
1208
- await persist(prepared); // A crash beyond here is an unknown actor effect, not rerun permission.
1209
- } 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");
1210
1075
  }
1076
+
1211
1077
  // Bounded live observation only. Native send owns the fresh policy decision;
1212
1078
  // child execution never starts a telemetry policy process or renewal timer.
1213
1079
  const owner = metricsOwner;
@@ -1225,7 +1091,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1225
1091
  metrics.started = metricsNow();
1226
1092
  return metrics.valid();
1227
1093
  } } : {}),
1228
- }, prepared);
1094
+ });
1229
1095
  if (observe) metricTasks.set(task.id, metrics);
1230
1096
  ownedTaskIds.add(task.id);
1231
1097
  store.subscribe(task.id, () => { publishActivity(); requestRender(); });
@@ -1279,6 +1145,82 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1279
1145
  });
1280
1146
  };
1281
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
+
1282
1224
  tool("list_agents", "List the subagents defined for this project and user, with their descriptions.", { properties: {} }, async (_params, ctx) => {
1283
1225
  const { agents, errors } = discoverAgents(roots(ctx));
1284
1226
  const lines = agents.map((agent) => `- ${agent.name} (${agent.scope}): ${agent.description || "no description"}`);
@@ -1297,8 +1239,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1297
1239
  label: { type: "string", description: "Three to six words naming the work, shown on the agents card, e.g. 'map footer data sources'." },
1298
1240
  context: { type: "string", description: "Optional extra context appended to the task." },
1299
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." },
1300
- research_artifact: RESEARCH_ARTIFACT_SCHEMA, research_selection: RESEARCH_SELECTION_SCHEMA,
1301
- 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." },
1302
1244
  mode: { type: "string", enum: ["task", "background"], description: "task waits for the result (default); background returns immediately." },
1303
1245
  },
1304
1246
  },
@@ -1310,7 +1252,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1310
1252
  let sddChange: SddChangeSelection | undefined;
1311
1253
  try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1312
1254
  catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1313
- 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);
1314
1256
  },
1315
1257
  );
1316
1258
 
@@ -1319,25 +1261,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1319
1261
  return task ? text(describeTask(task), taskDetails(task)) : text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
1320
1262
  });
1321
1263
 
1322
- tool("reconcile", "Reconcile one retained managed remediation mutation without launching an actor.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => {
1323
- const id = String(params.task_id);
1324
- const lock = acquireTaskLock(tasksDir, id);
1325
- try {
1326
- const stored = await loadStoredTask(tasksDir, id);
1327
- if (!stored) return text(`Error: no task ${id}`, { error: "unknown task" });
1328
- const task = stored.task, retainedThread = stored.thread;
1329
- const target = registryFor(ctx).validate(task.cwd);
1330
- 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");
1331
- const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
1332
- const persist = async (current: TaskRecord) => {
1333
- await saveTask(tasksDir, current, retainedThread);
1334
- store.update(current.id, { status: current.status, error: current.error, lastStep: current.lastStep, endedAt: current.endedAt, lastActivityAt: current.lastActivityAt, sddRemediation: current.sddRemediation });
1335
- };
1336
- const result = await reconcileManagedRemediation(task, native, persist);
1337
- 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 } });
1338
- } finally { lock.release(); }
1339
- });
1340
-
1341
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) => {
1342
1265
  const task = await resolveTask(String(params.task_id));
1343
1266
  if (!task) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
@@ -1370,7 +1293,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1370
1293
  tool(
1371
1294
  "continue",
1372
1295
  "Resume a finished subagent task in its own session with a follow-up prompt.",
1373
- { 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"] } } },
1374
1297
  async (params, ctx, signal) => {
1375
1298
  const previous = await resolveTask(String(params.task_id));
1376
1299
  if (!previous) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
@@ -1385,14 +1308,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1385
1308
  try { sddChange = parseSddChange(params.sdd_change, agent.name); }
1386
1309
  catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
1387
1310
  if (sddPhaseForAgent(agent.name) && !sddChange) return text("Error: continuing an SDD phase agent requires a fresh sdd_change selection.", { error: "missing sdd_change" });
1388
- let artifact: ResearchArtifactIntent | undefined;
1389
- if (agent.name === "sdd-research") {
1390
- try {
1391
- const prior = parseResearchArtifactIntent("researchArtifact" in previous ? previous.researchArtifact : undefined, previous.cwd);
1392
- artifact = parseResearchArtifactIntent(params.research_artifact ?? prior, previous.cwd, prior);
1393
- } catch (error) { return text(`Error: research continuation scope refused: ${String(error)}`, { error: "research scope" }); }
1394
- }
1395
- 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);
1396
1313
  },
1397
1314
  );
1398
1315
 
@@ -1424,7 +1341,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1424
1341
  });
1425
1342
  }
1426
1343
 
1427
- pi.on("session_start", (_event, ctx) => {
1344
+ pi.on("session_start", async (_event, ctx) => {
1428
1345
  // A resumed, reloaded, or replaced session starts with an empty completion
1429
1346
  // queue so nothing pending from another session can replay here.
1430
1347
  completions.dropAll();
@@ -1436,8 +1353,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1436
1353
  label: ctx.sessionManager.getSessionName?.() || ctx.sessionManager.getCwd().split(/[\\/]/).pop() || "Orchestrator", activity: [] });
1437
1354
  publishActivity();
1438
1355
  } catch { presence = undefined; }
1356
+ void startSessionTransport(ctx);
1439
1357
  });
1440
- pi.on("session_shutdown", () => {
1358
+ pi.on("session_shutdown", async () => {
1441
1359
  completions.dropAll();
1442
1360
  activeAgentRuns = 0;
1443
1361
  presence?.dispose();
@@ -1449,6 +1367,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1449
1367
  sidebarTui = undefined;
1450
1368
  worktrees?.close();
1451
1369
  worktrees = undefined;
1370
+ const stopped = shutdownSessionTransport();
1452
1371
  runner.cancelAll("cancelled: parent session shut down");
1372
+ await stopped;
1453
1373
  });
1454
1374
  }