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