gentle-pi 3.6.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -6
- package/assets/agents/gentle-ai-explore.md +4 -4
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -9
- package/assets/orchestrator-delegation.md +40 -41
- package/assets/orchestrator-memory.md +1 -22
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator.md +12 -24
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/gentle-shell.mjs +254 -29
- package/docs/delegated-verification.md +26 -1
- package/docs/gentle-agents-activity.md +24 -0
- package/docs/gentle-shell.md +95 -30
- package/docs/native-authority-architecture.md +2 -2
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +117 -235
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +26 -0
- package/extensions/child-safety.ts +23 -0
- package/extensions/gentle-agents.ts +509 -275
- package/extensions/gentle-ai.ts +781 -683
- package/extensions/gentle-shell.ts +1375 -88
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +18 -7
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1419 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/quiet-tools.ts +179 -87
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +16 -12
- package/extensions/startup-banner.ts +60 -31
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-protocol.ts +60 -0
- package/lib/agents-runner.ts +105 -98
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/bounded-writer-admission.ts +147 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/codemode-renderer.ts +185 -0
- package/lib/command-palette-catalog.ts +3 -9
- package/lib/command-palette.ts +25 -14
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/foreign-target-grants.ts +32 -0
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-launcher.ts +24 -13
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +227 -0
- package/lib/native-review-cli.ts +53 -108
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +67 -17
- package/lib/review-candidate-view.ts +112 -25
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +156 -11
- package/lib/review-sidebar-state.ts +223 -0
- package/lib/selection-engine.ts +515 -0
- package/lib/session-change-capture.ts +10 -1
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-worktree-registry.ts +14 -2
- package/lib/shell-bar.ts +179 -24
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +78 -14
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +23 -13
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +66 -10
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +379 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +108 -0
- package/lib/visual-customize-view.ts +330 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/yolo-session-policy.ts +240 -0
- package/package.json +20 -8
- package/runtime/gentle-shell-launcher.mjs +23 -12
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/native-review-cli.mjs +53 -108
- package/runtime/review-risk-assessment.mjs +154 -9
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +14 -13
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/run-test-suite.mjs +76 -0
- package/scripts/test-packed-runner.mjs +31 -14
- package/scripts/verify-package-files.mjs +11 -22
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +9 -15
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-profiles.test.ts +18 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +40 -0
- package/tests/agents-runner.test.ts +400 -89
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/bounded-writer-admission.test.ts +95 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/card-style-policy.test.ts +55 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-safety.test.ts +82 -0
- package/tests/codemode-rendering.test.ts +491 -0
- package/tests/command-palette.test.ts +39 -3
- package/tests/delegated-key-learnings-contract.test.ts +0 -76
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +170 -2
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/foreign-target-grants.test.ts +58 -0
- package/tests/generic-agent-tools.test.ts +54 -0
- package/tests/gentle-agents.test.ts +1411 -256
- package/tests/gentle-ai-binary.test.ts +3 -3
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +68 -54
- package/tests/gentle-ai-renderer.test.ts +487 -8
- package/tests/gentle-ai.test.ts +288 -65
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-shell-bin.test.ts +651 -114
- package/tests/gentle-shell-launcher.test.ts +99 -52
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell.test.ts +3839 -187
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +80 -8
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +29 -19
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/nan-provider.test.ts +471 -0
- package/tests/native-review-capability-contract.test.ts +13 -1
- package/tests/native-review-cli.test.ts +6 -120
- package/tests/native-review-parity-runtime.test.ts +100 -3
- package/tests/odd-integration.test.ts +67 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
- package/tests/odd-routing-contract.test.ts +86 -35
- package/tests/orchestrator-budget.test.ts +14 -39
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +252 -115
- package/tests/packed-runner-owned-path.test.ts +46 -0
- package/tests/persona-single-channel.test.ts +6 -6
- package/tests/provider-defect-handoff.test.ts +3 -11
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +409 -184
- package/tests/rdd-aware-verification-contract.test.ts +76 -1
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +176 -12
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +298 -0
- package/tests/review-contract-prompt.test.ts +108 -43
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-routing.test.ts +611 -5
- package/tests/review-controller-workspace-root.test.ts +163 -4
- package/tests/review-host-relay-routing.test.ts +338 -2
- package/tests/review-integration-v2-forward.test.ts +200 -0
- package/tests/review-ledger-contract.test.ts +10 -34
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +498 -6
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/run-test-suite.test.ts +124 -0
- package/tests/runtime-harness.mjs +145 -786
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/selection-engine.test.ts +421 -0
- package/tests/session-change-capture.test.ts +12 -1
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-worktree-registry.test.ts +77 -0
- package/tests/shell-bar.test.ts +382 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +52 -0
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-sidebar-layout.test.ts +325 -21
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +87 -1
- package/tests/shell-usage-view.test.ts +27 -0
- package/tests/shell-usage.test.ts +73 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/startup-banner.test.ts +130 -2
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/telemetry-trigger.test.ts +81 -20
- package/tests/theme-customization.test.ts +72 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +110 -0
- package/tests/visual-customize-view.test.ts +418 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/yolo-customize.test.ts +256 -0
- package/tests/yolo-mode-runtime.test.ts +161 -0
- package/tests/yolo-mode.test.ts +261 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
|
@@ -1,32 +1,38 @@
|
|
|
1
|
-
import { fileURLToPath } from "node:url";
|
|
2
1
|
import { agentsViewKey, agentsCollapseKey, agentsStopKey } from "../lib/agents-keys.ts";
|
|
3
|
-
import { extractParentConfirmedSddPreflightContext, getPackageAssetOwner, isParentConfirmedSddPreflightContext, SHIPPED_SDD_AGENT_NAMES } from "../lib/sdd-preflight.ts";
|
|
4
|
-
import { NativeReviewCliV216, createNodeExecFileAdapter, decodeNativeSddStatusV2, type NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
5
2
|
import { spawn } from "node:child_process";
|
|
6
3
|
import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
|
|
7
4
|
import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
|
|
5
|
+
import { publishForeignSessionChange } from "../lib/session-change-capture.ts";
|
|
8
6
|
import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
9
|
-
import {
|
|
7
|
+
import { ForeignTargetGrants } from "../lib/foreign-target-grants.ts";
|
|
8
|
+
import { MESSAGING_REASON_MAX_UTF8_BYTES, MESSAGING_REASON_MIN_CHARACTERS, normalizeMessagingReason, SessionMessagingGrants } from "../lib/session-messaging-grants.ts";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
12
|
import os from "node:os";
|
|
13
|
-
import { join, resolve, isAbsolute
|
|
14
|
-
import {
|
|
13
|
+
import { join, resolve, isAbsolute } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { keyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
16
|
import { Text, type TUI } from "@earendil-works/pi-tui";
|
|
16
17
|
import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
18
|
+
import { VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts";
|
|
19
|
+
import { resolveVisualSettings } from "../lib/visual-customization-policy.ts";
|
|
17
20
|
import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
|
|
18
|
-
import {
|
|
21
|
+
import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts";
|
|
22
|
+
import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
|
|
19
23
|
import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
|
|
20
24
|
import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
|
|
21
25
|
import { isFinished, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
22
|
-
import { AgentRunner, piCommand, abortReasonText,
|
|
26
|
+
import { AgentRunner, piCommand, abortReasonText, type AskAnswer, type RunnerDeps, type TaskRequest } from "../lib/agents-runner.ts";
|
|
23
27
|
import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
|
|
24
28
|
import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry, type PresenceRecord, type ReceivedNotification, type SentNotification, type SessionPresenceCandidate } from "../lib/agents-session-transport.ts";
|
|
25
29
|
import { WindowsActiveSessionClient, WindowsActiveSessionListener, WindowsSessionPresenceRegistry, type WindowsSessionRegistryPhaseObserver } from "../lib/windows-session-transport.ts";
|
|
26
30
|
import { hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentitySync, type ReviewSessionManager } from "../lib/review-session-standing-permission.ts";
|
|
31
|
+
import { inheritedUnsafeGitEnvironmentKeys } from "../lib/review-repository.ts";
|
|
27
32
|
import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
|
|
28
33
|
import { sessionToMarkdown } from "../lib/agents-transcript.ts";
|
|
29
34
|
import { AgentsView } from "../lib/agents-view.ts";
|
|
35
|
+
import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
|
|
30
36
|
import { PresencePublisher } from "../lib/orchestrator-presence.ts";
|
|
31
37
|
import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts";
|
|
32
38
|
import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
|
|
@@ -35,8 +41,8 @@ import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../l
|
|
|
35
41
|
import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
|
|
36
42
|
import { openInExternalEditor } from "./gentle-shell.ts";
|
|
37
43
|
import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
38
|
-
import { resolveProfilePin } from "../lib/agent-profile-pin.ts";
|
|
39
|
-
import {
|
|
44
|
+
import { resolveProfilePin, resolveUnversionedProjectProfile } from "../lib/agent-profile-pin.ts";
|
|
45
|
+
import { allowedEditSurfaces, isDevelopmentSurface, isGenericBoundedWriter, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts";
|
|
40
46
|
import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
|
|
41
47
|
import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
|
|
42
48
|
|
|
@@ -54,125 +60,14 @@ export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result";
|
|
|
54
60
|
const RENDER_COALESCE_MS = 400;
|
|
55
61
|
const CLOCK_TICK_MS = 1000;
|
|
56
62
|
const TOOL_PREFIX = "subagent_";
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"sdd-archive": "archive",
|
|
64
|
-
} as const;
|
|
65
|
-
|
|
66
|
-
function sddPhaseForAgent(name: string): SddChangeSelection["phase"] | undefined {
|
|
67
|
-
return Object.hasOwn(SDD_PHASE_BY_AGENT, name) ? SDD_PHASE_BY_AGENT[name as keyof typeof SDD_PHASE_BY_AGENT] : undefined;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function parseSddChange(value: unknown, agentName: string): SddChangeSelection | undefined {
|
|
71
|
-
if (value === undefined) return undefined;
|
|
72
|
-
const expectedPhase = sddPhaseForAgent(agentName);
|
|
73
|
-
if (!expectedPhase) throw new Error("sdd_change is allowed only for SDD apply, verify, or archive agents.");
|
|
74
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdd_change must be an object with changeName, workspaceRoot, and phase.");
|
|
75
|
-
const selection = value as Record<string, unknown>;
|
|
76
|
-
const keys = Object.keys(selection).sort();
|
|
77
|
-
if (keys.join(",") !== (expectedPhase === "remediate" ? "changeName,failedEvidenceRevision,phase,workspaceRoot" : "changeName,phase,workspaceRoot") ||
|
|
78
|
-
typeof selection.changeName !== "string" || selection.changeName.length === 0 ||
|
|
79
|
-
typeof selection.workspaceRoot !== "string" || selection.workspaceRoot.length === 0 ||
|
|
80
|
-
selection.phase !== expectedPhase) {
|
|
81
|
-
throw new Error("sdd_change must contain only a non-empty changeName, workspaceRoot, and the agent's matching phase.");
|
|
82
|
-
}
|
|
83
|
-
if (expectedPhase === "remediate" && (typeof selection.failedEvidenceRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(selection.failedEvidenceRevision))) throw new Error("Invalid remediation revision");
|
|
84
|
-
return { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot, phase: expectedPhase, ...(expectedPhase === "remediate" ? { failedEvidenceRevision: selection.failedEvidenceRevision as string } : {}) };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const REMEDIATION_SCHEMA = {
|
|
88
|
-
type: "object", additionalProperties: false, required: ["plan"],
|
|
89
|
-
properties: {
|
|
90
|
-
plan: { type: "object", additionalProperties: false, required: ["cwd", "commands", "runtimeHarness", "rollback"], properties: {
|
|
91
|
-
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" } },
|
|
92
|
-
runtimeHarness: { type: "object", additionalProperties: false, description: "Exactly one concrete command or prior concrete naReason containing because.", properties: { command: { type: "string" }, naReason: { type: "string" } } },
|
|
93
|
-
rollback: { type: "object", additionalProperties: false, required: ["boundary", "command"], properties: { boundary: { type: "string" }, command: { type: "string" } } },
|
|
94
|
-
} },
|
|
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, context?: Pick<ExtensionContext, "hasUI" | "ui">): 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 || 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 };
|
|
141
|
-
const plan = parseRemediationPlan(value?.plan, request.cwd);
|
|
142
|
-
const status = decodeNativeSddStatusV2(await native.sddStatus({ workspaceRoot: request.cwd, changeName: selected.changeName }), selected);
|
|
143
|
-
// Legacy remediation status carries the verification failure being repaired;
|
|
144
|
-
// that is not permission to bypass a missing native edit grant.
|
|
145
|
-
if (status.blockedReasons.some(reason => reason.includes("edit_authority_missing")) || status.nextRecommended !== "remediate" || !status.phaseInstructions?.remediate || status.remediationState?.failedEvidenceRevision !== selected.failedEvidenceRevision) throw new Error("Stale remediation selection; refresh native status");
|
|
146
|
-
const scope = await confirmRemediationScope(plan, status.actionContext, context);
|
|
147
|
-
return { sddRemediation: { scope, failedEvidenceRevision: selected.failedEvidenceRevision!, plan } };
|
|
148
|
-
}
|
|
63
|
+
// Wakes an idle parent after child content was stored as a custom message.
|
|
64
|
+
// It names itself as automated so the model never attributes it to the human.
|
|
65
|
+
const PARENT_WAKE_TEXT = "[System-generated Gentle Agents notification, not written by the user] Subagent output was delivered to this session above. Review it and continue.";
|
|
66
|
+
// How long a dispatched wake may take to start a parent run before a later
|
|
67
|
+
// delivery may send another one.
|
|
68
|
+
export const PARENT_WAKE_GRACE_MS = 30_000;
|
|
149
69
|
|
|
150
|
-
|
|
151
|
-
// cancellation, truncation and rendering remain owned by the SDK definition.
|
|
152
|
-
export function remediationBash(cwd: string, operations: BashOperations = createLocalBashOperations(), scope?: RemediationScope) {
|
|
153
|
-
const captured = new Map<string, { toolCallId: string; command: string; cwd: string; exitCode: number | null }>();
|
|
154
|
-
const remaining = [...(scope?.commands ?? [])], used = new Set<string>();
|
|
155
|
-
const stock = createBashToolDefinition(cwd);
|
|
156
|
-
return {
|
|
157
|
-
definition: { ...stock, async execute(...input: Parameters<typeof stock.execute>) {
|
|
158
|
-
const [id, args, signal, onUpdate, ctx] = input;
|
|
159
|
-
const slot = remaining.indexOf(args.command);
|
|
160
|
-
if (!remediationToolAllowed(scope, ctx?.cwd ?? cwd, "bash", args) || slot < 0 || used.has(id)) throw new Error("Bash invocation is outside this launch human authorization");
|
|
161
|
-
remaining.splice(slot, 1); used.add(id);
|
|
162
|
-
const definition = createBashToolDefinition(cwd, { operations: { exec: async (command, directory, options) => {
|
|
163
|
-
const result = await operations.exec(command, directory, options);
|
|
164
|
-
if (captured.size < 32) captured.set(id, { toolCallId: id, command, cwd: directory, exitCode: result.exitCode });
|
|
165
|
-
return result;
|
|
166
|
-
} } });
|
|
167
|
-
return definition.execute(id, args, signal, onUpdate, ctx);
|
|
168
|
-
} },
|
|
169
|
-
result(event: { toolCallId: string; details?: unknown }) {
|
|
170
|
-
const observation = captured.get(event.toolCallId);
|
|
171
|
-
captured.delete(event.toolCallId);
|
|
172
|
-
return observation ? { details: { ...(event.details as object ?? {}), remediationCommand: observation } } : undefined;
|
|
173
|
-
},
|
|
174
|
-
};
|
|
175
|
-
}
|
|
70
|
+
const retiredSddAgent = (name: string): boolean => /^sdd(?:-|$)/.test(name);
|
|
176
71
|
|
|
177
72
|
export interface SessionTransportRegistry {
|
|
178
73
|
list(excludeSessionId?: string): Promise<readonly SessionPresenceCandidate[]>;
|
|
@@ -199,7 +94,6 @@ export interface SessionTransportFactory {
|
|
|
199
94
|
}
|
|
200
95
|
|
|
201
96
|
export interface AgentsDeps extends RunnerDeps {
|
|
202
|
-
nativeSdd?: NativeReviewCli;
|
|
203
97
|
home: string;
|
|
204
98
|
agentHome?: string;
|
|
205
99
|
childIpc?: IpcEndpoint;
|
|
@@ -209,6 +103,22 @@ export interface AgentsDeps extends RunnerDeps {
|
|
|
209
103
|
runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps;
|
|
210
104
|
metricsNow?: () => number;
|
|
211
105
|
metricsSchedule?: RunnerDeps["schedule"];
|
|
106
|
+
// Extensions every child loads with --extension (gentle-shell#1587).
|
|
107
|
+
childExtensionPaths?: string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// gentle-shell#1587: children do not load the gentle-pi package in the
|
|
111
|
+
// isolated Gentle Shell home, so context filtering and destructive-command
|
|
112
|
+
// safety are passed to every child explicitly. Missing files are omitted;
|
|
113
|
+
// installations must include both entries to provide the delegated boundary.
|
|
114
|
+
export function childContextExtensionPaths(exists: (path: string) => boolean = existsSync): string[] {
|
|
115
|
+
try {
|
|
116
|
+
return ["./child-context.ts", "./child-safety.ts"]
|
|
117
|
+
.map((path) => fileURLToPath(new URL(path, import.meta.url)))
|
|
118
|
+
.filter(exists);
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
212
122
|
}
|
|
213
123
|
|
|
214
124
|
export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "agent")): { sessions: string; transcripts: string } {
|
|
@@ -231,9 +141,11 @@ const defaultDeps = (env: NodeJS.ProcessEnv): AgentsDeps => ({
|
|
|
231
141
|
return () => clearTimeout(timer);
|
|
232
142
|
},
|
|
233
143
|
pi: piCommand(),
|
|
144
|
+
resolvePi: () => piCommand(),
|
|
234
145
|
home: os.homedir(),
|
|
235
146
|
resolveWorktree: resolveSessionWorktree,
|
|
236
147
|
env,
|
|
148
|
+
childExtensionPaths: childContextExtensionPaths(),
|
|
237
149
|
});
|
|
238
150
|
|
|
239
151
|
export function agentsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
@@ -319,6 +231,22 @@ function text(value: string, details: Record<string, unknown> = {}, terminate =
|
|
|
319
231
|
return { content: [{ type: "text", text: value }], details, ...(terminate ? { terminate: true } : {}) };
|
|
320
232
|
}
|
|
321
233
|
|
|
234
|
+
// gentle-pi#1175: the writer profile the runtime resolved for this task,
|
|
235
|
+
// carried on its review-mutation receipt so ASSESS never trusts a model
|
|
236
|
+
// re-declaration. `task.model` is the child's reported model once get_state
|
|
237
|
+
// answers, or the launch profile's model. An inherited model is recorded as
|
|
238
|
+
// the unresolved `formatModelRef(undefined)` placeholder; the parent's current
|
|
239
|
+
// model is not reliable evidence (the child resolves its own default), so it is
|
|
240
|
+
// omitted and ASSESS treats the writer as unknown, i.e. small.
|
|
241
|
+
function runtimeWriterProfile(task: TaskRecord): { writerModelId?: string; writerEffort?: string } {
|
|
242
|
+
const modelId = typeof task.model === "string" && task.model !== formatModelRef(undefined) && task.model.trim() ? task.model : undefined;
|
|
243
|
+
const effort = typeof task.thinking === "string" && task.thinking.trim() ? task.thinking : undefined;
|
|
244
|
+
return {
|
|
245
|
+
...(modelId === undefined ? {} : { writerModelId: modelId }),
|
|
246
|
+
...(effort === undefined ? {} : { writerEffort: effort }),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
322
250
|
function taskDetails(task: TaskRecord): Record<string, unknown> {
|
|
323
251
|
return { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode, cwd: task.cwd } };
|
|
324
252
|
}
|
|
@@ -361,12 +289,24 @@ export function resolveDefaultSubagentMode(input: {
|
|
|
361
289
|
}
|
|
362
290
|
|
|
363
291
|
// What the model reads when a background task ends: the outcome first, then
|
|
364
|
-
// the answer itself. The card
|
|
292
|
+
// the answer itself. The expanded card shows the same text.
|
|
365
293
|
export function completionText(task: TaskRecord): string {
|
|
366
294
|
const outcome = task.status === "completed" ? "finished" : task.status.replace("_", " ");
|
|
367
295
|
return `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome}.\n\n${finishedText(task)}`;
|
|
368
296
|
}
|
|
369
297
|
|
|
298
|
+
const COMPLETION_HEADER = /^Subagent [^\n]+ \(task [^\n]*\) [^\n]+\.$/;
|
|
299
|
+
|
|
300
|
+
// The collapsed card leads with the answer or error: completionText's
|
|
301
|
+
// bookkeeping paragraph stays for the model and the expanded card. Entries
|
|
302
|
+
// without that header (older sessions) preview their full text.
|
|
303
|
+
export function agentResultPreview(text: string): string {
|
|
304
|
+
const split = text.indexOf("\n\n");
|
|
305
|
+
if (split < 0 || !COMPLETION_HEADER.test(text.slice(0, split))) return text;
|
|
306
|
+
const rest = text.slice(split + 2);
|
|
307
|
+
return rest.trim() === "" ? text : rest;
|
|
308
|
+
}
|
|
309
|
+
|
|
370
310
|
// Host-side answer to a child's dialog: the same ctx.ui the human already
|
|
371
311
|
// uses, so a subagent's question looks like any other pi dialog.
|
|
372
312
|
export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, ask: AskRequest, raw: Record<string, unknown>): Promise<AskAnswer> {
|
|
@@ -393,66 +333,21 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as
|
|
|
393
333
|
}
|
|
394
334
|
}
|
|
395
335
|
|
|
396
|
-
const RESEARCH_SELECTION_SCHEMA = {
|
|
397
|
-
type: "object",
|
|
398
|
-
additionalProperties: false,
|
|
399
|
-
description: "Untrusted narrowing intent for sdd-research; exact tools and existing sourceInfo.path per tool. Never grants permissions or installs extensions.",
|
|
400
|
-
properties: Object.fromEntries(["documentation", "open-web"].map(kind => [kind, {
|
|
401
|
-
type: "object", additionalProperties: false, required: ["tools", "extensions"],
|
|
402
|
-
properties: {
|
|
403
|
-
tools: { type: "array", items: { type: "string" } },
|
|
404
|
-
extensions: { type: "object", additionalProperties: { type: "string" } },
|
|
405
|
-
},
|
|
406
|
-
}])),
|
|
407
|
-
};
|
|
408
|
-
|
|
409
336
|
export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<AgentsDeps> = {}): void {
|
|
410
|
-
if (env.GENTLE_PI_AGENTS_CHILD === "1" && env[RESEARCH_CHILD_TOOLS_ENV] !== undefined) {
|
|
411
|
-
let allowed: string[] = [];
|
|
412
|
-
try {
|
|
413
|
-
const parsed: unknown = JSON.parse(env[RESEARCH_CHILD_TOOLS_ENV]!);
|
|
414
|
-
if (Array.isArray(parsed) && parsed.every(value => typeof value === "string")) allowed = parsed;
|
|
415
|
-
} catch { /* Invalid launch restrictions deny every tool. */ }
|
|
416
|
-
let selection: unknown;
|
|
417
|
-
try { selection = JSON.parse(env[RESEARCH_SELECTION_ENV] ?? "null"); } catch { /* Missing selection grants no research. */ }
|
|
418
|
-
const current = () => researchAgent({ tools: allowed, instructions: "" } as AgentDefinition, pi, selection);
|
|
419
|
-
pi.on("before_agent_start", event => ({
|
|
420
|
-
systemPrompt: `${event.systemPrompt}\n\n${renderResearchCapabilities(current().capabilities)}\n\nResearch is output-only. Use parent-supplied local context; do not read or mutate repository or Engram artifacts. Return useful partial findings and unavailable sources honestly. The parent owns authorized persistence and actual readback.`,
|
|
421
|
-
}));
|
|
422
|
-
pi.on("tool_call", event => {
|
|
423
|
-
const registered = pi.getAllTools().some(tool => tool.name === event.toolName && tool.sourceInfo?.source !== "sdk");
|
|
424
|
-
const selected = current().agent.tools.includes(event.toolName) || event.toolName === "subagent_parent_message";
|
|
425
|
-
if (!registered || !selected || !allowed.includes(event.toolName) || !pi.getActiveTools().includes(event.toolName)) {
|
|
426
|
-
return { block: true, reason: "Tool is outside the output-only research child's active launch allowlist." };
|
|
427
|
-
}
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
337
|
const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined));
|
|
431
338
|
if (env.GENTLE_PI_AGENTS_CHILD === "1") {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
pi.on("tool_call", (
|
|
435
|
-
|
|
436
|
-
granted = undefined;
|
|
437
|
-
try {
|
|
438
|
-
const retained = JSON.parse(env[REMEDIATION_PLAN_ENV]!);
|
|
439
|
-
// SDK flags are owner-local; the runner transports the same selected context.
|
|
440
|
-
const selection = Object.hasOwn(retained, "selection") ? retained.selection : JSON.parse(String(pi.getFlag("gentle-sdd-change")));
|
|
441
|
-
parseSddChange(selection, "sdd-remediate");
|
|
442
|
-
const plan = parseRemediationPlan(retained.plan, ctx.cwd);
|
|
443
|
-
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");
|
|
444
|
-
const shell = remediationBash(ctx.cwd, undefined, retained.scope);
|
|
445
|
-
pi.registerTool(shell.definition);
|
|
446
|
-
pi.on("tool_result", event => event.toolName === "bash" ? shell.result(event) : undefined);
|
|
447
|
-
granted = retained.scope;
|
|
448
|
-
} catch { /* No valid host grant: deny every tool, even if Pi continues initialization. */ }
|
|
449
|
-
});
|
|
339
|
+
// A stale managed-SDD child must never inherit unrestricted ordinary tools.
|
|
340
|
+
if (env.GENTLE_PI_RESEARCH_TOOLS !== undefined || env.GENTLE_PI_RESEARCH_SELECTION !== undefined || env.GENTLE_PI_RESEARCH_ARTIFACT !== undefined || env.GENTLE_PI_SDD_REMEDIATION_PLAN !== undefined) {
|
|
341
|
+
pi.on("tool_call", () => ({ block: true, reason: "Retired SDD child launch is not supported." }));
|
|
342
|
+
return;
|
|
450
343
|
}
|
|
451
344
|
if (childIpc) registerChildMessaging(pi, childIpc);
|
|
452
345
|
return;
|
|
453
346
|
}
|
|
454
347
|
if (!agentsEnabled(env)) return;
|
|
455
348
|
const deps: AgentsDeps = { ...defaultDeps(env), ...overrides };
|
|
349
|
+
// An explicitly injected pi command wins over the default per-spawn resolver.
|
|
350
|
+
if (overrides?.pi && !overrides.resolvePi) delete deps.resolvePi;
|
|
456
351
|
const selectedHome = overrides.agentHome ?? (overrides.home === undefined ? resolveGentlePiAgentHome(deps.env) : join(deps.home, ".pi", "agent"));
|
|
457
352
|
// Expand environment tildes like Pi, but leave explicit path APIs literal.
|
|
458
353
|
const environmentHome = overrides.agentHome === undefined && overrides.home === undefined;
|
|
@@ -476,6 +371,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
476
371
|
let ui: ExtensionContext["ui"] | undefined;
|
|
477
372
|
let host: { requestRender(): void } | undefined;
|
|
478
373
|
let sidebarTui: TUI | undefined;
|
|
374
|
+
const visualHome = gentlePiConfigHome(env);
|
|
375
|
+
let agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
|
|
376
|
+
let stopVisualUpdates: (() => void) | undefined;
|
|
479
377
|
let sessions: ExtensionContext["sessionManager"] | undefined;
|
|
480
378
|
let presence: PresencePublisher | undefined;
|
|
481
379
|
let rpcActivityPublisher: RpcActivityPublisher | undefined;
|
|
@@ -496,9 +394,17 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
496
394
|
} catch { presence?.dispose(); presence = undefined; }
|
|
497
395
|
};
|
|
498
396
|
let worktrees: SessionWorktreeRegistry | undefined;
|
|
397
|
+
let worktreeManager: ExtensionContext["sessionManager"] | undefined;
|
|
398
|
+
let worktreeAuthority: (() => boolean) | undefined;
|
|
399
|
+
const foreignGrants = new ForeignTargetGrants();
|
|
400
|
+
const messagingGrants = new SessionMessagingGrants();
|
|
401
|
+
const foreignTasks = new Map<string, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
|
|
402
|
+
const foreignRequests = new WeakMap<TaskRequest, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
|
|
499
403
|
const registryFor = (ctx: ExtensionContext) => {
|
|
500
|
-
if (!worktrees || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
404
|
+
if (!worktrees || worktreeManager !== ctx.sessionManager || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
501
405
|
worktrees?.close();
|
|
406
|
+
worktreeManager = ctx.sessionManager;
|
|
407
|
+
worktreeAuthority = sessionRepositoryAuthority(ctx.sessionManager.getCwd(), deps.resolveWorktree);
|
|
502
408
|
worktrees = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.sessionManager.getCwd(), deps.resolveWorktree);
|
|
503
409
|
}
|
|
504
410
|
return worktrees;
|
|
@@ -682,20 +588,128 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
682
588
|
// flushed at the next turn boundary, and a stale one never re-enters the
|
|
683
589
|
// conversation.
|
|
684
590
|
const completions = createCompletionQueue<TaskRecord>();
|
|
591
|
+
const messages = createAgentMessageQueue();
|
|
685
592
|
let activeAgentRuns = 0;
|
|
593
|
+
// ExtensionAPI has no idle probe; ctx.isIdle() is the only one. It is live
|
|
594
|
+
// and also reports compaction, which activeAgentRuns cannot see. The
|
|
595
|
+
// session_start context is kept for it and dropped at shutdown; a stale
|
|
596
|
+
// context throws instead of answering, so delivery fails closed.
|
|
597
|
+
let parentCtx: ExtensionContext | undefined;
|
|
598
|
+
// Mirrors the host's agent run, which spans agent_start through
|
|
599
|
+
// agent_settled, including post-run retries and in-run compaction. Unlike
|
|
600
|
+
// activeAgentRuns it stays set between agent_end and agent_settled, where
|
|
601
|
+
// Pi still streams and still drains steering.
|
|
602
|
+
let parentRunActive = false;
|
|
603
|
+
// Idle wake-up state. Child content stored while the parent is idle owes a
|
|
604
|
+
// wake, and one wake covers everything stored before the run it starts.
|
|
605
|
+
// While a prompt is starting (a dispatched wake, or any prompt seen at
|
|
606
|
+
// before_agent_start) no second wake is sent: two prompts racing through
|
|
607
|
+
// Pi's asynchronous pre-run phase can both reach the agent, and the loser
|
|
608
|
+
// resets the winner's run state. A prompt that never starts a run (handled
|
|
609
|
+
// by an input handler, or rejected) emits no extension event, so the
|
|
610
|
+
// starting state expires after PARENT_WAKE_GRACE_MS and an owed wake is
|
|
611
|
+
// sent then; it never suppresses later wakes for longer than that.
|
|
612
|
+
let wakeOwed = false;
|
|
613
|
+
let wakeQueued = false;
|
|
614
|
+
let promptStarting = false;
|
|
615
|
+
let cancelPromptGrace: (() => void) | undefined;
|
|
616
|
+
let cancelBoundaryFlush: (() => void) | undefined;
|
|
617
|
+
|
|
618
|
+
const isTaskLive = (id: string): boolean => {
|
|
619
|
+
const task = store.get(id);
|
|
620
|
+
return Boolean(task && ownedTaskIds.has(task.id) && !isFinished(task.status));
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// Hands model-visible child content to the parent session.
|
|
624
|
+
//
|
|
625
|
+
// A busy parent gets "steer" + triggerTurn, which keeps delivery bounded to
|
|
626
|
+
// the current turn: the host polls steering each turn and injects the
|
|
627
|
+
// message before the next LLM call. "followUp" is NOT acceptable here
|
|
628
|
+
// because the host drains the follow-up queue only in the run loop's stop
|
|
629
|
+
// branch, so a parent that keeps calling tools would see the content only
|
|
630
|
+
// when the whole run ends — the original #867 delay.
|
|
631
|
+
//
|
|
632
|
+
// An idle parent must not get triggerTurn: the host would run the custom
|
|
633
|
+
// message as a direct turn that skips the prompt lifecycle
|
|
634
|
+
// (before_agent_start and the prompt refresh), and prompt-capture
|
|
635
|
+
// integrations such as the Claude bridge reject that turn. The structured
|
|
636
|
+
// message is stored durably without a turn instead, and a short
|
|
637
|
+
// system-generated user message wakes the parent through the normal prompt
|
|
638
|
+
// path. The wake never repeats child content, so the model sees it once.
|
|
639
|
+
//
|
|
640
|
+
// A parent that is busy without a run (compaction, or a prompt's pre-run
|
|
641
|
+
// compaction) is not streaming, so steer + triggerTurn would also start a
|
|
642
|
+
// direct turn. Its content stays queued ("hold") until a later boundary.
|
|
643
|
+
type ParentRoute = "idle" | "run" | "hold";
|
|
644
|
+
// Throws for a missing or stale parent context, so delivery fails closed.
|
|
645
|
+
const parentRoute = (): ParentRoute => {
|
|
646
|
+
if (!parentCtx) throw new Error("Gentle Agents has no live parent session context");
|
|
647
|
+
if (parentCtx.isIdle()) {
|
|
648
|
+
// The host is authoritative: an idle parent has no run, even if a
|
|
649
|
+
// lifecycle event was missed.
|
|
650
|
+
parentRunActive = false;
|
|
651
|
+
return "idle";
|
|
652
|
+
}
|
|
653
|
+
return parentRunActive ? "run" : "hold";
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const endPromptStart = () => {
|
|
657
|
+
promptStarting = false;
|
|
658
|
+
cancelPromptGrace?.();
|
|
659
|
+
cancelPromptGrace = undefined;
|
|
660
|
+
};
|
|
661
|
+
const beginPromptStart = () => {
|
|
662
|
+
endPromptStart();
|
|
663
|
+
promptStarting = true;
|
|
664
|
+
cancelPromptGrace = deps.schedule(() => {
|
|
665
|
+
cancelPromptGrace = undefined;
|
|
666
|
+
promptStarting = false;
|
|
667
|
+
requestWake();
|
|
668
|
+
}, PARENT_WAKE_GRACE_MS);
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
// Every wake requested during one synchronous delivery pass is coalesced
|
|
672
|
+
// into a single dispatch after it, so the wake follows all stored content.
|
|
673
|
+
const requestWake = () => {
|
|
674
|
+
if (!wakeOwed || wakeQueued || promptStarting) return;
|
|
675
|
+
wakeQueued = true;
|
|
676
|
+
queueMicrotask(dispatchWake);
|
|
677
|
+
};
|
|
678
|
+
const dispatchWake = () => {
|
|
679
|
+
wakeQueued = false;
|
|
680
|
+
if (!wakeOwed || promptStarting) return;
|
|
681
|
+
let route: ParentRoute;
|
|
682
|
+
try { route = parentRoute(); } catch { return; }
|
|
683
|
+
// A run in progress already carries the stored content; a parent busy
|
|
684
|
+
// without a run keeps the wake owed until a later boundary flush.
|
|
685
|
+
if (route !== "idle") return;
|
|
686
|
+
wakeOwed = false;
|
|
687
|
+
beginPromptStart();
|
|
688
|
+
try {
|
|
689
|
+
// "steer" matters only when a run started in between: the wake is then
|
|
690
|
+
// queued into it instead of being rejected as a concurrent prompt.
|
|
691
|
+
pi.sendUserMessage(PARENT_WAKE_TEXT, { deliverAs: "steer" });
|
|
692
|
+
} catch {
|
|
693
|
+
// A stale runtime fails closed instead of throwing from a microtask.
|
|
694
|
+
endPromptStart();
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const sendToParent = (message: Parameters<ExtensionAPI["sendMessage"]>[0], route: Exclude<ParentRoute, "hold">) => {
|
|
699
|
+
if (route === "run") {
|
|
700
|
+
pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
pi.sendMessage(message, { triggerTurn: false });
|
|
704
|
+
wakeOwed = true;
|
|
705
|
+
requestWake();
|
|
706
|
+
};
|
|
686
707
|
|
|
687
|
-
const deliver = (task: TaskRecord) => {
|
|
708
|
+
const deliver = (task: TaskRecord, route: Exclude<ParentRoute, "hold">) => {
|
|
688
709
|
// Ownership is consulted at delivery time, matching onNotification and
|
|
689
710
|
// onQuery: a completion owned by another session is dropped, not delivered.
|
|
690
711
|
if (activeSessionId() !== task.parentSessionId) return;
|
|
691
|
-
|
|
692
|
-
// the parent streams, the host polls steering each turn and injects the
|
|
693
|
-
// message before the next LLM call; "followUp" is NOT acceptable here
|
|
694
|
-
// because the host drains the follow-up queue only in the run loop's stop
|
|
695
|
-
// branch, so a parent that keeps calling tools would see the completion
|
|
696
|
-
// only when the whole run ends — the original #867 delay. When the parent
|
|
697
|
-
// is idle, triggerTurn runs the prompt immediately, preserving wake-up.
|
|
698
|
-
pi.sendMessage({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true });
|
|
712
|
+
sendToParent({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, route);
|
|
699
713
|
};
|
|
700
714
|
|
|
701
715
|
// A stale completion must not re-enter the LLM conversation, so it is
|
|
@@ -706,22 +720,85 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
706
720
|
pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds });
|
|
707
721
|
};
|
|
708
722
|
|
|
723
|
+
const deliverMessage = (msg: PendingAgentMessage, route: Exclude<ParentRoute, "hold">) => {
|
|
724
|
+
if (activeSessionId() !== msg.parentSessionId) return;
|
|
725
|
+
sendToParent({ customType: AGENTS_MESSAGE_TYPE, content: msg.content, display: msg.display, details: msg.details }, route);
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
// Child content leaves its queue only when the parent can receive it. A
|
|
729
|
+
// held or undeliverable flush leaves everything queued for a later boundary;
|
|
730
|
+
// `rethrow` lets a query report a stale parent back to its child.
|
|
731
|
+
const deliveryRoute = (rethrow: boolean): Exclude<ParentRoute, "hold"> | undefined => {
|
|
732
|
+
try {
|
|
733
|
+
const route = parentRoute();
|
|
734
|
+
return route === "hold" ? undefined : route;
|
|
735
|
+
} catch (error) {
|
|
736
|
+
if (rethrow) throw error;
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const flushMessages = (rethrow = false) => {
|
|
742
|
+
const route = deliveryRoute(rethrow);
|
|
743
|
+
if (!route) return;
|
|
744
|
+
for (const msg of messages.takeDeliverable(deps.now(), activeSessionId() ?? "", isTaskLive)) {
|
|
745
|
+
try {
|
|
746
|
+
deliverMessage(msg, route);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (rethrow) throw error;
|
|
749
|
+
/* Best-effort delivery: at most once, even if forwarding fails. */
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
709
754
|
const flushCompletions = () => {
|
|
755
|
+
const route = deliveryRoute(false);
|
|
756
|
+
if (!route) return;
|
|
710
757
|
for (const { task, settledAt, stale } of completions.takeDeliverable(deps.now())) {
|
|
711
758
|
try {
|
|
712
759
|
if (stale) deliverStale(task, settledAt);
|
|
713
|
-
else deliver(task);
|
|
760
|
+
else deliver(task, route);
|
|
714
761
|
} catch { /* Best-effort delivery: at most once, even if forwarding fails. */ }
|
|
715
762
|
}
|
|
716
763
|
};
|
|
717
764
|
|
|
765
|
+
const flushAll = () => {
|
|
766
|
+
flushMessages();
|
|
767
|
+
flushCompletions();
|
|
768
|
+
// A wake left owed by an earlier held or expired attempt is retried here.
|
|
769
|
+
requestWake();
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
// Pi still reports compaction while these handlers run (a manual
|
|
773
|
+
// session_compact, an automatic compaction's session_compact_failed), so
|
|
774
|
+
// held content is flushed once on the next scheduler tick instead. A parent
|
|
775
|
+
// still busy then keeps it held for the next boundary; nothing polls.
|
|
776
|
+
const scheduleBoundaryFlush = () => {
|
|
777
|
+
if (cancelBoundaryFlush) return;
|
|
778
|
+
cancelBoundaryFlush = deps.schedule(() => {
|
|
779
|
+
cancelBoundaryFlush = undefined;
|
|
780
|
+
flushAll();
|
|
781
|
+
}, 0);
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
// Session changes discard every pending wake and boundary flush.
|
|
785
|
+
const resetParentDelivery = (ctx: ExtensionContext | undefined) => {
|
|
786
|
+
parentCtx = ctx;
|
|
787
|
+
parentRunActive = false;
|
|
788
|
+
wakeOwed = false;
|
|
789
|
+
endPromptStart();
|
|
790
|
+
cancelBoundaryFlush?.();
|
|
791
|
+
cancelBoundaryFlush = undefined;
|
|
792
|
+
};
|
|
793
|
+
|
|
718
794
|
// A completion settles into our queue. An idle parent flushes right away so
|
|
719
795
|
// the wake-up behavior is unchanged; a busy parent flushes at the next turn
|
|
720
796
|
// boundary, and the steer mode injects it before that turn's next LLM call
|
|
721
797
|
// instead of parking it behind the whole run.
|
|
722
798
|
const settleCompletion = (task: TaskRecord) => {
|
|
799
|
+
messages.invalidateTask(task.id);
|
|
723
800
|
completions.enqueue(task, deps.now());
|
|
724
|
-
if (activeAgentRuns === 0)
|
|
801
|
+
if (activeAgentRuns === 0) flushAll();
|
|
725
802
|
};
|
|
726
803
|
|
|
727
804
|
// `agent_start`/`agent_end` bracket a parent agent run; `turn_end` fires at
|
|
@@ -731,41 +808,63 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
731
808
|
// a final `turn_end` (an aborted run, or the host's early post-run return
|
|
732
809
|
// when a run produced no assistant message; the host compensates via
|
|
733
810
|
// hasQueuedMessages() + continue(), so steering there is still bounded).
|
|
734
|
-
// `agent_settled` is the final idle boundary after retries
|
|
735
|
-
//
|
|
736
|
-
|
|
811
|
+
// `agent_settled` is the final idle boundary after retries: it ends the
|
|
812
|
+
// host run and flushes anything held, including content held while a
|
|
813
|
+
// compaction ran inside or right before the run. `before_agent_start`
|
|
814
|
+
// marks a prompt that is about to start a run, and the compaction events
|
|
815
|
+
// release content held while the parent compacted without a run.
|
|
816
|
+
pi.on("before_agent_start", () => {
|
|
817
|
+
if (!parentRunActive) beginPromptStart();
|
|
818
|
+
});
|
|
819
|
+
pi.on("agent_start", () => {
|
|
820
|
+
activeAgentRuns += 1;
|
|
821
|
+
parentRunActive = true;
|
|
822
|
+
// The run carries everything stored while the parent was idle.
|
|
823
|
+
wakeOwed = false;
|
|
824
|
+
endPromptStart();
|
|
825
|
+
});
|
|
737
826
|
pi.on("agent_end", () => {
|
|
738
827
|
activeAgentRuns = Math.max(0, activeAgentRuns - 1);
|
|
739
|
-
|
|
828
|
+
flushAll();
|
|
829
|
+
});
|
|
830
|
+
pi.on("agent_settled", () => {
|
|
831
|
+
parentRunActive = false;
|
|
832
|
+
endPromptStart();
|
|
833
|
+
flushAll();
|
|
740
834
|
});
|
|
741
|
-
pi.on("
|
|
742
|
-
pi.on("
|
|
835
|
+
pi.on("turn_end", () => flushAll());
|
|
836
|
+
pi.on("session_compact", scheduleBoundaryFlush);
|
|
837
|
+
pi.on("session_compact_failed", scheduleBoundaryFlush);
|
|
743
838
|
|
|
744
839
|
const runner = new AgentRunner(store, loadAgentsConfig({ cwd: process.cwd(), home: deps.home, agentHome }), deps, {
|
|
745
840
|
askUser: (_taskId, ask, raw) => answerThroughUi(ui, ask, raw),
|
|
746
841
|
onNotification: (task, message) => {
|
|
747
|
-
if (activeSessionId() !== task.parentSessionId) return false;
|
|
748
|
-
|
|
842
|
+
if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false;
|
|
843
|
+
messages.enqueueNotification(task, message, deps.now());
|
|
844
|
+
if (activeAgentRuns === 0) flushMessages();
|
|
749
845
|
return true;
|
|
750
846
|
},
|
|
751
847
|
onQuery: (task, requestId, message) => {
|
|
752
|
-
if (activeSessionId() !== task.parentSessionId) return false;
|
|
848
|
+
if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false;
|
|
753
849
|
const hadYield = yieldedTaskIds.has(task.id);
|
|
754
850
|
if (task.mode === AGENT_MODE.TASK) yieldedTaskIds.add(task.id);
|
|
755
851
|
try {
|
|
756
|
-
|
|
852
|
+
messages.enqueueQuery(task, requestId, message, deps.now());
|
|
853
|
+
if (activeAgentRuns === 0) flushMessages(true);
|
|
757
854
|
return true;
|
|
758
855
|
} catch (error) {
|
|
856
|
+
messages.expireQuery(task.id, requestId);
|
|
759
857
|
if (task.mode === AGENT_MODE.TASK && !hadYield) yieldedTaskIds.delete(task.id);
|
|
760
858
|
throw error;
|
|
761
859
|
}
|
|
762
860
|
},
|
|
861
|
+
onQuerySettled: (taskId, requestId, outcome) => {
|
|
862
|
+
if (outcome === "replied") messages.consumeQuery(taskId, requestId);
|
|
863
|
+
else messages.expireQuery(taskId, requestId);
|
|
864
|
+
},
|
|
763
865
|
onSuccessfulMutation: (task, tool) => {
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
// explain a drop -- they never widen or narrow what gets attributed.
|
|
767
|
-
// The cheap session/ownership guards decide before any worktree
|
|
768
|
-
// resolution, so a foreign or stale mutation never reaches git.
|
|
866
|
+
// Same-clone registry attribution remains unchanged. A foreign task
|
|
867
|
+
// uses a separately bound, live-grant path only for successful tool evidence.
|
|
769
868
|
let root: string | undefined;
|
|
770
869
|
let childRoot: string | undefined;
|
|
771
870
|
const noteDrop = (guard: string) => {
|
|
@@ -782,13 +881,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
782
881
|
childRoot = deps.resolveWorktree(task.cwd, task.cwd)?.root;
|
|
783
882
|
if (!root) return noteDrop("root-unresolved");
|
|
784
883
|
if (root !== childRoot) return noteDrop("root-mismatch");
|
|
785
|
-
|
|
884
|
+
const foreignTask = foreignTasks.get(task.id);
|
|
885
|
+
if (foreignTask) {
|
|
886
|
+
if (sessions !== foreignTask.manager || root !== foreignTask.root || tool.evidence?.root !== root) return noteDrop("foreign-identity-mismatch");
|
|
887
|
+
const identity = resolveSessionWorktree(root, root);
|
|
888
|
+
if (!identity || identity.commonDir !== foreignTask.commonDir) return noteDrop("foreign-identity-drift");
|
|
889
|
+
try { foreignGrants.assertCurrent({ sessionManager: sessions }, identity); }
|
|
890
|
+
catch { return noteDrop("foreign-grant-lost"); }
|
|
891
|
+
} else if (!worktrees.roots().includes(root)) return noteDrop("root-not-registered");
|
|
786
892
|
if (!tool.evidence) {
|
|
787
893
|
noteDrop("evidence-missing");
|
|
788
894
|
} else if (tool.evidence.root !== root) {
|
|
789
895
|
noteDrop("evidence-root-mismatch");
|
|
790
896
|
} else {
|
|
791
|
-
let path = tool.path.replace(/^@/, "");
|
|
897
|
+
let path = tool.path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
|
|
792
898
|
if (path === "~" || path.startsWith("~/")) path = os.homedir() + path.slice(1);
|
|
793
899
|
let resolvedPath: string | undefined;
|
|
794
900
|
try {
|
|
@@ -796,10 +902,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
796
902
|
} catch {
|
|
797
903
|
noteDrop("evidence-path-unreadable");
|
|
798
904
|
}
|
|
799
|
-
if (resolvedPath === resolve(root, tool.evidence.path))
|
|
905
|
+
if (resolvedPath === resolve(root, tool.evidence.path)) {
|
|
906
|
+
const evidence = { ...tool.evidence, id: `${task.id}:${tool.toolCallId}` };
|
|
907
|
+
if (foreignTask) publishForeignSessionChange(pi, task.parentSessionId, evidence);
|
|
908
|
+
else pi.events.emit(SESSION_CHANGE_RELAY, { sessionId: task.parentSessionId, evidence });
|
|
909
|
+
}
|
|
800
910
|
else if (resolvedPath !== undefined) noteDrop("evidence-path-mismatch");
|
|
801
911
|
}
|
|
802
|
-
recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId });
|
|
912
|
+
if (!foreignTask) recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId, ...runtimeWriterProfile(task) });
|
|
803
913
|
},
|
|
804
914
|
onFinish: (task, observations) => {
|
|
805
915
|
// Completion is the only forwarding opportunity. No pending event, policy
|
|
@@ -817,6 +927,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
817
927
|
} catch { /* Metrics must never interrupt task finalization. */ }
|
|
818
928
|
try {
|
|
819
929
|
ownedTaskIds.delete(task.id);
|
|
930
|
+
messages.invalidateTask(task.id);
|
|
820
931
|
requestRender();
|
|
821
932
|
persist(task);
|
|
822
933
|
const yielded = yieldedTaskIds.delete(task.id);
|
|
@@ -845,12 +956,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
845
956
|
pi.registerMessageRenderer(AGENTS_RESULT_TYPE, (message, options, theme) => {
|
|
846
957
|
const details = (message.details as { gentleAgents?: { agent?: string; status?: string } } | undefined)?.gentleAgents;
|
|
847
958
|
const content = message.content as string | Array<{ type: string; text?: string }>;
|
|
848
|
-
const
|
|
959
|
+
const text = typeof content === "string" ? content : content.map((part) => (part.type === "text" ? (part.text ?? "") : "")).join("\n");
|
|
960
|
+
const body = options.expanded ? text.split("\n") : agentResultPreview(text).split("\n").filter((line) => line.trim() !== "");
|
|
849
961
|
const tone = details?.status === "completed" ? CARD_TONE.SUCCESS : CARD_TONE.ERROR;
|
|
850
962
|
const hint = expandHint(options.expanded);
|
|
851
963
|
return {
|
|
852
964
|
render(width: number) {
|
|
853
|
-
return renderCard({ title: "Agent result", subtitle: details?.agent, body, tone, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, hint });
|
|
965
|
+
return renderCard({ title: "Agent result", subtitle: details?.agent, body, tone, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, previewRows: 3, hint });
|
|
854
966
|
},
|
|
855
967
|
invalidate() {},
|
|
856
968
|
};
|
|
@@ -872,7 +984,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
872
984
|
];
|
|
873
985
|
return {
|
|
874
986
|
render(width: number) {
|
|
875
|
-
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) });
|
|
987
|
+
return renderCard({ title: "Stale agent result", subtitle: `${agent} · task ${taskId}`, body, tone: CARD_TONE.WARNING, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, previewRows: 3, hint: expandHint(options.expanded) });
|
|
876
988
|
},
|
|
877
989
|
invalidate() {},
|
|
878
990
|
};
|
|
@@ -976,6 +1088,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
976
1088
|
let overlayHost: { requestRender(force?: boolean): void; stop(): void; start(): void } | undefined;
|
|
977
1089
|
const chosen = await ctx.ui.custom<TaskRecord | null>(
|
|
978
1090
|
(tui, theme, _keybindings, done) => {
|
|
1091
|
+
const close = withOverlayRepaint(tui, done);
|
|
979
1092
|
overlayHost = tui;
|
|
980
1093
|
view = new AgentsView({
|
|
981
1094
|
theme,
|
|
@@ -990,8 +1103,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
990
1103
|
onCancel: (task) => void stopSelected(task, ctx),
|
|
991
1104
|
canCancel: isOwnedActive,
|
|
992
1105
|
isLocalTask: (task) => !restoredTaskIds.has(task.id),
|
|
993
|
-
onOpen: (task) =>
|
|
994
|
-
onClose: () =>
|
|
1106
|
+
onOpen: (task) => close(task),
|
|
1107
|
+
onClose: () => close(null),
|
|
995
1108
|
requestRender: () => tui.requestRender(),
|
|
996
1109
|
});
|
|
997
1110
|
overlays.add(view);
|
|
@@ -1044,6 +1157,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1044
1157
|
const showWidget = (ctx: ExtensionContext) => {
|
|
1045
1158
|
ui = ctx.hasUI ? ctx.ui : undefined;
|
|
1046
1159
|
sessions = ctx.sessionManager;
|
|
1160
|
+
agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
|
|
1161
|
+
stopVisualUpdates?.();
|
|
1162
|
+
stopVisualUpdates = pi.events.on(VISUAL_SETTINGS_CHANGED, (event) => {
|
|
1163
|
+
if ((event as { configHome?: unknown } | undefined)?.configHome !== visualHome) return;
|
|
1164
|
+
agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
|
|
1165
|
+
if (sidebarTui) invalidateSidebar(sidebarTui);
|
|
1166
|
+
host?.requestRender();
|
|
1167
|
+
});
|
|
1047
1168
|
tickClock();
|
|
1048
1169
|
// Agents is not a rail part: the fullscreen sidebar only suppresses
|
|
1049
1170
|
// bottom components registered through sidebarPart, and this widget is
|
|
@@ -1053,6 +1174,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1053
1174
|
sidebarTui = tui;
|
|
1054
1175
|
return {
|
|
1055
1176
|
render(width: number) {
|
|
1177
|
+
if (!agentsVisible) return [];
|
|
1056
1178
|
const lines = renderAgentsCard(visibleTasks(), theme, width, deps.now(), { collapsed, collapseKey, maxRows: widgetRows(tui.terminal?.rows), viewKey });
|
|
1057
1179
|
return lines.length === 0 ? [] : [...lines, ""];
|
|
1058
1180
|
},
|
|
@@ -1063,21 +1185,59 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1063
1185
|
|
|
1064
1186
|
const roots = (ctx: ExtensionContext) => ({ cwd: ctx.sessionManager.getCwd(), home: deps.home, agentHome });
|
|
1065
1187
|
|
|
1066
|
-
const buildRequest = (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string, workspaceRoot?: string,
|
|
1188
|
+
const buildRequest = async (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string, workspaceRoot?: string, signal?: AbortSignal, repositoryRoot?: string): Promise<TaskRequest> => {
|
|
1189
|
+
if (retiredSddAgent(agent.name)) throw new Error("Retired SDD agents cannot be dispatched.");
|
|
1190
|
+
if (![AGENT_MODE.TASK, AGENT_MODE.BACKGROUND].includes(mode)) throw new Error("Subagent mode must be task or background.");
|
|
1191
|
+
if (ctx.mode === "print" && mode === AGENT_MODE.BACKGROUND) throw new Error("Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.");
|
|
1192
|
+
const scopeDenied = rejectUnscopedBoundedWriterDispatch({ agent: agent.name, task: prompt, context });
|
|
1193
|
+
if (scopeDenied) throw new Error(scopeDenied.reason);
|
|
1194
|
+
if (signal?.aborted) throw new Error("Subagent launch aborted before authorization.");
|
|
1067
1195
|
const registry = registryFor(ctx);
|
|
1196
|
+
const authorityCurrent = worktreeAuthority!;
|
|
1197
|
+
const originalManager = ctx.sessionManager;
|
|
1198
|
+
const originalId = originalManager.getSessionId();
|
|
1199
|
+
const originalCwd = originalManager.getCwd();
|
|
1200
|
+
const current = () => sessions === originalManager && originalManager.getSessionId() === originalId && originalManager.getCwd() === originalCwd && !signal?.aborted && authorityCurrent();
|
|
1201
|
+
if (isGenericBoundedWriter(agent.name) && !current()) throw new Error("Writer session Git authority changed before admission.");
|
|
1202
|
+
let admittedModel: string | undefined;
|
|
1203
|
+
const surfaces = allowedEditSurfaces(prompt, context);
|
|
1204
|
+
if (!resume && isGenericBoundedWriter(agent.name) && surfaces?.some(isDevelopmentSurface) && !deps.resolveWorktree(originalCwd, originalCwd) && repositoryRoot === undefined) {
|
|
1205
|
+
const root = safeBootstrapDirectory(originalCwd);
|
|
1206
|
+
if (!root || (workspaceRoot !== undefined && (!isAbsolute(workspaceRoot) || safeBootstrapDirectory(workspaceRoot) !== root))) throw new Error("Writer bootstrap requires the original safe project root.");
|
|
1207
|
+
const config = withPinnedModelProfiles(loadAgentsConfig(roots(ctx)), resolveUnversionedProjectProfile(root, gentlePiConfigHome(deps.env))?.modelProfiles);
|
|
1208
|
+
const model = resolveAgentProfile(agent, config).model ?? ctx.model;
|
|
1209
|
+
const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
|
|
1210
|
+
if (!catalogModel) throw new Error("Writer bootstrap requires a valid effective model in this session's catalog.");
|
|
1211
|
+
admittedModel = `${catalogModel.provider}/${catalogModel.id}`;
|
|
1212
|
+
if (!current() || !await prepareBoundSessionRepository(originalManager, originalCwd, signal) || !current() || safeBootstrapDirectory(originalCwd) !== root || resolveSessionWorktree(originalCwd, originalCwd)?.root !== root) throw new Error("Writer repository preparation was unavailable or its session/target changed.");
|
|
1213
|
+
}
|
|
1068
1214
|
const parentCwd = ctx.sessionManager.getCwd();
|
|
1069
1215
|
// An explicit target is validated before any queue or session-dir writes.
|
|
1070
1216
|
const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd);
|
|
1071
|
-
|
|
1217
|
+
if (repositoryRoot !== undefined && workspaceRoot !== undefined) throw new Error("repository_root and workspace_root are mutually exclusive.");
|
|
1218
|
+
if (repositoryRoot !== undefined && (deps.env.GENTLE_PI_AGENTS_CHILD === "1" || ctx.mode !== "tui" || !ctx.hasUI)) throw new Error("Foreign repository launch requires an interactive parent session.");
|
|
1219
|
+
const selectedRoot = repositoryRoot ?? workspaceRoot;
|
|
1072
1220
|
// Preserve ordinary non-Git continuation, without admitting any new root.
|
|
1073
|
-
const sameNonGitContinuation = resume !== undefined && selectedRoot === parentCwd && !parentIdentity;
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1221
|
+
const sameNonGitContinuation = resume !== undefined && repositoryRoot === undefined && selectedRoot === parentCwd && !parentIdentity;
|
|
1222
|
+
let foreign = false;
|
|
1223
|
+
let foreignIdentity: { root: string; commonDir: string } | undefined;
|
|
1224
|
+
let foreignParent: { root: string; commonDir: string } | undefined;
|
|
1225
|
+
let target: string | undefined;
|
|
1226
|
+
if (selectedRoot !== undefined && !sameNonGitContinuation) {
|
|
1227
|
+
if (repositoryRoot === undefined) target = registry.validate(selectedRoot);
|
|
1228
|
+
else {
|
|
1229
|
+
// Only an explicitly selected canonical foreign Git root can escape the
|
|
1230
|
+
// same-clone registry. Never use a caller-injected resolver for this identity.
|
|
1231
|
+
const identity = resolveSessionWorktree(selectedRoot, parentCwd);
|
|
1232
|
+
const canonicalParent = resolveSessionWorktree(parentCwd, parentCwd);
|
|
1233
|
+
if (!identity || (canonicalParent && identity.commonDir === canonicalParent.commonDir) || selectedRoot !== identity.root || !isAbsolute(selectedRoot) || realpathSync(selectedRoot) !== selectedRoot) throw new Error("repository_root must select a canonical independent Git repository.");
|
|
1234
|
+
await foreignGrants.authorize(ctx, identity, { continuation: resume !== undefined, signal });
|
|
1235
|
+
foreignIdentity = identity;
|
|
1236
|
+
foreignParent = canonicalParent;
|
|
1237
|
+
target = identity.root;
|
|
1238
|
+
foreign = true;
|
|
1239
|
+
}
|
|
1240
|
+
} else target = parentIdentity?.root;
|
|
1081
1241
|
// A per-repository profile pin replaces subagent routing for this launch without
|
|
1082
1242
|
// materialising anything: the global stores stay untouched, so two repositories
|
|
1083
1243
|
// worked in parallel stop fighting over one global routing. The child's own
|
|
@@ -1086,7 +1246,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1086
1246
|
// launch stays in the session's own worktree the identity already resolved above
|
|
1087
1247
|
// is reused instead of asking Git a second time. The orchestrator is out of
|
|
1088
1248
|
// scope: only `modelProfiles` is replaced.
|
|
1089
|
-
const pinIdentity: WorktreeResolver = target !== undefined && parentIdentity !== undefined && target === parentIdentity.root
|
|
1249
|
+
const pinIdentity: WorktreeResolver = foreign ? resolveSessionWorktree : target !== undefined && parentIdentity !== undefined && target === parentIdentity.root
|
|
1090
1250
|
? () => parentIdentity
|
|
1091
1251
|
: deps.resolveWorktree;
|
|
1092
1252
|
const config = withPinnedModelProfiles(
|
|
@@ -1098,35 +1258,52 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1098
1258
|
})?.modelProfiles,
|
|
1099
1259
|
);
|
|
1100
1260
|
const profile = resolveAgentProfile(agent, config);
|
|
1101
|
-
|
|
1261
|
+
if (admittedModel !== undefined) {
|
|
1262
|
+
const model = profile.model ?? ctx.model;
|
|
1263
|
+
const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
|
|
1264
|
+
if (!catalogModel || `${catalogModel.provider}/${catalogModel.id}` !== admittedModel || !current()) throw new Error("Writer effective profile or session changed during preparation.");
|
|
1265
|
+
}
|
|
1102
1266
|
const sessionDir = agentRuntimePaths(deps.home, agentHome).sessions;
|
|
1267
|
+
if (foreign && target) {
|
|
1268
|
+
const identity = resolveSessionWorktree(target, parentCwd);
|
|
1269
|
+
if (!identity || identity.root !== target || identity.commonDir !== foreignIdentity?.commonDir) throw new Error("Foreign clone identity changed before launch.");
|
|
1270
|
+
foreignGrants.assertCurrent(ctx, identity);
|
|
1271
|
+
}
|
|
1272
|
+
if (signal?.aborted) throw new Error("Subagent launch aborted before queueing.");
|
|
1103
1273
|
mkdirSync(sessionDir, { recursive: true });
|
|
1104
1274
|
const parentSessionManager = ctx.sessionManager as unknown as ReviewSessionManager;
|
|
1105
1275
|
const parentSessionId = ctx.sessionManager.getSessionId() ?? "";
|
|
1106
1276
|
const parentWorktreeRoot = ctx.sessionManager.getCwd();
|
|
1107
1277
|
const parentRepositoryIdentity = resolveCanonicalGitRepositoryIdentitySync(parentWorktreeRoot);
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
agent: research?.agent ?? agent,
|
|
1113
|
-
remediationIntent,
|
|
1278
|
+
const childEnv = { ...deps.env };
|
|
1279
|
+
if (foreign) for (const key of inheritedUnsafeGitEnvironmentKeys(childEnv)) delete childEnv[key];
|
|
1280
|
+
const request: TaskRequest = {
|
|
1281
|
+
agent,
|
|
1114
1282
|
prompt,
|
|
1115
1283
|
label,
|
|
1116
1284
|
context,
|
|
1117
|
-
...(sddPreflightContext === undefined ? {} : { sddPreflightContext }),
|
|
1118
1285
|
mode,
|
|
1119
1286
|
cwd: target ?? parentWorktreeRoot,
|
|
1120
1287
|
parentSessionId,
|
|
1121
|
-
...(
|
|
1288
|
+
...(admittedModel === undefined ? {} : { beforeSpawn: () => {
|
|
1289
|
+
if (!current()) throw new Error("Writer session changed before spawn.");
|
|
1290
|
+
registry.validate(originalCwd);
|
|
1291
|
+
} }),
|
|
1292
|
+
...(foreign && target ? { beforeSpawn: () => {
|
|
1293
|
+
if (signal?.aborted || sessions !== ctx.sessionManager || ctx.sessionManager.getSessionId() !== registry.sessionId || ctx.sessionManager.getCwd() !== parentCwd) throw new Error("Foreign clone session or tool call changed before spawn.");
|
|
1294
|
+
const identity = resolveSessionWorktree(target, parentCwd);
|
|
1295
|
+
const parent = resolveSessionWorktree(parentCwd, parentCwd);
|
|
1296
|
+
if (!identity || identity.root !== target || identity.commonDir !== foreignIdentity?.commonDir || parent?.root !== foreignParent?.root || parent?.commonDir !== foreignParent?.commonDir) throw new Error("Foreign clone identity changed before spawn.");
|
|
1297
|
+
foreignGrants.assertCurrent(ctx, identity);
|
|
1298
|
+
} } : {}),
|
|
1299
|
+
...(target === undefined || foreign ? {} : { onLaunch: () => { registry.register(target, "subagent:spawn"); } }),
|
|
1122
1300
|
model: profile.model,
|
|
1123
1301
|
thinking: profile.thinking,
|
|
1124
1302
|
sessionDir,
|
|
1125
1303
|
resumeSessionPath: resume,
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
...(
|
|
1129
|
-
...(parentRepositoryIdentity === undefined ? {} : {
|
|
1304
|
+
...(deps.childExtensionPaths && deps.childExtensionPaths.length > 0 ? { extensionPaths: [...deps.childExtensionPaths] } : {}),
|
|
1305
|
+
env: childEnv,
|
|
1306
|
+
...(foreign || parentRepositoryIdentity === undefined ? {} : {
|
|
1130
1307
|
authorizeParentStandingReviewPermission: (repositoryIdentity: string) => {
|
|
1131
1308
|
try {
|
|
1132
1309
|
return repositoryIdentity === parentRepositoryIdentity &&
|
|
@@ -1144,23 +1321,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1144
1321
|
},
|
|
1145
1322
|
}),
|
|
1146
1323
|
};
|
|
1324
|
+
if (foreign && target && foreignIdentity) foreignRequests.set(request, { root: target, commonDir: foreignIdentity.commonDir, manager: ctx.sessionManager });
|
|
1325
|
+
return request;
|
|
1147
1326
|
};
|
|
1148
1327
|
|
|
1149
1328
|
const launch = async (ctx: ExtensionContext, request: TaskRequest, signal?: AbortSignal): Promise<ToolText> => {
|
|
1150
1329
|
if (ctx.mode === "print" && request.mode === AGENT_MODE.BACKGROUND) {
|
|
1151
1330
|
throw new Error("Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.");
|
|
1152
1331
|
}
|
|
1153
|
-
|
|
1154
|
-
// after its RPC process starts, so validate the single parent transport here
|
|
1155
|
-
// rather than letting a child invent/persist preferences during startup.
|
|
1156
|
-
if (SHIPPED_SDD_AGENT_NAME_SET.has(request.agent.name) && !isParentConfirmedSddPreflightContext(request.context)) {
|
|
1157
|
-
throw new Error("SDD child dispatch refused: parent-confirmed SDD preflight context is missing or malformed.");
|
|
1158
|
-
}
|
|
1159
|
-
if (request.agent.name === "sdd-remediate") {
|
|
1160
|
-
const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
|
|
1161
|
-
Object.assign(request, await admitManagedRemediation(request, request.remediationIntent, native, ctx));
|
|
1162
|
-
if (activeSessionId() !== request.parentSessionId) throw new Error("Parent session changed; request fresh authorization before dispatch");
|
|
1163
|
-
}
|
|
1332
|
+
if (retiredSddAgent(request.agent.name)) throw new Error("Retired SDD agents cannot be dispatched.");
|
|
1164
1333
|
|
|
1165
1334
|
// Bounded live observation only. Native send owns the fresh policy decision;
|
|
1166
1335
|
// child execution never starts a telemetry policy process or renewal timer.
|
|
@@ -1170,8 +1339,16 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1170
1339
|
current: () => owner === metricsOwner && request.parentSessionId === activeSessionId() && runtimeMetricsEnvAllows(deps.env),
|
|
1171
1340
|
valid: () => !metrics.finished && metrics.current() };
|
|
1172
1341
|
const observe = runtimeMetricsEnvAllows(deps.env) && metricTasks.size < 256;
|
|
1342
|
+
let launched = false;
|
|
1343
|
+
let launchedTaskId: string | undefined;
|
|
1173
1344
|
const task = runner.run({ ...request, collectResponseObservations: false,
|
|
1174
|
-
onLaunch: () => {
|
|
1345
|
+
onLaunch: () => {
|
|
1346
|
+
metrics.launched = true;
|
|
1347
|
+
request.onLaunch?.();
|
|
1348
|
+
launched = true;
|
|
1349
|
+
const foreign = foreignRequests.get(request);
|
|
1350
|
+
if (foreign && launchedTaskId) foreignTasks.set(launchedTaskId, foreign);
|
|
1351
|
+
},
|
|
1175
1352
|
...(observe ? { canCollectResponseObservations: metrics.valid, prepareResponseObservations: async () => {
|
|
1176
1353
|
if (metrics.finished || owner !== metricsOwner || request.parentSessionId !== activeSessionId() || !runtimeMetricsEnvAllows(deps.env)) return false;
|
|
1177
1354
|
if (!metrics.valid()) return false;
|
|
@@ -1181,6 +1358,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1181
1358
|
} } : {}),
|
|
1182
1359
|
});
|
|
1183
1360
|
if (observe) metricTasks.set(task.id, metrics);
|
|
1361
|
+
launchedTaskId = task.id;
|
|
1362
|
+
const foreignRequest = foreignRequests.get(request);
|
|
1363
|
+
if (launched && foreignRequest) foreignTasks.set(task.id, foreignRequest);
|
|
1184
1364
|
ownedTaskIds.add(task.id);
|
|
1185
1365
|
store.subscribe(task.id, () => { publishActivity(); requestRender(); });
|
|
1186
1366
|
if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Retain that id; completion is pushed automatically. Never sleep or periodically poll subagent_status/subagent_result for completion or cache maintenance. Inspect status only at a real orchestration decision boundary; never relaunch equivalent queued/running work.`, taskDetails(task));
|
|
@@ -1202,10 +1382,17 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1202
1382
|
const query = await runner.waitForQuery(task.id);
|
|
1203
1383
|
if (query) {
|
|
1204
1384
|
const live = store.get(task.id) ?? task;
|
|
1205
|
-
|
|
1385
|
+
messages.consumeQuery(task.id, query.requestId);
|
|
1386
|
+
const questionSuffix = query.message ? `\n\nQuestion:\n${query.message}` : "";
|
|
1387
|
+
return text(
|
|
1388
|
+
`Subagent ${live.agent} is waiting for your reply to request ${query.requestId}.${questionSuffix}`,
|
|
1389
|
+
{ gentleAgents: { taskId: live.id, agent: live.agent, status: live.status, mode: live.mode, requestId: query.requestId, ...(query.message ? { question: query.message } : {}) } },
|
|
1390
|
+
true,
|
|
1391
|
+
);
|
|
1206
1392
|
}
|
|
1207
1393
|
const finished = await runner.waitFor(task.id);
|
|
1208
1394
|
completions.consume(finished.id);
|
|
1395
|
+
messages.invalidateTask(finished.id);
|
|
1209
1396
|
return text(finishedText(finished), taskDetails(finished));
|
|
1210
1397
|
} finally {
|
|
1211
1398
|
signal?.removeEventListener("abort", onAbort);
|
|
@@ -1220,12 +1407,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1220
1407
|
description,
|
|
1221
1408
|
parameters: { type: "object", additionalProperties: false, ...parameters } as never,
|
|
1222
1409
|
renderCall(args, theme) {
|
|
1410
|
+
// The result title belongs with the result body so a running poll
|
|
1411
|
+
// can hide both without changing the tool call or its model output.
|
|
1412
|
+
if (name === "result") return { render: () => [], invalidate() {} };
|
|
1223
1413
|
const params = args as { agent?: string; task_id?: string };
|
|
1224
1414
|
return new Text(theme.fg("toolTitle", `${AGENTS_GLYPH} agent ${name.replace(/_/g, " ")}${params.agent ? ` · ${params.agent}` : params.task_id ? ` · ${params.task_id}` : ""}`), 0, 0);
|
|
1225
1415
|
},
|
|
1226
1416
|
renderResult(result, options, theme) {
|
|
1417
|
+
const task = (result.details as { gentleAgents?: { status?: string; taskId?: string } } | undefined)?.gentleAgents;
|
|
1418
|
+
if (name === "result" && task?.status === TASK_STATUS.RUNNING) {
|
|
1419
|
+
return { render: () => [], invalidate() {} };
|
|
1420
|
+
}
|
|
1227
1421
|
const body = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
|
|
1228
|
-
|
|
1422
|
+
const visibleBody = options.expanded ? body : theme.fg("muted", body.split("\n")[0] ?? "");
|
|
1423
|
+
const title = `${AGENTS_GLYPH} agent result${task?.taskId ? ` · ${sanitizeTerminalText(task.taskId)}` : ""}`;
|
|
1424
|
+
return new Text(name === "result" ? `${theme.fg("toolTitle", title)}\n${visibleBody}` : visibleBody, 0, 0);
|
|
1229
1425
|
},
|
|
1230
1426
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1231
1427
|
return execute(params as Record<string, unknown>, ctx, signal);
|
|
@@ -1264,12 +1460,25 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1264
1460
|
name: "orchestrator_send_message",
|
|
1265
1461
|
label: "Send orchestrator message",
|
|
1266
1462
|
description: "Send a notification to another active session in this trusted local profile. If recipient_session_id is omitted, the sole peer is selected or the user selects one. Acceptance means enqueued, not read or completed.",
|
|
1267
|
-
parameters: {
|
|
1463
|
+
parameters: {
|
|
1464
|
+
type: "object",
|
|
1465
|
+
additionalProperties: false,
|
|
1466
|
+
required: ["message", "reason"],
|
|
1467
|
+
properties: {
|
|
1468
|
+
recipient_session_id: { type: "string" },
|
|
1469
|
+
message: { type: "string" },
|
|
1470
|
+
reason: { type: "string", minLength: MESSAGING_REASON_MIN_CHARACTERS, maxLength: MESSAGING_REASON_MAX_UTF8_BYTES, description: "Required concrete caller-supplied reason: at least 8 characters after trimming and at most 512 UTF-8 bytes." },
|
|
1471
|
+
},
|
|
1472
|
+
} as never,
|
|
1268
1473
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1269
1474
|
const transport = activeTransportFor(ctx);
|
|
1270
|
-
const input = params as { recipient_session_id?: unknown; message?: unknown };
|
|
1475
|
+
const input = params as { recipient_session_id?: unknown; message?: unknown; reason?: unknown };
|
|
1271
1476
|
if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" });
|
|
1272
|
-
|
|
1477
|
+
const message = input.message;
|
|
1478
|
+
if (typeof message !== "string" || Buffer.byteLength(message, "utf8") > 8192) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
|
|
1479
|
+
let reason: string;
|
|
1480
|
+
try { reason = normalizeMessagingReason(input.reason); }
|
|
1481
|
+
catch { return text("Error: recipient session ID or message is invalid; a concrete reason of at least 8 trimmed characters and at most 512 UTF-8 bytes is required.", { error: "invalid input" }); }
|
|
1273
1482
|
let recipient: string | undefined;
|
|
1274
1483
|
let activation: PresenceRecord | undefined;
|
|
1275
1484
|
if (input.recipient_session_id !== undefined) {
|
|
@@ -1301,7 +1510,13 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1301
1510
|
if (recipient === undefined || !validTransportSessionId(recipient)) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
|
|
1302
1511
|
if (recipient === transport.sessionId) return text("Error: cannot send a message to the active session.", { error: "self" });
|
|
1303
1512
|
try {
|
|
1304
|
-
|
|
1513
|
+
await messagingGrants.authorize(ctx, recipient, { message, reason, signal });
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
if (signal?.aborted) return text("Cross-orchestrator communication was cancelled.", { error: "cancelled" });
|
|
1516
|
+
return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "denied" });
|
|
1517
|
+
}
|
|
1518
|
+
try {
|
|
1519
|
+
const accepted = await transport.client.sendNotification(recipient, message, { signal, expectedActivation: activation, beforeConnect: () => activeTransportFor(ctx) === transport });
|
|
1305
1520
|
return activeTransportFor(ctx) === transport ? text(`Message ${accepted.id} from ${transport.sessionId} to ${recipient} accepted for delivery; it is not a delivery or read receipt.`, { gentleAgents: { messageId: accepted.id, senderSessionId: transport.sessionId, recipientSessionId: recipient, state: "accepted" } }) : text("Error: session messaging is not ready.", { error: "stale" });
|
|
1306
1521
|
} catch {
|
|
1307
1522
|
return text("Error: session message was not accepted.", { error: "not accepted" });
|
|
@@ -1326,13 +1541,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1326
1541
|
task: { type: "string", description: "What the subagent must do, self-contained." },
|
|
1327
1542
|
label: { type: "string", description: "Three to six words naming the work, shown on the agents card, e.g. 'map footer data sources'." },
|
|
1328
1543
|
context: { type: "string", description: "Optional extra context appended to the task." },
|
|
1329
|
-
workspace_root: { type: "string", description: "Optional
|
|
1330
|
-
|
|
1331
|
-
remediation: REMEDIATION_SCHEMA, sdd_change: { type: "object", additionalProperties: false, required: ["changeName", "workspaceRoot", "phase"], properties: { changeName: { type: "string" }, workspaceRoot: { type: "string" }, failedEvidenceRevision: { type: "string" }, phase: { type: "string", enum: ["apply", "verify", "archive", "remediate"] } }, description: "Launch-local selected SDD identity, accepted only by matching SDD phase agents." },
|
|
1544
|
+
workspace_root: { type: "string", description: "Optional canonical main or linked Git worktree within the parent's same clone only; mutually exclusive with repository_root." },
|
|
1545
|
+
repository_root: { type: "string", description: "Optional canonical independent Git repository; requires direct interactive session-scoped consent before queueing; mutually exclusive with workspace_root." },
|
|
1332
1546
|
mode: { type: "string", enum: ["task", "background"], description: "task waits for the result (default); background returns immediately." },
|
|
1333
1547
|
},
|
|
1334
1548
|
},
|
|
1335
1549
|
async (params, ctx, signal) => {
|
|
1550
|
+
if (typeof params.agent !== "string" || !params.agent.trim() || typeof params.task !== "string" || !params.task.trim() || (params.context !== undefined && typeof params.context !== "string") || (params.label !== undefined && typeof params.label !== "string")) throw new Error("Subagent dispatch requires a named agent, non-empty task and string context/label.");
|
|
1551
|
+
if (params.mode !== undefined && params.mode !== AGENT_MODE.TASK && params.mode !== AGENT_MODE.BACKGROUND) throw new Error("Subagent mode must be task or background.");
|
|
1552
|
+
// An empty selector names no destination: only real roots are mutually
|
|
1553
|
+
// exclusive, so a blank string must not masquerade as a second target.
|
|
1554
|
+
const hasWorkspaceRoot = Object.hasOwn(params, "workspace_root") && params.workspace_root !== "";
|
|
1555
|
+
const hasRepositoryRoot = Object.hasOwn(params, "repository_root") && params.repository_root !== "";
|
|
1556
|
+
if (hasRepositoryRoot && hasWorkspaceRoot) throw new Error("repository_root and workspace_root are mutually exclusive.");
|
|
1557
|
+
if ((Object.hasOwn(params, "repository_root") && typeof params.repository_root !== "string") || (Object.hasOwn(params, "workspace_root") && typeof params.workspace_root !== "string")) throw new Error("Root selectors must be strings.");
|
|
1558
|
+
if (Object.hasOwn(params, "sdd_change") || Object.hasOwn(params, "remediation") || Object.hasOwn(params, "research_selection") || retiredSddAgent(String(params.agent))) return text("Error: retired SDD delegation is not supported.", { error: "retired SDD delegation" });
|
|
1336
1559
|
const { agents } = discoverAgents(roots(ctx));
|
|
1337
1560
|
const agent = agents.find((candidate) => candidate.name === params.agent);
|
|
1338
1561
|
if (!agent) return text(`Error: no subagent named "${String(params.agent)}". Known: ${agents.map((candidate) => candidate.name).join(", ") || "none"}`, { error: "unknown agent" });
|
|
@@ -1341,10 +1564,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1341
1564
|
policy: resolveBackgroundSubagentsPolicy(ctx.cwd).policy,
|
|
1342
1565
|
parentMode: ctx.mode,
|
|
1343
1566
|
});
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
return launch(ctx, buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, typeof params.workspace_root === "string" ? params.workspace_root : undefined, sddChange, params.research_selection, params.remediation), signal);
|
|
1567
|
+
const workspaceRoot = typeof params.workspace_root === "string" && params.workspace_root !== "" ? params.workspace_root : undefined;
|
|
1568
|
+
const repositoryRoot = typeof params.repository_root === "string" && params.repository_root !== "" ? params.repository_root : undefined;
|
|
1569
|
+
return launch(ctx, await buildRequest(ctx, agent, String(params.task ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, undefined, workspaceRoot, signal, repositoryRoot), signal);
|
|
1348
1570
|
},
|
|
1349
1571
|
);
|
|
1350
1572
|
|
|
@@ -1358,7 +1580,10 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1358
1580
|
if (!task) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
|
|
1359
1581
|
// The parent just pulled a finished result; its pending completion must
|
|
1360
1582
|
// never be replayed on top of it.
|
|
1361
|
-
if (isFinished(task.status))
|
|
1583
|
+
if (isFinished(task.status)) {
|
|
1584
|
+
completions.consume(task.id);
|
|
1585
|
+
messages.invalidateTask(task.id);
|
|
1586
|
+
}
|
|
1362
1587
|
return text(isFinished(task.status) ? finishedText(task) : `Task ${task.id} is still ${task.status} (last: ${task.lastStep}).`, taskDetails(task));
|
|
1363
1588
|
});
|
|
1364
1589
|
|
|
@@ -1368,12 +1593,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1368
1593
|
});
|
|
1369
1594
|
|
|
1370
1595
|
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) => {
|
|
1371
|
-
const
|
|
1596
|
+
const taskId = String(params.task_id);
|
|
1597
|
+
const requestId = String(params.request_id);
|
|
1598
|
+
const accepted = await runner.reply(taskId, requestId, typeof params.message === "string" ? params.message : "", ctx.sessionManager.getSessionId() ?? "");
|
|
1372
1599
|
return accepted ? text("Reply accepted for delivery.") : text("Error: query is unavailable.", { error: "query unavailable" });
|
|
1373
1600
|
});
|
|
1374
1601
|
|
|
1375
1602
|
tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
|
|
1376
1603
|
const id = String(params.task_id);
|
|
1604
|
+
messages.invalidateTask(id);
|
|
1377
1605
|
return runner.cancel(id, "cancelled by the cancel tool") ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
|
|
1378
1606
|
});
|
|
1379
1607
|
|
|
@@ -1385,23 +1613,22 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1385
1613
|
tool(
|
|
1386
1614
|
"continue",
|
|
1387
1615
|
"Resume a finished subagent task in its own session with a follow-up prompt.",
|
|
1388
|
-
{ required: ["task_id", "prompt"], properties: {
|
|
1616
|
+
{ required: ["task_id", "prompt"], properties: { task_id: { type: "string" }, prompt: { type: "string" }, label: { type: "string", description: "Three to six words naming the follow-up." }, mode: { type: "string", enum: ["task", "background"] } } },
|
|
1389
1617
|
async (params, ctx, signal) => {
|
|
1618
|
+
if (Object.hasOwn(params, "sdd_change") || Object.hasOwn(params, "remediation") || Object.hasOwn(params, "research_selection")) return text("Error: retired SDD delegation is not supported.", { error: "retired SDD delegation" });
|
|
1390
1619
|
const previous = await resolveTask(String(params.task_id));
|
|
1391
1620
|
if (!previous) return text(`Error: no task ${String(params.task_id)}`, { error: "unknown task" });
|
|
1621
|
+
if (retiredSddAgent(previous.agent)) return text("Error: retired SDD agents cannot be continued.", { error: "retired SDD delegation" });
|
|
1392
1622
|
if (!isFinished(previous.status) || !previous.sessionPath) return text(`Error: task ${previous.id} cannot be continued yet (${previous.status}).`, { error: "not continuable" });
|
|
1393
1623
|
// Continuing acts on the previous result, so any pending completion for
|
|
1394
1624
|
// it is already consumed by the parent.
|
|
1395
1625
|
completions.consume(previous.id);
|
|
1626
|
+
messages.invalidateTask(previous.id);
|
|
1396
1627
|
const agent = discoverAgents(roots(ctx)).agents.find((candidate) => candidate.name === previous.agent);
|
|
1397
1628
|
if (!agent) return text(`Error: subagent "${previous.agent}" is no longer defined.`, { error: "unknown agent" });
|
|
1398
1629
|
const mode = (params.mode as AgentMode | undefined) ?? (previous.mode as AgentMode);
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
|
|
1402
|
-
if (sddPhaseForAgent(agent.name) && !sddChange) return text("Error: continuing an SDD phase agent requires a fresh sdd_change selection.", { error: "missing sdd_change" });
|
|
1403
|
-
|
|
1404
|
-
return launch(ctx, buildRequest(ctx, agent, String(params.prompt ?? ""), typeof params.label === "string" ? params.label : undefined, previous.sddPreflightContext, mode, previous.sessionPath, sddChange?.workspaceRoot ?? previous.cwd, sddChange, params.research_selection, params.remediation), signal);
|
|
1630
|
+
const foreignContinuation = foreignTasks.has(previous.id);
|
|
1631
|
+
return launch(ctx, await buildRequest(ctx, agent, String(params.prompt ?? ""), typeof params.label === "string" ? params.label : undefined, typeof params.context === "string" ? params.context : undefined, mode, previous.sessionPath, foreignContinuation ? undefined : previous.cwd, signal, foreignContinuation ? previous.cwd : undefined), signal);
|
|
1405
1632
|
},
|
|
1406
1633
|
);
|
|
1407
1634
|
|
|
@@ -1437,6 +1664,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1437
1664
|
// A resumed, reloaded, or replaced session starts with an empty completion
|
|
1438
1665
|
// queue so nothing pending from another session can replay here.
|
|
1439
1666
|
completions.dropAll();
|
|
1667
|
+
messages.dropAll();
|
|
1668
|
+
resetParentDelivery(ctx);
|
|
1440
1669
|
presence?.dispose();
|
|
1441
1670
|
registryFor(ctx);
|
|
1442
1671
|
showWidget(ctx);
|
|
@@ -1484,7 +1713,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1484
1713
|
});
|
|
1485
1714
|
pi.on("session_shutdown", async () => {
|
|
1486
1715
|
completions.dropAll();
|
|
1716
|
+
messages.dropAll();
|
|
1487
1717
|
activeAgentRuns = 0;
|
|
1718
|
+
resetParentDelivery(undefined);
|
|
1488
1719
|
presence?.dispose();
|
|
1489
1720
|
presence = undefined;
|
|
1490
1721
|
rpcActivityPublisher?.stop();
|
|
@@ -1493,9 +1724,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1493
1724
|
for (const view of overlays) { view.handleInput("q"); view.dispose(); }
|
|
1494
1725
|
overlays.clear();
|
|
1495
1726
|
sessions = undefined;
|
|
1727
|
+
stopVisualUpdates?.();
|
|
1728
|
+
stopVisualUpdates = undefined;
|
|
1496
1729
|
sidebarTui = undefined;
|
|
1497
1730
|
worktrees?.close();
|
|
1498
1731
|
worktrees = undefined;
|
|
1732
|
+
worktreeManager = undefined;
|
|
1499
1733
|
const stopped = shutdownSessionTransport();
|
|
1500
1734
|
runner.cancelAll("cancelled: parent session shut down");
|
|
1501
1735
|
await stopped;
|