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