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