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.
- package/README.md +24 -6
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +14 -4
- package/docs/readme-reference.md +171 -32
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +67 -111
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +10 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +9 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +0 -1
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +307 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +849 -356
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +115 -27
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- 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,
|
|
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 {
|
|
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,
|
|
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 {
|
|
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 {
|
|
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,
|
|
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:
|
|
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"
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
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",
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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", "
|
|
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.
|
|
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: {
|
|
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
|
-
|
|
1389
|
-
|
|
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
|
}
|