gentle-pi 3.7.0 → 4.1.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 +155 -10
- package/assets/agents/gentle-ai-explore.md +13 -6
- package/assets/agents/gentle-ai-verify.md +27 -6
- package/assets/agents/gentle-ai-worker.md +13 -15
- package/assets/install-wizard/index.html +47 -0
- package/assets/install-wizard/wizard.css +1279 -0
- package/assets/install-wizard/wizard.js +951 -0
- package/assets/orchestrator-delegation.md +75 -169
- package/assets/orchestrator-memory.md +12 -26
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator-tracking.md +53 -0
- package/assets/orchestrator-verification.md +28 -0
- package/assets/orchestrator-writer.md +38 -0
- package/assets/orchestrator.md +30 -38
- package/assets/sounds/LICENSE.md +52 -0
- package/assets/sounds/attention.wav +0 -0
- package/assets/sounds/error.wav +0 -0
- package/assets/sounds/success.wav +0 -0
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +956 -50
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/bundled-install.md +397 -0
- package/docs/delegated-verification.md +27 -4
- package/docs/gentle-agents-activity.md +537 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +165 -51
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +3 -3
- package/docs/permission-modes.md +82 -0
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +364 -231
- package/docs/render-cache-performance.md +28 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +35 -0
- package/extensions/child-safety.ts +25 -0
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +1093 -326
- package/extensions/gentle-ai.ts +1636 -890
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +1619 -136
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +126 -20
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1424 -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/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +254 -92
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +191 -58
- package/extensions/startup-banner.ts +71 -33
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +99 -0
- package/lib/agents-runner.ts +180 -107
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/auto-permission-classifier.ts +280 -0
- package/lib/auto-permission-runtime.ts +268 -0
- package/lib/background-jobs-tools.ts +202 -0
- package/lib/background-jobs.ts +253 -0
- package/lib/background-monitor.ts +154 -0
- package/lib/bounded-writer-admission.ts +220 -0
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +195 -0
- package/lib/command-palette-catalog.ts +5 -10
- package/lib/command-palette.ts +25 -14
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-bundled.ts +517 -0
- package/lib/gentle-shell-launcher.ts +89 -27
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/gentle-shell-startup-flow.ts +54 -0
- package/lib/gentle-shell-startup-update.ts +92 -0
- package/lib/gentle-shell-uninstall.ts +840 -0
- package/lib/gentle-shell-update-panel.ts +231 -0
- package/lib/herdr-activity.ts +183 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/inprocess-reviewer.ts +3 -0
- package/lib/jobs-sidebar-state.ts +20 -0
- package/lib/jobs-view.ts +161 -0
- package/lib/model-price-ratio.ts +83 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +249 -0
- package/lib/native-review-cli.ts +242 -180
- package/lib/notification-audio-native.ts +162 -0
- package/lib/notification-audio-windows.ts +399 -0
- package/lib/notification-audio.ts +332 -0
- package/lib/notification-customize.ts +241 -0
- package/lib/notification-events.ts +113 -0
- package/lib/notification-policy.ts +148 -0
- package/lib/notification-pulse-client.ts +367 -0
- package/lib/notification-pulse-protocol.ts +305 -0
- package/lib/notification-pulse-stream.ts +213 -0
- package/lib/notification-pulse-worker.ts +66 -0
- package/lib/notification-scheduler.ts +173 -0
- package/lib/notification-service.ts +202 -0
- package/lib/notification-ui.ts +87 -0
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/orchestrator-alias.ts +20 -0
- package/lib/orchestrator-catalog.ts +79 -0
- package/lib/orchestrator-consultation.ts +61 -0
- package/lib/orchestrator-discovery.ts +49 -0
- package/lib/orchestrator-helper-consent.ts +122 -0
- package/lib/orchestrator-helper.ts +167 -0
- package/lib/orchestrator-message-card.ts +105 -0
- package/lib/orchestrator-presence.ts +115 -14
- package/lib/orchestrator-scope.ts +80 -0
- package/lib/orchestrator-state.ts +107 -0
- package/lib/orchestrator-tool-card.ts +280 -0
- package/lib/orchestrator-work-search.ts +218 -0
- package/lib/orchestrator-work.ts +79 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +73 -21
- package/lib/review-candidate-view.ts +115 -27
- package/lib/review-compact-contract.ts +2 -2
- package/lib/review-host-relay.ts +7 -2
- package/lib/review-integration-v2.ts +100 -22
- package/lib/review-publication-gate.ts +1 -1
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +227 -56
- package/lib/review-sidebar-state.ts +224 -0
- package/lib/review-transaction.ts +72 -3
- package/lib/runtime-metrics-children.ts +1 -1
- package/lib/runtime-metrics-native.ts +3 -2
- package/lib/selection-engine.ts +527 -0
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +266 -0
- package/lib/session-worktree-registry.ts +87 -9
- package/lib/shell-bar.ts +215 -30
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +42 -14
- package/lib/shell-changes.ts +86 -71
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +83 -15
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +84 -31
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +70 -13
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +383 -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 +119 -0
- package/lib/visual-customize-view.ts +481 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +317 -0
- package/package.json +20 -8
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +518 -0
- package/runtime/gentle-shell-launcher.mjs +87 -25
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/gentle-shell-startup-flow.mjs +55 -0
- package/runtime/gentle-shell-startup-update.mjs +93 -0
- package/runtime/gentle-shell-uninstall.mjs +841 -0
- package/runtime/gentle-shell-update-panel.mjs +232 -0
- package/runtime/native-review-cli.mjs +239 -177
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +225 -54
- package/runtime/status-timing-diagnostics.mjs +202 -0
- package/runtime/telemetry-trigger.mjs +3 -1
- package/scripts/bootstrap.cmd +255 -0
- package/scripts/bootstrap.sh +168 -0
- package/scripts/build-distribution.mjs +157 -0
- package/scripts/build-installer-bundles.mjs +136 -0
- package/scripts/build-runtime-modules.mjs +8 -0
- package/scripts/bundled-install.mjs +1308 -0
- package/scripts/bundled-wizard.mjs +399 -0
- package/scripts/check-types.mjs +4 -6
- package/scripts/gentle-ai-installer.mjs +83 -35
- package/scripts/install-gentle-ai.mjs +48 -1
- package/scripts/install-tui-mode-setting.mjs +10 -1
- package/scripts/install-wizard-preview.mjs +154 -0
- package/scripts/installer-downloads.mjs +648 -0
- package/scripts/installer-preflight.mjs +387 -0
- package/scripts/installer-probes.mjs +564 -0
- package/scripts/installer-runner.mjs +1102 -0
- package/scripts/installer-server.mjs +881 -0
- package/scripts/installer-windows-artifacts.json +15 -0
- package/scripts/installer-windows.mjs +752 -0
- package/scripts/main-channel.mjs +452 -0
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +90 -0
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +54 -19
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +91 -38
- 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 +16 -21
- 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-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +30 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +97 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +467 -93
- 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/auto-mode-invariants.test.ts +258 -0
- package/tests/auto-mode-sdk.test.ts +57 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-classifier.test.ts +283 -0
- package/tests/auto-permission-runtime.test.ts +196 -0
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/background-cache-warming.test.ts +2 -1
- package/tests/background-jobs.test.ts +227 -0
- package/tests/background-monitor.test.ts +195 -0
- package/tests/background-subagents-default-mode.test.ts +28 -5
- package/tests/bounded-stop-contract.test.ts +85 -0
- package/tests/bounded-writer-admission.test.ts +146 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/bridge-wake-lifecycle.test.ts +129 -0
- package/tests/bridge-wake-rendering.test.ts +56 -0
- package/tests/build-distribution.test.ts +269 -0
- package/tests/builtin-codemode-optout.test.ts +242 -0
- package/tests/bundled-install.test.ts +1319 -0
- package/tests/bundled-wizard.test.ts +505 -0
- package/tests/card-content-policy.test.ts +68 -0
- package/tests/card-style-policy.test.ts +87 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-package-entrypoints.test.ts +65 -0
- package/tests/child-package-injection.test.ts +74 -0
- package/tests/child-safety.test.ts +94 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +596 -0
- package/tests/command-palette.test.ts +43 -7
- package/tests/context-threshold.test.ts +31 -0
- package/tests/delegate-for-reason-contract.test.ts +327 -0
- package/tests/delegated-key-learnings-contract.test.ts +3 -78
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +171 -3
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +288 -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/generic-agent-tools.test.ts +69 -0
- package/tests/gentle-agents.test.ts +2804 -346
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +21 -4
- package/tests/gentle-ai-child-guards.test.ts +202 -0
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +566 -62
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +493 -14
- package/tests/gentle-ai.test.ts +1427 -236
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +896 -115
- package/tests/gentle-shell-bundled.test.ts +1002 -0
- package/tests/gentle-shell-launcher.test.ts +333 -65
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell-startup-flow.test.ts +75 -0
- package/tests/gentle-shell-startup-update.test.ts +85 -0
- package/tests/gentle-shell-uninstall.test.ts +978 -0
- package/tests/gentle-shell-update-panel.test.ts +158 -0
- package/tests/gentle-shell.test.ts +4216 -119
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +333 -10
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-child-guard.test.ts +70 -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 +49 -19
- package/tests/install-gentle-ai-failure.test.ts +69 -0
- package/tests/install-tui-mode-setting.test.ts +15 -0
- package/tests/install-wizard.test.ts +1223 -0
- package/tests/installer-bundles.test.ts +122 -0
- package/tests/installer-posix-bootstrap.test.ts +1201 -0
- package/tests/installer-preflight.test.ts +750 -0
- package/tests/installer-probes.test.ts +1106 -0
- package/tests/installer-runner.test.ts +2158 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +389 -0
- package/tests/main-channel.test.ts +344 -0
- package/tests/maintainer/provider-relay.maintest.ts +13 -12
- package/tests/model-price-ratio.test.ts +116 -0
- package/tests/model-profiles-preservation.test.ts +153 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/model-routing-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +481 -0
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +13 -1
- package/tests/native-review-cli.test.ts +239 -118
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +101 -4
- package/tests/native-review-parity.test.ts +12 -48
- package/tests/native-risk-evidence-parity.test.ts +21 -0
- package/tests/no-self-pause-contract.test.ts +87 -0
- package/tests/notification-audio-native.test.ts +281 -0
- package/tests/notification-audio-windows.test.ts +581 -0
- package/tests/notification-audio.test.ts +548 -0
- package/tests/notification-customize.test.ts +655 -0
- package/tests/notification-events.test.ts +115 -0
- package/tests/notification-policy.test.ts +201 -0
- package/tests/notification-pulse-client.test.ts +356 -0
- package/tests/notification-pulse-protocol.test.ts +213 -0
- package/tests/notification-pulse-stream.test.ts +320 -0
- package/tests/notification-scheduler.test.ts +258 -0
- package/tests/notification-ui.test.ts +157 -0
- package/tests/npm-pack-result.test.ts +41 -0
- package/tests/odd-integration.test.ts +76 -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 +60 -14
- package/tests/odd-routing-contract.test.ts +158 -50
- package/tests/orchestrator-budget.test.ts +43 -45
- package/tests/orchestrator-catalog.test.ts +146 -0
- package/tests/orchestrator-consultation-sdk.test.ts +614 -0
- package/tests/orchestrator-consultation.test.ts +111 -0
- package/tests/orchestrator-discovery.test.ts +241 -0
- package/tests/orchestrator-helper-consent.test.ts +171 -0
- package/tests/orchestrator-helper.test.ts +297 -0
- package/tests/orchestrator-message-card.test.ts +190 -0
- package/tests/orchestrator-presence.test.ts +23 -0
- package/tests/orchestrator-rdd-ownership.test.ts +8 -7
- package/tests/orchestrator-scope.test.ts +107 -0
- package/tests/orchestrator-state.test.ts +114 -0
- package/tests/orchestrator-status-publication-contract.test.ts +34 -0
- package/tests/orchestrator-tool-card.test.ts +398 -0
- package/tests/orchestrator-work-search.test.ts +318 -0
- package/tests/orchestrator-work.test.ts +147 -0
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +344 -123
- package/tests/packed-runner-owned-path.test.ts +80 -0
- package/tests/parallel-reason-contract.test.ts +21 -0
- package/tests/parallel-writer-admission.test.ts +210 -0
- package/tests/permission-mode-policy.test.ts +194 -0
- package/tests/persona-single-channel.test.ts +10 -18
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/provider-defect-handoff.test.ts +7 -14
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +569 -187
- package/tests/rdd-aware-verification-contract.test.ts +103 -40
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +237 -21
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +399 -12
- package/tests/review-contract-prompt.test.ts +138 -44
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +866 -6
- package/tests/review-controller-workspace-root.test.ts +206 -6
- package/tests/review-controller.test.ts +2 -2
- package/tests/review-correction-lifecycle.test.ts +2 -1
- package/tests/review-gate.test.ts +7 -4
- package/tests/review-graph-schema.test.ts +2 -2
- package/tests/review-host-relay-routing.test.ts +342 -4
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +221 -1
- package/tests/review-json-arguments.test.ts +167 -0
- package/tests/review-last-event-closure.test.ts +157 -8
- package/tests/review-ledger-contract.test.ts +14 -37
- package/tests/review-policy-judgment-day.test.ts +1 -1
- package/tests/review-policy-ordinary.test.ts +2 -2
- package/tests/review-relay-transport-agent.test.ts +66 -1
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +594 -26
- package/tests/review-session-standing-permission-controller.test.ts +16 -17
- package/tests/review-session-standing-permission-ipc.test.ts +6 -5
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +202 -0
- package/tests/runtime-harness.mjs +186 -800
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +451 -0
- package/tests/session-change-capture.test.ts +10 -10
- package/tests/session-changes-shell.test.ts +242 -48
- package/tests/session-changes.test.ts +17 -24
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +381 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +134 -2
- package/tests/shell-bar.test.ts +422 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +135 -32
- package/tests/shell-changes.test.ts +61 -62
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-render-cache-runtime.test.ts +69 -0
- package/tests/shell-render-cache.test.ts +129 -0
- package/tests/shell-sidebar-layout.test.ts +351 -22
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +207 -3
- package/tests/shell-usage-view.test.ts +28 -1
- package/tests/shell-usage.test.ts +108 -0
- package/tests/short-check-routing-contract.test.ts +49 -0
- package/tests/skill-registry-watcher.test.ts +119 -0
- package/tests/skill-registry.test.ts +357 -2
- package/tests/startup-banner.test.ts +246 -4
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/status-timing-diagnostics.test.ts +283 -0
- package/tests/support/orchestrator-modules.ts +19 -0
- package/tests/task-size-routing-contract.test.ts +241 -0
- package/tests/telemetry-trigger.test.ts +107 -20
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/theme-customization.test.ts +72 -0
- package/tests/verify-agent-contract.test.ts +90 -0
- package/tests/verify-first-launch-contract.test.ts +139 -0
- package/tests/verify-package-files.test.ts +56 -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 +139 -0
- package/tests/visual-customize-view.test.ts +696 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +271 -0
- package/tests/yolo-mode-runtime.test.ts +188 -0
- package/tests/yolo-mode.test.ts +289 -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
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -1,27 +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";
|
|
3
|
+
import { Type } from "typebox";
|
|
6
4
|
import { recordReviewMutation } from "../lib/review-reminder-receipt.ts";
|
|
7
5
|
import { SESSION_CHANGE_RELAY } from "../lib/session-changes.ts";
|
|
8
6
|
import { publishForeignSessionChange } from "../lib/session-change-capture.ts";
|
|
9
|
-
import { SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
7
|
+
import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED, SessionWorktreeRegistry, resolveSessionWorktree, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
|
|
8
|
+
import { canonicalWriterRoot } from "../lib/writer-surfaces.ts";
|
|
10
9
|
import { ForeignTargetGrants } from "../lib/foreign-target-grants.ts";
|
|
11
|
-
import {
|
|
10
|
+
import { MESSAGING_REASON_MAX_UTF8_BYTES, MESSAGING_REASON_MIN_CHARACTERS, normalizeMessagingReason, SessionMessagingGrants } from "../lib/session-messaging-grants.ts";
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
14
14
|
import os from "node:os";
|
|
15
|
-
import { join, resolve, isAbsolute
|
|
16
|
-
import {
|
|
15
|
+
import { join, resolve, isAbsolute } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { createLocalBashOperations, keyHint, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { createJobRegistry, isStandaloneSleep, type JobExecOperations, type JobRecord } from "../lib/background-jobs.ts";
|
|
19
|
+
import { JOB_GLYPH, JOB_NOTICE_TYPE, jobDetails, jobNoticeText, monitorEventsText, monitorStoppedText, registerBackgroundJobTools } from "../lib/background-jobs-tools.ts";
|
|
20
|
+
import { createMonitorController, mergeMonitorBatches, type MonitorNotice } from "../lib/background-monitor.ts";
|
|
21
|
+
import { JobsView } from "../lib/jobs-view.ts";
|
|
22
|
+
import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY } from "../lib/jobs-sidebar-state.ts";
|
|
17
23
|
import { Text, type TUI } from "@earendil-works/pi-tui";
|
|
18
24
|
import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
|
|
25
|
+
import { VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts";
|
|
26
|
+
import { resolveVisualSettings } from "../lib/visual-customization-policy.ts";
|
|
19
27
|
import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
|
|
20
|
-
import {
|
|
28
|
+
import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts";
|
|
29
|
+
import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
|
|
30
|
+
import { readSessionProfileBinding, sessionOrPinModelProfiles } from "../lib/session-profile-binding.ts";
|
|
21
31
|
import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
|
|
22
32
|
import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
|
|
23
|
-
import { isFinished, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
24
|
-
import { AgentRunner, piCommand, abortReasonText,
|
|
33
|
+
import { isFinished, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
34
|
+
import { AgentRunner, piCommand, abortReasonText, REQUESTED_TOOLS_ENV, type AskAnswer, type RunnerDeps, type TaskRequest } from "../lib/agents-runner.ts";
|
|
35
|
+
import { parseChildPackageInjection } from "../lib/child-package-injection.ts";
|
|
25
36
|
import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
|
|
26
37
|
import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry, type PresenceRecord, type ReceivedNotification, type SentNotification, type SessionPresenceCandidate } from "../lib/agents-session-transport.ts";
|
|
27
38
|
import { WindowsActiveSessionClient, WindowsActiveSessionListener, WindowsSessionPresenceRegistry, type WindowsSessionRegistryPhaseObserver } from "../lib/windows-session-transport.ts";
|
|
@@ -30,18 +41,31 @@ import { inheritedUnsafeGitEnvironmentKeys } from "../lib/review-repository.ts";
|
|
|
30
41
|
import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
|
|
31
42
|
import { sessionToMarkdown } from "../lib/agents-transcript.ts";
|
|
32
43
|
import { AgentsView } from "../lib/agents-view.ts";
|
|
33
|
-
import {
|
|
44
|
+
import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
|
|
45
|
+
import { PresencePublisher, sanitizeDisplayLabel, listPresence, sessionHash } from "../lib/orchestrator-presence.ts";
|
|
46
|
+
import { incomingMessageCard, outgoingMessageCall, outgoingMessageResult, type OrchestratorMessageDetails } from "../lib/orchestrator-message-card.ts";
|
|
47
|
+
import { orchestratorToolRenderers } from "../lib/orchestrator-tool-card.ts";
|
|
48
|
+
import { discoverOrchestrators } from "../lib/orchestrator-discovery.ts";
|
|
49
|
+
import { consultPublishedMetadata, unavailableMetadata, type MetadataReceipt } from "../lib/orchestrator-consultation.ts";
|
|
50
|
+
import { HelperCostPermission } from "../lib/orchestrator-helper-consent.ts";
|
|
51
|
+
import { OrchestratorStateCache } from "../lib/orchestrator-state.ts";
|
|
52
|
+
import { normalizeTaskSubject } from "../lib/orchestrator-alias.ts";
|
|
53
|
+
import { decodeWorkDescriptor } from "../lib/orchestrator-work.ts";
|
|
54
|
+
import { validateWorkFilter, searchPublishedWork } from "../lib/orchestrator-work-search.ts";
|
|
55
|
+
import { OrchestratorScopeCache, type RepositoryFact } from "../lib/orchestrator-scope.ts";
|
|
34
56
|
import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts";
|
|
35
57
|
import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
|
|
36
58
|
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
37
59
|
import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts";
|
|
38
60
|
import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
|
|
39
61
|
import { openInExternalEditor } from "./gentle-shell.ts";
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
62
|
+
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
63
|
+
import { resolveAgentHomeDirectory } from "../lib/agent-model-resolution.ts";
|
|
64
|
+
import { resolveProfilePin, resolveUnversionedProjectProfile } from "../lib/agent-profile-pin.ts";
|
|
65
|
+
import { allowedEditSurfaces, inheritAllowedEditSurfaces, isBoundedWriter, isDevelopmentSurface, isGenericBoundedWriter, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts";
|
|
43
66
|
import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
|
|
44
67
|
import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
|
|
68
|
+
import { publishTaskStatusChanges } from "../lib/notification-events.ts";
|
|
45
69
|
|
|
46
70
|
// Gentle Agents: subagents as isolated `pi --mode rpc` children, a task
|
|
47
71
|
// store that notifies per task, and a Gentle Shell card above the editor.
|
|
@@ -50,132 +74,48 @@ import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/r
|
|
|
50
74
|
|
|
51
75
|
export const AGENTS_WIDGET_KEY = "gentle-agents";
|
|
52
76
|
export const AGENTS_COMMAND_NAME = "gentle:agents";
|
|
77
|
+
export const JOBS_COMMAND_NAME = "gentle:jobs";
|
|
78
|
+
// How long session shutdown waits for stopped jobs to close their logs.
|
|
79
|
+
const JOBS_SHUTDOWN_GRACE_MS = 2000;
|
|
53
80
|
export const AGENTS_RESULT_TYPE = "gentle-agents.result";
|
|
54
81
|
export const AGENTS_MESSAGE_TYPE = "gentle-agents.message";
|
|
55
82
|
export const AGENTS_ORCHESTRATOR_MESSAGE_TYPE = "gentle-agents.orchestrator-message";
|
|
83
|
+
|
|
84
|
+
// Display labels are best-effort local snapshots, never routing or authority.
|
|
85
|
+
function messagePeerLabel(profile: string, sessionId: string): string | undefined {
|
|
86
|
+
try {
|
|
87
|
+
const page = listPresence(profile);
|
|
88
|
+
if (page.unavailable || page.overflow || page.rejected) return undefined;
|
|
89
|
+
const matches = page.entries.filter(header => header.sessionHash === sessionHash(sessionId));
|
|
90
|
+
return matches.length === 1 && matches[0].recent ? sanitizeDisplayLabel(matches[0].label) || undefined : undefined;
|
|
91
|
+
} catch { return undefined; }
|
|
92
|
+
}
|
|
56
93
|
export const AGENTS_STALE_RESULT_TYPE = "gentle-agents.stale-result";
|
|
94
|
+
export const AGENTS_STALE_NOTICE_TYPE = "gentle-agents.stale-notice";
|
|
57
95
|
const RENDER_COALESCE_MS = 400;
|
|
58
96
|
const CLOCK_TICK_MS = 1000;
|
|
59
97
|
const TOOL_PREFIX = "subagent_";
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
function sddPhaseForAgent(name: string): SddChangeSelection["phase"] | undefined {
|
|
70
|
-
return Object.hasOwn(SDD_PHASE_BY_AGENT, name) ? SDD_PHASE_BY_AGENT[name as keyof typeof SDD_PHASE_BY_AGENT] : undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function parseSddChange(value: unknown, agentName: string): SddChangeSelection | undefined {
|
|
74
|
-
if (value === undefined) return undefined;
|
|
75
|
-
const expectedPhase = sddPhaseForAgent(agentName);
|
|
76
|
-
if (!expectedPhase) throw new Error("sdd_change is allowed only for SDD apply, verify, or archive agents.");
|
|
77
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("sdd_change must be an object with changeName, workspaceRoot, and phase.");
|
|
78
|
-
const selection = value as Record<string, unknown>;
|
|
79
|
-
const keys = Object.keys(selection).sort();
|
|
80
|
-
if (keys.join(",") !== (expectedPhase === "remediate" ? "changeName,failedEvidenceRevision,phase,workspaceRoot" : "changeName,phase,workspaceRoot") ||
|
|
81
|
-
typeof selection.changeName !== "string" || selection.changeName.length === 0 ||
|
|
82
|
-
typeof selection.workspaceRoot !== "string" || selection.workspaceRoot.length === 0 ||
|
|
83
|
-
selection.phase !== expectedPhase) {
|
|
84
|
-
throw new Error("sdd_change must contain only a non-empty changeName, workspaceRoot, and the agent's matching phase.");
|
|
85
|
-
}
|
|
86
|
-
if (expectedPhase === "remediate" && (typeof selection.failedEvidenceRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(selection.failedEvidenceRevision))) throw new Error("Invalid remediation revision");
|
|
87
|
-
return { changeName: selection.changeName, workspaceRoot: selection.workspaceRoot, phase: expectedPhase, ...(expectedPhase === "remediate" ? { failedEvidenceRevision: selection.failedEvidenceRevision as string } : {}) };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const REMEDIATION_SCHEMA = {
|
|
91
|
-
type: "object", additionalProperties: false, required: ["plan"],
|
|
92
|
-
properties: {
|
|
93
|
-
plan: { type: "object", additionalProperties: false, required: ["cwd", "commands", "runtimeHarness", "rollback"], properties: {
|
|
94
|
-
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" } },
|
|
95
|
-
runtimeHarness: { type: "object", additionalProperties: false, description: "Exactly one concrete command or prior concrete naReason containing because.", properties: { command: { type: "string" }, naReason: { type: "string" } } },
|
|
96
|
-
rollback: { type: "object", additionalProperties: false, required: ["boundary", "command"], properties: { boundary: { type: "string" }, command: { type: "string" } } },
|
|
97
|
-
} },
|
|
98
|
-
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
export async function confirmRemediationScope(plan: RemediationPlan, action: Record<string, unknown>, context?: Pick<ExtensionContext, "hasUI" | "ui">): Promise<RemediationScope> {
|
|
103
|
-
const cwd = plan.cwd, roots = action.allowedEditRoots;
|
|
104
|
-
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");
|
|
105
|
-
const inside = (path: string, root: string) => path === root || path.startsWith(`${root}${sep}`);
|
|
106
|
-
const editPaths = plan.editPaths ?? [];
|
|
107
|
-
if (!Array.isArray(editPaths) || editPaths.length > 32 || new Set(editPaths).size !== editPaths.length) throw new Error("Ambiguous remediation paths");
|
|
108
|
-
for (const path of editPaths) {
|
|
109
|
-
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");
|
|
110
|
-
if (existsSync(path) && !lstatSync(path).isFile()) throw new Error("Remediation requires exact file paths, not directories");
|
|
111
|
-
}
|
|
112
|
-
const scope: RemediationScope = { cwd, editPaths: [...editPaths], commands: plannedCommands(plan), allowedEditRoots: [...roots] };
|
|
113
|
-
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");
|
|
114
|
-
if (realpathSync(cwd) !== cwd || editPaths.some(path => canonicalArtifactPath(path) !== path)) throw new Error("Remediation scope changed during authorization");
|
|
115
|
-
return scope;
|
|
116
|
-
}
|
|
117
|
-
export function remediationToolAllowed(scope: RemediationScope | undefined, cwd: string, tool: string, input: Record<string, unknown>): boolean {
|
|
118
|
-
try {
|
|
119
|
-
if (!scope || scope.cwd !== cwd || canonicalArtifactPath(cwd) !== cwd) return false;
|
|
120
|
-
if (tool === "bash") return typeof input.command === "string" && scope.commands.includes(input.command);
|
|
121
|
-
if (["edit", "write"].includes(tool)) {
|
|
122
|
-
if (typeof input.path !== "string") return false;
|
|
123
|
-
const path = resolve(cwd, input.path);
|
|
124
|
-
return scope.editPaths.includes(path) && canonicalArtifactPath(path) === path && scope.allowedEditRoots.some(root => path === root || path.startsWith(`${root}${sep}`));
|
|
125
|
-
}
|
|
126
|
-
if (["read", "grep", "find"].includes(tool)) {
|
|
127
|
-
if (input.path === undefined) return true;
|
|
128
|
-
if (typeof input.path !== "string" || !input.path || isAbsolute(input.path)) return false;
|
|
129
|
-
const path = resolve(cwd, input.path);
|
|
130
|
-
return canonicalArtifactPath(path) === path && (path === cwd || path.startsWith(`${cwd}${sep}`));
|
|
131
|
-
}
|
|
132
|
-
return tool === "subagent_parent_message";
|
|
133
|
-
} catch { return false; }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export async function admitManagedRemediation(request: TaskRequest, input: unknown, native: NativeReviewCli, context?: Pick<ExtensionContext, "hasUI" | "ui">): Promise<Partial<TaskRequest>> {
|
|
137
|
-
const selected = request.sddChange;
|
|
138
|
-
const asset = fileURLToPath(new URL("../assets/agents/sdd-remediate.md", import.meta.url));
|
|
139
|
-
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");
|
|
140
|
-
const owned = parseAgentDefinition(readFileSync(asset, "utf8"), asset, "global");
|
|
141
|
-
const installed = parseAgentDefinition(readFileSync(request.agent.filePath, "utf8"), request.agent.filePath, request.agent.scope);
|
|
142
|
-
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");
|
|
143
|
-
const value = input as { plan?: unknown };
|
|
144
|
-
const plan = parseRemediationPlan(value?.plan, request.cwd);
|
|
145
|
-
const status = decodeNativeSddStatusV2(await native.sddStatus({ workspaceRoot: request.cwd, changeName: selected.changeName }), selected);
|
|
146
|
-
// Legacy remediation status carries the verification failure being repaired;
|
|
147
|
-
// that is not permission to bypass a missing native edit grant.
|
|
148
|
-
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");
|
|
149
|
-
const scope = await confirmRemediationScope(plan, status.actionContext, context);
|
|
150
|
-
return { sddRemediation: { scope, failedEvidenceRevision: selected.failedEvidenceRevision!, plan } };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Installed only for the admitted remediation child. Stock shell execution,
|
|
154
|
-
// cancellation, truncation and rendering remain owned by the SDK definition.
|
|
155
|
-
export function remediationBash(cwd: string, operations: BashOperations = createLocalBashOperations(), scope?: RemediationScope) {
|
|
156
|
-
const captured = new Map<string, { toolCallId: string; command: string; cwd: string; exitCode: number | null }>();
|
|
157
|
-
const remaining = [...(scope?.commands ?? [])], used = new Set<string>();
|
|
158
|
-
const stock = createBashToolDefinition(cwd);
|
|
159
|
-
return {
|
|
160
|
-
definition: { ...stock, async execute(...input: Parameters<typeof stock.execute>) {
|
|
161
|
-
const [id, args, signal, onUpdate, ctx] = input;
|
|
162
|
-
const slot = remaining.indexOf(args.command);
|
|
163
|
-
if (!remediationToolAllowed(scope, ctx?.cwd ?? cwd, "bash", args) || slot < 0 || used.has(id)) throw new Error("Bash invocation is outside this launch human authorization");
|
|
164
|
-
remaining.splice(slot, 1); used.add(id);
|
|
165
|
-
const definition = createBashToolDefinition(cwd, { operations: { exec: async (command, directory, options) => {
|
|
166
|
-
const result = await operations.exec(command, directory, options);
|
|
167
|
-
if (captured.size < 32) captured.set(id, { toolCallId: id, command, cwd: directory, exitCode: result.exitCode });
|
|
168
|
-
return result;
|
|
169
|
-
} } });
|
|
170
|
-
return definition.execute(id, args, signal, onUpdate, ctx);
|
|
171
|
-
} },
|
|
172
|
-
result(event: { toolCallId: string; details?: unknown }) {
|
|
173
|
-
const observation = captured.get(event.toolCallId);
|
|
174
|
-
captured.delete(event.toolCallId);
|
|
175
|
-
return observation ? { details: { ...(event.details as object ?? {}), remediationCommand: observation } } : undefined;
|
|
176
|
-
},
|
|
177
|
-
};
|
|
98
|
+
// Wakes an idle parent after child content was stored as a custom message.
|
|
99
|
+
// It names itself as automated so the model never attributes it to the human.
|
|
100
|
+
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.";
|
|
101
|
+
const PARENT_WAKE_TYPE = "gentle-agents.wake";
|
|
102
|
+
const BRIDGE_WAKE_IDENTITY_TYPE = "gentle-agents.wake-identity";
|
|
103
|
+
interface BridgeWakeIdentity {
|
|
104
|
+
sessionId: string;
|
|
105
|
+
nonce: string;
|
|
106
|
+
text: string;
|
|
178
107
|
}
|
|
108
|
+
const bridgeWakeText = (nonce: string): string => `${PARENT_WAKE_TEXT} [gentle-agents wake: ${nonce}]`;
|
|
109
|
+
const NATIVE_PARENT_WAKE_TEXT = "Review the delivered subagent output and continue.";
|
|
110
|
+
// How long a dispatched wake may take to start a parent run before a later
|
|
111
|
+
// delivery may send another one.
|
|
112
|
+
export const PARENT_WAKE_GRACE_MS = 30_000;
|
|
113
|
+
// A delivery or wake the host rejected while the parent was idle is retried
|
|
114
|
+
// after the grace at most this many times in a row, so a permanently broken
|
|
115
|
+
// runtime never retries forever.
|
|
116
|
+
const PARENT_DELIVERY_RETRY_LIMIT = 3;
|
|
117
|
+
|
|
118
|
+
const retiredSddAgent = (name: string): boolean => /^sdd(?:-|$)/.test(name);
|
|
179
119
|
|
|
180
120
|
export interface SessionTransportRegistry {
|
|
181
121
|
list(excludeSessionId?: string): Promise<readonly SessionPresenceCandidate[]>;
|
|
@@ -184,6 +124,7 @@ export interface SessionTransportRegistry {
|
|
|
184
124
|
}
|
|
185
125
|
|
|
186
126
|
export interface SessionTransportListener {
|
|
127
|
+
readonly record?: PresenceRecord;
|
|
187
128
|
readonly registry: SessionTransportRegistry;
|
|
188
129
|
readonly closesRegistry?: boolean;
|
|
189
130
|
start(): Promise<void>;
|
|
@@ -202,7 +143,6 @@ export interface SessionTransportFactory {
|
|
|
202
143
|
}
|
|
203
144
|
|
|
204
145
|
export interface AgentsDeps extends RunnerDeps {
|
|
205
|
-
nativeSdd?: NativeReviewCli;
|
|
206
146
|
home: string;
|
|
207
147
|
agentHome?: string;
|
|
208
148
|
childIpc?: IpcEndpoint;
|
|
@@ -212,6 +152,39 @@ export interface AgentsDeps extends RunnerDeps {
|
|
|
212
152
|
runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps;
|
|
213
153
|
metricsNow?: () => number;
|
|
214
154
|
metricsSchedule?: RunnerDeps["schedule"];
|
|
155
|
+
// Extensions every child loads with --extension (gentle-shell#1587).
|
|
156
|
+
childExtensionPaths?: string[];
|
|
157
|
+
/** Shell for background jobs; defaults to Pi's configured Bash. */
|
|
158
|
+
jobShell?: (cwd: string) => { operations: JobExecOperations; commandPrefix?: string };
|
|
159
|
+
/** Directory for background job output; defaults to a private temp dir. */
|
|
160
|
+
jobOutputDir?: () => string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// gentle-shell#1587: fallback for a parent without the launcher's package
|
|
164
|
+
// injection signal (#1690), e.g. a manual `pi -e <package>` launch. Children
|
|
165
|
+
// then get context filtering, destructive-command safety, and the nan
|
|
166
|
+
// provider (gentle-shell#1731 T32: a model routed to nan/* is not found
|
|
167
|
+
// without it). When the signal is present, children load the whole package
|
|
168
|
+
// instead. This list is frozen: new child-facing behavior ships inside the
|
|
169
|
+
// package, never here. Missing files are omitted.
|
|
170
|
+
export function childContextExtensionPaths(exists: (path: string) => boolean = existsSync): string[] {
|
|
171
|
+
try {
|
|
172
|
+
return ["./child-context.ts", "./child-safety.ts", "./nan-provider.ts"]
|
|
173
|
+
.map((path) => fileURLToPath(new URL(path, import.meta.url)))
|
|
174
|
+
.filter(exists);
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// #1690: when the gentle-shell launcher injected the package with -e, the
|
|
181
|
+
// host env carries that exact set and every child loads it too, which already
|
|
182
|
+
// includes the curated entries. Without a valid signal (declared package,
|
|
183
|
+
// regular gentle-pi, malformed value) children keep the curated entries.
|
|
184
|
+
function childExtensionRequest(env: NodeJS.ProcessEnv, curated: string[] | undefined): Pick<TaskRequest, "extensionPaths" | "noExtensions"> {
|
|
185
|
+
const injection = parseChildPackageInjection(env);
|
|
186
|
+
if (injection) return { extensionPaths: [...injection.extensionPaths], ...(injection.noExtensions ? { noExtensions: true } : {}) };
|
|
187
|
+
return curated && curated.length > 0 ? { extensionPaths: [...curated] } : {};
|
|
215
188
|
}
|
|
216
189
|
|
|
217
190
|
export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "agent")): { sessions: string; transcripts: string } {
|
|
@@ -219,6 +192,22 @@ export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "a
|
|
|
219
192
|
return { sessions: join(root, "sessions"), transcripts: join(root, "transcripts") };
|
|
220
193
|
}
|
|
221
194
|
|
|
195
|
+
const workDescriptorSchema = {
|
|
196
|
+
type: "object", additionalProperties: false,
|
|
197
|
+
description: "Explicit non-authoritative classification, not child context. Topic requires area; limits are UTF-8 bytes. No nested tasks.",
|
|
198
|
+
properties: {
|
|
199
|
+
area: { type: "string", maxLength: 64 }, topic: { type: "string", maxLength: 64 },
|
|
200
|
+
tags: { type: "array", maxItems: 8, uniqueItems: true, items: { type: "string", maxLength: 64 } },
|
|
201
|
+
refs: { type: "array", maxItems: 8, uniqueItems: true, items: {
|
|
202
|
+
type: "object", additionalProperties: false, required: ["kind", "repository", "id"], properties: {
|
|
203
|
+
kind: { type: "string", enum: ["issue", "pr", "task"] },
|
|
204
|
+
repository: { type: "string", maxLength: 256, description: "Explicit public host/owner/repo; not a URL or inferred identity." },
|
|
205
|
+
id: { type: "string", maxLength: 256, description: "Canonical positive decimal issue/PR ID or opaque historical task ID; never a peer route." },
|
|
206
|
+
},
|
|
207
|
+
} },
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
222
211
|
interface ToolText {
|
|
223
212
|
content: Array<{ type: "text"; text: string }>;
|
|
224
213
|
details: Record<string, unknown>;
|
|
@@ -234,9 +223,11 @@ const defaultDeps = (env: NodeJS.ProcessEnv): AgentsDeps => ({
|
|
|
234
223
|
return () => clearTimeout(timer);
|
|
235
224
|
},
|
|
236
225
|
pi: piCommand(),
|
|
226
|
+
resolvePi: () => piCommand(),
|
|
237
227
|
home: os.homedir(),
|
|
238
228
|
resolveWorktree: resolveSessionWorktree,
|
|
239
229
|
env,
|
|
230
|
+
childExtensionPaths: childContextExtensionPaths(),
|
|
240
231
|
});
|
|
241
232
|
|
|
242
233
|
export function agentsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
@@ -281,6 +272,43 @@ function ownedChildIpc(env: NodeJS.ProcessEnv, candidate: IpcEndpoint | undefine
|
|
|
281
272
|
return candidate;
|
|
282
273
|
}
|
|
283
274
|
|
|
275
|
+
// Catalog declarations are routing hints, not proof of child availability.
|
|
276
|
+
// Filter non-name entries such as `"*": false`, but do not claim that a
|
|
277
|
+
// syntactically valid name is registered. Empty declarations omit --tools and
|
|
278
|
+
// use Pi defaults. The runner adds parent messaging separately.
|
|
279
|
+
const TOOL_NAME = /^[A-Za-z0-9_.:-]+$/;
|
|
280
|
+
|
|
281
|
+
function declaredToolInventory(agent: AgentDefinition): string {
|
|
282
|
+
const usable = agent.tools.filter((name) => TOOL_NAME.test(name));
|
|
283
|
+
if (usable.length > 0) return `[declared tools (not verified): ${usable.join(", ")}]`;
|
|
284
|
+
return agent.tools.length === 0
|
|
285
|
+
? "[declared tools: Pi defaults (no allowlist)]"
|
|
286
|
+
: "[declared tools: no tool names declared]";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Pi drops unknown --tools names without a diagnostic (#1690), so the child
|
|
290
|
+
// reports them once through the one notify the parent keeps. The check runs
|
|
291
|
+
// at the first before_agent_start, not session_start: Pi runs session_start
|
|
292
|
+
// handlers in extension load order, so an extension loaded after this one may
|
|
293
|
+
// still register its tools there. MCP tools register later still, and entries
|
|
294
|
+
// that cannot be tool names (the `"*": false` frontmatter entry) are not
|
|
295
|
+
// checkable.
|
|
296
|
+
function registerMissingToolsCheck(pi: ExtensionAPI, env: NodeJS.ProcessEnv): void {
|
|
297
|
+
const requested = (env[REQUESTED_TOOLS_ENV] ?? "").split(",").map((name) => name.trim())
|
|
298
|
+
.filter((name) => /^[A-Za-z0-9_.:-]+$/.test(name) && !name.startsWith("mcp__"));
|
|
299
|
+
if (requested.length === 0) return;
|
|
300
|
+
let reported = false;
|
|
301
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
302
|
+
if (reported) return;
|
|
303
|
+
reported = true;
|
|
304
|
+
try {
|
|
305
|
+
const available = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
306
|
+
const missing = [...new Set(requested.filter((name) => !available.has(name)))];
|
|
307
|
+
if (missing.length > 0) ctx.ui.notify(`${MISSING_TOOLS_NOTE_PREFIX} ${missing.join(", ")}`, "warning");
|
|
308
|
+
} catch { /* A diagnostic must never break the child's first prompt. */ }
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
284
312
|
function registerChildMessaging(pi: ExtensionAPI, ipc: IpcEndpoint): void {
|
|
285
313
|
const messenger = new ChildMessenger(ipc);
|
|
286
314
|
pi.registerTool({
|
|
@@ -288,7 +316,7 @@ function registerChildMessaging(pi: ExtensionAPI, ipc: IpcEndpoint): void {
|
|
|
288
316
|
label: "Agent parent message",
|
|
289
317
|
description: "Send a bounded notification or correlated query to this subagent's parent.",
|
|
290
318
|
parameters: { type: "object", additionalProperties: false, required: ["message"], properties: { kind: { type: "string", enum: ["notification", "query"] }, message: { type: "string" } } } as never,
|
|
291
|
-
async execute(_id, params) {
|
|
319
|
+
async execute(_id, params): Promise<ToolText> {
|
|
292
320
|
const input = params as { kind?: unknown; message?: unknown };
|
|
293
321
|
if (typeof input.message !== "string") throw new Error("parent messages require text");
|
|
294
322
|
if (input.kind === undefined || input.kind === "notification") {
|
|
@@ -322,6 +350,22 @@ function text(value: string, details: Record<string, unknown> = {}, terminate =
|
|
|
322
350
|
return { content: [{ type: "text", text: value }], details, ...(terminate ? { terminate: true } : {}) };
|
|
323
351
|
}
|
|
324
352
|
|
|
353
|
+
// gentle-pi#1175: the writer profile the runtime resolved for this task,
|
|
354
|
+
// carried on its review-mutation receipt so ASSESS never trusts a model
|
|
355
|
+
// re-declaration. `task.model` is the child's reported model once get_state
|
|
356
|
+
// answers, or the launch profile's model. An inherited model is recorded as
|
|
357
|
+
// the unresolved `formatModelRef(undefined)` placeholder; the parent's current
|
|
358
|
+
// model is not reliable evidence (the child resolves its own default), so it is
|
|
359
|
+
// omitted and ASSESS treats the writer as unknown, i.e. small.
|
|
360
|
+
function runtimeWriterProfile(task: TaskRecord): { writerModelId?: string; writerEffort?: string } {
|
|
361
|
+
const modelId = typeof task.model === "string" && task.model !== formatModelRef(undefined) && task.model.trim() ? task.model : undefined;
|
|
362
|
+
const effort = typeof task.thinking === "string" && task.thinking.trim() ? task.thinking : undefined;
|
|
363
|
+
return {
|
|
364
|
+
...(modelId === undefined ? {} : { writerModelId: modelId }),
|
|
365
|
+
...(effort === undefined ? {} : { writerEffort: effort }),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
325
369
|
function taskDetails(task: TaskRecord): Record<string, unknown> {
|
|
326
370
|
return { gentleAgents: { taskId: task.id, agent: task.agent, status: task.status, mode: task.mode, cwd: task.cwd } };
|
|
327
371
|
}
|
|
@@ -347,29 +391,50 @@ function expandHint(expanded: boolean): string {
|
|
|
347
391
|
}
|
|
348
392
|
}
|
|
349
393
|
|
|
394
|
+
// pi runs `-p` and `--mode json` through one one-shot runner that disposes
|
|
395
|
+
// the runtime as soon as the prompt returns, so a background result has no
|
|
396
|
+
// parent session left to reach (gentle-shell#1731 T18).
|
|
397
|
+
export function isSingleShotMode(mode: string | undefined): boolean {
|
|
398
|
+
return mode === "print" || mode === "json";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const SINGLE_SHOT_BACKGROUND_ERROR = "Background subagents are unavailable in single-shot modes: pi -p and pi --mode json exit before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
|
|
402
|
+
|
|
350
403
|
// The default mode for a subagent_run request that named neither an explicit
|
|
351
404
|
// mode nor an agent-defined one. Background is a runtime default only when
|
|
352
405
|
// the background-subagents policy is on AND the parent can receive results:
|
|
353
|
-
//
|
|
354
|
-
// the
|
|
355
|
-
//
|
|
406
|
+
// single-shot modes exit before a parent session exists to deliver them to
|
|
407
|
+
// (see the guard in `launch` below), so they keep the configured default
|
|
408
|
+
// (normally task) even when the policy is on.
|
|
356
409
|
export function resolveDefaultSubagentMode(input: {
|
|
357
410
|
configuredDefault: AgentMode;
|
|
358
411
|
policy: "on" | "off";
|
|
359
412
|
parentMode: string | undefined;
|
|
360
413
|
}): AgentMode {
|
|
361
|
-
return input.policy === "on" && input.parentMode
|
|
414
|
+
return input.policy === "on" && !isSingleShotMode(input.parentMode)
|
|
362
415
|
? AGENT_MODE.BACKGROUND
|
|
363
416
|
: input.configuredDefault;
|
|
364
417
|
}
|
|
365
418
|
|
|
366
419
|
// What the model reads when a background task ends: the outcome first, then
|
|
367
|
-
// the answer itself. The card
|
|
420
|
+
// the answer itself. The expanded card shows the same text.
|
|
368
421
|
export function completionText(task: TaskRecord): string {
|
|
369
422
|
const outcome = task.status === "completed" ? "finished" : task.status.replace("_", " ");
|
|
370
423
|
return `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${outcome}.\n\n${finishedText(task)}`;
|
|
371
424
|
}
|
|
372
425
|
|
|
426
|
+
const COMPLETION_HEADER = /^Subagent [^\n]+ \(task [^\n]*\) [^\n]+\.$/;
|
|
427
|
+
|
|
428
|
+
// The collapsed card leads with the answer or error: completionText's
|
|
429
|
+
// bookkeeping paragraph stays for the model and the expanded card. Entries
|
|
430
|
+
// without that header (older sessions) preview their full text.
|
|
431
|
+
export function agentResultPreview(text: string): string {
|
|
432
|
+
const split = text.indexOf("\n\n");
|
|
433
|
+
if (split < 0 || !COMPLETION_HEADER.test(text.slice(0, split))) return text;
|
|
434
|
+
const rest = text.slice(split + 2);
|
|
435
|
+
return rest.trim() === "" ? text : rest;
|
|
436
|
+
}
|
|
437
|
+
|
|
373
438
|
// Host-side answer to a child's dialog: the same ctx.ui the human already
|
|
374
439
|
// uses, so a subagent's question looks like any other pi dialog.
|
|
375
440
|
export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, ask: AskRequest, raw: Record<string, unknown>): Promise<AskAnswer> {
|
|
@@ -396,73 +461,24 @@ export async function answerThroughUi(ui: ExtensionContext["ui"] | undefined, as
|
|
|
396
461
|
}
|
|
397
462
|
}
|
|
398
463
|
|
|
399
|
-
const RESEARCH_SELECTION_SCHEMA = {
|
|
400
|
-
type: "object",
|
|
401
|
-
additionalProperties: false,
|
|
402
|
-
description: "Untrusted narrowing intent for sdd-research; exact tools and existing sourceInfo.path per tool. Never grants permissions or installs extensions.",
|
|
403
|
-
properties: Object.fromEntries(["documentation", "open-web"].map(kind => [kind, {
|
|
404
|
-
type: "object", additionalProperties: false, required: ["tools", "extensions"],
|
|
405
|
-
properties: {
|
|
406
|
-
tools: { type: "array", items: { type: "string" } },
|
|
407
|
-
extensions: { type: "object", additionalProperties: { type: "string" } },
|
|
408
|
-
},
|
|
409
|
-
}])),
|
|
410
|
-
};
|
|
411
|
-
|
|
412
464
|
export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<AgentsDeps> = {}): void {
|
|
413
|
-
if (env.GENTLE_PI_AGENTS_CHILD === "1" && env[RESEARCH_CHILD_TOOLS_ENV] !== undefined) {
|
|
414
|
-
let allowed: string[] = [];
|
|
415
|
-
try {
|
|
416
|
-
const parsed: unknown = JSON.parse(env[RESEARCH_CHILD_TOOLS_ENV]!);
|
|
417
|
-
if (Array.isArray(parsed) && parsed.every(value => typeof value === "string")) allowed = parsed;
|
|
418
|
-
} catch { /* Invalid launch restrictions deny every tool. */ }
|
|
419
|
-
let selection: unknown;
|
|
420
|
-
try { selection = JSON.parse(env[RESEARCH_SELECTION_ENV] ?? "null"); } catch { /* Missing selection grants no research. */ }
|
|
421
|
-
const current = () => researchAgent({ tools: allowed, instructions: "" } as AgentDefinition, pi, selection);
|
|
422
|
-
pi.on("before_agent_start", event => ({
|
|
423
|
-
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.`,
|
|
424
|
-
}));
|
|
425
|
-
pi.on("tool_call", event => {
|
|
426
|
-
const registered = pi.getAllTools().some(tool => tool.name === event.toolName && tool.sourceInfo?.source !== "sdk");
|
|
427
|
-
const selected = current().agent.tools.includes(event.toolName) || event.toolName === "subagent_parent_message";
|
|
428
|
-
if (!registered || !selected || !allowed.includes(event.toolName) || !pi.getActiveTools().includes(event.toolName)) {
|
|
429
|
-
return { block: true, reason: "Tool is outside the output-only research child's active launch allowlist." };
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
465
|
const childIpc = ownedChildIpc(env, overrides.childIpc ?? (process.send ? process as unknown as IpcEndpoint : undefined));
|
|
434
466
|
if (env.GENTLE_PI_AGENTS_CHILD === "1") {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
pi.on("tool_call", (
|
|
438
|
-
|
|
439
|
-
granted = undefined;
|
|
440
|
-
try {
|
|
441
|
-
const retained = JSON.parse(env[REMEDIATION_PLAN_ENV]!);
|
|
442
|
-
// SDK flags are owner-local; the runner transports the same selected context.
|
|
443
|
-
const selection = Object.hasOwn(retained, "selection") ? retained.selection : JSON.parse(String(pi.getFlag("gentle-sdd-change")));
|
|
444
|
-
parseSddChange(selection, "sdd-remediate");
|
|
445
|
-
const plan = parseRemediationPlan(retained.plan, ctx.cwd);
|
|
446
|
-
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");
|
|
447
|
-
const shell = remediationBash(ctx.cwd, undefined, retained.scope);
|
|
448
|
-
pi.registerTool(shell.definition);
|
|
449
|
-
pi.on("tool_result", event => event.toolName === "bash" ? shell.result(event) : undefined);
|
|
450
|
-
granted = retained.scope;
|
|
451
|
-
} catch { /* No valid host grant: deny every tool, even if Pi continues initialization. */ }
|
|
452
|
-
});
|
|
467
|
+
// A stale managed-SDD child must never inherit unrestricted ordinary tools.
|
|
468
|
+
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) {
|
|
469
|
+
pi.on("tool_call", () => ({ block: true, reason: "Retired SDD child launch is not supported." }));
|
|
470
|
+
return;
|
|
453
471
|
}
|
|
454
472
|
if (childIpc) registerChildMessaging(pi, childIpc);
|
|
473
|
+
registerMissingToolsCheck(pi, env);
|
|
455
474
|
return;
|
|
456
475
|
}
|
|
457
476
|
if (!agentsEnabled(env)) return;
|
|
458
477
|
const deps: AgentsDeps = { ...defaultDeps(env), ...overrides };
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const environmentHome = overrides.agentHome === undefined && overrides.home === undefined;
|
|
462
|
-
const expandedHome = environmentHome && selectedHome === "~" ? deps.home
|
|
463
|
-
: environmentHome && (selectedHome.startsWith("~/") || (process.platform === "win32" && selectedHome.startsWith("~\\"))) ? join(deps.home, selectedHome.slice(2)) : selectedHome;
|
|
478
|
+
// An explicitly injected pi command wins over the default per-spawn resolver.
|
|
479
|
+
if (overrides?.pi && !overrides.resolvePi) delete deps.resolvePi;
|
|
464
480
|
// Freeze the host's root before a child uses a different session cwd.
|
|
465
|
-
const agentHome =
|
|
481
|
+
const agentHome = resolveAgentHomeDirectory({ env: deps.env, home: deps.home, agentHome: overrides.agentHome, homeOverridden: overrides.home !== undefined });
|
|
466
482
|
const sessionTransport = deps.sessionTransport ?? createDefaultSessionTransport();
|
|
467
483
|
if (legacySubagentsInstalledAt(agentHome)) {
|
|
468
484
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -474,13 +490,22 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
474
490
|
const viewKey = agentsViewKey(env);
|
|
475
491
|
const stopKey = agentsStopKey(env);
|
|
476
492
|
const store = new TaskStore();
|
|
493
|
+
// Attach before any live add/start; restore and unchanged updates are silent.
|
|
494
|
+
// TaskStore contains bus listener failures so notifications cannot fail tools.
|
|
495
|
+
const stopStatusNotifications = publishTaskStatusChanges(store, pi.events, () => performance.now());
|
|
496
|
+
pi.on("session_shutdown", () => { stopStatusNotifications(); });
|
|
477
497
|
const restoredTaskIds = new Set<string>();
|
|
478
498
|
const tasksDir = historyDir(deps.home, agentHome);
|
|
479
499
|
let ui: ExtensionContext["ui"] | undefined;
|
|
480
500
|
let host: { requestRender(): void } | undefined;
|
|
481
501
|
let sidebarTui: TUI | undefined;
|
|
502
|
+
const visualHome = gentlePiConfigHome(env);
|
|
503
|
+
let agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
|
|
504
|
+
let stopVisualUpdates: (() => void) | undefined;
|
|
482
505
|
let sessions: ExtensionContext["sessionManager"] | undefined;
|
|
483
506
|
let presence: PresencePublisher | undefined;
|
|
507
|
+
const scopeCache = new OrchestratorScopeCache(deps.resolveWorktree);
|
|
508
|
+
const stateCache = new OrchestratorStateCache();
|
|
484
509
|
let rpcActivityPublisher: RpcActivityPublisher | undefined;
|
|
485
510
|
// Messages already surfaced to the user this session through the RPC
|
|
486
511
|
// activity publisher's `onError`, so a recurring push failure (the
|
|
@@ -488,23 +513,55 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
488
513
|
// session instead of flooding the UI. Reset on every `session_start`.
|
|
489
514
|
let notifiedRpcActivityErrors: Set<string> | undefined;
|
|
490
515
|
const overlays = new Set<AgentsView>();
|
|
516
|
+
const startPresence = (manager: ExtensionContext["sessionManager"]) => {
|
|
517
|
+
scopeCache.clear();
|
|
518
|
+
const sessionId = manager.getSessionId() ?? "";
|
|
519
|
+
const labelSource = () => {
|
|
520
|
+
if (sessions !== manager || (manager.getSessionId() ?? "") !== sessionId) throw new Error("stale-session");
|
|
521
|
+
return manager.getSessionName?.() || manager.getCwd().split(/[\\/]/).pop() || "Orchestrator";
|
|
522
|
+
};
|
|
523
|
+
return PresencePublisher.start({ profile: agentHome, sessionId, label: labelSource(), labelSource, activity: [] });
|
|
524
|
+
};
|
|
491
525
|
const publishActivity = () => {
|
|
492
526
|
if (!sessions) return;
|
|
493
527
|
try {
|
|
494
528
|
if (!presence || presence.error) {
|
|
495
|
-
presence
|
|
496
|
-
|
|
529
|
+
presence?.dispose();
|
|
530
|
+
presence = startPresence(sessions);
|
|
531
|
+
}
|
|
532
|
+
const tasks = store.list(activeSessionId()).filter((task) => ownedTaskIds.has(task.id) && !isFinished(task.status) && !restoredTaskIds.has(task.id));
|
|
533
|
+
presence?.update(tasks.map((task) => ({ task, thread: store.thread(task.id) })));
|
|
534
|
+
const transport = activeSessionTransport;
|
|
535
|
+
if (transport?.sessionManager === sessions && transport.sessionId === activeSessionId() && transport.listener.record) {
|
|
536
|
+
// Read the existing durable registry, without roots()'s repeated Git validation.
|
|
537
|
+
// These are recorded contexts, never authority for admission or messaging.
|
|
538
|
+
const registered = sessions.getEntries().flatMap(entry => {
|
|
539
|
+
if (entry.type !== "custom" || entry.customType !== SESSION_WORKTREE_ENTRY) return [];
|
|
540
|
+
const data = entry.data as { sessionId?: string; root?: string; evidence?: string } | undefined;
|
|
541
|
+
return data?.sessionId === activeSessionId() && typeof data.root === "string" && typeof data.evidence === "string" ? [data.root] : [];
|
|
542
|
+
});
|
|
543
|
+
presence?.updateDiscovery(transport.listener.record, { workspace: sessions.getCwd(), tasks, registered,
|
|
544
|
+
scope: scopeCache.project(sessions.getCwd(), tasks, registered), state: stateCache.get(sessions) });
|
|
497
545
|
}
|
|
498
|
-
presence?.update(store.list(activeSessionId()).filter((task) => !isFinished(task.status) && !restoredTaskIds.has(task.id)).map((task) => ({ task, thread: store.thread(task.id) })));
|
|
499
546
|
} catch { presence?.dispose(); presence = undefined; }
|
|
500
547
|
};
|
|
548
|
+
const unsubscribeScope = pi.events.on(SESSION_WORKTREE_CHANGED, (event) => {
|
|
549
|
+
if ((event as { sessionId?: string } | undefined)?.sessionId !== sessions?.getSessionId()) return;
|
|
550
|
+
scopeCache.clear();
|
|
551
|
+
publishActivity();
|
|
552
|
+
});
|
|
501
553
|
let worktrees: SessionWorktreeRegistry | undefined;
|
|
554
|
+
let worktreeManager: ExtensionContext["sessionManager"] | undefined;
|
|
555
|
+
let worktreeAuthority: (() => boolean) | undefined;
|
|
502
556
|
const foreignGrants = new ForeignTargetGrants();
|
|
557
|
+
const messagingGrants = new SessionMessagingGrants();
|
|
503
558
|
const foreignTasks = new Map<string, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
|
|
504
559
|
const foreignRequests = new WeakMap<TaskRequest, { root: string; commonDir: string; manager: ExtensionContext["sessionManager"] }>();
|
|
505
560
|
const registryFor = (ctx: ExtensionContext) => {
|
|
506
|
-
if (!worktrees || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
561
|
+
if (!worktrees || worktreeManager !== ctx.sessionManager || worktrees.sessionId !== ctx.sessionManager.getSessionId()) {
|
|
507
562
|
worktrees?.close();
|
|
563
|
+
worktreeManager = ctx.sessionManager;
|
|
564
|
+
worktreeAuthority = sessionRepositoryAuthority(ctx.sessionManager.getCwd(), deps.resolveWorktree);
|
|
508
565
|
worktrees = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.sessionManager.getCwd(), deps.resolveWorktree);
|
|
509
566
|
}
|
|
510
567
|
return worktrees;
|
|
@@ -537,6 +594,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
537
594
|
pi.on("session_shutdown", () => {
|
|
538
595
|
clearTaskMetrics();
|
|
539
596
|
unsubscribeMetrics();
|
|
597
|
+
unsubscribeScope();
|
|
598
|
+
scopeCache.clear();
|
|
540
599
|
});
|
|
541
600
|
let stopAllConfirmation: Promise<void> | undefined;
|
|
542
601
|
|
|
@@ -598,7 +657,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
598
657
|
listener = sessionTransport.createListener(registry, sessionId, async (notification) => {
|
|
599
658
|
const active = activeSessionTransport;
|
|
600
659
|
if (!active || active.generation !== generation || active.sessionManager !== sessionManager || active.sessionId !== sessionId || sessions !== sessionManager || activeSessionId() !== sessionId) throw new Error("stale session transport");
|
|
601
|
-
pi.sendMessage({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming" } } }, { deliverAs: "followUp", triggerTurn: true });
|
|
660
|
+
pi.sendMessage({ customType: AGENTS_ORCHESTRATOR_MESSAGE_TYPE, content: `Session message from ${notification.senderSessionId} (correlation ${notification.id}): ${notification.message}`, display: true, details: { gentleAgents: { senderSessionId: notification.senderSessionId, recipientSessionId: sessionId, correlationId: notification.id, direction: "incoming", message: notification.message, senderLabel: messagePeerLabel(agentHome, notification.senderSessionId), recipientLabel: sanitizeDisplayLabel(sessionManager.getSessionName?.() ?? "") } } }, { deliverAs: "followUp", triggerTurn: true });
|
|
602
661
|
});
|
|
603
662
|
client = sessionTransport.createClient(registry, sessionId);
|
|
604
663
|
if (sessions !== sessionManager || generation !== transportGeneration || activeSessionId() !== sessionId) {
|
|
@@ -615,6 +674,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
615
674
|
await closeStartupTransport();
|
|
616
675
|
return;
|
|
617
676
|
}
|
|
677
|
+
publishActivity();
|
|
618
678
|
} catch {
|
|
619
679
|
if (activeSessionTransport?.generation === generation) activeSessionTransport = undefined;
|
|
620
680
|
await closeStartupTransport();
|
|
@@ -688,46 +748,417 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
688
748
|
// flushed at the next turn boundary, and a stale one never re-enters the
|
|
689
749
|
// conversation.
|
|
690
750
|
const completions = createCompletionQueue<TaskRecord>();
|
|
751
|
+
const messages = createAgentMessageQueue();
|
|
752
|
+
// A job's exit, or a monitor's line events and harness stop, waiting for
|
|
753
|
+
// the parent delivery router.
|
|
754
|
+
type JobNotice = { job: JobRecord; kind: "exit" } | MonitorNotice;
|
|
755
|
+
const jobNotices: JobNotice[] = [];
|
|
756
|
+
let jobOutputDir: string | undefined;
|
|
757
|
+
const jobs = createJobRegistry({
|
|
758
|
+
outputDir: deps.jobOutputDir ?? (() => (jobOutputDir ??= mkdtempSync(join(os.tmpdir(), "gentle-jobs-")))),
|
|
759
|
+
now: deps.now,
|
|
760
|
+
shell: deps.jobShell ?? ((cwd) => {
|
|
761
|
+
const settings = SettingsManager.create(cwd, agentHome);
|
|
762
|
+
return { operations: createLocalBashOperations({ shellPath: settings.getShellPath() }), commandPrefix: settings.getShellCommandPrefix() };
|
|
763
|
+
}),
|
|
764
|
+
onSettled: (job) => settleJob(job),
|
|
765
|
+
});
|
|
766
|
+
const monitors = createMonitorController({
|
|
767
|
+
registry: jobs,
|
|
768
|
+
schedule: (fn, ms) => deps.schedule(fn, ms),
|
|
769
|
+
now: deps.now,
|
|
770
|
+
deliver: (notice) => queueJobNotice(notice),
|
|
771
|
+
});
|
|
772
|
+
// A parent that sleeps while background work runs only burns the turn: each
|
|
773
|
+
// completion already arrives as a session message (#1903). bash_background
|
|
774
|
+
// and monitor stay allowed because they are the sanctioned way to wait.
|
|
775
|
+
pi.on("tool_call", (event) => {
|
|
776
|
+
if (event.toolName !== "bash") return undefined;
|
|
777
|
+
const command = (event.input as { command?: unknown }).command;
|
|
778
|
+
if (typeof command !== "string" || !isStandaloneSleep(command)) return undefined;
|
|
779
|
+
const sessionId = activeSessionId();
|
|
780
|
+
// Same active-task predicate as publishActivity.
|
|
781
|
+
const tasks = store.list(sessionId).filter((task) => ownedTaskIds.has(task.id) && !isFinished(task.status) && !restoredTaskIds.has(task.id)).length;
|
|
782
|
+
const running = jobs.list(sessionId ?? "").filter((job) => job.status === "running").length;
|
|
783
|
+
if (tasks + running === 0) return undefined;
|
|
784
|
+
return {
|
|
785
|
+
block: true,
|
|
786
|
+
reason: `Standalone sleep is blocked while background work is active (${tasks} task(s), ${running} job(s)). End the turn now; each completion arrives automatically as a session message and starts a new turn.`,
|
|
787
|
+
};
|
|
788
|
+
});
|
|
691
789
|
let activeAgentRuns = 0;
|
|
790
|
+
// ExtensionAPI has no idle probe; ctx.isIdle() is the only one. It is live
|
|
791
|
+
// and also reports compaction, which activeAgentRuns cannot see. The
|
|
792
|
+
// session_start context is kept for it and dropped at shutdown; a stale
|
|
793
|
+
// context throws instead of answering, so delivery fails closed.
|
|
794
|
+
let parentCtx: ExtensionContext | undefined;
|
|
795
|
+
const helperPermission = new HelperCostPermission(() => parentCtx && activeTransportFor(parentCtx) ? parentCtx : undefined);
|
|
796
|
+
pi.on("session_before_switch", () => helperPermission.clear());
|
|
797
|
+
pi.on("session_before_fork", () => helperPermission.clear());
|
|
798
|
+
pi.on("session_before_tree", () => helperPermission.clear());
|
|
799
|
+
pi.on("model_select", () => helperPermission.clear());
|
|
800
|
+
pi.on("resources_discover", () => helperPermission.clear());
|
|
801
|
+
let bridgeWakeIdentity: BridgeWakeIdentity | undefined;
|
|
802
|
+
let wakeVisibilityWarning = false;
|
|
803
|
+
const restoreBridgeWakeIdentity = (ctx: ExtensionContext | undefined) => {
|
|
804
|
+
bridgeWakeIdentity = undefined;
|
|
805
|
+
if (!ctx) return;
|
|
806
|
+
// Rendering is session-wide, not model/branch state: an identity on an
|
|
807
|
+
// abandoned branch still owns its generated bubbles in the session tree.
|
|
808
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
809
|
+
if (entry.type !== "custom" || entry.customType !== BRIDGE_WAKE_IDENTITY_TYPE) continue;
|
|
810
|
+
const data = entry.data as Partial<BridgeWakeIdentity> | undefined;
|
|
811
|
+
if (data?.sessionId === ctx.sessionManager.getSessionId() && typeof data.nonce === "string"
|
|
812
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(data.nonce)
|
|
813
|
+
&& data.text === bridgeWakeText(data.nonce)) {
|
|
814
|
+
bridgeWakeIdentity = data as BridgeWakeIdentity;
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
const hasWakeTransformer = typeof pi.registerMarkdownTransformer === "function";
|
|
820
|
+
if (hasWakeTransformer) {
|
|
821
|
+
// Register once in this runtime. Reload replaces the runtime; session
|
|
822
|
+
// replacement changes the identity, never the transformer registration.
|
|
823
|
+
pi.registerMarkdownTransformer((markdown, context) =>
|
|
824
|
+
context.messageType === "user" && bridgeWakeIdentity?.sessionId === parentCtx?.sessionManager.getSessionId()
|
|
825
|
+
&& markdown === bridgeWakeIdentity?.text ? "" : markdown);
|
|
826
|
+
}
|
|
827
|
+
const bridgeWake = (): string => {
|
|
828
|
+
if (!parentCtx) return PARENT_WAKE_TEXT;
|
|
829
|
+
try {
|
|
830
|
+
if (!hasWakeTransformer) throw new Error("Markdown transformer API unavailable");
|
|
831
|
+
if (!bridgeWakeIdentity || bridgeWakeIdentity.sessionId !== parentCtx.sessionManager.getSessionId()) {
|
|
832
|
+
const nonce = randomUUID();
|
|
833
|
+
const identity = { sessionId: parentCtx.sessionManager.getSessionId(), nonce, text: bridgeWakeText(nonce) };
|
|
834
|
+
// Persist before sending so a restart can reconstruct exact ownership.
|
|
835
|
+
pi.appendEntry(BRIDGE_WAKE_IDENTITY_TYPE, identity);
|
|
836
|
+
bridgeWakeIdentity = identity;
|
|
837
|
+
}
|
|
838
|
+
return bridgeWakeIdentity.text;
|
|
839
|
+
} catch {
|
|
840
|
+
// Compatibility fallback preserves continuation, not invisibility.
|
|
841
|
+
if (!wakeVisibilityWarning) {
|
|
842
|
+
wakeVisibilityWarning = true;
|
|
843
|
+
try { parentCtx.ui.notify("Gentle Agents cannot hide Claude Bridge continuation on this runtime; the generated user wake remains visible.", "warning"); } catch { /* UI failure must not drop continuation. */ }
|
|
844
|
+
}
|
|
845
|
+
return PARENT_WAKE_TEXT;
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
// Mirrors the host's agent run, which spans agent_start through
|
|
849
|
+
// agent_settled, including post-run retries and in-run compaction. Unlike
|
|
850
|
+
// activeAgentRuns it stays set between agent_end and agent_settled, where
|
|
851
|
+
// Pi still streams and still drains steering.
|
|
852
|
+
let parentRunActive = false;
|
|
853
|
+
// Idle wake-up state. Child content stored while the parent is idle owes a
|
|
854
|
+
// wake, and one wake covers everything stored before the run it starts.
|
|
855
|
+
// While a prompt is starting (a dispatched wake, or any prompt seen at
|
|
856
|
+
// before_agent_start) no second wake is sent: two prompts racing through
|
|
857
|
+
// Pi's asynchronous pre-run phase can both reach the agent, and the loser
|
|
858
|
+
// resets the winner's run state. A prompt that never starts a run (handled
|
|
859
|
+
// by an input handler, or rejected) emits no extension event, so the
|
|
860
|
+
// starting state expires after PARENT_WAKE_GRACE_MS and an owed wake is
|
|
861
|
+
// sent then; it never suppresses later wakes for longer than that.
|
|
862
|
+
let wakeOwed = false;
|
|
863
|
+
let wakeQueued = false;
|
|
864
|
+
let promptStarting = false;
|
|
865
|
+
let cancelPromptGrace: (() => void) | undefined;
|
|
866
|
+
let cancelBoundaryFlush: (() => void) | undefined;
|
|
867
|
+
// An idle parent has no turn boundary coming, so a failed idle delivery or
|
|
868
|
+
// wake is retried by one bounded timer that runs a full flush.
|
|
869
|
+
let cancelDeliveryRetry: (() => void) | undefined;
|
|
870
|
+
let deliveryRetries = 0;
|
|
871
|
+
const endDeliveryRetry = () => {
|
|
872
|
+
cancelDeliveryRetry?.();
|
|
873
|
+
cancelDeliveryRetry = undefined;
|
|
874
|
+
};
|
|
875
|
+
const armDeliveryRetry = () => {
|
|
876
|
+
if (cancelDeliveryRetry || deliveryRetries >= PARENT_DELIVERY_RETRY_LIMIT) return;
|
|
877
|
+
deliveryRetries += 1;
|
|
878
|
+
cancelDeliveryRetry = deps.schedule(() => {
|
|
879
|
+
cancelDeliveryRetry = undefined;
|
|
880
|
+
flushAll();
|
|
881
|
+
}, PARENT_WAKE_GRACE_MS);
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
const isTaskLive = (id: string): boolean => {
|
|
885
|
+
const task = store.get(id);
|
|
886
|
+
return Boolean(task && ownedTaskIds.has(task.id) && !isFinished(task.status));
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
// Hands model-visible child content to the parent session.
|
|
890
|
+
//
|
|
891
|
+
// A busy parent gets "steer" + triggerTurn, which keeps delivery bounded to
|
|
892
|
+
// the current turn: the host polls steering each turn and injects the
|
|
893
|
+
// message before the next LLM call. "followUp" is NOT acceptable here
|
|
894
|
+
// because the host drains the follow-up queue only in the run loop's stop
|
|
895
|
+
// branch, so a parent that keeps calling tools would see the content only
|
|
896
|
+
// when the whole run ends — the original #867 delay.
|
|
897
|
+
//
|
|
898
|
+
// Idle child content is stored durably without a turn, then a separate
|
|
899
|
+
// coalesced wake requests continuation without repeating that content.
|
|
900
|
+
// Claude Bridge needs a user wake through the prompt lifecycle for capture;
|
|
901
|
+
// native providers can use a hidden custom-message turn instead.
|
|
902
|
+
//
|
|
903
|
+
// A parent that is busy without a run (compaction, or a prompt's pre-run
|
|
904
|
+
// compaction) is not streaming, so steer + triggerTurn would also start a
|
|
905
|
+
// direct turn. Its content stays queued ("hold") until a later boundary.
|
|
906
|
+
type ParentRoute = "idle" | "run" | "hold";
|
|
907
|
+
// Throws for a missing or stale parent context, so delivery fails closed.
|
|
908
|
+
const parentRoute = (): ParentRoute => {
|
|
909
|
+
if (!parentCtx) throw new Error("Gentle Agents has no live parent session context");
|
|
910
|
+
if (parentCtx.isIdle()) {
|
|
911
|
+
// The host is authoritative: an idle parent has no run, even if a
|
|
912
|
+
// lifecycle event was missed.
|
|
913
|
+
parentRunActive = false;
|
|
914
|
+
return "idle";
|
|
915
|
+
}
|
|
916
|
+
return parentRunActive ? "run" : "hold";
|
|
917
|
+
};
|
|
692
918
|
|
|
693
|
-
const
|
|
919
|
+
const endPromptStart = () => {
|
|
920
|
+
promptStarting = false;
|
|
921
|
+
cancelPromptGrace?.();
|
|
922
|
+
cancelPromptGrace = undefined;
|
|
923
|
+
};
|
|
924
|
+
const beginPromptStart = () => {
|
|
925
|
+
endPromptStart();
|
|
926
|
+
promptStarting = true;
|
|
927
|
+
cancelPromptGrace = deps.schedule(() => {
|
|
928
|
+
cancelPromptGrace = undefined;
|
|
929
|
+
promptStarting = false;
|
|
930
|
+
requestWake();
|
|
931
|
+
}, PARENT_WAKE_GRACE_MS);
|
|
932
|
+
};
|
|
933
|
+
|
|
934
|
+
// Every wake requested during one synchronous delivery pass is coalesced
|
|
935
|
+
// into a single dispatch after it, so the wake follows all stored content.
|
|
936
|
+
const requestWake = () => {
|
|
937
|
+
if (!wakeOwed || wakeQueued || promptStarting) return;
|
|
938
|
+
wakeQueued = true;
|
|
939
|
+
queueMicrotask(dispatchWake);
|
|
940
|
+
};
|
|
941
|
+
const dispatchWake = () => {
|
|
942
|
+
wakeQueued = false;
|
|
943
|
+
if (!wakeOwed || promptStarting) return;
|
|
944
|
+
let route: ParentRoute;
|
|
945
|
+
try { route = parentRoute(); } catch { return; }
|
|
946
|
+
// A run in progress already carries the stored content; a parent busy
|
|
947
|
+
// without a run keeps the wake owed until a later boundary flush.
|
|
948
|
+
if (route !== "idle") return;
|
|
949
|
+
wakeOwed = false;
|
|
950
|
+
endDeliveryRetry();
|
|
951
|
+
beginPromptStart();
|
|
952
|
+
try {
|
|
953
|
+
// "steer" matters only when a run started in between: the wake is then
|
|
954
|
+
// queued into it instead of being rejected as a concurrent prompt.
|
|
955
|
+
// Read the live selection at dispatch, not when child content arrived.
|
|
956
|
+
// Only Claude Bridge is currently evidenced to require prompt capture;
|
|
957
|
+
// registering a custom provider alone does not make it a bridge.
|
|
958
|
+
if (parentCtx?.model?.provider === "claude-bridge") {
|
|
959
|
+
pi.sendUserMessage(bridgeWake(), { deliverAs: "steer" });
|
|
960
|
+
} else {
|
|
961
|
+
pi.sendMessage({
|
|
962
|
+
customType: PARENT_WAKE_TYPE,
|
|
963
|
+
content: NATIVE_PARENT_WAKE_TEXT,
|
|
964
|
+
display: false,
|
|
965
|
+
}, { deliverAs: "steer", triggerTurn: true });
|
|
966
|
+
}
|
|
967
|
+
deliveryRetries = 0;
|
|
968
|
+
} catch {
|
|
969
|
+
// A stale runtime fails closed instead of throwing from a microtask.
|
|
970
|
+
// The stored content still needs a turn, so the wake stays owed and is
|
|
971
|
+
// retried once per grace, a bounded number of times in a row.
|
|
972
|
+
endPromptStart();
|
|
973
|
+
wakeOwed = true;
|
|
974
|
+
armDeliveryRetry();
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
const sendToParent = (message: Parameters<ExtensionAPI["sendMessage"]>[0], route: Exclude<ParentRoute, "hold">) => {
|
|
979
|
+
if (route === "run") {
|
|
980
|
+
pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
pi.sendMessage(message, { triggerTurn: false });
|
|
984
|
+
wakeOwed = true;
|
|
985
|
+
requestWake();
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
const deliver = (task: TaskRecord, route: Exclude<ParentRoute, "hold">) => {
|
|
694
989
|
// Ownership is consulted at delivery time, matching onNotification and
|
|
695
990
|
// onQuery: a completion owned by another session is dropped, not delivered.
|
|
696
991
|
if (activeSessionId() !== task.parentSessionId) return;
|
|
697
|
-
|
|
698
|
-
// the parent streams, the host polls steering each turn and injects the
|
|
699
|
-
// message before the next LLM call; "followUp" is NOT acceptable here
|
|
700
|
-
// because the host drains the follow-up queue only in the run loop's stop
|
|
701
|
-
// branch, so a parent that keeps calling tools would see the completion
|
|
702
|
-
// only when the whole run ends — the original #867 delay. When the parent
|
|
703
|
-
// is idle, triggerTurn runs the prompt immediately, preserving wake-up.
|
|
704
|
-
pi.sendMessage({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, { deliverAs: "steer", triggerTurn: true });
|
|
992
|
+
sendToParent({ customType: AGENTS_RESULT_TYPE, content: completionText(task), display: true, details: taskDetails(task) }, route);
|
|
705
993
|
};
|
|
706
994
|
|
|
707
|
-
// A stale
|
|
708
|
-
//
|
|
709
|
-
|
|
995
|
+
// A stale report must not re-enter the LLM conversation, but the parent is
|
|
996
|
+
// told never to poll: it gets a compact model-facing notice naming the task,
|
|
997
|
+
// and the human gets durable TUI-only content.
|
|
998
|
+
const deliverStale = (task: TaskRecord, settledAt: number, route: Exclude<ParentRoute, "hold">) => {
|
|
710
999
|
if (activeSessionId() !== task.parentSessionId) return;
|
|
711
1000
|
const ageSeconds = Math.max(0, Math.round((deps.now() - settledAt) / 1000));
|
|
712
|
-
|
|
1001
|
+
const status = task.status.replace("_", " ");
|
|
1002
|
+
sendToParent({
|
|
1003
|
+
customType: AGENTS_STALE_NOTICE_TYPE,
|
|
1004
|
+
content: `Subagent ${task.agent} (task ${task.id}, "${task.label}") ${status} about ${ageSeconds}s ago while you were busy. Its report was not replayed here; call subagent_result with task_id ${task.id} to read it.`,
|
|
1005
|
+
display: false,
|
|
1006
|
+
details: taskDetails(task),
|
|
1007
|
+
}, route);
|
|
1008
|
+
// The notice already reached the parent; a failed card must not resend it.
|
|
1009
|
+
try { pi.appendEntry(AGENTS_STALE_RESULT_TYPE, { taskId: task.id, agent: task.agent, label: task.label, status: task.status, ageSeconds }); } catch { /* TUI-only content. */ }
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
const deliverMessage = (msg: PendingAgentMessage, route: Exclude<ParentRoute, "hold">) => {
|
|
1013
|
+
if (activeSessionId() !== msg.parentSessionId) return;
|
|
1014
|
+
sendToParent({ customType: AGENTS_MESSAGE_TYPE, content: msg.content, display: msg.display, details: msg.details }, route);
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
// Child content leaves its queue only when the parent can receive it. A
|
|
1018
|
+
// held or undeliverable flush leaves everything queued for a later boundary;
|
|
1019
|
+
// `rethrow` lets a query report a stale parent back to its child.
|
|
1020
|
+
const deliveryRoute = (rethrow: boolean): Exclude<ParentRoute, "hold"> | undefined => {
|
|
1021
|
+
try {
|
|
1022
|
+
const route = parentRoute();
|
|
1023
|
+
return route === "hold" ? undefined : route;
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
if (rethrow) throw error;
|
|
1026
|
+
return undefined;
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const flushMessages = (rethrow = false) => {
|
|
1031
|
+
const route = deliveryRoute(rethrow);
|
|
1032
|
+
if (!route) return;
|
|
1033
|
+
for (const msg of messages.takeDeliverable(deps.now(), activeSessionId() ?? "", isTaskLive)) {
|
|
1034
|
+
try {
|
|
1035
|
+
deliverMessage(msg, route);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
if (rethrow) throw error;
|
|
1038
|
+
/* Best-effort delivery: at most once, even if forwarding fails. */
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
713
1041
|
};
|
|
714
1042
|
|
|
715
1043
|
const flushCompletions = () => {
|
|
716
|
-
|
|
1044
|
+
const route = deliveryRoute(false);
|
|
1045
|
+
if (!route) return;
|
|
1046
|
+
for (const entry of completions.takeDeliverable(deps.now())) {
|
|
1047
|
+
try {
|
|
1048
|
+
if (entry.stale) deliverStale(entry.task, entry.settledAt, route);
|
|
1049
|
+
else deliver(entry.task, route);
|
|
1050
|
+
} catch {
|
|
1051
|
+
// The parent is told never to poll, so a failed forward must not
|
|
1052
|
+
// lose the completion: it waits for the next boundary instead, and
|
|
1053
|
+
// an idle parent, which has none coming, gets a bounded retry.
|
|
1054
|
+
completions.requeue(entry);
|
|
1055
|
+
if (route === "idle") armDeliveryRetry();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
// A background job's exit notice rides the same router as a completion. It
|
|
1061
|
+
// is compact (no full output), so it has no stale form; a failed forward
|
|
1062
|
+
// requeues it like a completion does.
|
|
1063
|
+
const flushJobNotices = () => {
|
|
1064
|
+
const route = deliveryRoute(false);
|
|
1065
|
+
if (!route) return;
|
|
1066
|
+
const failed: JobNotice[] = [];
|
|
1067
|
+
for (const notice of jobNotices.splice(0)) {
|
|
1068
|
+
const { job } = notice;
|
|
1069
|
+
if (activeSessionId() !== job.ownerSessionId) continue;
|
|
1070
|
+
const content = notice.kind === "events" ? monitorEventsText(job, notice.batch)
|
|
1071
|
+
: notice.kind === "stopped" ? monitorStoppedText(job, notice.reason) : jobNoticeText(job);
|
|
1072
|
+
const event = notice.kind === "events" ? "events" : notice.kind === "stopped" ? notice.reason : undefined;
|
|
717
1073
|
try {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
1074
|
+
sendToParent({ customType: JOB_NOTICE_TYPE, content, display: true, details: jobDetails(job, event) }, route);
|
|
1075
|
+
} catch {
|
|
1076
|
+
failed.push(notice);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (failed.length === 0) return;
|
|
1080
|
+
jobNotices.unshift(...failed);
|
|
1081
|
+
if (route === "idle") armDeliveryRetry();
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
const flushAll = () => {
|
|
1085
|
+
flushMessages();
|
|
1086
|
+
flushCompletions();
|
|
1087
|
+
flushJobNotices();
|
|
1088
|
+
// A wake left owed by an earlier held or expired attempt is retried here.
|
|
1089
|
+
requestWake();
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
// Pi still reports compaction while these handlers run (a manual
|
|
1093
|
+
// session_compact, an automatic compaction's session_compact_failed), so
|
|
1094
|
+
// held content is flushed once on the next scheduler tick instead. A parent
|
|
1095
|
+
// still busy then keeps it held for the next boundary; nothing polls.
|
|
1096
|
+
const scheduleBoundaryFlush = () => {
|
|
1097
|
+
if (cancelBoundaryFlush) return;
|
|
1098
|
+
cancelBoundaryFlush = deps.schedule(() => {
|
|
1099
|
+
cancelBoundaryFlush = undefined;
|
|
1100
|
+
flushAll();
|
|
1101
|
+
}, 0);
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
// Session changes discard every pending wake and boundary flush.
|
|
1105
|
+
const resetParentDelivery = (ctx: ExtensionContext | undefined) => {
|
|
1106
|
+
helperPermission.clear(); // Revoke before dropping old callback authority.
|
|
1107
|
+
parentCtx = ctx;
|
|
1108
|
+
restoreBridgeWakeIdentity(ctx);
|
|
1109
|
+
wakeVisibilityWarning = false;
|
|
1110
|
+
parentRunActive = false;
|
|
1111
|
+
wakeOwed = false;
|
|
1112
|
+
endPromptStart();
|
|
1113
|
+
cancelBoundaryFlush?.();
|
|
1114
|
+
cancelBoundaryFlush = undefined;
|
|
1115
|
+
endDeliveryRetry();
|
|
1116
|
+
deliveryRetries = 0;
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
// Structured descriptions feed Status; native hosts keep the compact count.
|
|
1120
|
+
const refreshJobStatus = () => {
|
|
1121
|
+
const sessionId = activeSessionId();
|
|
1122
|
+
const running = jobs.list(sessionId ?? "").filter((job) => job.status === "running")
|
|
1123
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
1124
|
+
try { parentCtx?.ui.setStatus(JOBS_STATUS_KEY, running.length === 0 ? undefined : `${JOB_GLYPH} ${running.length} job${running.length === 1 ? "" : "s"}`); } catch { /* Status is cosmetic. */ }
|
|
1125
|
+
if (sessionId) {
|
|
1126
|
+
try { pi.events.emit(JOBS_SIDEBAR_EVENT, { sessionId, snapshot: { jobs: running.map(({ id, label, startedAt }) => ({ id, label, startedAt })) } }); } catch { /* Display only. */ }
|
|
721
1127
|
}
|
|
722
1128
|
};
|
|
723
1129
|
|
|
1130
|
+
// A job exit settles like a completion below: flushed at once for an idle
|
|
1131
|
+
// parent, at the next turn boundary for a busy one.
|
|
1132
|
+
// Line events still waiting for a busy parent coalesce per monitor.
|
|
1133
|
+
const queueJobNotice = (notice: JobNotice) => {
|
|
1134
|
+
// Merge into this monitor's latest pending notice when that is still
|
|
1135
|
+
// an events notice, so events never move past its own end.
|
|
1136
|
+
let index = -1;
|
|
1137
|
+
for (let i = jobNotices.length - 1; i >= 0; i--) if (jobNotices[i]!.job === notice.job) { index = i; break; }
|
|
1138
|
+
const pending = index >= 0 ? jobNotices[index]! : undefined;
|
|
1139
|
+
if (notice.kind === "events" && pending?.kind === "events") {
|
|
1140
|
+
jobNotices[index] = { ...pending, batch: mergeMonitorBatches(pending.batch, notice.batch) };
|
|
1141
|
+
} else {
|
|
1142
|
+
jobNotices.push(notice);
|
|
1143
|
+
}
|
|
1144
|
+
refreshJobStatus();
|
|
1145
|
+
requestRender();
|
|
1146
|
+
if (activeAgentRuns === 0) flushAll();
|
|
1147
|
+
};
|
|
1148
|
+
const settleJob = (job: JobRecord) => {
|
|
1149
|
+
// A monitor's pending lines are delivered before its end.
|
|
1150
|
+
if (job.kind === "monitor") monitors.finish(job);
|
|
1151
|
+
queueJobNotice({ job, kind: "exit" });
|
|
1152
|
+
};
|
|
1153
|
+
|
|
724
1154
|
// A completion settles into our queue. An idle parent flushes right away so
|
|
725
1155
|
// the wake-up behavior is unchanged; a busy parent flushes at the next turn
|
|
726
1156
|
// boundary, and the steer mode injects it before that turn's next LLM call
|
|
727
1157
|
// instead of parking it behind the whole run.
|
|
728
1158
|
const settleCompletion = (task: TaskRecord) => {
|
|
1159
|
+
messages.invalidateTask(task.id);
|
|
729
1160
|
completions.enqueue(task, deps.now());
|
|
730
|
-
if (activeAgentRuns === 0)
|
|
1161
|
+
if (activeAgentRuns === 0) flushAll();
|
|
731
1162
|
};
|
|
732
1163
|
|
|
733
1164
|
// `agent_start`/`agent_end` bracket a parent agent run; `turn_end` fires at
|
|
@@ -737,35 +1168,62 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
737
1168
|
// a final `turn_end` (an aborted run, or the host's early post-run return
|
|
738
1169
|
// when a run produced no assistant message; the host compensates via
|
|
739
1170
|
// hasQueuedMessages() + continue(), so steering there is still bounded).
|
|
740
|
-
// `agent_settled` is the final idle boundary after retries
|
|
741
|
-
//
|
|
742
|
-
|
|
1171
|
+
// `agent_settled` is the final idle boundary after retries: it ends the
|
|
1172
|
+
// host run and flushes anything held, including content held while a
|
|
1173
|
+
// compaction ran inside or right before the run. `before_agent_start`
|
|
1174
|
+
// marks a prompt that is about to start a run, and the compaction events
|
|
1175
|
+
// release content held while the parent compacted without a run.
|
|
1176
|
+
pi.on("before_agent_start", () => {
|
|
1177
|
+
if (!parentRunActive) beginPromptStart();
|
|
1178
|
+
});
|
|
1179
|
+
pi.on("agent_start", () => {
|
|
1180
|
+
activeAgentRuns += 1;
|
|
1181
|
+
parentRunActive = true;
|
|
1182
|
+
// The run carries everything stored while the parent was idle.
|
|
1183
|
+
wakeOwed = false;
|
|
1184
|
+
endDeliveryRetry();
|
|
1185
|
+
deliveryRetries = 0;
|
|
1186
|
+
endPromptStart();
|
|
1187
|
+
});
|
|
743
1188
|
pi.on("agent_end", () => {
|
|
744
1189
|
activeAgentRuns = Math.max(0, activeAgentRuns - 1);
|
|
745
|
-
|
|
1190
|
+
flushAll();
|
|
1191
|
+
});
|
|
1192
|
+
pi.on("agent_settled", () => {
|
|
1193
|
+
parentRunActive = false;
|
|
1194
|
+
endPromptStart();
|
|
1195
|
+
flushAll();
|
|
746
1196
|
});
|
|
747
|
-
pi.on("
|
|
748
|
-
pi.on("
|
|
1197
|
+
pi.on("turn_end", () => flushAll());
|
|
1198
|
+
pi.on("session_compact", scheduleBoundaryFlush);
|
|
1199
|
+
pi.on("session_compact_failed", scheduleBoundaryFlush);
|
|
749
1200
|
|
|
750
1201
|
const runner = new AgentRunner(store, loadAgentsConfig({ cwd: process.cwd(), home: deps.home, agentHome }), deps, {
|
|
751
1202
|
askUser: (_taskId, ask, raw) => answerThroughUi(ui, ask, raw),
|
|
752
1203
|
onNotification: (task, message) => {
|
|
753
|
-
if (activeSessionId() !== task.parentSessionId) return false;
|
|
754
|
-
|
|
1204
|
+
if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false;
|
|
1205
|
+
messages.enqueueNotification(task, message, deps.now());
|
|
1206
|
+
if (activeAgentRuns === 0) flushMessages();
|
|
755
1207
|
return true;
|
|
756
1208
|
},
|
|
757
1209
|
onQuery: (task, requestId, message) => {
|
|
758
|
-
if (activeSessionId() !== task.parentSessionId) return false;
|
|
1210
|
+
if (activeSessionId() !== task.parentSessionId || isFinished(task.status)) return false;
|
|
759
1211
|
const hadYield = yieldedTaskIds.has(task.id);
|
|
760
1212
|
if (task.mode === AGENT_MODE.TASK) yieldedTaskIds.add(task.id);
|
|
761
1213
|
try {
|
|
762
|
-
|
|
1214
|
+
messages.enqueueQuery(task, requestId, message, deps.now());
|
|
1215
|
+
if (activeAgentRuns === 0) flushMessages(true);
|
|
763
1216
|
return true;
|
|
764
1217
|
} catch (error) {
|
|
1218
|
+
messages.expireQuery(task.id, requestId);
|
|
765
1219
|
if (task.mode === AGENT_MODE.TASK && !hadYield) yieldedTaskIds.delete(task.id);
|
|
766
1220
|
throw error;
|
|
767
1221
|
}
|
|
768
1222
|
},
|
|
1223
|
+
onQuerySettled: (taskId, requestId, outcome) => {
|
|
1224
|
+
if (outcome === "replied") messages.consumeQuery(taskId, requestId);
|
|
1225
|
+
else messages.expireQuery(taskId, requestId);
|
|
1226
|
+
},
|
|
769
1227
|
onSuccessfulMutation: (task, tool) => {
|
|
770
1228
|
// Same-clone registry attribution remains unchanged. A foreign task
|
|
771
1229
|
// uses a separately bound, live-grant path only for successful tool evidence.
|
|
@@ -813,7 +1271,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
813
1271
|
}
|
|
814
1272
|
else if (resolvedPath !== undefined) noteDrop("evidence-path-mismatch");
|
|
815
1273
|
}
|
|
816
|
-
if (!foreignTask) recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId });
|
|
1274
|
+
if (!foreignTask) recordReviewMutation(pi, sessions, root, { source: "subagent", taskId: task.id, toolName: tool.toolName, toolCallId: tool.toolCallId, ...runtimeWriterProfile(task) });
|
|
817
1275
|
},
|
|
818
1276
|
onFinish: (task, observations) => {
|
|
819
1277
|
// Completion is the only forwarding opportunity. No pending event, policy
|
|
@@ -831,6 +1289,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
831
1289
|
} catch { /* Metrics must never interrupt task finalization. */ }
|
|
832
1290
|
try {
|
|
833
1291
|
ownedTaskIds.delete(task.id);
|
|
1292
|
+
messages.invalidateTask(task.id);
|
|
834
1293
|
requestRender();
|
|
835
1294
|
persist(task);
|
|
836
1295
|
const yielded = yieldedTaskIds.delete(task.id);
|
|
@@ -839,6 +1298,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
839
1298
|
},
|
|
840
1299
|
});
|
|
841
1300
|
|
|
1301
|
+
registerBackgroundJobTools(pi, { registry: jobs, monitors, sessionId: (ctx) => ctx.sessionManager.getSessionId() ?? "", now: deps.now, onChange: () => { refreshJobStatus(); requestRender(); } });
|
|
1302
|
+
|
|
842
1303
|
pi.registerMessageRenderer(AGENTS_MESSAGE_TYPE, (message, options, theme) => {
|
|
843
1304
|
const details = (message.details as { gentleAgents?: { taskId?: unknown; agent?: unknown } } | undefined)?.gentleAgents;
|
|
844
1305
|
const taskId = typeof details?.taskId === "string" ? details.taskId : "unknown";
|
|
@@ -849,22 +1310,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
849
1310
|
});
|
|
850
1311
|
|
|
851
1312
|
pi.registerMessageRenderer(AGENTS_ORCHESTRATOR_MESSAGE_TYPE, (message, options, theme) => {
|
|
852
|
-
const details = (message.details as { gentleAgents?:
|
|
853
|
-
|
|
854
|
-
const heading = `⇄ Orchestrator message · Received · From ${sanitizeTerminalText(sender)}`;
|
|
855
|
-
const body = sanitizeTerminalText(messageText(message.content));
|
|
856
|
-
return new Text(`${theme.fg("customMessageLabel", heading)}\n${theme.fg("customMessageText", body)}`, options.outputPad, 0);
|
|
1313
|
+
const details = (message.details as { gentleAgents?: OrchestratorMessageDetails } | undefined)?.gentleAgents ?? {};
|
|
1314
|
+
return incomingMessageCard(details, messageText(message.content), options.expanded, theme, expandHint(options.expanded));
|
|
857
1315
|
});
|
|
858
1316
|
|
|
859
1317
|
pi.registerMessageRenderer(AGENTS_RESULT_TYPE, (message, options, theme) => {
|
|
860
1318
|
const details = (message.details as { gentleAgents?: { agent?: string; status?: string } } | undefined)?.gentleAgents;
|
|
861
1319
|
const content = message.content as string | Array<{ type: string; text?: string }>;
|
|
862
|
-
const
|
|
1320
|
+
const text = typeof content === "string" ? content : content.map((part) => (part.type === "text" ? (part.text ?? "") : "")).join("\n");
|
|
1321
|
+
const body = options.expanded ? text.split("\n") : agentResultPreview(text).split("\n").filter((line) => line.trim() !== "");
|
|
863
1322
|
const tone = details?.status === "completed" ? CARD_TONE.SUCCESS : CARD_TONE.ERROR;
|
|
864
1323
|
const hint = expandHint(options.expanded);
|
|
865
1324
|
return {
|
|
866
1325
|
render(width: number) {
|
|
867
|
-
return renderCard({ title: "Agent result", subtitle: details?.agent, body, tone, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, hint });
|
|
1326
|
+
return renderCard({ title: "Agent result", subtitle: details?.agent, body, tone, glyph: AGENTS_GLYPH }, theme, width, { expanded: options.expanded, previewRows: 3, hint });
|
|
868
1327
|
},
|
|
869
1328
|
invalidate() {},
|
|
870
1329
|
};
|
|
@@ -886,7 +1345,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
886
1345
|
];
|
|
887
1346
|
return {
|
|
888
1347
|
render(width: number) {
|
|
889
|
-
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) });
|
|
1348
|
+
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) });
|
|
890
1349
|
},
|
|
891
1350
|
invalidate() {},
|
|
892
1351
|
};
|
|
@@ -953,6 +1412,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
953
1412
|
return stored?.task;
|
|
954
1413
|
};
|
|
955
1414
|
|
|
1415
|
+
// A guessed id ("1") leads back to real ids instead of a dead end, so the
|
|
1416
|
+
// parent retries the same call rather than re-summarizing it (gentle-shell#1713).
|
|
1417
|
+
const unknownTask = (id: unknown, ctx: ExtensionContext | undefined) => {
|
|
1418
|
+
const recent = [...store.list(ctx?.sessionManager?.getSessionId() ?? "")].sort((a, b) => b.createdAt - a.createdAt).slice(0, 5);
|
|
1419
|
+
const hint = recent.length ? ` Recent task ids: ${recent.map((task) => `${task.id} (${task.agent})`).join(", ")}.` : "";
|
|
1420
|
+
return text(`Error: no task ${String(id)}.${hint}`, { error: "unknown task" });
|
|
1421
|
+
};
|
|
1422
|
+
|
|
956
1423
|
// Restores this exact session's own finished subagents as visible history
|
|
957
1424
|
// on an explicit resume, or on startup into a session that already has
|
|
958
1425
|
// entries -- never on new, fork, or reload (see the session_start handler's
|
|
@@ -967,12 +1434,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
967
1434
|
} catch {
|
|
968
1435
|
return;
|
|
969
1436
|
}
|
|
970
|
-
//
|
|
971
|
-
// must never
|
|
972
|
-
if (ctx.sessionManager.getSessionId() !== sessionId) return;
|
|
973
|
-
// Fire-and-forget from session_start: a throwing summary subscriber must
|
|
974
|
-
// never surface as an unhandled rejection. History is best-effort.
|
|
1437
|
+
// Fire-and-forget from session_start: a stale SDK context or throwing
|
|
1438
|
+
// summary subscriber must never surface as an unhandled rejection.
|
|
975
1439
|
try {
|
|
1440
|
+
// The session may have moved on while disk was read; a stale restore
|
|
1441
|
+
// must never land in the wrong session's store.
|
|
1442
|
+
if (ctx.sessionManager.getSessionId() !== sessionId) return;
|
|
976
1443
|
for (const { task, thread } of history) {
|
|
977
1444
|
if (task.parentSessionId !== sessionId) continue;
|
|
978
1445
|
if (store.restore(task, thread)) restoredTaskIds.add(task.id);
|
|
@@ -990,6 +1457,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
990
1457
|
let overlayHost: { requestRender(force?: boolean): void; stop(): void; start(): void } | undefined;
|
|
991
1458
|
const chosen = await ctx.ui.custom<TaskRecord | null>(
|
|
992
1459
|
(tui, theme, _keybindings, done) => {
|
|
1460
|
+
const close = withOverlayRepaint(tui, done);
|
|
993
1461
|
overlayHost = tui;
|
|
994
1462
|
view = new AgentsView({
|
|
995
1463
|
theme,
|
|
@@ -1004,8 +1472,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1004
1472
|
onCancel: (task) => void stopSelected(task, ctx),
|
|
1005
1473
|
canCancel: isOwnedActive,
|
|
1006
1474
|
isLocalTask: (task) => !restoredTaskIds.has(task.id),
|
|
1007
|
-
onOpen: (task) =>
|
|
1008
|
-
onClose: () =>
|
|
1475
|
+
onOpen: (task) => close(task),
|
|
1476
|
+
onClose: () => close(null),
|
|
1009
1477
|
requestRender: () => tui.requestRender(),
|
|
1010
1478
|
});
|
|
1011
1479
|
overlays.add(view);
|
|
@@ -1058,6 +1526,14 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1058
1526
|
const showWidget = (ctx: ExtensionContext) => {
|
|
1059
1527
|
ui = ctx.hasUI ? ctx.ui : undefined;
|
|
1060
1528
|
sessions = ctx.sessionManager;
|
|
1529
|
+
agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
|
|
1530
|
+
stopVisualUpdates?.();
|
|
1531
|
+
stopVisualUpdates = pi.events.on(VISUAL_SETTINGS_CHANGED, (event) => {
|
|
1532
|
+
if ((event as { configHome?: unknown } | undefined)?.configHome !== visualHome) return;
|
|
1533
|
+
agentsVisible = resolveVisualSettings({ gentlePiConfigHome: visualHome }).settings.visibility.agents;
|
|
1534
|
+
if (sidebarTui) invalidateSidebar(sidebarTui);
|
|
1535
|
+
host?.requestRender();
|
|
1536
|
+
});
|
|
1061
1537
|
tickClock();
|
|
1062
1538
|
// Agents is not a rail part: the fullscreen sidebar only suppresses
|
|
1063
1539
|
// bottom components registered through sidebarPart, and this widget is
|
|
@@ -1067,6 +1543,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1067
1543
|
sidebarTui = tui;
|
|
1068
1544
|
return {
|
|
1069
1545
|
render(width: number) {
|
|
1546
|
+
if (!agentsVisible) return [];
|
|
1070
1547
|
const lines = renderAgentsCard(visibleTasks(), theme, width, deps.now(), { collapsed, collapseKey, maxRows: widgetRows(tui.terminal?.rows), viewKey });
|
|
1071
1548
|
return lines.length === 0 ? [] : [...lines, ""];
|
|
1072
1549
|
},
|
|
@@ -1077,15 +1554,50 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1077
1554
|
|
|
1078
1555
|
const roots = (ctx: ExtensionContext) => ({ cwd: ctx.sessionManager.getCwd(), home: deps.home, agentHome });
|
|
1079
1556
|
|
|
1080
|
-
const buildRequest = async (ctx: ExtensionContext, agent: AgentDefinition, prompt: string, label: string | undefined, context: string | undefined, mode: AgentMode, resume?: string, workspaceRoot?: string,
|
|
1557
|
+
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> => {
|
|
1558
|
+
if (retiredSddAgent(agent.name)) throw new Error("Retired SDD agents cannot be dispatched.");
|
|
1559
|
+
if (![AGENT_MODE.TASK, AGENT_MODE.BACKGROUND].includes(mode)) throw new Error("Subagent mode must be task or background.");
|
|
1560
|
+
if (isSingleShotMode(ctx.mode) && mode === AGENT_MODE.BACKGROUND) throw new Error(SINGLE_SHOT_BACKGROUND_ERROR);
|
|
1561
|
+
const scopeDenied = rejectUnscopedBoundedWriterDispatch({ agent: agent.name, task: prompt, context });
|
|
1562
|
+
if (scopeDenied) throw new Error(scopeDenied.reason);
|
|
1081
1563
|
if (signal?.aborted) throw new Error("Subagent launch aborted before authorization.");
|
|
1082
1564
|
const registry = registryFor(ctx);
|
|
1565
|
+
const authorityCurrent = worktreeAuthority!;
|
|
1566
|
+
const originalManager = ctx.sessionManager;
|
|
1567
|
+
const originalId = originalManager.getSessionId();
|
|
1568
|
+
const originalCwd = originalManager.getCwd();
|
|
1569
|
+
const current = () => sessions === originalManager && originalManager.getSessionId() === originalId && originalManager.getCwd() === originalCwd && !signal?.aborted && authorityCurrent();
|
|
1570
|
+
if (isGenericBoundedWriter(agent.name) && !current()) throw new Error("Writer session Git authority changed before admission.");
|
|
1571
|
+
let admittedModel: string | undefined;
|
|
1572
|
+
const surfaces = allowedEditSurfaces(prompt, context);
|
|
1573
|
+
// gentle-shell#1064 slice 2: the binding is read once per task request,
|
|
1574
|
+
// before admission, so the admitted model and the launch routing resolve
|
|
1575
|
+
// the same session layer and can never disagree about it (#1558: a bound
|
|
1576
|
+
// session used to kill its own non-git writer mid-preparation because
|
|
1577
|
+
// admission still read only the pin/global layers).
|
|
1578
|
+
const sessionBinding = readSessionProfileBinding(ctx.sessionManager.getSessionId());
|
|
1579
|
+
if (!resume && isGenericBoundedWriter(agent.name) && surfaces?.some(isDevelopmentSurface) && !deps.resolveWorktree(originalCwd, originalCwd) && repositoryRoot === undefined) {
|
|
1580
|
+
const root = safeBootstrapDirectory(originalCwd);
|
|
1581
|
+
if (!root || (workspaceRoot !== undefined && (!isAbsolute(workspaceRoot) || safeBootstrapDirectory(workspaceRoot) !== root))) throw new Error("Writer bootstrap requires the original safe project root.");
|
|
1582
|
+
const config = withPinnedModelProfiles(
|
|
1583
|
+
loadAgentsConfig(roots(ctx)),
|
|
1584
|
+
sessionOrPinModelProfiles(
|
|
1585
|
+
sessionBinding?.modelProfiles,
|
|
1586
|
+
resolveUnversionedProjectProfile(root, gentlePiConfigHome(deps.env))?.modelProfiles,
|
|
1587
|
+
),
|
|
1588
|
+
);
|
|
1589
|
+
const model = resolveAgentProfile(agent, config).model ?? ctx.model;
|
|
1590
|
+
const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
|
|
1591
|
+
if (!catalogModel) throw new Error("Writer bootstrap requires a valid effective model in this session's catalog.");
|
|
1592
|
+
admittedModel = `${catalogModel.provider}/${catalogModel.id}`;
|
|
1593
|
+
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.");
|
|
1594
|
+
}
|
|
1083
1595
|
const parentCwd = ctx.sessionManager.getCwd();
|
|
1084
1596
|
// An explicit target is validated before any queue or session-dir writes.
|
|
1085
1597
|
const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd);
|
|
1086
1598
|
if (repositoryRoot !== undefined && workspaceRoot !== undefined) throw new Error("repository_root and workspace_root are mutually exclusive.");
|
|
1087
|
-
if (repositoryRoot !== undefined && (
|
|
1088
|
-
const selectedRoot = repositoryRoot ?? workspaceRoot
|
|
1599
|
+
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.");
|
|
1600
|
+
const selectedRoot = repositoryRoot ?? workspaceRoot;
|
|
1089
1601
|
// Preserve ordinary non-Git continuation, without admitting any new root.
|
|
1090
1602
|
const sameNonGitContinuation = resume !== undefined && repositoryRoot === undefined && selectedRoot === parentCwd && !parentIdentity;
|
|
1091
1603
|
let foreign = false;
|
|
@@ -1099,7 +1611,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1099
1611
|
// same-clone registry. Never use a caller-injected resolver for this identity.
|
|
1100
1612
|
const identity = resolveSessionWorktree(selectedRoot, parentCwd);
|
|
1101
1613
|
const canonicalParent = resolveSessionWorktree(parentCwd, parentCwd);
|
|
1102
|
-
if (!identity || (canonicalParent && identity.commonDir === canonicalParent.commonDir) || selectedRoot !== identity.root || !isAbsolute(selectedRoot) || realpathSync(selectedRoot) !== selectedRoot
|
|
1614
|
+
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.");
|
|
1103
1615
|
await foreignGrants.authorize(ctx, identity, { continuation: resume !== undefined, signal });
|
|
1104
1616
|
foreignIdentity = identity;
|
|
1105
1617
|
foreignParent = canonicalParent;
|
|
@@ -1107,12 +1619,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1107
1619
|
foreign = true;
|
|
1108
1620
|
}
|
|
1109
1621
|
} else target = parentIdentity?.root;
|
|
1110
|
-
if (sddChange && target !== sddChange.workspaceRoot && target !== resolve(sddChange.workspaceRoot)) {
|
|
1111
|
-
throw new Error("sdd_change workspaceRoot must resolve to the selected child worktree.");
|
|
1112
|
-
}
|
|
1113
|
-
const launchSddChange = sddChange === undefined || target === undefined
|
|
1114
|
-
? undefined
|
|
1115
|
-
: { ...sddChange, workspaceRoot: target };
|
|
1116
1622
|
// A per-repository profile pin replaces subagent routing for this launch without
|
|
1117
1623
|
// materialising anything: the global stores stay untouched, so two repositories
|
|
1118
1624
|
// worked in parallel stop fighting over one global routing. The child's own
|
|
@@ -1124,16 +1630,28 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1124
1630
|
const pinIdentity: WorktreeResolver = foreign ? resolveSessionWorktree : target !== undefined && parentIdentity !== undefined && target === parentIdentity.root
|
|
1125
1631
|
? () => parentIdentity
|
|
1126
1632
|
: deps.resolveWorktree;
|
|
1633
|
+
// gentle-shell#1064 slice 1: a parent-session profile binding outranks the
|
|
1634
|
+
// pin layers for launches from that session (`session → p → P → global`),
|
|
1635
|
+
// with the same wholesale-replacement contract as the pin. The binding is
|
|
1636
|
+
// resolved here, at task-request creation, so queued and running children
|
|
1637
|
+
// keep the routing frozen into their requests even if the session rebinds.
|
|
1127
1638
|
const config = withPinnedModelProfiles(
|
|
1128
1639
|
loadAgentsConfig(roots(ctx)),
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1640
|
+
sessionOrPinModelProfiles(
|
|
1641
|
+
sessionBinding?.modelProfiles,
|
|
1642
|
+
resolveProfilePin({
|
|
1643
|
+
cwd: target ?? parentCwd,
|
|
1644
|
+
configHome: gentlePiConfigHome(deps.env),
|
|
1645
|
+
resolveWorktree: pinIdentity,
|
|
1646
|
+
})?.modelProfiles,
|
|
1647
|
+
),
|
|
1134
1648
|
);
|
|
1135
1649
|
const profile = resolveAgentProfile(agent, config);
|
|
1136
|
-
|
|
1650
|
+
if (admittedModel !== undefined) {
|
|
1651
|
+
const model = profile.model ?? ctx.model;
|
|
1652
|
+
const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
|
|
1653
|
+
if (!catalogModel || `${catalogModel.provider}/${catalogModel.id}` !== admittedModel || !current()) throw new Error("Writer effective profile or session changed during preparation.");
|
|
1654
|
+
}
|
|
1137
1655
|
const sessionDir = agentRuntimePaths(deps.home, agentHome).sessions;
|
|
1138
1656
|
if (foreign && target) {
|
|
1139
1657
|
const identity = resolveSessionWorktree(target, parentCwd);
|
|
@@ -1146,21 +1664,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1146
1664
|
const parentSessionId = ctx.sessionManager.getSessionId() ?? "";
|
|
1147
1665
|
const parentWorktreeRoot = ctx.sessionManager.getCwd();
|
|
1148
1666
|
const parentRepositoryIdentity = resolveCanonicalGitRepositoryIdentitySync(parentWorktreeRoot);
|
|
1149
|
-
const sddPreflightContext = SHIPPED_SDD_AGENT_NAME_SET.has(agent.name)
|
|
1150
|
-
? extractParentConfirmedSddPreflightContext(context)
|
|
1151
|
-
: undefined;
|
|
1152
1667
|
const childEnv = { ...deps.env };
|
|
1153
1668
|
if (foreign) for (const key of inheritedUnsafeGitEnvironmentKeys(childEnv)) delete childEnv[key];
|
|
1154
1669
|
const request: TaskRequest = {
|
|
1155
|
-
agent
|
|
1156
|
-
remediationIntent,
|
|
1670
|
+
agent,
|
|
1157
1671
|
prompt,
|
|
1158
1672
|
label,
|
|
1159
1673
|
context,
|
|
1160
|
-
...(sddPreflightContext === undefined ? {} : { sddPreflightContext }),
|
|
1161
1674
|
mode,
|
|
1162
1675
|
cwd: target ?? parentWorktreeRoot,
|
|
1163
1676
|
parentSessionId,
|
|
1677
|
+
...(admittedModel === undefined ? {} : { beforeSpawn: () => {
|
|
1678
|
+
if (!current()) throw new Error("Writer session changed before spawn.");
|
|
1679
|
+
registry.validate(originalCwd);
|
|
1680
|
+
} }),
|
|
1164
1681
|
...(foreign && target ? { beforeSpawn: () => {
|
|
1165
1682
|
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.");
|
|
1166
1683
|
const identity = resolveSessionWorktree(target, parentCwd);
|
|
@@ -1173,9 +1690,11 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1173
1690
|
thinking: profile.thinking,
|
|
1174
1691
|
sessionDir,
|
|
1175
1692
|
resumeSessionPath: resume,
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1693
|
+
...childExtensionRequest(deps.env, deps.childExtensionPaths),
|
|
1694
|
+
// A writer claims its surfaces in its canonical worktree root for its queued
|
|
1695
|
+
// and running lifetime; a continuation is a new task and claims them again.
|
|
1696
|
+
...(isBoundedWriter(agent.name) && surfaces ? { writerSurfaces: surfaces, writerRoot: canonicalWriterRoot(target ?? parentWorktreeRoot, foreign ? resolveSessionWorktree : deps.resolveWorktree) } : {}),
|
|
1697
|
+
env: childEnv,
|
|
1179
1698
|
...(foreign || parentRepositoryIdentity === undefined ? {} : {
|
|
1180
1699
|
authorizeParentStandingReviewPermission: (repositoryIdentity: string) => {
|
|
1181
1700
|
try {
|
|
@@ -1198,21 +1717,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1198
1717
|
return request;
|
|
1199
1718
|
};
|
|
1200
1719
|
|
|
1201
|
-
const launch = async (ctx: ExtensionContext, request: TaskRequest, signal?: AbortSignal): Promise<ToolText> => {
|
|
1202
|
-
if (ctx.mode
|
|
1203
|
-
|
|
1204
|
-
}
|
|
1205
|
-
// This is the process-spawn boundary. A child receives its task context only
|
|
1206
|
-
// after its RPC process starts, so validate the single parent transport here
|
|
1207
|
-
// rather than letting a child invent/persist preferences during startup.
|
|
1208
|
-
if (SHIPPED_SDD_AGENT_NAME_SET.has(request.agent.name) && !isParentConfirmedSddPreflightContext(request.context)) {
|
|
1209
|
-
throw new Error("SDD child dispatch refused: parent-confirmed SDD preflight context is missing or malformed.");
|
|
1210
|
-
}
|
|
1211
|
-
if (request.agent.name === "sdd-remediate") {
|
|
1212
|
-
const native = deps.nativeSdd ?? new NativeReviewCliV216(createNodeExecFileAdapter());
|
|
1213
|
-
Object.assign(request, await admitManagedRemediation(request, request.remediationIntent, native, ctx));
|
|
1214
|
-
if (activeSessionId() !== request.parentSessionId) throw new Error("Parent session changed; request fresh authorization before dispatch");
|
|
1215
|
-
}
|
|
1720
|
+
const launch = async (ctx: ExtensionContext, request: TaskRequest, signal?: AbortSignal, publishWork?: (id: string) => void): Promise<ToolText> => {
|
|
1721
|
+
if (isSingleShotMode(ctx.mode) && request.mode === AGENT_MODE.BACKGROUND) throw new Error(SINGLE_SHOT_BACKGROUND_ERROR);
|
|
1722
|
+
if (retiredSddAgent(request.agent.name)) throw new Error("Retired SDD agents cannot be dispatched.");
|
|
1216
1723
|
|
|
1217
1724
|
// Bounded live observation only. Native send owns the fresh policy decision;
|
|
1218
1725
|
// child execution never starts a telemetry policy process or renewal timer.
|
|
@@ -1245,6 +1752,8 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1245
1752
|
const foreignRequest = foreignRequests.get(request);
|
|
1246
1753
|
if (launched && foreignRequest) foreignTasks.set(task.id, foreignRequest);
|
|
1247
1754
|
ownedTaskIds.add(task.id);
|
|
1755
|
+
publishWork?.(task.id);
|
|
1756
|
+
publishActivity(); // Admission's summary notification precedes runtime ownership.
|
|
1248
1757
|
store.subscribe(task.id, () => { publishActivity(); requestRender(); });
|
|
1249
1758
|
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));
|
|
1250
1759
|
// A tool call aborted by the host (a human interrupting the turn, a timeout)
|
|
@@ -1265,11 +1774,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1265
1774
|
const query = await runner.waitForQuery(task.id);
|
|
1266
1775
|
if (query) {
|
|
1267
1776
|
const live = store.get(task.id) ?? task;
|
|
1268
|
-
|
|
1777
|
+
messages.consumeQuery(task.id, query.requestId);
|
|
1778
|
+
const questionSuffix = query.message ? `\n\nQuestion:\n${query.message}` : "";
|
|
1779
|
+
return text(
|
|
1780
|
+
`Subagent ${live.agent} is waiting for your reply to request ${query.requestId}.${questionSuffix}`,
|
|
1781
|
+
{ gentleAgents: { taskId: live.id, agent: live.agent, status: live.status, mode: live.mode, requestId: query.requestId, ...(query.message ? { question: query.message } : {}) } },
|
|
1782
|
+
true,
|
|
1783
|
+
);
|
|
1269
1784
|
}
|
|
1270
1785
|
const finished = await runner.waitFor(task.id);
|
|
1271
1786
|
completions.consume(finished.id);
|
|
1272
|
-
|
|
1787
|
+
messages.invalidateTask(finished.id);
|
|
1788
|
+
// The id rides in the text too: details never reach the model, which
|
|
1789
|
+
// otherwise guesses ordinal ids for subagent_continue (#1731 T19).
|
|
1790
|
+
return text(completionText(finished), taskDetails(finished));
|
|
1273
1791
|
} finally {
|
|
1274
1792
|
signal?.removeEventListener("abort", onAbort);
|
|
1275
1793
|
}
|
|
@@ -1283,12 +1801,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1283
1801
|
description,
|
|
1284
1802
|
parameters: { type: "object", additionalProperties: false, ...parameters } as never,
|
|
1285
1803
|
renderCall(args, theme) {
|
|
1804
|
+
// The result title belongs with the result body so a running poll
|
|
1805
|
+
// can hide both without changing the tool call or its model output.
|
|
1806
|
+
if (name === "result") return { render: () => [], invalidate() {} };
|
|
1286
1807
|
const params = args as { agent?: string; task_id?: string };
|
|
1287
1808
|
return new Text(theme.fg("toolTitle", `${AGENTS_GLYPH} agent ${name.replace(/_/g, " ")}${params.agent ? ` · ${params.agent}` : params.task_id ? ` · ${params.task_id}` : ""}`), 0, 0);
|
|
1288
1809
|
},
|
|
1289
1810
|
renderResult(result, options, theme) {
|
|
1811
|
+
const task = (result.details as { gentleAgents?: { status?: string; taskId?: string } } | undefined)?.gentleAgents;
|
|
1812
|
+
if (name === "result" && task?.status === TASK_STATUS.RUNNING) {
|
|
1813
|
+
return { render: () => [], invalidate() {} };
|
|
1814
|
+
}
|
|
1290
1815
|
const body = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
|
|
1291
|
-
|
|
1816
|
+
const visibleBody = options.expanded ? body : theme.fg("muted", agentResultPreview(body).split("\n")[0] ?? "");
|
|
1817
|
+
const title = `${AGENTS_GLYPH} agent result${task?.taskId ? ` · ${sanitizeTerminalText(task.taskId)}` : ""}`;
|
|
1818
|
+
return new Text(name === "result" ? `${theme.fg("toolTitle", title)}\n${visibleBody}` : visibleBody, 0, 0);
|
|
1292
1819
|
},
|
|
1293
1820
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1294
1821
|
return execute(params as Record<string, unknown>, ctx, signal);
|
|
@@ -1299,25 +1826,150 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1299
1826
|
pi.registerTool({
|
|
1300
1827
|
name: "orchestrator_session_id",
|
|
1301
1828
|
label: "Orchestrator session ID",
|
|
1302
|
-
description: "Return this host session's
|
|
1303
|
-
|
|
1304
|
-
|
|
1829
|
+
description: "Return this host session's stable routing ID, Initial alias (FIRST TASK), Current alias (CURRENT TASK), and separate human Session name. Explicit subjects update Current even when named; Initial stays the first declared branch topic, unknown for legacy history. Before delegation or cross-session coordination, declare a short recognizable subject here. Do not require a subject declaration for small direct tasks; do not query all peers. Names never authenticate. Existing Pi names and human renames are preserved. Use a concise non-sensitive label, not a prompt. For work beyond small direct tasks, publish subject and current/next status here; refresh at task changes/completion. Owner-curated state (2048 UTF-8 bytes total): null withdraws, omission leaves unchanged. Never include credentials, internal instructions, or raw prompts. Historical notes are not consent or an owner reply.",
|
|
1830
|
+
...orchestratorToolRenderers("session", expandHint),
|
|
1831
|
+
parameters: { type: "object", additionalProperties: false, properties: {
|
|
1832
|
+
subject: { type: "string", maxLength: 120, description: "Optional short task subject; updates task aliases independently of human names. Also names an unnamed Pi session." },
|
|
1833
|
+
state: { anyOf: [{ type: "null" }, { type: "object", additionalProperties: false, properties: {
|
|
1834
|
+
objective: { type: "string" }, progress: { type: "string" }, decisions: { type: "string" }, blockers: { type: "string" },
|
|
1835
|
+
work: { type: "object", additionalProperties: false,
|
|
1836
|
+
description: "Optional historical, non-authoritative classification; topic requires area. Text plus work JSON fits 2048 UTF-8 bytes. Exact duplicate tags/refs are rejected; no ref resolution or routing.",
|
|
1837
|
+
properties: {
|
|
1838
|
+
...workDescriptorSchema.properties,
|
|
1839
|
+
tasks: { type: "object", maxProperties: 8, propertyNames: { type: "string", maxLength: 256 },
|
|
1840
|
+
additionalProperties: workDescriptorSchema,
|
|
1841
|
+
description: "Exact actual owner-declared task IDs, at most 256 UTF-8 bytes; controls, surrogates and __proto__/prototype/constructor rejected. Root-only; nonempty tasks alone are valid. Replacement omitting tasks clears annotations." },
|
|
1842
|
+
},
|
|
1843
|
+
},
|
|
1844
|
+
} }] },
|
|
1845
|
+
} } as never,
|
|
1846
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
1305
1847
|
const transport = activeTransportFor(ctx);
|
|
1306
|
-
|
|
1848
|
+
if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" });
|
|
1849
|
+
const { subject, state } = params as { subject?: unknown; state?: unknown };
|
|
1850
|
+
const declared = normalizeTaskSubject(subject);
|
|
1851
|
+
if (state !== undefined || declared) stateCache.publish(ctx.sessionManager, state, (type, data) => pi.appendEntry(type, data), Date.now(), declared);
|
|
1852
|
+
if (activeTransportFor(ctx) !== transport) throw new Error("stale-published-state");
|
|
1853
|
+
if (declared && !ctx.sessionManager.getSessionName?.()) pi.setSessionName(declared);
|
|
1854
|
+
const alias = sanitizeDisplayLabel(ctx.sessionManager.getSessionName?.() ?? "");
|
|
1855
|
+
const aliases = stateCache.get(ctx.sessionManager)?.aliases;
|
|
1856
|
+
if (state !== undefined || declared) publishActivity();
|
|
1857
|
+
presence?.refreshLabel();
|
|
1858
|
+
return text(`Active session ID: ${transport.sessionId}\nCurrent alias (CURRENT TASK): ${aliases?.currentAlias ?? "unknown"}\nInitial alias (FIRST TASK): ${aliases?.initialAlias ?? "unknown"}\nSession name: ${alias || "unnamed"}`,
|
|
1859
|
+
{ gentleAgents: { senderSessionId: transport.sessionId, alias, sessionName: alias, initialAlias: aliases?.initialAlias ?? null, currentAlias: aliases?.currentAlias ?? null } });
|
|
1860
|
+
},
|
|
1861
|
+
});
|
|
1862
|
+
pi.registerTool({
|
|
1863
|
+
name: "orchestrator_consult",
|
|
1864
|
+
label: "Consult published context",
|
|
1865
|
+
...orchestratorToolRenderers("consult", expandHint),
|
|
1866
|
+
description: "Read published metadata (default), request bounded helper reasoning with explicit UI model-cost permission and a question, or revoke-reasoning for an exact target. Never an owner reply, consent or private context access.",
|
|
1867
|
+
parameters: { type: "object", additionalProperties: false, required: ["recipient_session_id"], properties: {
|
|
1868
|
+
kind: { type: "string", enum: ["metadata", "reasoning", "revoke-reasoning"] },
|
|
1869
|
+
question: { type: "string", maxLength: 1024 },
|
|
1870
|
+
recipient_session_id: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$" },
|
|
1871
|
+
cursor: { type: "string", maxLength: 1024 },
|
|
1872
|
+
} } as never,
|
|
1873
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
1874
|
+
const input = params as { kind?: unknown; recipient_session_id?: unknown; cursor?: unknown; question?: unknown };
|
|
1875
|
+
const kind = input?.kind === undefined ? "metadata" : input.kind;
|
|
1876
|
+
if (!input || Object.keys(input).some(k => !["kind", "recipient_session_id", "cursor", "question"].includes(k))
|
|
1877
|
+
|| !["metadata", "reasoning", "revoke-reasoning"].includes(kind as string) || !validTransportSessionId(input.recipient_session_id)
|
|
1878
|
+
|| (kind === "revoke-reasoning" && input.cursor !== undefined)
|
|
1879
|
+
|| (kind !== "reasoning" && input.question !== undefined)
|
|
1880
|
+
|| (kind === "reasoning" && (typeof input.question !== "string" || !input.question.trim()
|
|
1881
|
+
|| Buffer.byteLength(input.question) > 1024 || /[\p{Cc}\p{Cf}\p{Cs}]/u.test(input.question)))
|
|
1882
|
+
|| (input.cursor !== undefined && (typeof input.cursor !== "string" || input.cursor.length > 1024))) throw new Error("Invalid metadata consultation parameters.");
|
|
1883
|
+
const selection = { recipientSessionId: input.recipient_session_id, cursor: input.cursor as string | undefined };
|
|
1884
|
+
const transport = activeTransportFor(ctx);
|
|
1885
|
+
const result = (receipt: MetadataReceipt) => {
|
|
1886
|
+
const output = kind === "metadata" ? receipt : { status: "unavailable", code: receipt.unknowns[0], source: "helper_advice", ownerReply: false, authority: "none" };
|
|
1887
|
+
return text(JSON.stringify(output), { gentleAgents: { senderSessionId: transport?.sessionId, receipt: output } });
|
|
1888
|
+
};
|
|
1889
|
+
if (!transport) return result(unavailableMetadata(selection.recipientSessionId, "not-ready"));
|
|
1890
|
+
const reply = (receipt: unknown) => text(JSON.stringify(receipt), { gentleAgents: { senderSessionId: transport.sessionId, receipt } });
|
|
1891
|
+
if (kind === "revoke-reasoning") {
|
|
1892
|
+
helperPermission.revoke(selection.recipientSessionId);
|
|
1893
|
+
return reply({ status: "revoked", source: "helper_advice", ownerReply: false, authority: "none" });
|
|
1894
|
+
}
|
|
1895
|
+
try {
|
|
1896
|
+
const activations = await transport.listener.registry.listActivations(transport.sessionId);
|
|
1897
|
+
if (activeTransportFor(ctx) !== transport) return result(unavailableMetadata(selection.recipientSessionId, "source-session-changed"));
|
|
1898
|
+
const receipt = consultPublishedMetadata(agentHome, activations, selection);
|
|
1899
|
+
if (kind === "metadata") return result(receipt);
|
|
1900
|
+
const current = () => activeTransportFor(ctx) === transport && parentCtx?.sessionManager === ctx.sessionManager;
|
|
1901
|
+
const readSource = async () => {
|
|
1902
|
+
const peers = await transport.listener.registry.listActivations(transport.sessionId);
|
|
1903
|
+
return current() ? consultPublishedMetadata(agentHome, peers, selection)
|
|
1904
|
+
: unavailableMetadata(selection.recipientSessionId, "source-session-changed");
|
|
1905
|
+
};
|
|
1906
|
+
const advice = await helperPermission.run({ receipt, question: input.question as string, signal: _signal,
|
|
1907
|
+
readSource, isSourceCurrent: () => current() && consultPublishedMetadata(agentHome, activations, selection).digest === receipt.digest });
|
|
1908
|
+
return reply(advice);
|
|
1909
|
+
} catch { return result(unavailableMetadata(selection.recipientSessionId, "discovery-unavailable")); }
|
|
1307
1910
|
},
|
|
1308
1911
|
});
|
|
1309
1912
|
pi.registerTool({
|
|
1310
1913
|
name: "orchestrator_list",
|
|
1311
1914
|
label: "List orchestrators",
|
|
1312
|
-
|
|
1313
|
-
|
|
1915
|
+
...orchestratorToolRenderers("list", expandHint),
|
|
1916
|
+
description: "List other sessions advertised by the trusted local profile. Optional filter searches only published classified work with bounded, non-exhaustive coverage, no authority and unknown reachability. Omit filter for the existing session list.",
|
|
1917
|
+
parameters: Type.Object({
|
|
1918
|
+
recipient_session_id: Type.Optional(Type.String({ description: "Exact routing ID of the peer to inspect." })),
|
|
1919
|
+
cursor: Type.Optional(Type.String({ description: "Opaque catalog continuation from that peer; requires recipient_session_id." })),
|
|
1920
|
+
filter: Type.Optional(Type.Object({
|
|
1921
|
+
area: Type.Optional(Type.String()),
|
|
1922
|
+
topic: Type.Optional(Type.String({ description: "Requires area." })),
|
|
1923
|
+
tag: Type.Optional(Type.String()),
|
|
1924
|
+
text: Type.Optional(Type.String({ description: "Literal label/descriptor search, never state prose or history." })),
|
|
1925
|
+
ref: Type.Optional(Type.Object({
|
|
1926
|
+
kind: Type.Union([Type.Literal("issue"), Type.Literal("pr"), Type.Literal("task")]),
|
|
1927
|
+
repository: Type.String({ description: "Exact public host/owner/repo scope." }),
|
|
1928
|
+
id: Type.String(),
|
|
1929
|
+
}, { additionalProperties: false })),
|
|
1930
|
+
repository_root: Type.Optional(Type.String({ description: "Recorded absolute Git root; no new Git probe." })),
|
|
1931
|
+
related_to: Type.Optional(Type.Object({
|
|
1932
|
+
session_id: Type.String({ description: "Exact stable source owner session ID." }),
|
|
1933
|
+
task_id: Type.Optional(Type.String({ description: "Actual task ID on the current catalog page, not child session ID." })),
|
|
1934
|
+
}, { additionalProperties: false })),
|
|
1935
|
+
}, { additionalProperties: false, description: "Explicit {} indexes classified work; criteria combine with AND." })),
|
|
1936
|
+
}, { additionalProperties: false }),
|
|
1314
1937
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
1938
|
+
const params = _params as { recipient_session_id?: string; cursor?: string; filter?: unknown };
|
|
1939
|
+
let filter;
|
|
1940
|
+
try {
|
|
1941
|
+
if (!params || typeof params !== "object" || Array.isArray(params)
|
|
1942
|
+
|| Object.keys(params).some(key => !["recipient_session_id", "cursor", "filter"].includes(key))
|
|
1943
|
+
|| (params.recipient_session_id !== undefined && !validTransportSessionId(params.recipient_session_id))
|
|
1944
|
+
|| (params.cursor !== undefined && (typeof params.cursor !== "string" || params.cursor.length > 1024))) throw new Error();
|
|
1945
|
+
if (Object.hasOwn(params, "filter")) filter = validateWorkFilter(params.filter);
|
|
1946
|
+
} catch { throw new Error("Invalid orchestrator list parameters."); }
|
|
1947
|
+
if (params.cursor !== undefined && !params.recipient_session_id) return text("Error: cursor requires recipient_session_id.", { error: "invalid-cursor" });
|
|
1315
1948
|
const transport = activeTransportFor(ctx);
|
|
1316
1949
|
if (!transport) return text("Error: session discovery is not ready.", { error: "not ready" });
|
|
1317
1950
|
try {
|
|
1318
|
-
const
|
|
1951
|
+
const activations = await transport.listener.registry.listActivations(transport.sessionId);
|
|
1319
1952
|
if (activeTransportFor(ctx) !== transport) return text("Error: session discovery became unavailable before results were confirmed.", { error: "stale" });
|
|
1320
|
-
|
|
1953
|
+
if (filter !== undefined) {
|
|
1954
|
+
const workSearch = searchPublishedWork(agentHome, activations, filter, params.recipient_session_id
|
|
1955
|
+
? { recipientSessionId: params.recipient_session_id, cursor: params.cursor } : undefined);
|
|
1956
|
+
return text(JSON.stringify(workSearch), { gentleAgents: { workSearch } });
|
|
1957
|
+
}
|
|
1958
|
+
const peers = discoverOrchestrators(agentHome, activations, Date.now(), params.recipient_session_id
|
|
1959
|
+
? { recipientSessionId: params.recipient_session_id, cursor: params.cursor } : undefined);
|
|
1960
|
+
const repository = (fact?: RepositoryFact) => fact?.root
|
|
1961
|
+
? `repository: ${fact.root} · clone: ${fact.cloneHash} · Git resolved at: ${fact.resolvedAt} (${fact.source})`
|
|
1962
|
+
: "repository: unknown";
|
|
1963
|
+
const rows = peers.map(peer => {
|
|
1964
|
+
const context = peer.freshness === "recent" ? ` · ${peer.aliases?.currentAlias || peer.label || "unnamed"} · Session name: ${peer.label || "unnamed"} · Current alias (CURRENT TASK): ${peer.aliases?.currentAlias ?? "unknown"} · Initial alias (FIRST TASK): ${peer.aliases?.initialAlias ?? "unknown"} · recorded workspace: ${peer.workspace || "unknown"}` : " · context: unknown";
|
|
1965
|
+
const tasks = peer.tasks?.map(task => `\n - ${task.label || task.id} [${task.status}] · launch workspace: ${task.workspace || "unknown"} · ${repository(peer.scope?.tasks.find(t => t.id === task.id)?.repository)}`).join("") ?? "";
|
|
1966
|
+
const registered = peer.scope?.registered.map(fact => `\n registered: ${repository(fact)}`).join("") ?? "";
|
|
1967
|
+
const gaps = peer.scope && !peer.scope.complete ? `\n (scope incomplete: ${peer.scope.omittedTasks} tasks, ${peer.scope.omittedRegistered} registered roots omitted)` : "";
|
|
1968
|
+
const catalog = peer.catalog ? `\n recorded catalog (not Git identity): ${JSON.stringify(peer.catalog)}` : "\n recorded catalog: unknown";
|
|
1969
|
+
const note = params.recipient_session_id ? `\n owner-curated recorded state (not consent or owner reply; authority none): ${peer.state ? JSON.stringify(peer.state) : "unknown"}` : "";
|
|
1970
|
+
return `- ${peer.sessionId}${context} · ${repository(peer.scope?.host)} · metadata: ${peer.freshness}${tasks}${registered}${gaps}${catalog}${note}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`;
|
|
1971
|
+
});
|
|
1972
|
+
return peers.length === 0 ? text("No other sessions are currently advertised. Advertisements have unknown reachability and do not guarantee a live session.") : text(`Advertised sessions (reachability is unknown):\n${rows.join("\n")}`, { gentleAgents: { candidates: peers } });
|
|
1321
1973
|
} catch {
|
|
1322
1974
|
return text("Error: session discovery is unavailable.", { error: "unavailable" });
|
|
1323
1975
|
}
|
|
@@ -1326,13 +1978,33 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1326
1978
|
pi.registerTool({
|
|
1327
1979
|
name: "orchestrator_send_message",
|
|
1328
1980
|
label: "Send orchestrator message",
|
|
1981
|
+
renderShell: "self",
|
|
1982
|
+
renderCall(args, theme, context) {
|
|
1983
|
+
return outgoingMessageCall(args as { message?: unknown }, theme, context, expandHint(context.expanded));
|
|
1984
|
+
},
|
|
1985
|
+
renderResult(result, options, theme, context) {
|
|
1986
|
+
return outgoingMessageResult(result, options.expanded, options.isPartial, theme, context);
|
|
1987
|
+
},
|
|
1329
1988
|
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.",
|
|
1330
|
-
parameters: {
|
|
1989
|
+
parameters: {
|
|
1990
|
+
type: "object",
|
|
1991
|
+
additionalProperties: false,
|
|
1992
|
+
required: ["message", "reason"],
|
|
1993
|
+
properties: {
|
|
1994
|
+
recipient_session_id: { type: "string" },
|
|
1995
|
+
message: { type: "string" },
|
|
1996
|
+
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." },
|
|
1997
|
+
},
|
|
1998
|
+
} as never,
|
|
1331
1999
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1332
2000
|
const transport = activeTransportFor(ctx);
|
|
1333
|
-
const input = params as { recipient_session_id?: unknown; message?: unknown };
|
|
2001
|
+
const input = params as { recipient_session_id?: unknown; message?: unknown; reason?: unknown };
|
|
1334
2002
|
if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" });
|
|
1335
|
-
|
|
2003
|
+
const message = input.message;
|
|
2004
|
+
if (typeof message !== "string" || Buffer.byteLength(message, "utf8") > 8192) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
|
|
2005
|
+
let reason: string;
|
|
2006
|
+
try { reason = normalizeMessagingReason(input.reason); }
|
|
2007
|
+
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" }); }
|
|
1336
2008
|
let recipient: string | undefined;
|
|
1337
2009
|
let activation: PresenceRecord | undefined;
|
|
1338
2010
|
if (input.recipient_session_id !== undefined) {
|
|
@@ -1364,17 +2036,23 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1364
2036
|
if (recipient === undefined || !validTransportSessionId(recipient)) return text("Error: recipient session ID or message is invalid.", { error: "invalid input" });
|
|
1365
2037
|
if (recipient === transport.sessionId) return text("Error: cannot send a message to the active session.", { error: "self" });
|
|
1366
2038
|
try {
|
|
1367
|
-
|
|
1368
|
-
|
|
2039
|
+
await messagingGrants.authorize(ctx, recipient, { message, reason, signal });
|
|
2040
|
+
} catch (error) {
|
|
2041
|
+
if (signal?.aborted) return text("Cross-orchestrator communication was cancelled.", { error: "cancelled" });
|
|
2042
|
+
return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "denied" });
|
|
2043
|
+
}
|
|
2044
|
+
try {
|
|
2045
|
+
const accepted = await transport.client.sendNotification(recipient, message, { signal, expectedActivation: activation, beforeConnect: () => activeTransportFor(ctx) === transport });
|
|
2046
|
+
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", message, reason, senderLabel: sanitizeDisplayLabel(ctx.sessionManager.getSessionName?.() ?? ""), recipientLabel: messagePeerLabel(agentHome, recipient) } }) : text("Error: session messaging is not ready.", { error: "stale" });
|
|
1369
2047
|
} catch {
|
|
1370
2048
|
return text("Error: session message was not accepted.", { error: "not accepted" });
|
|
1371
2049
|
}
|
|
1372
2050
|
},
|
|
1373
2051
|
});
|
|
1374
2052
|
|
|
1375
|
-
tool("list_agents", "List the subagents defined for this project and user, with their descriptions.", { properties: {} }, async (_params, ctx) => {
|
|
2053
|
+
tool("list_agents", "List the subagents defined for this project and user, with their descriptions and declared tool inventory.", { properties: {} }, async (_params, ctx) => {
|
|
1376
2054
|
const { agents, errors } = discoverAgents(roots(ctx));
|
|
1377
|
-
const lines = agents.map((agent) => `- ${agent.name} (${agent.scope}): ${agent.description || "no description"}`);
|
|
2055
|
+
const lines = agents.map((agent) => `- ${agent.name} (${agent.scope}): ${agent.description || "no description"} ${declaredToolInventory(agent)}`);
|
|
1378
2056
|
const problems = errors.map((error) => `! ${error}`);
|
|
1379
2057
|
return text(lines.length === 0 ? "No subagents defined." : [...lines, ...problems].join("\n"));
|
|
1380
2058
|
});
|
|
@@ -1389,16 +2067,24 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1389
2067
|
task: { type: "string", description: "What the subagent must do, self-contained." },
|
|
1390
2068
|
label: { type: "string", description: "Three to six words naming the work, shown on the agents card, e.g. 'map footer data sources'." },
|
|
1391
2069
|
context: { type: "string", description: "Optional extra context appended to the task." },
|
|
2070
|
+
work: workDescriptorSchema,
|
|
1392
2071
|
workspace_root: { type: "string", description: "Optional canonical main or linked Git worktree within the parent's same clone only; mutually exclusive with repository_root." },
|
|
1393
2072
|
repository_root: { type: "string", description: "Optional canonical independent Git repository; requires direct interactive session-scoped consent before queueing; mutually exclusive with workspace_root." },
|
|
1394
|
-
research_selection: RESEARCH_SELECTION_SCHEMA,
|
|
1395
|
-
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." },
|
|
1396
2073
|
mode: { type: "string", enum: ["task", "background"], description: "task waits for the result (default); background returns immediately." },
|
|
1397
2074
|
},
|
|
1398
2075
|
},
|
|
1399
2076
|
async (params, ctx, signal) => {
|
|
1400
|
-
|
|
2077
|
+
const work = Object.hasOwn(params, "work") ? decodeWorkDescriptor(params.work) : undefined;
|
|
2078
|
+
const manager = ctx.sessionManager, sessionId = manager.getSessionId();
|
|
2079
|
+
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.");
|
|
2080
|
+
if (params.mode !== undefined && params.mode !== AGENT_MODE.TASK && params.mode !== AGENT_MODE.BACKGROUND) throw new Error("Subagent mode must be task or background.");
|
|
2081
|
+
// An empty selector names no destination: only real roots are mutually
|
|
2082
|
+
// exclusive, so a blank string must not masquerade as a second target.
|
|
2083
|
+
const hasWorkspaceRoot = Object.hasOwn(params, "workspace_root") && params.workspace_root !== "";
|
|
2084
|
+
const hasRepositoryRoot = Object.hasOwn(params, "repository_root") && params.repository_root !== "";
|
|
2085
|
+
if (hasRepositoryRoot && hasWorkspaceRoot) throw new Error("repository_root and workspace_root are mutually exclusive.");
|
|
1401
2086
|
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.");
|
|
2087
|
+
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" });
|
|
1402
2088
|
const { agents } = discoverAgents(roots(ctx));
|
|
1403
2089
|
const agent = agents.find((candidate) => candidate.name === params.agent);
|
|
1404
2090
|
if (!agent) return text(`Error: no subagent named "${String(params.agent)}". Known: ${agents.map((candidate) => candidate.name).join(", ") || "none"}`, { error: "unknown agent" });
|
|
@@ -1407,24 +2093,41 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1407
2093
|
policy: resolveBackgroundSubagentsPolicy(ctx.cwd).policy,
|
|
1408
2094
|
parentMode: ctx.mode,
|
|
1409
2095
|
});
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
2096
|
+
const workspaceRoot = typeof params.workspace_root === "string" && params.workspace_root !== "" ? params.workspace_root : undefined;
|
|
2097
|
+
const repositoryRoot = typeof params.repository_root === "string" && params.repository_root !== "" ? params.repository_root : undefined;
|
|
2098
|
+
const publication: { status: "recorded" | "unavailable" } = { status: "unavailable" };
|
|
2099
|
+
const current = () => ctx.sessionManager === manager && manager.getSessionId() === sessionId && !!activeTransportFor(ctx);
|
|
2100
|
+
const result = await 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, work ? id => {
|
|
2101
|
+
try {
|
|
2102
|
+
if (!current()) return;
|
|
2103
|
+
const state = stateCache.get(manager)?.state ?? {};
|
|
2104
|
+
stateCache.publish(manager, { ...state, work: { ...state.work, tasks: { ...state.work?.tasks, [id]: work } } }, (type, data) => pi.appendEntry(type, data));
|
|
2105
|
+
if (current()) publication.status = "recorded";
|
|
2106
|
+
} catch { /* Optional metadata cannot invalidate an allocated task. */ }
|
|
2107
|
+
} : undefined);
|
|
2108
|
+
if (!work) return result;
|
|
2109
|
+
if (!current()) publication.status = "unavailable";
|
|
2110
|
+
const note = publication.status === "recorded"
|
|
2111
|
+
? "Work recorded in local curated state; peer advertisement is best-effort."
|
|
2112
|
+
: "Work publication unavailable/unknown. Do not relaunch this allocated task. Use orchestrator_session_id with a bounded replacement state and this actual task ID when the owner session is active.";
|
|
2113
|
+
return { ...result, content: [...result.content, { type: "text", text: note }], details: { ...result.details, workPublication: publication } };
|
|
1414
2114
|
},
|
|
1415
2115
|
);
|
|
1416
2116
|
|
|
1417
|
-
tool("status", "Report the status of one subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
|
|
2117
|
+
tool("status", "Report the status of one subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => {
|
|
1418
2118
|
const task = await resolveTask(String(params.task_id));
|
|
1419
|
-
return task ? text(describeTask(task), taskDetails(task)) :
|
|
2119
|
+
return task ? text(describeTask(task), taskDetails(task)) : unknownTask(params.task_id, ctx);
|
|
1420
2120
|
});
|
|
1421
2121
|
|
|
1422
|
-
tool("result", "Return the final answer of a finished subagent task, or its current state if it is still running.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
|
|
2122
|
+
tool("result", "Return the final answer of a finished subagent task, or its current state if it is still running.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params, ctx) => {
|
|
1423
2123
|
const task = await resolveTask(String(params.task_id));
|
|
1424
|
-
if (!task) return
|
|
2124
|
+
if (!task) return unknownTask(params.task_id, ctx);
|
|
1425
2125
|
// The parent just pulled a finished result; its pending completion must
|
|
1426
2126
|
// never be replayed on top of it.
|
|
1427
|
-
if (isFinished(task.status))
|
|
2127
|
+
if (isFinished(task.status)) {
|
|
2128
|
+
completions.consume(task.id);
|
|
2129
|
+
messages.invalidateTask(task.id);
|
|
2130
|
+
}
|
|
1428
2131
|
return text(isFinished(task.status) ? finishedText(task) : `Task ${task.id} is still ${task.status} (last: ${task.lastStep}).`, taskDetails(task));
|
|
1429
2132
|
});
|
|
1430
2133
|
|
|
@@ -1434,12 +2137,15 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1434
2137
|
});
|
|
1435
2138
|
|
|
1436
2139
|
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) => {
|
|
1437
|
-
const
|
|
2140
|
+
const taskId = String(params.task_id);
|
|
2141
|
+
const requestId = String(params.request_id);
|
|
2142
|
+
const accepted = await runner.reply(taskId, requestId, typeof params.message === "string" ? params.message : "", ctx.sessionManager.getSessionId() ?? "");
|
|
1438
2143
|
return accepted ? text("Reply accepted for delivery.") : text("Error: query is unavailable.", { error: "query unavailable" });
|
|
1439
2144
|
});
|
|
1440
2145
|
|
|
1441
2146
|
tool("cancel", "Cancel a queued or running subagent task.", { required: ["task_id"], properties: { task_id: { type: "string" } } }, async (params) => {
|
|
1442
2147
|
const id = String(params.task_id);
|
|
2148
|
+
messages.invalidateTask(id);
|
|
1443
2149
|
return runner.cancel(id, "cancelled by the cancel tool") ? text(`Cancelled task ${id}.`) : text(`Error: task ${id} is not running.`, { error: "not running" });
|
|
1444
2150
|
});
|
|
1445
2151
|
|
|
@@ -1451,24 +2157,23 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1451
2157
|
tool(
|
|
1452
2158
|
"continue",
|
|
1453
2159
|
"Resume a finished subagent task in its own session with a follow-up prompt.",
|
|
1454
|
-
{ required: ["task_id", "prompt"], properties: {
|
|
2160
|
+
{ 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"] } } },
|
|
1455
2161
|
async (params, ctx, signal) => {
|
|
2162
|
+
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" });
|
|
1456
2163
|
const previous = await resolveTask(String(params.task_id));
|
|
1457
|
-
if (!previous) return
|
|
2164
|
+
if (!previous) return unknownTask(params.task_id, ctx);
|
|
2165
|
+
if (retiredSddAgent(previous.agent)) return text("Error: retired SDD agents cannot be continued.", { error: "retired SDD delegation" });
|
|
1458
2166
|
if (!isFinished(previous.status) || !previous.sessionPath) return text(`Error: task ${previous.id} cannot be continued yet (${previous.status}).`, { error: "not continuable" });
|
|
1459
2167
|
// Continuing acts on the previous result, so any pending completion for
|
|
1460
2168
|
// it is already consumed by the parent.
|
|
1461
2169
|
completions.consume(previous.id);
|
|
2170
|
+
messages.invalidateTask(previous.id);
|
|
1462
2171
|
const agent = discoverAgents(roots(ctx)).agents.find((candidate) => candidate.name === previous.agent);
|
|
1463
2172
|
if (!agent) return text(`Error: subagent "${previous.agent}" is no longer defined.`, { error: "unknown agent" });
|
|
1464
2173
|
const mode = (params.mode as AgentMode | undefined) ?? (previous.mode as AgentMode);
|
|
1465
|
-
let sddChange: SddChangeSelection | undefined;
|
|
1466
|
-
try { sddChange = parseSddChange(params.sdd_change, agent.name); }
|
|
1467
|
-
catch (error) { return text(`Error: ${error instanceof Error ? error.message : String(error)}`, { error: "invalid sdd_change" }); }
|
|
1468
|
-
if (sddPhaseForAgent(agent.name) && !sddChange) return text("Error: continuing an SDD phase agent requires a fresh sdd_change selection.", { error: "missing sdd_change" });
|
|
1469
|
-
|
|
1470
2174
|
const foreignContinuation = foreignTasks.has(previous.id);
|
|
1471
|
-
|
|
2175
|
+
const prompt = inheritAllowedEditSurfaces(previous.agent, String(params.prompt ?? ""), params.context, previous.prompt);
|
|
2176
|
+
return launch(ctx, await buildRequest(ctx, agent, 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);
|
|
1472
2177
|
},
|
|
1473
2178
|
);
|
|
1474
2179
|
|
|
@@ -1483,6 +2188,37 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1483
2188
|
});
|
|
1484
2189
|
}
|
|
1485
2190
|
|
|
2191
|
+
pi.registerCommand(JOBS_COMMAND_NAME, {
|
|
2192
|
+
description: "Show background jobs: running first, newest first within each group; status, command, output tail; s stops the selected job.",
|
|
2193
|
+
handler: async (_args, ctx) => {
|
|
2194
|
+
if (!ctx.hasUI) return;
|
|
2195
|
+
if (ctx.mode !== "tui") {
|
|
2196
|
+
ctx.ui.notify("The jobs overlay requires TUI mode.", "warning");
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
let view: JobsView | undefined;
|
|
2200
|
+
await ctx.ui.custom<null>((tui, theme, _keybindings, done) => {
|
|
2201
|
+
const close = withOverlayRepaint(tui, done);
|
|
2202
|
+
view = new JobsView({
|
|
2203
|
+
theme,
|
|
2204
|
+
rows: () => Math.max(0, tui.terminal.rows),
|
|
2205
|
+
jobs: () => jobs.list(ctx.sessionManager.getSessionId() ?? ""),
|
|
2206
|
+
now: () => deps.now(),
|
|
2207
|
+
// The agent was promised a notice, so a human stop is reported;
|
|
2208
|
+
// a job_stop needs none because the agent itself stopped it.
|
|
2209
|
+
onStop: (job) => {
|
|
2210
|
+
// Lines already seen are delivered before the stop notice.
|
|
2211
|
+
monitors.finish(job);
|
|
2212
|
+
const stopped = jobs.stop(job.id);
|
|
2213
|
+
if (stopped) queueJobNotice({ job: stopped, kind: "exit" });
|
|
2214
|
+
},
|
|
2215
|
+
onClose: () => close(null),
|
|
2216
|
+
requestRender: () => tui.requestRender(),
|
|
2217
|
+
});
|
|
2218
|
+
return view;
|
|
2219
|
+
}, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } }).finally(() => view?.dispose());
|
|
2220
|
+
},
|
|
2221
|
+
});
|
|
1486
2222
|
pi.registerCommand(AGENTS_COMMAND_NAME, {
|
|
1487
2223
|
description: "Show this session's active subagents; a lists open orchestrators in this profile. Peer threads are read-only; o opens a local task's transcript in $EDITOR.",
|
|
1488
2224
|
handler: async (_args, ctx) => openOverlay(ctx),
|
|
@@ -1500,10 +2236,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1500
2236
|
});
|
|
1501
2237
|
}
|
|
1502
2238
|
|
|
2239
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
2240
|
+
helperPermission.clear();
|
|
2241
|
+
if (sessions !== ctx.sessionManager) return;
|
|
2242
|
+
stateCache.load(ctx.sessionManager);
|
|
2243
|
+
publishActivity();
|
|
2244
|
+
});
|
|
1503
2245
|
pi.on("session_start", async (event, ctx) => {
|
|
2246
|
+
stateCache.load(ctx.sessionManager);
|
|
1504
2247
|
// A resumed, reloaded, or replaced session starts with an empty completion
|
|
1505
2248
|
// queue so nothing pending from another session can replay here.
|
|
1506
2249
|
completions.dropAll();
|
|
2250
|
+
messages.dropAll();
|
|
2251
|
+
jobNotices.length = 0;
|
|
2252
|
+
resetParentDelivery(ctx);
|
|
1507
2253
|
presence?.dispose();
|
|
1508
2254
|
registryFor(ctx);
|
|
1509
2255
|
showWidget(ctx);
|
|
@@ -1520,8 +2266,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1520
2266
|
const preexisting = event.reason === "resume" || (event.reason === "startup" && ctx.sessionManager.getEntries().length > 0);
|
|
1521
2267
|
if (preexisting && sessionId) void restoreSessionHistory(ctx, sessionId);
|
|
1522
2268
|
try {
|
|
1523
|
-
presence =
|
|
1524
|
-
label: ctx.sessionManager.getSessionName?.() || ctx.sessionManager.getCwd().split(/[\\/]/).pop() || "Orchestrator", activity: [] });
|
|
2269
|
+
presence = startPresence(ctx.sessionManager);
|
|
1525
2270
|
publishActivity();
|
|
1526
2271
|
} catch { presence = undefined; }
|
|
1527
2272
|
void startSessionTransport(ctx);
|
|
@@ -1551,20 +2296,42 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
|
|
|
1551
2296
|
});
|
|
1552
2297
|
pi.on("session_shutdown", async () => {
|
|
1553
2298
|
completions.dropAll();
|
|
2299
|
+
messages.dropAll();
|
|
2300
|
+
// Jobs never outlive their session: a stopped job sends no notice.
|
|
2301
|
+
monitors.cancelAll();
|
|
2302
|
+
const jobsStopped = jobs.stopAll();
|
|
2303
|
+
jobNotices.length = 0;
|
|
2304
|
+
refreshJobStatus();
|
|
1554
2305
|
activeAgentRuns = 0;
|
|
2306
|
+
resetParentDelivery(undefined);
|
|
1555
2307
|
presence?.dispose();
|
|
1556
2308
|
presence = undefined;
|
|
2309
|
+
stateCache.clear();
|
|
1557
2310
|
rpcActivityPublisher?.stop();
|
|
1558
2311
|
rpcActivityPublisher = undefined;
|
|
1559
2312
|
cancelClock?.();
|
|
1560
2313
|
for (const view of overlays) { view.handleInput("q"); view.dispose(); }
|
|
1561
2314
|
overlays.clear();
|
|
1562
2315
|
sessions = undefined;
|
|
2316
|
+
stopVisualUpdates?.();
|
|
2317
|
+
stopVisualUpdates = undefined;
|
|
1563
2318
|
sidebarTui = undefined;
|
|
1564
2319
|
worktrees?.close();
|
|
1565
2320
|
worktrees = undefined;
|
|
2321
|
+
worktreeManager = undefined;
|
|
1566
2322
|
const stopped = shutdownSessionTransport();
|
|
1567
2323
|
runner.cancelAll("cancelled: parent session shut down");
|
|
1568
2324
|
await stopped;
|
|
2325
|
+
// The default log directory goes with the session once every log is
|
|
2326
|
+
// closed, but a process that ignores its stop must not hang shutdown.
|
|
2327
|
+
await new Promise<void>((done) => {
|
|
2328
|
+
const cancel = deps.schedule(done, JOBS_SHUTDOWN_GRACE_MS);
|
|
2329
|
+
void jobsStopped.then(() => { cancel(); done(); });
|
|
2330
|
+
});
|
|
2331
|
+
if (jobOutputDir) {
|
|
2332
|
+
const dir = jobOutputDir;
|
|
2333
|
+
jobOutputDir = undefined;
|
|
2334
|
+
try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { /* A leftover temp directory is harmless. */ }
|
|
2335
|
+
}
|
|
1569
2336
|
});
|
|
1570
2337
|
}
|