gentle-pi 4.0.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 +122 -8
- package/assets/agents/gentle-ai-explore.md +11 -4
- package/assets/agents/gentle-ai-verify.md +23 -4
- package/assets/agents/gentle-ai-worker.md +6 -6
- 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 +70 -163
- package/assets/orchestrator-memory.md +11 -4
- package/assets/orchestrator-prompts.md +48 -0
- 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 +29 -25
- 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/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +703 -22
- 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 +3 -5
- package/docs/gentle-agents-activity.md +513 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +81 -31
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +1 -1
- package/docs/permission-modes.md +82 -0
- package/docs/prompt-history.md +1 -1
- package/docs/readme-reference.md +267 -40
- 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/yolo-mode.md +9 -9
- package/extensions/child-context.ts +11 -2
- package/extensions/child-safety.ts +4 -2
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +688 -88
- package/extensions/gentle-ai.ts +886 -238
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +265 -69
- package/extensions/gentle-todo.ts +108 -13
- package/extensions/history/index.ts +11 -6
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +91 -21
- package/extensions/skill-registry.ts +175 -46
- package/extensions/startup-banner.ts +11 -2
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +40 -1
- package/lib/agents-runner.ts +90 -15
- 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 +83 -10
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +14 -4
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/context-threshold.ts +26 -0
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-shell-bundled.ts +517 -0
- package/lib/gentle-shell-launcher.ts +74 -23
- 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/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/nan-provider.ts +33 -11
- package/lib/native-review-cli.ts +194 -73
- 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.ts +1 -1
- 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/permission-mode-policy.ts +59 -0
- package/lib/review-candidate-view-owner.ts +8 -6
- package/lib/review-candidate-view.ts +3 -2
- 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-risk-assessment.ts +77 -51
- package/lib/review-sidebar-state.ts +2 -1
- 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 +18 -6
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +266 -0
- package/lib/session-worktree-registry.ts +74 -8
- package/lib/shell-bar.ts +41 -11
- package/lib/shell-changes-view.ts +40 -13
- package/lib/shell-changes.ts +86 -71
- package/lib/shell-sidebar-layout.ts +5 -1
- package/lib/shell-todo.ts +62 -19
- package/lib/shell-usage.ts +4 -3
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/vim-editor-adapter.ts +7 -3
- package/lib/visual-customization-policy.ts +20 -9
- package/lib/visual-customize-view.ts +171 -20
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +198 -121
- package/package.json +1 -1
- 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 +73 -22
- 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 +191 -70
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +77 -51
- 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 +7 -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 +79 -32
- 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/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 +22 -8
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +23 -5
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +82 -18
- package/skills/gentle-ai/SKILL.md +12 -11
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +12 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-protocol.test.ts +57 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +89 -4
- 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/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 +52 -1
- 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 +34 -2
- package/tests/child-codegraph-bootstrap.test.ts +74 -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 +12 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +107 -2
- package/tests/command-palette.test.ts +5 -5
- 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 -2
- package/tests/devbinary/native-review-parity.devtest.ts +1 -1
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +288 -0
- package/tests/generic-agent-tools.test.ts +16 -1
- package/tests/gentle-agents.test.ts +1722 -59
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +19 -2
- 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-installer.test.ts +548 -58
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +6 -6
- package/tests/gentle-ai.test.ts +999 -31
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +249 -5
- package/tests/gentle-shell-bundled.test.ts +1002 -0
- package/tests/gentle-shell-launcher.test.ts +234 -13
- 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 +541 -96
- package/tests/gentle-todo.test.ts +253 -2
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/inprocess-reviewer.test.ts +20 -0
- 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/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-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +28 -18
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +236 -1
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +1 -1
- 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 +14 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
- package/tests/odd-routing-contract.test.ts +80 -23
- package/tests/orchestrator-budget.test.ts +29 -6
- 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 +6 -5
- 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/package-manifest.test.ts +101 -17
- package/tests/packed-runner-owned-path.test.ts +35 -1
- 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 +4 -12
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/provider-defect-handoff.test.ts +4 -3
- package/tests/quiet-tool-rendering.test.ts +160 -3
- package/tests/rdd-aware-verification-contract.test.ts +29 -41
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/review-agent-end-preflight.test.ts +62 -10
- package/tests/review-candidate-view.test.ts +101 -12
- package/tests/review-contract-prompt.test.ts +33 -4
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +255 -1
- package/tests/review-controller-workspace-root.test.ts +43 -2
- 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 +5 -3
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +21 -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 +5 -4
- 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-risk-assessment.test.ts +109 -33
- 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-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +83 -5
- package/tests/runtime-harness.mjs +42 -15
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +30 -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-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 +57 -2
- package/tests/shell-bar.test.ts +40 -0
- package/tests/shell-changes-view.test.ts +85 -34
- package/tests/shell-changes.test.ts +61 -62
- 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 +27 -2
- package/tests/shell-todo.test.ts +120 -2
- package/tests/shell-usage-view.test.ts +1 -1
- package/tests/shell-usage.test.ts +35 -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 +311 -5
- package/tests/startup-banner.test.ts +117 -3
- package/tests/startup-home-repo.test.ts +170 -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 +27 -1
- package/tests/test-strength-contract.test.ts +124 -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.test.ts +7 -7
- package/tests/visual-customization-policy.test.ts +32 -3
- package/tests/visual-customize-view.test.ts +288 -10
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +18 -3
- package/tests/yolo-mode-runtime.test.ts +37 -10
- package/tests/yolo-mode.test.ts +32 -4
- package/tests/yolo-session-policy.test.ts +1 -1
- /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
package/lib/selection-engine.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type EditorCom
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Native text-selection engine for GentlePromptEditor: shift+home / shift+end
|
|
6
|
-
* selection, alt+a select-all, and replace-on-key (backspace / delete /
|
|
6
|
+
* selection, alt+e / alt+a select-all, and replace-on-key (backspace / delete /
|
|
7
7
|
* printable character) with reverse-video highlight and a bottom-rule hint.
|
|
8
8
|
* Ported from @exopro/pi-select-del so the petal prompt owns the feature
|
|
9
9
|
* natively — no factory composition, no cross-extension focus handoff.
|
|
@@ -12,7 +12,10 @@ import { isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type EditorCom
|
|
|
12
12
|
* - shift+home — anchor at the cursor, move to line start (held presses at
|
|
13
13
|
* the edge keep the selection; only a zero-width span collapses)
|
|
14
14
|
* - shift+end — anchor at the cursor, move to line end
|
|
15
|
-
* - alt+
|
|
15
|
+
* - alt+e — select all; alt+a too when no extension shortcut claims it
|
|
16
|
+
* (Gentle Agents owns alt+a by default, gentle-shell#1565)
|
|
17
|
+
* - a registered extension shortcut always wins over these selection chords,
|
|
18
|
+
* matching CustomEditor's own precedence
|
|
16
19
|
* - backspace / delete / printable character or bracketed paste over an
|
|
17
20
|
* active selection — replace it in one atomic edit (undo restores text AND cursor)
|
|
18
21
|
* - any other key — collapse the selection first, then behave natively
|
|
@@ -275,8 +278,10 @@ export class SelectionEngine {
|
|
|
275
278
|
* Selection dispatch in front of the native chain. Selection and replace
|
|
276
279
|
* keys are handled here; everything else collapses the anchor first, then
|
|
277
280
|
* behaves natively. Degraded hosts keep pure native key handling.
|
|
281
|
+
* `shortcut` probes registered extension shortcuts before a selection chord
|
|
282
|
+
* is consumed; it returns true when a shortcut handled the key.
|
|
278
283
|
*/
|
|
279
|
-
handleInput(data: string, native: (data: string) => void): void {
|
|
284
|
+
handleInput(data: string, native: (data: string) => void, shortcut?: (data: string) => boolean): void {
|
|
280
285
|
if (this.degraded) {
|
|
281
286
|
native(data);
|
|
282
287
|
return;
|
|
@@ -314,12 +319,13 @@ export class SelectionEngine {
|
|
|
314
319
|
this.pasteBufferedBytes = 0;
|
|
315
320
|
this.pasteTail = "";
|
|
316
321
|
this.replaceSelectionWithPaste(frame, native);
|
|
317
|
-
if (remaining) this.handleInput(remaining, native);
|
|
322
|
+
if (remaining) this.handleInput(remaining, native, shortcut);
|
|
318
323
|
return;
|
|
319
324
|
}
|
|
320
325
|
// Kitty flag 2 release byte strings still match their own key, so
|
|
321
326
|
// releases must be dropped before any matchesKey.
|
|
322
327
|
if (isKeyRelease(data)) return;
|
|
328
|
+
if (this.isSelectionChord(data) && shortcut?.(data)) return;
|
|
323
329
|
if (matchesKey(data, "shift+home")) {
|
|
324
330
|
this.selectToLineEdge(false);
|
|
325
331
|
return;
|
|
@@ -328,7 +334,7 @@ export class SelectionEngine {
|
|
|
328
334
|
this.selectToLineEdge(true);
|
|
329
335
|
return;
|
|
330
336
|
}
|
|
331
|
-
if (matchesKey(data, "alt+a")) {
|
|
337
|
+
if (matchesKey(data, "alt+e") || matchesKey(data, "alt+a")) {
|
|
332
338
|
this.selectAll();
|
|
333
339
|
return;
|
|
334
340
|
}
|
|
@@ -424,7 +430,13 @@ export class SelectionEngine {
|
|
|
424
430
|
this.internals.tui.requestRender();
|
|
425
431
|
}
|
|
426
432
|
|
|
427
|
-
/**
|
|
433
|
+
/** Chords the engine itself consumes; extension shortcuts are probed first. */
|
|
434
|
+
private isSelectionChord(data: string): boolean {
|
|
435
|
+
return matchesKey(data, "shift+home") || matchesKey(data, "shift+end")
|
|
436
|
+
|| matchesKey(data, "alt+e") || matchesKey(data, "alt+a");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Select the entire editor text (alt+e / alt+a). Cursor moves to the end of the last line. */
|
|
428
440
|
private selectAll(): void {
|
|
429
441
|
const lines = this.s.lines;
|
|
430
442
|
const lastLine = Math.max(0, lines.length - 1);
|
|
@@ -5,7 +5,7 @@ import { dirname, relative, resolve } from "node:path";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { resolveSessionWorktree, type WorktreeResolver } from "./session-worktree-registry.ts";
|
|
7
7
|
import { SessionChanges, readChangeSnapshot, sameSnapshot, textSnapshot, isSessionChangeEvidence,
|
|
8
|
-
SESSION_CHANGE_ENTRY,
|
|
8
|
+
SESSION_CHANGE_ENTRY, SESSION_CHANGE_RELAY, type SessionChangeEvidence, type ChangeSnapshot } from "./session-changes.ts";
|
|
9
9
|
|
|
10
10
|
const foreignPublishers = new WeakMap<ExtensionAPI, (sessionId: string, evidence: SessionChangeEvidence) => void>();
|
|
11
11
|
/** Internal authenticated handoff from a successful child tool callback, never a model message or public event. */
|
|
@@ -17,7 +17,10 @@ interface Pending { sessionId: string; inputPath: string; path: string; root: st
|
|
|
17
17
|
const normalized = (text: string) => text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
18
18
|
const unknown = (): ChangeSnapshot => ({ kind: "unavailable", reason: "Tool snapshots could not be verified; diff unavailable." });
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Observe explicit write/edit outcomes, never infer ownership from Git status or shell text.
|
|
22
|
+
* The evidence feeds session statistics and names the roots /gentle:changes reads from Git.
|
|
23
|
+
*/
|
|
21
24
|
export function installSessionChangeCapture(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, resolver: WorktreeResolver = resolveSessionWorktree): void {
|
|
22
25
|
const child = env.GENTLE_PI_AGENTS_CHILD === "1";
|
|
23
26
|
const pending = new Map<string, Pending>();
|
|
@@ -26,8 +29,6 @@ export function installSessionChangeCapture(pi: ExtensionAPI, env: NodeJS.Proces
|
|
|
26
29
|
const publish = (evidence: SessionChangeEvidence) => {
|
|
27
30
|
if (!current || !store || current.sessionManager.getSessionId() !== store.sessionId) return;
|
|
28
31
|
if (store.record(evidence)) pi.appendEntry(SESSION_CHANGE_ENTRY, { sessionId: store.sessionId, evidence });
|
|
29
|
-
const notice = store.takeNotice();
|
|
30
|
-
pi.events.emit(SESSION_CHANGE_EVENT, { sessionId: store.sessionId, ...(notice ? { notice } : {}) });
|
|
31
32
|
};
|
|
32
33
|
foreignPublishers.set(pi, (sessionId, evidence) => {
|
|
33
34
|
if (!child && current?.sessionManager.getSessionId() === sessionId && isSessionChangeEvidence(evidence)) publish(evidence);
|
package/lib/session-changes.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
2
|
import { open } from "node:fs/promises";
|
|
4
3
|
import { isAbsolute } from "node:path";
|
|
5
4
|
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import { changesModel, type ChangedFile, type WorktreeChanges } from "./shell-changes.ts";
|
|
6
|
+
import { SESSION_WORKTREE_ENTRY } from "./session-worktree-registry.ts";
|
|
7
7
|
|
|
8
|
+
// Captured write/edit evidence of a session and its subagents. It feeds
|
|
9
|
+
// session statistics and tells /gentle:changes which roots the session
|
|
10
|
+
// touched; the files Changes lists always come from Git.
|
|
8
11
|
export const SESSION_CHANGE_ENTRY = "gentle-pi.session-change/v1";
|
|
9
|
-
export const SESSION_CHANGE_EVENT = "gentle-pi:session-change";
|
|
10
12
|
export const SESSION_CHANGE_RELAY = "gentle-pi:child-session-change";
|
|
11
13
|
export const MAX_CHANGE_BYTES = 64 * 1024;
|
|
12
14
|
const MAX_RECORDS = 256;
|
|
@@ -65,13 +67,29 @@ export function isSessionChangeEvidence(value: unknown): value is SessionChangeE
|
|
|
65
67
|
export const sameSnapshot = (a: ChangeSnapshot, b: ChangeSnapshot): boolean =>
|
|
66
68
|
a.kind === b.kind && (a.kind === "absent" || (a.kind === "text" && b.kind === "text" && a.text === b.text));
|
|
67
69
|
const snapshotText = (value: ChangeSnapshot) => value.kind === "text" ? value.text : "";
|
|
70
|
+
const shown = (state: FileState) => Boolean(state.unavailable) || !sameSnapshot(state.before, state.after);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Roots this session works in: its registered worktrees (own root, explicit and
|
|
74
|
+
* subagent registrations) and the roots of captured evidence (subagents in an
|
|
75
|
+
* independent repository). Only roots, never files.
|
|
76
|
+
*/
|
|
77
|
+
export function sessionChangeRoots(sessionId: string, entries: readonly Entry[]): string[] {
|
|
78
|
+
const roots = new Set<string>();
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry.type !== "custom" || !entry.data || typeof entry.data !== "object") continue;
|
|
81
|
+
const data = entry.data as { sessionId?: unknown; root?: unknown; evidence?: unknown };
|
|
82
|
+
if (data.sessionId !== sessionId) continue;
|
|
83
|
+
if (entry.customType === SESSION_WORKTREE_ENTRY && typeof data.root === "string" && isAbsolute(data.root)) roots.add(data.root);
|
|
84
|
+
if (entry.customType === SESSION_CHANGE_ENTRY && isSessionChangeEvidence(data.evidence)) roots.add(data.evidence.root);
|
|
85
|
+
}
|
|
86
|
+
return [...roots];
|
|
87
|
+
}
|
|
68
88
|
|
|
69
89
|
export class SessionChanges {
|
|
70
90
|
private readonly seen = new Set<string>();
|
|
71
91
|
private readonly files = new Map<string, Map<string, FileState>>();
|
|
72
92
|
private bytes = 0;
|
|
73
|
-
private announced = false;
|
|
74
|
-
notice: string | undefined;
|
|
75
93
|
readonly sessionId: string;
|
|
76
94
|
private cached: WorktreeChanges[] | undefined;
|
|
77
95
|
constructor(sessionId: string, entries: readonly Entry[] = []) { this.sessionId = sessionId; this.restore(entries); }
|
|
@@ -86,10 +104,8 @@ export class SessionChanges {
|
|
|
86
104
|
record(evidence: SessionChangeEvidence): boolean {
|
|
87
105
|
if (!isSessionChangeEvidence(evidence) || this.seen.has(evidence.id)) return false;
|
|
88
106
|
const bytes = Buffer.byteLength(JSON.stringify(evidence));
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
107
|
+
// Bounds session-file growth; Changes itself lists from Git without a cap.
|
|
108
|
+
if (this.seen.size >= MAX_RECORDS || this.bytes + bytes > MAX_SESSION_BYTES) return false;
|
|
93
109
|
this.seen.add(evidence.id);
|
|
94
110
|
this.bytes += bytes;
|
|
95
111
|
if (sameSnapshot(evidence.before, evidence.after)) return false;
|
|
@@ -109,13 +125,13 @@ export class SessionChanges {
|
|
|
109
125
|
}
|
|
110
126
|
private changed(root: string): ChangedFile[] {
|
|
111
127
|
return [...(this.files.get(root) ?? [])].flatMap(([path, state]) => {
|
|
112
|
-
if (!
|
|
128
|
+
if (!shown(state)) return [];
|
|
113
129
|
const patch = this.patch(path, state);
|
|
114
130
|
const patchLines = patch.split("\n");
|
|
115
131
|
const firstHunk = patchLines.findIndex(line => line.startsWith("@@"));
|
|
116
132
|
const lines = firstHunk < 0 ? [] : patchLines.slice(firstHunk + 1);
|
|
117
133
|
return [{
|
|
118
|
-
path,
|
|
134
|
+
path, status: state.before.kind === "absent" ? "added" as const : state.after.kind === "absent" ? "deleted" as const : "modified" as const,
|
|
119
135
|
added: state.unavailable ? 0 : lines.filter(line => line.startsWith("+")).length,
|
|
120
136
|
deleted: state.unavailable ? 0 : lines.filter(line => line.startsWith("-")).length,
|
|
121
137
|
...(state.unavailable ? { countsUnavailable: state.unavailable } : {}),
|
|
@@ -131,17 +147,6 @@ export class SessionChanges {
|
|
|
131
147
|
}
|
|
132
148
|
get model() {
|
|
133
149
|
const trees = this.worktrees;
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
/** Return the capture-limit notice once, for a single user notification. The `notice` field stays set. */
|
|
137
|
-
takeNotice(): string | undefined {
|
|
138
|
-
if (!this.notice || this.announced) return undefined;
|
|
139
|
-
this.announced = true;
|
|
140
|
-
return this.notice;
|
|
141
|
-
}
|
|
142
|
-
async refresh() { return this.model; }
|
|
143
|
-
loadDiff(root: string, file: ChangedFile): string {
|
|
144
|
-
const state = this.files.get(root)?.get(file.path);
|
|
145
|
-
return state ? this.patch(file.path, state) : "No captured agent diff for this file.";
|
|
150
|
+
return changesModel(trees.flatMap(tree => tree.model.files.map(file => ({ ...file, path: trees.length === 1 ? file.path : tree.root + "/" + file.path }))));
|
|
146
151
|
}
|
|
147
152
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Session-bound active profiles (gentle-shell#1064, slice 1).
|
|
2
|
+
//
|
|
3
|
+
// One parent Pi session may bind one saved profile for its own launches without
|
|
4
|
+
// touching any shared state: no clone pin (`p`), no repository declaration (`P`),
|
|
5
|
+
// no global routing, no materialized stores, no agent frontmatter, no Pi default
|
|
6
|
+
// settings. The binding is an in-process store keyed by the parent session id, so
|
|
7
|
+
// concurrent Pi processes are isolated by construction and a resumed session
|
|
8
|
+
// starts from the shared layers (`p → P → global`) until it binds again.
|
|
9
|
+
// Persistence across resume and reload semantics are deliberate follow-up
|
|
10
|
+
// slices; this module never writes a file.
|
|
11
|
+
//
|
|
12
|
+
// Pi loads every extension entrypoint with its own Jiti instance and
|
|
13
|
+
// `moduleCache: false` (loader.js `loadExtensionModule`), so a module-local Map
|
|
14
|
+
// would be re-evaluated per entrypoint: the panel writes one copy while the
|
|
15
|
+
// launch, usage, and status readers hold their own empty copies
|
|
16
|
+
// (gentle-shell#1558). The store therefore lives on `globalThis` behind a
|
|
17
|
+
// `Symbol.for` key, which every evaluation of this module in one process
|
|
18
|
+
// resolves to the same symbol and therefore the same shared Map.
|
|
19
|
+
import type { AgentModelConfig } from "./model-routing-authority.ts";
|
|
20
|
+
|
|
21
|
+
/** A session's bound profile: its saved name plus the routing snapshot taken at bind time. */
|
|
22
|
+
export interface SessionProfileBinding {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly modelProfiles: AgentModelConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const bindingsRegistryKey = Symbol.for("gentle-pi:session-profile-bindings");
|
|
28
|
+
|
|
29
|
+
/** The one process-wide binding store, shared across every extension entrypoint that imports this module. */
|
|
30
|
+
function bindingsBySession(): Map<string, SessionProfileBinding> {
|
|
31
|
+
const holder = globalThis as { [bindingsRegistryKey]?: Map<string, SessionProfileBinding> };
|
|
32
|
+
(holder[bindingsRegistryKey] ??= new Map());
|
|
33
|
+
return holder[bindingsRegistryKey]!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Copy one routing snapshot level deep — the layer a defensive copy needs, so a binding never aliases its source or its readers. */
|
|
37
|
+
function cloneProfiles(profiles: AgentModelConfig): AgentModelConfig {
|
|
38
|
+
return Object.fromEntries(Object.entries(profiles).map(([agent, entry]) => [agent, { ...entry }]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bind (or rebind) one parent session to a profile. The routing snapshot is
|
|
43
|
+
* copied, so later edits to the caller's object or to the saved profile
|
|
44
|
+
* definition never mutate a session that already bound it; refreshing a bound
|
|
45
|
+
* session requires an explicit re-selection.
|
|
46
|
+
*/
|
|
47
|
+
export function bindSessionProfile(sessionId: string, name: string, modelProfiles: AgentModelConfig): void {
|
|
48
|
+
bindingsBySession().set(sessionId, { name, modelProfiles: cloneProfiles(modelProfiles) });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read a session's binding as a private copy. Returns `undefined` for unknown,
|
|
53
|
+
* cleared, or undefined session ids: an unbound session keeps resolving the
|
|
54
|
+
* shared layers exactly as before.
|
|
55
|
+
*/
|
|
56
|
+
export function readSessionProfileBinding(sessionId: string | undefined): SessionProfileBinding | undefined {
|
|
57
|
+
if (sessionId === undefined) return undefined;
|
|
58
|
+
const binding = bindingsBySession().get(sessionId);
|
|
59
|
+
return binding === undefined ? undefined : { name: binding.name, modelProfiles: cloneProfiles(binding.modelProfiles) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Remove one session's binding without touching any other session's. */
|
|
63
|
+
export function clearSessionProfileBinding(sessionId: string): void {
|
|
64
|
+
bindingsBySession().delete(sessionId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Testing-only reset of every binding. */
|
|
68
|
+
export function resetSessionProfileBindingsForTesting(): void {
|
|
69
|
+
bindingsBySession().clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The launch-time precedence for wholesale routing replacement: a session
|
|
74
|
+
* binding outranks a winning pin, a pin alone still replaces, and neither
|
|
75
|
+
* leaves today's routing untouched. Named so the precedence rule a launch and
|
|
76
|
+
* the status display agree on lives in exactly one place.
|
|
77
|
+
*/
|
|
78
|
+
export function sessionOrPinModelProfiles(
|
|
79
|
+
session: AgentModelConfig | undefined,
|
|
80
|
+
pin: AgentModelConfig | undefined,
|
|
81
|
+
): AgentModelConfig | undefined {
|
|
82
|
+
return session ?? pin;
|
|
83
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// Inherited profile freeze (gentle-shell#1064 slice 3b-ii).
|
|
2
|
+
//
|
|
3
|
+
// A parent session that never pressed Enter in `/gentle:profiles` resolves the
|
|
4
|
+
// shared defaults `p → P → G` (local pin, repository declaration, global
|
|
5
|
+
// `active` in profiles.json) ONCE, at startup, and keeps that snapshot with its
|
|
6
|
+
// origin. Changing a default later only affects new sessions, exactly like Pi's
|
|
7
|
+
// "set as default" for the orchestrator model; an open session changes profile
|
|
8
|
+
// only through its own Enter. Each launch re-resolves the defaults to compare
|
|
9
|
+
// and shows one drift notice per distinct change.
|
|
10
|
+
//
|
|
11
|
+
// Precedence, the single rule every consumer reads through
|
|
12
|
+
// `resolveSessionProfile`:
|
|
13
|
+
//
|
|
14
|
+
// explicit Enter binding → frozen inherited snapshot → live p → P → G
|
|
15
|
+
//
|
|
16
|
+
// The live resolution applies only in `follow` mode (GENTLE_PI_PROFILE_FOLLOW=1,
|
|
17
|
+
// for CI and headless runs), in subagent children (their routing comes from the
|
|
18
|
+
// parent through --model), and when no session id is available. In `follow`
|
|
19
|
+
// mode the orchestrator still comes from the startup profile while children
|
|
20
|
+
// follow later defaults: an accepted, documented limitation.
|
|
21
|
+
//
|
|
22
|
+
// The frozen state is in-process only, like the explicit binding of slice 2: it
|
|
23
|
+
// lives on `globalThis` behind a `Symbol.for` key because Pi evaluates this
|
|
24
|
+
// module once per extension entrypoint. A resumed session (`--resume`,
|
|
25
|
+
// `--continue`, `--session`) therefore freezes again from the current defaults,
|
|
26
|
+
// and its drift memory starts empty. Persisting the freeze as a
|
|
27
|
+
// `gentle-pi.session-profile/v1` entry with origin `local | repo | global` is
|
|
28
|
+
// the integration point with slice 3b-i.
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { profileRoleEntries, profilesFilePath, readProfilesFileResult } from "./agent-profiles.ts";
|
|
31
|
+
import { readProfilePinStatus, resolveProfilePin, resolveUnversionedProjectProfile } from "./agent-profile-pin.ts";
|
|
32
|
+
import type { AgentModelConfig } from "./model-routing-authority.ts";
|
|
33
|
+
import { readSessionProfileBinding } from "./session-profile-binding.ts";
|
|
34
|
+
import type { WorktreeIdentity, WorktreeResolver } from "./session-worktree-registry.ts";
|
|
35
|
+
|
|
36
|
+
/** Opt-in that keeps the live `p → P → G` resolution instead of freezing it at startup. */
|
|
37
|
+
export const PROFILE_FOLLOW_ENV = "GENTLE_PI_PROFILE_FOLLOW";
|
|
38
|
+
|
|
39
|
+
/** Which shared default supplied an inherited profile. */
|
|
40
|
+
export type InheritedProfileOrigin = "local" | "repo" | "global";
|
|
41
|
+
|
|
42
|
+
/** A profile resolved from the shared defaults: its name, its origin, and its role routing. */
|
|
43
|
+
export interface InheritedProfile {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly origin: InheritedProfileOrigin;
|
|
46
|
+
readonly modelProfiles: AgentModelConfig;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A frozen session: `profile` is `undefined` when no layer had a profile at startup. */
|
|
50
|
+
export interface FrozenInheritedProfile {
|
|
51
|
+
readonly profile: InheritedProfile | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The profile a session uses. `inherited` is the startup snapshot (with or
|
|
56
|
+
* without a profile); `follow` and `live` carry the current defaults.
|
|
57
|
+
*/
|
|
58
|
+
export type SessionProfileResolution =
|
|
59
|
+
| { kind: "explicit"; name: string; modelProfiles: AgentModelConfig }
|
|
60
|
+
| { kind: "inherited"; profile: InheritedProfile | undefined }
|
|
61
|
+
| { kind: "follow"; profile: InheritedProfile | undefined }
|
|
62
|
+
| { kind: "live"; profile: InheritedProfile | undefined };
|
|
63
|
+
|
|
64
|
+
export interface InheritedProfileOptions {
|
|
65
|
+
cwd: string;
|
|
66
|
+
configHome: string;
|
|
67
|
+
resolveWorktree?: WorktreeResolver;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SessionProfileOptions extends InheritedProfileOptions {
|
|
71
|
+
sessionId: string | undefined;
|
|
72
|
+
env?: NodeJS.ProcessEnv;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface FreezeRegistry {
|
|
76
|
+
frozen: Map<string, FrozenInheritedProfile>;
|
|
77
|
+
// Drift fingerprints already reported, per session.
|
|
78
|
+
notified: Map<string, Set<string>>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const freezeRegistryKey = Symbol.for("gentle-pi:session-profile-freeze");
|
|
82
|
+
|
|
83
|
+
function registry(): FreezeRegistry {
|
|
84
|
+
const holder = globalThis as { [freezeRegistryKey]?: FreezeRegistry };
|
|
85
|
+
(holder[freezeRegistryKey] ??= { frozen: new Map(), notified: new Map() });
|
|
86
|
+
return holder[freezeRegistryKey]!;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cloneProfiles(profiles: AgentModelConfig): AgentModelConfig {
|
|
90
|
+
return Object.fromEntries(Object.entries(profiles).map(([agent, entry]) => [agent, { ...entry }]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cloneProfile(profile: InheritedProfile | undefined): InheritedProfile | undefined {
|
|
94
|
+
return profile === undefined ? undefined : { name: profile.name, origin: profile.origin, modelProfiles: cloneProfiles(profile.modelProfiles) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function roleRouting(config: AgentModelConfig): AgentModelConfig {
|
|
98
|
+
return Object.fromEntries(profileRoleEntries(config).map(([agent, entry]) => [agent, { ...entry }]));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function profileFollowEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
102
|
+
return env[PROFILE_FOLLOW_ENV] === "1";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isChildSession(env: NodeJS.ProcessEnv): boolean {
|
|
106
|
+
return env.GENTLE_PI_AGENTS_CHILD === "1";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The shared defaults `p → P → G` for one directory, with the layer that won.
|
|
111
|
+
* Pin layers keep `resolveProfilePin` semantics; outside a Git worktree the
|
|
112
|
+
* repository declaration is read as admission does for non-Git projects; the
|
|
113
|
+
* global layer is the `active` profile of profiles.json. The orchestrator key
|
|
114
|
+
* is never part of the routing. `undefined` means no layer has a profile.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveInheritedProfile(options: InheritedProfileOptions): InheritedProfile | undefined {
|
|
117
|
+
// One worktree lookup serves both the pin layers and the non-Git check.
|
|
118
|
+
let resolved = false;
|
|
119
|
+
let identity: WorktreeIdentity | undefined;
|
|
120
|
+
const resolveWorktree: WorktreeResolver | undefined = options.resolveWorktree === undefined ? undefined : (cwd, base) => {
|
|
121
|
+
if (!resolved) {
|
|
122
|
+
resolved = true;
|
|
123
|
+
identity = options.resolveWorktree!(cwd, base);
|
|
124
|
+
}
|
|
125
|
+
return identity;
|
|
126
|
+
};
|
|
127
|
+
const pin = resolveProfilePin({ cwd: options.cwd, configHome: options.configHome, resolveWorktree });
|
|
128
|
+
if (pin !== undefined) return { name: pin.profile, origin: pin.source, modelProfiles: pin.modelProfiles };
|
|
129
|
+
if (readProfilePinStatus(options.cwd, resolveWorktree) === undefined) {
|
|
130
|
+
const declaration = resolveUnversionedProjectProfile(options.cwd, options.configHome);
|
|
131
|
+
if (declaration !== undefined) return { name: declaration.profile, origin: "repo", modelProfiles: declaration.modelProfiles };
|
|
132
|
+
}
|
|
133
|
+
const store = readProfilesFileResult(profilesFilePath(options.configHome));
|
|
134
|
+
if (store.status !== "valid" || store.file.active === undefined) return undefined;
|
|
135
|
+
const active = store.file.active;
|
|
136
|
+
if (!Object.prototype.hasOwnProperty.call(store.file.profiles, active)) return undefined;
|
|
137
|
+
return { name: active, origin: "global", modelProfiles: roleRouting(store.file.profiles[active]!) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A copy of one session's frozen snapshot, or `undefined` when it never froze. */
|
|
141
|
+
export function readFrozenInheritedProfile(sessionId: string | undefined): FrozenInheritedProfile | undefined {
|
|
142
|
+
if (sessionId === undefined) return undefined;
|
|
143
|
+
const frozen = registry().frozen.get(sessionId);
|
|
144
|
+
return frozen === undefined ? undefined : { profile: cloneProfile(frozen.profile) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function freezes(options: SessionProfileOptions): boolean {
|
|
148
|
+
const env = options.env ?? process.env;
|
|
149
|
+
return options.sessionId !== undefined && options.sessionId.length > 0 && !isChildSession(env) && !profileFollowEnabled(env);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Only called after `freezes(options)`, which guarantees a session id.
|
|
153
|
+
function frozenOrFreeze(options: SessionProfileOptions): FrozenInheritedProfile {
|
|
154
|
+
const store = registry().frozen;
|
|
155
|
+
const sessionId = options.sessionId!;
|
|
156
|
+
let frozen = store.get(sessionId);
|
|
157
|
+
if (frozen === undefined) {
|
|
158
|
+
frozen = { profile: cloneProfile(resolveInheritedProfile(options)) };
|
|
159
|
+
store.set(sessionId, frozen);
|
|
160
|
+
}
|
|
161
|
+
return { profile: cloneProfile(frozen.profile) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Freeze a parent session's inherited profile at startup. Idempotent: the
|
|
166
|
+
* first snapshot wins, so a session keeps it across `/reload` in the same
|
|
167
|
+
* process. Children and `follow` sessions never freeze.
|
|
168
|
+
*/
|
|
169
|
+
export function freezeSessionProfileAtStartup(options: SessionProfileOptions): void {
|
|
170
|
+
if (freezes(options)) frozenOrFreeze(options);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The single precedence rule for launch, status and Usage. A parent session
|
|
175
|
+
* that has not frozen yet freezes here, so the snapshot never depends on which
|
|
176
|
+
* extension's `session_start` ran first.
|
|
177
|
+
*/
|
|
178
|
+
export function resolveSessionProfile(options: SessionProfileOptions): SessionProfileResolution {
|
|
179
|
+
const explicit = readSessionProfileBinding(options.sessionId);
|
|
180
|
+
if (explicit !== undefined) return { kind: "explicit", name: explicit.name, modelProfiles: explicit.modelProfiles };
|
|
181
|
+
if (freezes(options)) return { kind: "inherited", profile: frozenOrFreeze(options).profile };
|
|
182
|
+
const env = options.env ?? process.env;
|
|
183
|
+
const profile = resolveInheritedProfile(options);
|
|
184
|
+
return isChildSession(env) || !profileFollowEnabled(env) ? { kind: "live", profile } : { kind: "follow", profile };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The routing that replaces subagent model profiles, or `undefined` for today's routing. */
|
|
188
|
+
export function sessionProfileModelProfiles(resolution: SessionProfileResolution): AgentModelConfig | undefined {
|
|
189
|
+
return resolution.kind === "explicit" ? resolution.modelProfiles : resolution.profile?.modelProfiles;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The routing one launch target uses under a resolution read once per request.
|
|
194
|
+
* Explicit and frozen profiles belong to the session, so they apply wherever
|
|
195
|
+
* the launch goes, a foreign repository included; a `follow` or `live`
|
|
196
|
+
* resolution reads the defaults of the target itself. A session frozen without
|
|
197
|
+
* a profile has nothing to carry into a foreign repository, so that
|
|
198
|
+
* repository keeps its own pin layers (local pin, then declaration), as
|
|
199
|
+
* before the freeze; the global active profile is not the repository's own
|
|
200
|
+
* default and does not apply there either.
|
|
201
|
+
*/
|
|
202
|
+
export function sessionProfileRoutingAt(
|
|
203
|
+
resolution: SessionProfileResolution,
|
|
204
|
+
target: InheritedProfileOptions & { foreignRepository?: boolean },
|
|
205
|
+
): AgentModelConfig | undefined {
|
|
206
|
+
if (resolution.kind === "explicit") return resolution.modelProfiles;
|
|
207
|
+
if (resolution.kind === "inherited") {
|
|
208
|
+
if (resolution.profile !== undefined || target.foreignRepository !== true) return resolution.profile?.modelProfiles;
|
|
209
|
+
return resolveProfilePin(target)?.modelProfiles;
|
|
210
|
+
}
|
|
211
|
+
return resolveInheritedProfile(target)?.modelProfiles;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The status label, spelled as before: `name (session)`, `name (local)`, `name (repo)`, or the bare global name. */
|
|
215
|
+
export function sessionProfileLabel(resolution: SessionProfileResolution): string | undefined {
|
|
216
|
+
if (resolution.kind === "explicit") return `${resolution.name} (session)`;
|
|
217
|
+
const profile = resolution.profile;
|
|
218
|
+
if (profile === undefined) return undefined;
|
|
219
|
+
return profile.origin === "global" ? profile.name : `${profile.name} (${profile.origin})`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stableJson(value: unknown): string {
|
|
223
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
224
|
+
if (value !== null && typeof value === "object") {
|
|
225
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
|
|
226
|
+
}
|
|
227
|
+
return JSON.stringify(value) ?? "null";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The origin does not route anything: the same profile reached through another
|
|
231
|
+
// layer is not drift.
|
|
232
|
+
function fingerprint(profile: InheritedProfile | undefined): string {
|
|
233
|
+
if (profile === undefined) return "none";
|
|
234
|
+
return createHash("sha256").update(stableJson({ name: profile.name, modelProfiles: profile.modelProfiles })).digest("hex");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function describe(profile: InheritedProfile | undefined): string {
|
|
238
|
+
return profile === undefined ? "no profile" : `"${profile.name}" (${profile.origin})`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Compare a frozen inherited session with the current defaults. Returns the
|
|
243
|
+
* one-line notice the first time a given current default differs from the
|
|
244
|
+
* snapshot, and `undefined` otherwise: the same change is never reported
|
|
245
|
+
* twice in one session. Explicit bindings, `follow` sessions and children are
|
|
246
|
+
* never compared. The comparison never adopts the new default.
|
|
247
|
+
*/
|
|
248
|
+
export function inheritedProfileDriftNotice(options: SessionProfileOptions): string | undefined {
|
|
249
|
+
const resolution = resolveSessionProfile(options);
|
|
250
|
+
if (resolution.kind !== "inherited") return undefined;
|
|
251
|
+
const current = resolveInheritedProfile(options);
|
|
252
|
+
const key = fingerprint(current);
|
|
253
|
+
if (key === fingerprint(resolution.profile)) return undefined;
|
|
254
|
+
const notified = registry().notified;
|
|
255
|
+
const seen = notified.get(options.sessionId!) ?? new Set<string>();
|
|
256
|
+
if (seen.has(key)) return undefined;
|
|
257
|
+
seen.add(key);
|
|
258
|
+
notified.set(options.sessionId!, seen);
|
|
259
|
+
return `el Gentleman: the default profile changed to ${describe(current)}, this session keeps ${describe(resolution.profile)}. Press Enter on a profile in /gentle:profiles to adopt it.`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Testing-only reset of every frozen snapshot and drift memory. */
|
|
263
|
+
export function resetFrozenInheritedProfilesForTesting(): void {
|
|
264
|
+
registry().frozen.clear();
|
|
265
|
+
registry().notified.clear();
|
|
266
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { lstatSync, realpathSync, statSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
|
|
6
6
|
export const SESSION_WORKTREE_ENTRY = "gentle-pi.session-worktree/v1";
|
|
7
7
|
export const SESSION_WORKTREE_CHANGED = "gentle-pi:session-worktree-changed";
|
|
@@ -23,12 +23,16 @@ export function worktreeGitEnvironment(env: NodeJS.ProcessEnv = process.env): No
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
// Match Pi's ordinary path spelling; Git, not the argument, establishes identity.
|
|
26
|
+
function canonicalDirectory(path: string, cwd: string): string {
|
|
27
|
+
let spelling = path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
|
|
28
|
+
if (spelling === "~" || spelling.startsWith("~/")) spelling = homedir() + spelling.slice(1);
|
|
29
|
+
const canonical = realpathSync(resolve(cwd, spelling));
|
|
30
|
+
return statSync(canonical).isDirectory() ? canonical : dirname(canonical);
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
export function resolveSessionWorktreeWithGit(path: string, cwd: string, run: typeof execFileSync = execFileSync): WorktreeIdentity | undefined {
|
|
27
34
|
try {
|
|
28
|
-
|
|
29
|
-
if (spelling === "~" || spelling.startsWith("~/")) spelling = homedir() + spelling.slice(1);
|
|
30
|
-
const canonical = realpathSync(resolve(cwd, spelling));
|
|
31
|
-
const directory = statSync(canonical).isDirectory() ? canonical : dirname(canonical);
|
|
35
|
+
const directory = canonicalDirectory(path, cwd);
|
|
32
36
|
// Ambient Git routing must not redirect a path into another repository.
|
|
33
37
|
const env = worktreeGitEnvironment();
|
|
34
38
|
const git = (arg: string) => String(run("git", ["--no-optional-locks", "-C", directory, "rev-parse", "--path-format=absolute", arg], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true, env })).replace(/\r?\n$/, "");
|
|
@@ -38,7 +42,69 @@ export function resolveSessionWorktreeWithGit(path: string, cwd: string, run: ty
|
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
|
|
45
|
+
// Filesystem identity of everything Git used to answer: no `.git` between the
|
|
46
|
+
// directory and its root, and the same root, `.git` entry and common dir.
|
|
47
|
+
// Undefined when any of it cannot be proven, so the answer is not memoized.
|
|
48
|
+
function worktreeFingerprint(directory: string, identity: WorktreeIdentity): string | undefined {
|
|
49
|
+
const same = (a: string, b: string) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
50
|
+
try {
|
|
51
|
+
for (let current = directory; !same(current, identity.root); current = dirname(current)) {
|
|
52
|
+
if (dirname(current) === current) return undefined;
|
|
53
|
+
try { lstatSync(join(current, ".git")); return undefined; }
|
|
54
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined; }
|
|
55
|
+
}
|
|
56
|
+
const marker = lstatSync(join(identity.root, ".git"));
|
|
57
|
+
const parts = [statSync(identity.root), marker, statSync(identity.commonDir)].map(entry => `${entry.dev}:${entry.ino}`);
|
|
58
|
+
// A linked worktree's `.git` file can be rewritten in place to another clone.
|
|
59
|
+
if (marker.isFile()) parts.push(`${marker.size}:${marker.mtimeMs}`);
|
|
60
|
+
return parts.join("|");
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WorktreeMemoOptions { ttlMs?: number; now?: () => number }
|
|
67
|
+
interface MemoEntry { identity: WorktreeIdentity; directory: string; fingerprint: string; expiresAt: number }
|
|
68
|
+
const memoInvalidators = new Set<() => void>();
|
|
69
|
+
|
|
70
|
+
// Startup asks the same (cwd, cwd) question from several extensions; each
|
|
71
|
+
// answer costs two synchronous Git children. Only proven repositories are
|
|
72
|
+
// memoized, and every hit re-checks their filesystem identity, so removal,
|
|
73
|
+
// replacement, a nested `.git` and bootstrap (never cached) are seen at once.
|
|
74
|
+
// The TTL bounds what stat cannot see, such as Git config edits.
|
|
75
|
+
export function memoizeWorktreeResolver(resolver: WorktreeResolver, { ttlMs = 5000, now = Date.now }: WorktreeMemoOptions = {}): WorktreeResolver {
|
|
76
|
+
const memo = new Map<string, MemoEntry>();
|
|
77
|
+
memoInvalidators.add(() => memo.clear());
|
|
78
|
+
return (path, cwd) => {
|
|
79
|
+
const key = `${path}\u0000${cwd}`;
|
|
80
|
+
const cached = memo.get(key);
|
|
81
|
+
if (cached && cached.expiresAt > now()) {
|
|
82
|
+
try {
|
|
83
|
+
const directory = canonicalDirectory(path, cwd);
|
|
84
|
+
if (directory === cached.directory && worktreeFingerprint(directory, cached.identity) === cached.fingerprint) return { ...cached.identity };
|
|
85
|
+
} catch { /* Fall through to a fresh resolution. */ }
|
|
86
|
+
}
|
|
87
|
+
memo.delete(key);
|
|
88
|
+
const identity = resolver(path, cwd);
|
|
89
|
+
if (!identity) return undefined;
|
|
90
|
+
try {
|
|
91
|
+
const directory = canonicalDirectory(path, cwd);
|
|
92
|
+
const fingerprint = worktreeFingerprint(directory, identity);
|
|
93
|
+
if (fingerprint !== undefined) {
|
|
94
|
+
if (memo.size >= 256) memo.clear();
|
|
95
|
+
memo.set(key, { identity: { ...identity }, directory, fingerprint, expiresAt: now() + ttlMs });
|
|
96
|
+
}
|
|
97
|
+
} catch { /* Unprovable identity is returned but never memoized. */ }
|
|
98
|
+
return identity;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Drops every memoized worktree identity; the next lookup asks Git again. */
|
|
103
|
+
export function clearSessionWorktreeMemo(): void {
|
|
104
|
+
for (const invalidate of memoInvalidators) invalidate();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const resolveSessionWorktree: WorktreeResolver = memoizeWorktreeResolver(resolveSessionWorktreeWithGit);
|
|
42
108
|
|
|
43
109
|
export function toolWorktreePath(name: string, input: Record<string, unknown>): string | undefined {
|
|
44
110
|
if (!["read", "write", "edit", "grep", "find", "ls"].includes(name)) return undefined;
|
|
@@ -82,7 +148,7 @@ export class SessionWorktreeRegistry {
|
|
|
82
148
|
if (!this.isCurrent()) throw new Error("Cannot register a worktree for an inactive session.");
|
|
83
149
|
const identity = this.refreshIdentity();
|
|
84
150
|
const target = this.resolver(path, this.cwd);
|
|
85
|
-
if (!target || !identity || target.commonDir !== identity.commonDir) throw new Error("Select an existing worktree in the same Git clone as this session.");
|
|
151
|
+
if (!target || !identity || target.commonDir !== identity.commonDir) throw new Error("Select an existing worktree in the same Git clone as this session. To work in an independent Git repository, delegate with subagent_run with repository_root instead; it asks for your consent first.");
|
|
86
152
|
return target.root;
|
|
87
153
|
}
|
|
88
154
|
|