gentle-pi 4.2.0 → 4.4.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 +340 -138
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +485 -7
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +645 -21
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +147 -3
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +70 -10
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -13,8 +13,11 @@ import {
|
|
|
13
13
|
staleTurns,
|
|
14
14
|
TODO_DETAILS_KEY,
|
|
15
15
|
TODO_GLYPH,
|
|
16
|
+
TODO_PROGRESS_EVENT,
|
|
16
17
|
TODO_TOOL_NAME,
|
|
17
18
|
todoCardTone,
|
|
19
|
+
TODO_CLOSED_STATE,
|
|
20
|
+
todoProgress,
|
|
18
21
|
todoPromptBlock,
|
|
19
22
|
todoStateMessage,
|
|
20
23
|
todoSummary,
|
|
@@ -26,8 +29,9 @@ import {
|
|
|
26
29
|
// Gentle Shell card above the editor. Three things keep it current that a
|
|
27
30
|
// static tool description cannot: `write` replaces the whole list in one
|
|
28
31
|
// call, each run's system prompt carries a byte-stable rules block while the
|
|
29
|
-
// open tasks reach the model as a
|
|
30
|
-
// changes never invalidate the cached prompt prefix
|
|
32
|
+
// open tasks reach the model as a hidden transcript message appended when the
|
|
33
|
+
// list changes (so list changes never invalidate the cached prompt prefix and
|
|
34
|
+
// requests stay append-only, gentle-shell#1662, #2195), and
|
|
31
35
|
// a list that goes untouched while tasks stay open is marked stale for the
|
|
32
36
|
// human. Long runs also receive one request-local reminder after several
|
|
33
37
|
// tool-use turns, without waking the agent or changing tasks.
|
|
@@ -87,6 +91,10 @@ interface TodoSession {
|
|
|
87
91
|
collapsed: boolean;
|
|
88
92
|
/** A finished list stays on screen for the turn it finished in, then clears. */
|
|
89
93
|
clearOnNextTurn: boolean;
|
|
94
|
+
/** The last state snapshot sent to the transcript. */
|
|
95
|
+
lastSent: string | undefined;
|
|
96
|
+
/** A snapshot queued during the current turn; Pi appends it at turn_end. */
|
|
97
|
+
pending: string | undefined;
|
|
90
98
|
ui: ExtensionContext["ui"] | undefined;
|
|
91
99
|
host: { requestRender(): void } | undefined;
|
|
92
100
|
tui: TUI | undefined;
|
|
@@ -107,12 +115,21 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
|
|
|
107
115
|
const key = sessionKey(ctx);
|
|
108
116
|
let current = sessions.get(key);
|
|
109
117
|
if (!current) {
|
|
110
|
-
current = { state: emptyTodo(), turn: 0, toolTurnsSinceWrite: 0, reminded: false, wroteThisTurn: false, collapsed: false, clearOnNextTurn: false, ui: undefined, host: undefined, tui: undefined };
|
|
118
|
+
current = { state: emptyTodo(), turn: 0, toolTurnsSinceWrite: 0, reminded: false, wroteThisTurn: false, collapsed: false, clearOnNextTurn: false, lastSent: undefined, pending: undefined, ui: undefined, host: undefined, tui: undefined };
|
|
111
119
|
sessions.set(key, current);
|
|
112
120
|
}
|
|
113
121
|
return current;
|
|
114
122
|
};
|
|
115
123
|
|
|
124
|
+
// While the agent streams, Pi appends this hidden message at turn_end, after
|
|
125
|
+
// the tool results and before the next model request; it never starts a turn.
|
|
126
|
+
const persistState = (current: TodoSession, state: string) => {
|
|
127
|
+
if (!pi.sendMessage) return;
|
|
128
|
+
pi.sendMessage({ customType: "gentle-todo-state", content: state, display: false }, { triggerTurn: false });
|
|
129
|
+
current.lastSent = state;
|
|
130
|
+
current.pending = state;
|
|
131
|
+
};
|
|
132
|
+
|
|
116
133
|
const toggle = (current: TodoSession) => {
|
|
117
134
|
current.collapsed = !current.collapsed;
|
|
118
135
|
if (current.tui) invalidateSidebar(current.tui);
|
|
@@ -265,6 +282,11 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
|
|
|
265
282
|
current.toolTurnsSinceWrite = 0;
|
|
266
283
|
current.reminded = false;
|
|
267
284
|
current.wroteThisTurn = true;
|
|
285
|
+
// A closed list persists the closing marker, so the last open
|
|
286
|
+
// snapshot in the transcript never reads as current work.
|
|
287
|
+
const state = todoStateMessage(current.state) ?? (current.lastSent === undefined ? undefined : TODO_CLOSED_STATE);
|
|
288
|
+
if (state !== undefined && state !== current.lastSent) persistState(current, state);
|
|
289
|
+
pi.events?.emit(TODO_PROGRESS_EVENT, todoProgress(current.state, ctx.sessionManager.getSessionId()));
|
|
268
290
|
}
|
|
269
291
|
if (current.tui) invalidateSidebar(current.tui);
|
|
270
292
|
}
|
|
@@ -303,6 +325,8 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
|
|
|
303
325
|
current.toolTurnsSinceWrite = 0;
|
|
304
326
|
current.reminded = false;
|
|
305
327
|
current.wroteThisTurn = false;
|
|
328
|
+
current.lastSent = undefined;
|
|
329
|
+
current.pending = undefined;
|
|
306
330
|
current.ui = ctx.hasUI ? ctx.ui : undefined;
|
|
307
331
|
show(current);
|
|
308
332
|
});
|
|
@@ -335,17 +359,27 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
|
|
|
335
359
|
current.toolTurnsSinceWrite += 1;
|
|
336
360
|
}
|
|
337
361
|
current.wroteThisTurn = false;
|
|
362
|
+
// Pi flushes the queued snapshot right after this event.
|
|
363
|
+
current.pending = undefined;
|
|
338
364
|
});
|
|
339
365
|
|
|
340
366
|
pi.on("context", (event, ctx) => {
|
|
341
367
|
const current = session(ctx);
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
368
|
+
// gentle-shell#1662, #2195: providers cache the request prefix up to the
|
|
369
|
+
// last message (Anthropic) or send only the delta after the previous input
|
|
370
|
+
// (openai-codex), so a trailing message that the next request drops breaks
|
|
371
|
+
// both. The list is persisted when it changes; this transform only covers a
|
|
372
|
+
// transcript that lacks the current snapshot (compaction, branch navigation,
|
|
373
|
+
// sessions recorded earlier): it trails this one request and persists the
|
|
374
|
+
// snapshot once, so the following requests are append-only again.
|
|
346
375
|
const trailing: Array<{ role: "custom"; customType: string; content: string; display: false; timestamp: number }> = [];
|
|
347
|
-
const
|
|
348
|
-
|
|
376
|
+
const last = [...event.messages].reverse().find((message) => (message as { customType?: string }).customType === "gentle-todo-state") as { content?: unknown } | undefined;
|
|
377
|
+
const seen = typeof last?.content === "string" ? last.content : undefined;
|
|
378
|
+
const state = todoStateMessage(current.state) ?? (seen === undefined || seen === TODO_CLOSED_STATE ? undefined : TODO_CLOSED_STATE);
|
|
379
|
+
if (state !== undefined && seen !== state) {
|
|
380
|
+
trailing.push({ role: "custom", customType: "gentle-todo-state", content: state, display: false, timestamp: Date.now() });
|
|
381
|
+
if (current.pending !== state) persistState(current, state);
|
|
382
|
+
}
|
|
349
383
|
const actionable = current.state.tasks.some((task) => task.status === "pending" || task.status === "in_progress");
|
|
350
384
|
if (!current.reminded && current.toolTurnsSinceWrite >= REMIND_AFTER_TOOL_TURNS && actionable) {
|
|
351
385
|
current.reminded = true;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerVoiceCommand, type VoiceCommandDeps } from "../lib/voice-command.ts";
|
|
3
|
+
|
|
4
|
+
// Gentle Voice: /gentle:voice owns optional local dictation (docs/voice.md).
|
|
5
|
+
// Loading this extension registers the command and, when the push-to-talk
|
|
6
|
+
// toggle is on, a hold-Space listener at session start. Nothing is probed,
|
|
7
|
+
// downloaded, loaded or recorded until the user runs the command or holds Space.
|
|
8
|
+
|
|
9
|
+
export default function gentleVoice(pi: ExtensionAPI, deps: VoiceCommandDeps = {}): void {
|
|
10
|
+
registerVoiceCommand(pi, deps);
|
|
11
|
+
}
|
|
@@ -795,7 +795,7 @@ export function createQuietToolRenderer(
|
|
|
795
795
|
safeResult,
|
|
796
796
|
options,
|
|
797
797
|
theme,
|
|
798
|
-
sanitizedRenderContext(renderContext) as any,
|
|
798
|
+
{ ...sanitizedRenderContext(renderContext), lastComponent: undefined } as any,
|
|
799
799
|
), false);
|
|
800
800
|
}
|
|
801
801
|
let output = formatToolResultOutput(toolName, safeResult, {
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
6
|
+
import {
|
|
7
|
+
checkArtifact,
|
|
8
|
+
CLEAN_RECENT_MS,
|
|
9
|
+
createSessionScratch,
|
|
10
|
+
deleteLedger,
|
|
11
|
+
directorySize,
|
|
12
|
+
formatSize,
|
|
13
|
+
gitArtifactTargets,
|
|
14
|
+
isProcessAlive,
|
|
15
|
+
isProtectedPath,
|
|
16
|
+
isSessionScratchName,
|
|
17
|
+
isWithin,
|
|
18
|
+
LEDGER_ARTIFACTS_CAP,
|
|
19
|
+
LEDGER_VERSION,
|
|
20
|
+
ledgerPath,
|
|
21
|
+
listLedgers,
|
|
22
|
+
listLedgersSync,
|
|
23
|
+
liveSubagentScratch,
|
|
24
|
+
processStartToken,
|
|
25
|
+
readLedger,
|
|
26
|
+
realTempRoot,
|
|
27
|
+
removeArtifact,
|
|
28
|
+
removeSessionScratch,
|
|
29
|
+
scratchRefusal,
|
|
30
|
+
SWEEP_GRACE_MS,
|
|
31
|
+
type ArtifactCheck,
|
|
32
|
+
type ArtifactTarget,
|
|
33
|
+
type ScratchArtifact,
|
|
34
|
+
type ScratchLedger,
|
|
35
|
+
type StoredLedger,
|
|
36
|
+
writeLedger,
|
|
37
|
+
writeLedgerSync,
|
|
38
|
+
} from "../lib/session-scratch.ts";
|
|
39
|
+
|
|
40
|
+
// Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md):
|
|
41
|
+
// the primary session gets its own TMPDIR, the runtime records the clones and
|
|
42
|
+
// worktrees its bash commands create, removes the scratch at shutdown (and the
|
|
43
|
+
// scratch of crashed sessions at the next start), and /gentle:scratch lists or
|
|
44
|
+
// cleans the rest. Delegated children (GENTLE_PI_AGENTS_CHILD=1) do none of it:
|
|
45
|
+
// their own TMPDIR comes from the runner and nests under the parent's scratch.
|
|
46
|
+
|
|
47
|
+
export const SCRATCH_COMMAND_USAGE = [
|
|
48
|
+
"Usage: /gentle:scratch [list|clean]",
|
|
49
|
+
" list this session's scratch dir with its size, and the clones and worktrees recorded by this and ended sessions",
|
|
50
|
+
" clean remove scratch dirs; after one confirmation, remove recorded worktrees and clones with no uncommitted changes and no unpushed commits",
|
|
51
|
+
].join("\n");
|
|
52
|
+
|
|
53
|
+
export interface SessionScratchOptions {
|
|
54
|
+
env?: NodeJS.ProcessEnv;
|
|
55
|
+
configHome?: string;
|
|
56
|
+
now?: () => number;
|
|
57
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
58
|
+
/** When a process started (epoch ms), or undefined when it cannot be read. */
|
|
59
|
+
processStartToken?: (pid: number) => string | undefined;
|
|
60
|
+
graceMs?: number;
|
|
61
|
+
platform?: NodeJS.Platform;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ActiveSession {
|
|
65
|
+
sessionId: string;
|
|
66
|
+
path: string;
|
|
67
|
+
ledger: ScratchLedger;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Keyed by the environment the session scratch was applied to. A /reload that
|
|
71
|
+
// re-runs this factory in the same module instance finds the live session here;
|
|
72
|
+
// one that re-evaluates the module (Pi's loader) recovers it from the ledger.
|
|
73
|
+
const activeSessions = new WeakMap<NodeJS.ProcessEnv, ActiveSession>();
|
|
74
|
+
|
|
75
|
+
export function createSessionScratchExtension(options: SessionScratchOptions = {}): (pi: ExtensionAPI) => void {
|
|
76
|
+
const env = options.env ?? process.env;
|
|
77
|
+
const now = options.now ?? Date.now;
|
|
78
|
+
const alive = options.isProcessAlive ?? isProcessAlive;
|
|
79
|
+
const startToken = options.processStartToken ?? processStartToken;
|
|
80
|
+
// A live pid is the ledger's process only if it has the start token the ledger
|
|
81
|
+
// recorded: the OS may have reused it. A token that is missing or cannot be
|
|
82
|
+
// read keeps the ledger live.
|
|
83
|
+
const isLive = (ledger: ScratchLedger) => {
|
|
84
|
+
if (!alive(ledger.pid)) return false;
|
|
85
|
+
if (ledger.processStartToken === undefined) return true;
|
|
86
|
+
let current: string | undefined;
|
|
87
|
+
try { current = startToken(ledger.pid); } catch { current = undefined; }
|
|
88
|
+
return current === undefined || current === ledger.processStartToken;
|
|
89
|
+
};
|
|
90
|
+
// This process's own token, or undefined (omitted from the ledger JSON) when it cannot be read.
|
|
91
|
+
const safeStartToken = () => { try { return startToken(process.pid); } catch { return undefined; } };
|
|
92
|
+
// Real paths of this process's live and quarantined subagent TMPDIRs (lib/agents-runner.ts).
|
|
93
|
+
const subagentDirs = () => liveSubagentScratch().flatMap((dir) => realTempRoot(dir) ?? []);
|
|
94
|
+
const graceMs = options.graceMs ?? SWEEP_GRACE_MS;
|
|
95
|
+
const tempVariables = (options.platform ?? process.platform) === "win32" ? ["TMPDIR", "TMP", "TEMP"] : ["TMPDIR"];
|
|
96
|
+
const configHome = () => options.configHome ?? gentlePiConfigHome(env);
|
|
97
|
+
|
|
98
|
+
const persist = async (path: string, ledger: ScratchLedger) => {
|
|
99
|
+
try {
|
|
100
|
+
if (ledger.scratchDir === null && ledger.artifacts.length === 0) deleteLedger(path);
|
|
101
|
+
else await writeLedger(path, ledger);
|
|
102
|
+
} catch { /* Best effort: an unwritable config home keeps the session running. */ }
|
|
103
|
+
};
|
|
104
|
+
// The shutdown path stays synchronous: its callers (tests/agents-runner.ts, and any
|
|
105
|
+
// handler consumer that does not await) read its post-conditions right after it runs.
|
|
106
|
+
const persistSync = (path: string, ledger: ScratchLedger) => {
|
|
107
|
+
try {
|
|
108
|
+
if (ledger.scratchDir === null && ledger.artifacts.length === 0) deleteLedger(path);
|
|
109
|
+
else writeLedgerSync(path, ledger);
|
|
110
|
+
} catch { /* Best effort: an unwritable config home keeps the session running. */ }
|
|
111
|
+
};
|
|
112
|
+
// Paths the runtime never records or removes, whatever a ledger says.
|
|
113
|
+
const protectedDirs = (ledger: ScratchLedger) => [configHome(), ledger.cwd, process.cwd()];
|
|
114
|
+
|
|
115
|
+
// The temp root the asking session trusts inside the home: its own, as recorded.
|
|
116
|
+
const ownRoot = () => activeSessions.get(env)?.ledger.tempRoot ?? realTempRoot(env.TMPDIR?.trim() || tmpdir());
|
|
117
|
+
|
|
118
|
+
// S9: a scratch name no longer carries its session id, so a scratch dir that a
|
|
119
|
+
// live session's ledger records stays out of reach of every other ledger. This
|
|
120
|
+
// session is live whatever the process check says. Returns, per ledger path,
|
|
121
|
+
// what removal through that ledger must keep: its artifacts and others' live scratch.
|
|
122
|
+
const heldBy = (ledgers: readonly StoredLedger[]) => {
|
|
123
|
+
const own = activeSessions.get(env);
|
|
124
|
+
const live = ledgers.filter(({ ledger }) => ledger.scratchDir !== null && isLive(ledger)).map(({ path, ledger }) => ({ path, dir: ledger.scratchDir! }));
|
|
125
|
+
if (own?.ledger.scratchDir) live.push({ path: own.path, dir: own.ledger.scratchDir });
|
|
126
|
+
return (path: string, ledger: ScratchLedger) => [
|
|
127
|
+
...ledger.artifacts.map((artifact) => artifact.path),
|
|
128
|
+
...live.filter((entry) => entry.path !== path).map((entry) => entry.dir),
|
|
129
|
+
];
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const endSession = (session: ActiveSession) => {
|
|
133
|
+
const { ledger } = session;
|
|
134
|
+
// A running or quarantined subagent's TMPDIR stays; the scratch then stays in the ledger for a later sweep.
|
|
135
|
+
const removal = removeSessionScratch(ledger, heldBy(listLedgersSync(configHome()))(session.path, ledger), { ownRoot: ledger.tempRoot ?? undefined, liveSubagentDirs: subagentDirs() });
|
|
136
|
+
if (removal === "removed" || removal === "absent") ledger.scratchDir = null;
|
|
137
|
+
for (const [key, value] of Object.entries(ledger.inherited)) {
|
|
138
|
+
if (value === null) delete env[key];
|
|
139
|
+
else env[key] = value;
|
|
140
|
+
}
|
|
141
|
+
persistSync(session.path, ledger);
|
|
142
|
+
activeSessions.delete(env);
|
|
143
|
+
pending.clear();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// S5(b): scratch of ended sessions past the grace period; their artifacts stay for the command.
|
|
147
|
+
// The startup sweep stays synchronous: session_start's callers (tests/agents-runner.ts) read
|
|
148
|
+
// its post-conditions right after it runs, without awaiting the handler.
|
|
149
|
+
const sweep = (ownPath: string) => {
|
|
150
|
+
const ledgers = listLedgersSync(configHome());
|
|
151
|
+
const held = heldBy(ledgers);
|
|
152
|
+
for (const { path, ledger, mtimeMs } of ledgers) {
|
|
153
|
+
if (path === ownPath || isLive(ledger) || now() - mtimeMs < graceMs) continue;
|
|
154
|
+
const artifacts = ledger.artifacts.filter((artifact) => { try { lstatSync(artifact.path); return true; } catch { return false; } });
|
|
155
|
+
const removal = ledger.scratchDir === null ? "absent" : removeSessionScratch(ledger, held(path, { ...ledger, artifacts }), { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
|
|
156
|
+
persistSync(path, { ...ledger, scratchDir: removal === "removed" || removal === "absent" ? null : ledger.scratchDir, artifacts });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// The live scratch of this process: its ledger names this pid and a scratch it
|
|
161
|
+
// created that TMPDIR still points into, as after a reload that lost module state.
|
|
162
|
+
const isLiveScratch = (ledger: ScratchLedger | undefined): boolean => {
|
|
163
|
+
if (!ledger?.scratchDir || ledger.pid !== process.pid || scratchRefusal(ledger, ledger.tempRoot ?? undefined) !== undefined) return false;
|
|
164
|
+
const current = env.TMPDIR?.trim();
|
|
165
|
+
return !!current && [resolve(current), realTempRoot(current)].some((dir) => dir !== undefined && isWithin(dir, ledger.scratchDir!));
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// The startup path stays synchronous, like the shutdown path: its callers
|
|
169
|
+
// (tests/agents-runner.ts) read its post-conditions without awaiting the handler.
|
|
170
|
+
const startSession = (ctx: ExtensionContext, reason: string) => {
|
|
171
|
+
const sessionId = ctx.sessionManager.getSessionId?.();
|
|
172
|
+
if (!sessionId) return;
|
|
173
|
+
const current = activeSessions.get(env);
|
|
174
|
+
if (current?.sessionId === sessionId && reason === "reload") return;
|
|
175
|
+
if (current) endSession(current);
|
|
176
|
+
const path = ledgerPath(configHome(), sessionId);
|
|
177
|
+
const previous = readLedger(path);
|
|
178
|
+
if (previous && isLiveScratch(previous)) {
|
|
179
|
+
activeSessions.set(env, { sessionId, path, ledger: previous });
|
|
180
|
+
persistSync(path, previous);
|
|
181
|
+
sweep(path);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// A reload that could not recover the scratch finds TMPDIR pointing at it:
|
|
185
|
+
// the inherited values are the ones this process's ledger recorded, else
|
|
186
|
+
// TMPDIR as it is unless it is a session scratch, whose parent it was made in.
|
|
187
|
+
const reloaded = reason === "reload" && previous?.pid === process.pid ? previous.inherited : undefined;
|
|
188
|
+
const inheritedValue = (key: string) => {
|
|
189
|
+
if (reloaded && key in reloaded) return reloaded[key];
|
|
190
|
+
const value = env[key];
|
|
191
|
+
if (reason === "reload" && value?.trim() && isSessionScratchName(value.trim())) return dirname(value.trim());
|
|
192
|
+
return value ?? null;
|
|
193
|
+
};
|
|
194
|
+
const inherited = Object.fromEntries(tempVariables.map((key) => [key, inheritedValue(key)]));
|
|
195
|
+
// Restored first, so tmpdir() never answers with the old scratch.
|
|
196
|
+
for (const [key, value] of Object.entries(inherited)) {
|
|
197
|
+
if (value === null) delete env[key];
|
|
198
|
+
else env[key] = value;
|
|
199
|
+
}
|
|
200
|
+
const inheritedTmp = inherited.TMPDIR?.trim();
|
|
201
|
+
const created = createSessionScratch(inheritedTmp ? inheritedTmp : tmpdir());
|
|
202
|
+
// TMPDIR keeps the inherited spelling (S9: socket path length); the ledger keeps real paths.
|
|
203
|
+
if (created) for (const key of tempVariables) env[key] = created.path;
|
|
204
|
+
const scratchDir = created?.realPath;
|
|
205
|
+
// A resumed session keeps the artifacts an earlier run of it recorded.
|
|
206
|
+
const ledger: ScratchLedger = {
|
|
207
|
+
version: LEDGER_VERSION,
|
|
208
|
+
sessionId,
|
|
209
|
+
pid: process.pid,
|
|
210
|
+
processStartedAt: new Date(now() - process.uptime() * 1000).toISOString(),
|
|
211
|
+
processStartToken: safeStartToken(),
|
|
212
|
+
cwd: ctx.cwd,
|
|
213
|
+
tempRoot: scratchDir ? dirname(scratchDir) : null,
|
|
214
|
+
inherited,
|
|
215
|
+
scratchDir: scratchDir ?? null,
|
|
216
|
+
artifacts: previous?.artifacts ?? [],
|
|
217
|
+
};
|
|
218
|
+
// A scratch of this process is never removed at startup: it may still be in use.
|
|
219
|
+
if (previous?.scratchDir && previous.scratchDir !== scratchDir && previous.pid !== process.pid && !isLive(previous)) {
|
|
220
|
+
const held = heldBy(listLedgersSync(configHome()))(path, ledger);
|
|
221
|
+
removeSessionScratch(previous, scratchDir ? [...held, scratchDir] : held, { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
|
|
222
|
+
}
|
|
223
|
+
activeSessions.set(env, { sessionId, path, ledger });
|
|
224
|
+
persistSync(path, ledger);
|
|
225
|
+
sweep(path);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// S4: destinations a foreground bash command is about to create, by tool call
|
|
229
|
+
// id. A destination that already exists belongs to someone else.
|
|
230
|
+
const pending = new Map<string, ArtifactTarget[]>();
|
|
231
|
+
|
|
232
|
+
const note = (toolCallId: string, command: string, ctx: ExtensionContext) => {
|
|
233
|
+
if (!activeSessions.get(env)) return;
|
|
234
|
+
const absent = gitArtifactTargets(command, ctx.cwd).filter((target) => {
|
|
235
|
+
try { lstatSync(target.path); return false; } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
|
|
236
|
+
});
|
|
237
|
+
if (absent.length > 0) pending.set(toolCallId, absent);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// S4: the noted destinations that a successful command created. The ledger keeps
|
|
241
|
+
// only the most recent artifacts, evicting the oldest first, so a long session's
|
|
242
|
+
// ledger file stays bounded.
|
|
243
|
+
const capture = async (targets: readonly ArtifactTarget[], command: string) => {
|
|
244
|
+
const session = activeSessions.get(env);
|
|
245
|
+
if (!session) return;
|
|
246
|
+
const { ledger } = session;
|
|
247
|
+
let changed = false;
|
|
248
|
+
for (const target of targets) {
|
|
249
|
+
let path: string;
|
|
250
|
+
try {
|
|
251
|
+
if (!lstatSync(target.path).isDirectory()) continue;
|
|
252
|
+
path = realpathSync(target.path);
|
|
253
|
+
} catch { continue; }
|
|
254
|
+
if (isProtectedPath(path, protectedDirs(ledger)) || ledger.artifacts.some((artifact) => artifact.path === path)) continue;
|
|
255
|
+
ledger.artifacts.push({ kind: target.kind, path, command, recordedAt: new Date(now()).toISOString() });
|
|
256
|
+
changed = true;
|
|
257
|
+
}
|
|
258
|
+
if (!changed) return;
|
|
259
|
+
if (ledger.artifacts.length > LEDGER_ARTIFACTS_CAP) ledger.artifacts = ledger.artifacts.slice(-LEDGER_ARTIFACTS_CAP);
|
|
260
|
+
await persist(session.path, ledger);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const endedLedgers = async (ownPath: string | undefined) =>
|
|
264
|
+
(await listLedgers(configHome())).filter(({ path, ledger }) => path !== ownPath && !isLive(ledger));
|
|
265
|
+
|
|
266
|
+
const describe = (artifact: ScratchArtifact) => `${artifact.kind} ${artifact.path}`;
|
|
267
|
+
|
|
268
|
+
const list = async (): Promise<string> => {
|
|
269
|
+
const session = activeSessions.get(env);
|
|
270
|
+
const lines: string[] = [];
|
|
271
|
+
const own = session?.ledger;
|
|
272
|
+
lines.push(own?.scratchDir
|
|
273
|
+
? `Session scratch: ${own.scratchDir} (${formatSize(directorySize(own.scratchDir))})`
|
|
274
|
+
: `Session scratch: none (TMPDIR ${env.TMPDIR ?? tmpdir()})`);
|
|
275
|
+
const ended = await endedLedgers(session?.path);
|
|
276
|
+
for (const { ledger } of ended) if (ledger.scratchDir) lines.push(`Ended-session scratch: ${ledger.scratchDir} (session ${ledger.sessionId})`);
|
|
277
|
+
const artifacts = [
|
|
278
|
+
...(own?.artifacts ?? []).map((artifact) => ` ${describe(artifact)} (this session)`),
|
|
279
|
+
...ended.flatMap(({ ledger }) => ledger.artifacts.map((artifact) => ` ${describe(artifact)} (session ${ledger.sessionId}, ended)`)),
|
|
280
|
+
];
|
|
281
|
+
lines.push(artifacts.length > 0 ? "Recorded clones and worktrees:" : "Recorded clones and worktrees: none", ...artifacts);
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const clean = async (ctx: ExtensionContext): Promise<string> => {
|
|
286
|
+
// Entries of the live scratch changed since this moment may belong to a running command.
|
|
287
|
+
const recentSince = now() - CLEAN_RECENT_MS;
|
|
288
|
+
const session = activeSessions.get(env);
|
|
289
|
+
const groups = [
|
|
290
|
+
...(session ? [{ path: session.path, ledger: session.ledger, own: true }] : []),
|
|
291
|
+
...(await endedLedgers(session?.path)).map(({ path, ledger }) => ({ path, ledger, own: false })),
|
|
292
|
+
];
|
|
293
|
+
const lines: string[] = [];
|
|
294
|
+
const entries = groups.flatMap((group) => group.ledger.artifacts.map((artifact) => ({ group, artifact })));
|
|
295
|
+
const drop = (entry: (typeof entries)[number]) => {
|
|
296
|
+
entry.group.ledger.artifacts = entry.group.ledger.artifacts.filter((artifact) => artifact !== entry.artifact);
|
|
297
|
+
};
|
|
298
|
+
if (entries.length > 0 && !ctx.hasUI) {
|
|
299
|
+
lines.push("Recorded clones and worktrees kept (no UI to confirm their removal):", ...entries.map(({ artifact }) => ` ${describe(artifact)}`));
|
|
300
|
+
} else if (entries.length > 0) {
|
|
301
|
+
const checked = await Promise.all(entries.map(async (entry) => ({ ...entry, check: await checkArtifact(entry.artifact, protectedDirs(entry.group.ledger)) })));
|
|
302
|
+
const removable: Array<(typeof entries)[number] & { check: Extract<ArtifactCheck, { state: "removable" }> }> = [];
|
|
303
|
+
for (const entry of checked) {
|
|
304
|
+
if (entry.check.state === "missing") { drop(entry); lines.push(`Already gone: ${describe(entry.artifact)}`); }
|
|
305
|
+
else if (entry.check.state === "kept") lines.push(`Kept ${describe(entry.artifact)}: ${entry.check.reason}`);
|
|
306
|
+
else removable.push(entry as (typeof removable)[number]);
|
|
307
|
+
}
|
|
308
|
+
if (removable.length > 0) {
|
|
309
|
+
const confirmed = await ctx.ui.confirm(
|
|
310
|
+
`Remove ${removable.length} recorded clone${removable.length === 1 ? "" : "s"}/worktree${removable.length === 1 ? "" : "s"}?`,
|
|
311
|
+
`No uncommitted changes and no unpushed commits; ignored files inside these paths are deleted too:\n${removable.map(({ artifact }) => ` ${describe(artifact)}`).join("\n")}`,
|
|
312
|
+
);
|
|
313
|
+
for (const entry of removable) {
|
|
314
|
+
if (!confirmed) { lines.push(`Kept ${describe(entry.artifact)}: not confirmed`); continue; }
|
|
315
|
+
// The clone may have changed while the confirmation was open: check it again.
|
|
316
|
+
const recheck = await checkArtifact(entry.artifact, protectedDirs(entry.group.ledger));
|
|
317
|
+
if (recheck.state === "missing") { drop(entry); lines.push(`Already gone: ${describe(entry.artifact)}`); continue; }
|
|
318
|
+
if (recheck.state === "kept") { lines.push(`Kept ${describe(entry.artifact)}: ${recheck.reason}`); continue; }
|
|
319
|
+
if (recheck.identity?.dev !== entry.check.identity?.dev || recheck.identity?.ino !== entry.check.identity?.ino) { lines.push(`Kept ${describe(entry.artifact)}: replaced after the check`); continue; }
|
|
320
|
+
const failure = await removeArtifact(entry.artifact, recheck);
|
|
321
|
+
if (failure) lines.push(`Kept ${describe(entry.artifact)}: ${failure}`);
|
|
322
|
+
else { drop(entry); lines.push(`Removed ${describe(entry.artifact)}`); }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Scratch goes without asking; the live session keeps its TMPDIR and empties it.
|
|
327
|
+
const held = heldBy(await listLedgers(configHome()));
|
|
328
|
+
for (const group of groups) {
|
|
329
|
+
const { ledger } = group;
|
|
330
|
+
if (!ledger.scratchDir) continue;
|
|
331
|
+
const dir = ledger.scratchDir;
|
|
332
|
+
const removal = removeSessionScratch(ledger, held(group.path, ledger), { contentsOnly: group.own, ownRoot: ownRoot(), liveSubagentDirs: subagentDirs(), keepChangedSince: group.own ? recentSince : undefined });
|
|
333
|
+
if (!group.own && (removal === "removed" || removal === "absent")) ledger.scratchDir = null;
|
|
334
|
+
if (removal === "refused") lines.push(`Kept scratch ${dir}: not a directory this runtime created`);
|
|
335
|
+
else if (removal === "kept") lines.push(`${group.own ? "Emptied" : "Kept"} scratch ${dir}${group.own ? " except subagent scratch, live sessions' scratch, recorded artifacts, and entries changed in the last minute" : ": holds a recorded artifact, a live session's scratch, or a live subagent's scratch"}`);
|
|
336
|
+
else lines.push(`${group.own ? "Emptied" : "Removed"} scratch ${dir}`);
|
|
337
|
+
}
|
|
338
|
+
for (const group of groups) await persist(group.path, group.ledger);
|
|
339
|
+
return lines.length > 0 ? lines.join("\n") : "Nothing to clean.";
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return (pi: ExtensionAPI) => {
|
|
343
|
+
if (env.GENTLE_PI_AGENTS_CHILD === "1") return;
|
|
344
|
+
pi.on("session_start", (event, ctx) => {
|
|
345
|
+
try { startSession(ctx, (event as { reason?: string }).reason ?? "startup"); } catch { /* The session keeps the inherited TMPDIR. */ }
|
|
346
|
+
});
|
|
347
|
+
pi.on("session_shutdown", (event) => {
|
|
348
|
+
// A reload continues the same session and keeps its scratch.
|
|
349
|
+
if ((event as { reason?: string }).reason === "reload") return;
|
|
350
|
+
const session = activeSessions.get(env);
|
|
351
|
+
try { if (session) endSession(session); } catch { /* best effort */ }
|
|
352
|
+
});
|
|
353
|
+
pi.on("tool_call", (event, ctx) => {
|
|
354
|
+
// Only foreground bash: a background job's result arrives before its clone exists.
|
|
355
|
+
const command = (event.input as { command?: unknown } | undefined)?.command;
|
|
356
|
+
if (event.toolName !== "bash" || typeof command !== "string") return undefined;
|
|
357
|
+
try { note(event.toolCallId, command, ctx); } catch { /* Capture never blocks a tool call. */ }
|
|
358
|
+
return undefined;
|
|
359
|
+
});
|
|
360
|
+
pi.on("tool_result", async (event) => {
|
|
361
|
+
const targets = pending.get(event.toolCallId);
|
|
362
|
+
if (!targets) return undefined;
|
|
363
|
+
pending.delete(event.toolCallId);
|
|
364
|
+
const command = (event.input as { command?: unknown } | undefined)?.command;
|
|
365
|
+
if (event.toolName !== "bash" || event.isError !== false || typeof command !== "string") return undefined;
|
|
366
|
+
try { await capture(targets, command); } catch { /* Capture never changes a tool result. */ }
|
|
367
|
+
return undefined;
|
|
368
|
+
});
|
|
369
|
+
pi.registerCommand("gentle:scratch", {
|
|
370
|
+
description: "List or clean this session's scratch dir and the recorded clones and worktrees (list|clean).",
|
|
371
|
+
getArgumentCompletions: (prefix) => ["list", "clean"].filter((action) => action.startsWith(prefix.trim())).map((action) => ({ value: action, label: action })),
|
|
372
|
+
handler: async (args, ctx) => {
|
|
373
|
+
const action = args.trim() || "list";
|
|
374
|
+
if (action === "list") ctx.ui.notify(await list(), "info");
|
|
375
|
+
else if (action === "clean") ctx.ui.notify(await clean(ctx), "info");
|
|
376
|
+
else ctx.ui.notify(SCRATCH_COMMAND_USAGE, "warning");
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export default function sessionScratch(pi: ExtensionAPI): void {
|
|
383
|
+
createSessionScratchExtension()(pi);
|
|
384
|
+
}
|
|
@@ -25,6 +25,11 @@ const SECTION_MARKER = "## Skills";
|
|
|
25
25
|
const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
|
|
26
26
|
const EXCLUDE_PREFIXES = ["sdd-"];
|
|
27
27
|
const WATCH_DEBOUNCE_MS = 500;
|
|
28
|
+
// inotify budgets are finite (ENOSPC/EMFILE) and recursive watches degrade
|
|
29
|
+
// silently on exhausted systems -- WSL2 hits this constantly -- so a slow
|
|
30
|
+
// poll is the floor that keeps regeneration working when the OS cannot
|
|
31
|
+
// deliver change events. The fingerprint gate makes a redundant poll cheap.
|
|
32
|
+
const WATCH_FALLBACK_POLL_MS = 2_000;
|
|
28
33
|
const REGISTRY_SCHEMA_VERSION = 7;
|
|
29
34
|
const NO_SKILL_REGISTRY_FLAG = "no-skill-registry";
|
|
30
35
|
const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
|
|
@@ -98,6 +103,11 @@ function projectSkillDirs(cwd: string): string[] {
|
|
|
98
103
|
];
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
function isMissingSkillFileError(error: unknown): boolean {
|
|
107
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
108
|
+
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
async function findSkillFiles(root: string): Promise<string[]> {
|
|
102
112
|
if (!(await pathExists(root))) return [];
|
|
103
113
|
let entries;
|
|
@@ -107,7 +117,8 @@ async function findSkillFiles(root: string): Promise<string[]> {
|
|
|
107
117
|
return [];
|
|
108
118
|
}
|
|
109
119
|
|
|
110
|
-
const
|
|
120
|
+
const directSkills: string[] = [];
|
|
121
|
+
const nestedSkills: string[] = [];
|
|
111
122
|
for (const entry of entries) {
|
|
112
123
|
const skillDir = join(root, entry.name);
|
|
113
124
|
let dirInfo;
|
|
@@ -121,12 +132,40 @@ async function findSkillFiles(root: string): Promise<string[]> {
|
|
|
121
132
|
const candidate = join(skillDir, "SKILL.md");
|
|
122
133
|
try {
|
|
123
134
|
const skillInfo = await stat(candidate);
|
|
124
|
-
if (skillInfo.isFile())
|
|
135
|
+
if (skillInfo.isFile()) directSkills.push(candidate);
|
|
136
|
+
continue;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (!isMissingSkillFileError(error)) continue;
|
|
139
|
+
}
|
|
140
|
+
if (entry.isSymbolicLink() || isExcluded(entry.name)) continue;
|
|
141
|
+
|
|
142
|
+
let nestedEntries;
|
|
143
|
+
try {
|
|
144
|
+
nestedEntries = await readdir(skillDir, { withFileTypes: true });
|
|
125
145
|
} catch {
|
|
126
|
-
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const nestedEntry of nestedEntries) {
|
|
149
|
+
if (nestedEntry.isSymbolicLink()) continue;
|
|
150
|
+
const nestedSkillDir = join(skillDir, nestedEntry.name);
|
|
151
|
+
let nestedDirInfo;
|
|
152
|
+
try {
|
|
153
|
+
nestedDirInfo = await stat(nestedSkillDir);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (!nestedDirInfo.isDirectory()) continue;
|
|
158
|
+
|
|
159
|
+
const nestedCandidate = join(nestedSkillDir, "SKILL.md");
|
|
160
|
+
try {
|
|
161
|
+
const nestedSkillInfo = await stat(nestedCandidate);
|
|
162
|
+
if (nestedSkillInfo.isFile()) nestedSkills.push(nestedCandidate);
|
|
163
|
+
} catch {
|
|
164
|
+
// Missing or unreadable skill files are ignored; the registry is best-effort.
|
|
165
|
+
}
|
|
127
166
|
}
|
|
128
167
|
}
|
|
129
|
-
return
|
|
168
|
+
return [...directSkills.sort(), ...nestedSkills.sort()];
|
|
130
169
|
}
|
|
131
170
|
|
|
132
171
|
function parseFrontmatter(source: string): { name?: string; description?: string; body: string } {
|
|
@@ -346,7 +385,7 @@ async function hasNoGitMetadata(cwd: string): Promise<boolean> {
|
|
|
346
385
|
}
|
|
347
386
|
|
|
348
387
|
// Freshness (including forced self-heal) never grants automatic write permission.
|
|
349
|
-
async function automaticWriteWarning(
|
|
388
|
+
export async function automaticWriteWarning(
|
|
350
389
|
cwd: string,
|
|
351
390
|
targets: string[],
|
|
352
391
|
remedy = "Run /skill-registry:refresh to deliberately regenerate runtime files.",
|
|
@@ -357,6 +396,7 @@ async function automaticWriteWarning(
|
|
|
357
396
|
cwd,
|
|
358
397
|
env: { ...process.env, LC_ALL: "C" },
|
|
359
398
|
timeout: gitTimeoutMs,
|
|
399
|
+
windowsHide: true,
|
|
360
400
|
});
|
|
361
401
|
tracked = stdout.split("\0").filter(Boolean);
|
|
362
402
|
} catch (error) {
|
|
@@ -496,6 +536,7 @@ async function regenerateRegistry(
|
|
|
496
536
|
}
|
|
497
537
|
|
|
498
538
|
const watchedCwds = new Set<string>();
|
|
539
|
+
const watcherPolls = new Map<string, ReturnType<typeof setInterval>>();
|
|
499
540
|
// Startup regeneration runs after session_start returns; watcher refreshes
|
|
500
541
|
// wait for it so an older startup scan never overwrites a newer refresh.
|
|
501
542
|
const pendingStartups = new Map<string, Promise<void>>();
|
|
@@ -574,6 +615,8 @@ function closeSkillRegistryWatchers(): void {
|
|
|
574
615
|
}
|
|
575
616
|
}
|
|
576
617
|
activeWatchers.clear();
|
|
618
|
+
for (const poll of watcherPolls.values()) clearInterval(poll);
|
|
619
|
+
watcherPolls.clear();
|
|
577
620
|
watchedCwds.clear();
|
|
578
621
|
}
|
|
579
622
|
|
|
@@ -588,6 +631,9 @@ async function startSkillRegistryWatcher(
|
|
|
588
631
|
...userSkillDirs(),
|
|
589
632
|
]);
|
|
590
633
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
634
|
+
// The poll re-asks a state the watchers already answered: without this,
|
|
635
|
+
// an unchanged tracked-target warning would repeat every poll interval.
|
|
636
|
+
let lastWarning: string | undefined;
|
|
591
637
|
const refresh = () => {
|
|
592
638
|
if (timer) clearTimeout(timer);
|
|
593
639
|
timer = setTimeout(() => {
|
|
@@ -595,9 +641,14 @@ async function startSkillRegistryWatcher(
|
|
|
595
641
|
try {
|
|
596
642
|
await pendingStartups.get(cwd);
|
|
597
643
|
const result = await regenerateRegistry(cwd, false);
|
|
598
|
-
if (result.warning)
|
|
599
|
-
|
|
600
|
-
|
|
644
|
+
if (result.warning) {
|
|
645
|
+
if (result.warning !== lastWarning) notify(result.warning, "warning");
|
|
646
|
+
lastWarning = result.warning;
|
|
647
|
+
} else {
|
|
648
|
+
lastWarning = undefined;
|
|
649
|
+
if (result.regenerated) {
|
|
650
|
+
notify(`Skill registry refreshed (${result.skillCount} skills)`, "info");
|
|
651
|
+
}
|
|
601
652
|
}
|
|
602
653
|
} catch (error) {
|
|
603
654
|
notify(`Skill registry refresh failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
@@ -620,12 +671,16 @@ async function startSkillRegistryWatcher(
|
|
|
620
671
|
// Some filesystems do not support recursive watches; session_start/manual refresh still work.
|
|
621
672
|
}
|
|
622
673
|
}
|
|
674
|
+
// Watchers may be absent (unsupported filesystem) or silently eventless
|
|
675
|
+
// (exhausted inotify budget): the poll keeps regeneration alive either way.
|
|
676
|
+
if (!watcherPolls.has(cwd)) watcherPolls.set(cwd, setInterval(refresh, WATCH_FALLBACK_POLL_MS));
|
|
623
677
|
}
|
|
624
678
|
|
|
625
679
|
export const __testing = {
|
|
626
680
|
projectSkillDirs,
|
|
627
681
|
userSkillDirs,
|
|
628
682
|
findSkillFiles,
|
|
683
|
+
isMissingSkillFileError,
|
|
629
684
|
uniqueExistingDirs,
|
|
630
685
|
dedupeBySkillName,
|
|
631
686
|
scopeForPath,
|