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