gentle-pi 4.2.0 → 4.3.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 +336 -137
- 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/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 +9 -9
- package/bin/gentle-shell.mjs +170 -22
- 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 +33 -3
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +5 -4
- package/docs/install-wizard.md +8 -1
- package/docs/permission-modes.md +1 -1
- package/docs/readme-reference.md +107 -19
- package/docs/yolo-mode.md +21 -8
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +99 -12
- package/extensions/gentle-ai.ts +284 -52
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +27 -12
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +39 -3
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +145 -11
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- 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-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-todo.ts +23 -4
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- 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 +38 -2
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +143 -9
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/autonomous-guard.test.ts +122 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +1 -1
- 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 +282 -3
- 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-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +169 -6
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +79 -0
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +42 -9
- package/tests/gentle-todo.test.ts +179 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +24 -20
- package/tests/pi-update-launcher.test.ts +21 -2
- 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 +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- 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-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-gauge.test.ts +42 -0
- 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 +69 -9
- 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 +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +18 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { activitySummary, ActivityPublisher, createActivityColumns, metadataTransport } from "../lib/herdr-activity.ts";
|
|
3
|
+
import { activitySummary, ActivityPublisher, createActivityColumns, herdrDetected, metadataTransport } from "../lib/herdr-activity.ts";
|
|
4
4
|
import { replayTodo, type TodoState } from "../lib/shell-todo.ts";
|
|
5
5
|
|
|
6
6
|
interface ActivityOptions {
|
|
@@ -15,8 +15,7 @@ interface ActivityOptions {
|
|
|
15
15
|
export function createHerdrActivityExtension(options: ActivityOptions = {}) {
|
|
16
16
|
return (pi: ExtensionAPI): void => {
|
|
17
17
|
const env = options.env ?? process.env;
|
|
18
|
-
if (env.GENTLE_PI_AGENTS_CHILD === "1" || env
|
|
19
|
-
!env.HERDR_PANE_ID?.trim() || !env.HERDR_SOCKET_PATH?.trim()) return;
|
|
18
|
+
if (env.GENTLE_PI_AGENTS_CHILD === "1" || !herdrDetected(env)) return;
|
|
20
19
|
const isSocket = options.isSocket ?? ((path: string) => {
|
|
21
20
|
try { return statSync(path).isSocket(); } catch { return false; }
|
|
22
21
|
});
|
|
@@ -12,6 +12,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
12
12
|
import { buildShellHeaderModel, renderShellBar, renderShellBelowInputFloat, renderShellBottomOnlyBar, renderShellHeaderChrome, renderShellSidebarBar, keepNativeWorkingRow, shellEnabled, shellHeaderUsageHit, shellJobsCount, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
|
|
13
13
|
import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE, WorktreeChangesTracker, renderChangesWidget, type ChangedFile, type ChangesModel, type FileRevision, type GitRunner, type LineCounter, type WorktreeChanges } from "../lib/shell-changes.ts";
|
|
14
14
|
import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
|
|
15
|
+
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
15
16
|
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver, type WorktreeIdentity } from "../lib/session-worktree-registry.ts";
|
|
16
17
|
import { CARD_STYLE, CARD_TONE, cardStyle, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts";
|
|
17
18
|
import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
|
|
@@ -21,6 +22,8 @@ import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
|
|
|
21
22
|
import { discoverYoloUiAdapter, YOLO_DISPLAY, type YoloDisplay, type YoloUiAdapter } from "../lib/yolo-session-policy.ts";
|
|
22
23
|
import { VisualCustomizeView, type CustomizeCategory, type CustomizeRow, type ProfileActions } from "../lib/visual-customize-view.ts";
|
|
23
24
|
import { deleteVisualProfile, getVisualProfile, listVisualProfiles, resetVisualProfiles, saveVisualProfile } from "../lib/visual-profiles.ts";
|
|
25
|
+
import { gaugePaintFor } from "../lib/gauge-theme.ts";
|
|
26
|
+
import type { GaugePaint } from "../lib/shell-gauge.ts";
|
|
24
27
|
import { sourcePalettePreview } from "../lib/theme-customization.ts";
|
|
25
28
|
import { DEFAULT_VISUAL_SETTINGS, DENSITY, HEADER_PLACEMENT, STATUS_PLACEMENT, VISUAL_SECTION_KEYS, resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
|
|
26
29
|
import { BANNER_COLORS, DEFAULT_BANNER_CONFIG, readBannerConfig, readBannerConfigForEdit, writeBannerConfig } from "./startup-banner.ts";
|
|
@@ -31,7 +34,6 @@ import { oddPhaseRegistry } from "../lib/odd-phase.ts";
|
|
|
31
34
|
import { inferOddPhase } from "../lib/odd-phase-inference.ts";
|
|
32
35
|
import { isInteractiveMode } from "../lib/rpc-host.ts";
|
|
33
36
|
import { gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
34
|
-
import { bundledGentlePiConfigHome } from "../lib/gentle-shell-bundled.ts";
|
|
35
37
|
import { isNanAdaptiveReasoningModel } from "../lib/nan-provider.ts";
|
|
36
38
|
import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts";
|
|
37
39
|
import { resolveVimPolicy, writeVimPolicy, type VimPolicy } from "../lib/vim-policy.ts";
|
|
@@ -180,7 +182,7 @@ export type ActiveProfileReader = (() => string | undefined) & {
|
|
|
180
182
|
// Unbound reads retain the global file-identity cache, including atomic replacements.
|
|
181
183
|
// Bound reads are snapshots: no filesystem or Git work is done during a frame.
|
|
182
184
|
export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): ActiveProfileReader {
|
|
183
|
-
const path = profilesFilePath(
|
|
185
|
+
const path = profilesFilePath(gentlePiConfigHome(env));
|
|
184
186
|
let fingerprint: string | undefined;
|
|
185
187
|
let name: string | undefined;
|
|
186
188
|
let bound = false;
|
|
@@ -215,7 +217,7 @@ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env):
|
|
|
215
217
|
const next = cwd === undefined ? global() : sessionProfileLabel(resolveSessionProfile({
|
|
216
218
|
sessionId,
|
|
217
219
|
cwd,
|
|
218
|
-
configHome:
|
|
220
|
+
configHome: gentlePiConfigHome(env),
|
|
219
221
|
resolveWorktree: () => identity,
|
|
220
222
|
env,
|
|
221
223
|
}));
|
|
@@ -334,6 +336,16 @@ export function buildShellBarModel(
|
|
|
334
336
|
};
|
|
335
337
|
}
|
|
336
338
|
|
|
339
|
+
// Gauge-specific theme roles (issue #2222): resolved from the installed theme
|
|
340
|
+
// source and memoized by gauge-theme.ts, so the render hot path only stats the
|
|
341
|
+
// theme file. Undefined — and therefore today's shared roles — whenever the theme
|
|
342
|
+
// source is unavailable.
|
|
343
|
+
function gaugePaint(ctx: ExtensionContext): GaugePaint | undefined {
|
|
344
|
+
const themeName = ctx.ui.theme.name;
|
|
345
|
+
const source = ctx.ui.theme.sourcePath ?? ctx.ui.getAllThemes?.().find((item) => item?.name === themeName)?.path;
|
|
346
|
+
return gaugePaintFor(typeof source === "string" ? source : undefined);
|
|
347
|
+
}
|
|
348
|
+
|
|
337
349
|
export function createShellBarComponent(
|
|
338
350
|
pi: ExtensionAPI,
|
|
339
351
|
ctx: ExtensionContext,
|
|
@@ -350,7 +362,7 @@ export function createShellBarComponent(
|
|
|
350
362
|
});
|
|
351
363
|
return {
|
|
352
364
|
render(width: number) {
|
|
353
|
-
return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width, presentation?.());
|
|
365
|
+
return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width, presentation?.(), gaugePaint(ctx));
|
|
354
366
|
},
|
|
355
367
|
invalidate() {},
|
|
356
368
|
dispose() {
|
|
@@ -1223,8 +1235,6 @@ const CHANGES_SHORTCUT_DEFAULT = "alt+g";
|
|
|
1223
1235
|
const CHANGES_POLL_DEFAULT_MS = 2000;
|
|
1224
1236
|
const GIT_TIMEOUT_MS = 5000;
|
|
1225
1237
|
const COMMANDS_COMMAND_NAME = "gentle:commands";
|
|
1226
|
-
const OVERLAY_HEIGHT_RATIO = 0.8;
|
|
1227
|
-
const OVERLAY_MIN_ROWS = 8;
|
|
1228
1238
|
|
|
1229
1239
|
export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env, run: typeof execFile = execFile, maxBuffer = Infinity): GitRunner {
|
|
1230
1240
|
// Pi exec cannot replace the inherited environment. Use argv directly and
|
|
@@ -1329,6 +1339,8 @@ interface OverlayDeps {
|
|
|
1329
1339
|
}
|
|
1330
1340
|
|
|
1331
1341
|
// The overlay polls each root's Git state while it is open; diffs load lazily.
|
|
1342
|
+
// Like Gentle Agents it takes the whole terminal, and the native fullscreen
|
|
1343
|
+
// wrapper keeps mouse dispatch on the view while it owns the keyboard.
|
|
1332
1344
|
async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
|
|
1333
1345
|
let host: ExternalEditorHost | undefined;
|
|
1334
1346
|
let view: WorktreeChangesView | undefined;
|
|
@@ -1346,16 +1358,18 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
|
|
|
1346
1358
|
host = tui;
|
|
1347
1359
|
view = new WorktreeChangesView(deps.worktrees(), {
|
|
1348
1360
|
theme,
|
|
1349
|
-
rows: () => Math.max(
|
|
1361
|
+
rows: () => Math.max(0, tui.terminal.rows),
|
|
1350
1362
|
loadDiff: deps.loadDiff,
|
|
1351
1363
|
onOpen: (root, file) => close({ root, file }),
|
|
1352
1364
|
onRefresh: () => void refresh().catch(() => {}),
|
|
1353
1365
|
onClose: () => close(null),
|
|
1354
1366
|
requestRender: () => tui.requestRender(),
|
|
1355
1367
|
});
|
|
1356
|
-
|
|
1368
|
+
const interaction = createNativeFullscreenInteraction({ keyboardTarget: view, requestRender: () => tui.requestRender() });
|
|
1369
|
+
interaction.addChild(view);
|
|
1370
|
+
return interaction;
|
|
1357
1371
|
},
|
|
1358
|
-
{ overlay: true, overlayOptions: { width: "
|
|
1372
|
+
{ overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, anchor: "center" } },
|
|
1359
1373
|
);
|
|
1360
1374
|
if (!chosen || !host) return;
|
|
1361
1375
|
if (!openInExternalEditor(host, chosen.file.path, process.env, spawnSync, chosen.root)) ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
|
|
@@ -1738,6 +1752,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1738
1752
|
onClose: () => withOverlayRepaint(tui, done)(null),
|
|
1739
1753
|
requestRender: () => tui.requestRender(),
|
|
1740
1754
|
scope: () => ({ providers: usageScope, failed: new Set(usageFailures) }),
|
|
1755
|
+
gauge: () => gaugePaint(ctx),
|
|
1741
1756
|
});
|
|
1742
1757
|
// Open promptly: the panel draws whatever the store already holds
|
|
1743
1758
|
// while the forced refresh runs underneath it, and repaints when
|
|
@@ -1924,7 +1939,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1924
1939
|
const statusOwner = () => narrowStatusOwner({ mode: (tui as TUI & { mode?: string }).mode, columns: tui.terminal?.columns ?? 0, statusPlacement: visualSettings.statusPlacement, headerPlacement: visualSettings.headerPlacement });
|
|
1925
1940
|
const statusCardVisible = () => Boolean(tui.terminal && tui.terminal.columns >= SIDEBAR_BREAKPOINT && sidebarState(tui).ownsHost?.() && visualSettings.statusPlacement !== "hidden");
|
|
1926
1941
|
const belowFloat = (width: number, statuses: boolean) => (tui as TUI & { mode?: string }).mode === "fullscreen"
|
|
1927
|
-
? renderShellBelowInputFloat({ ...footerModel(), ...(statuses ? {} : { statuses: [] }), ...(statusCardVisible() ? { jobs: undefined } : {}) }, theme, width, usageShortcutKey, visualSettings, tracker.model)
|
|
1942
|
+
? renderShellBelowInputFloat({ ...footerModel(), ...(statuses ? {} : { statuses: [] }), ...(statusCardVisible() ? { jobs: undefined } : {}) }, theme, width, usageShortcutKey, visualSettings, tracker.model, gaugePaint(ctx))
|
|
1928
1943
|
: undefined;
|
|
1929
1944
|
// Match sidebarPart's live paint ownership, including an unavailable rail.
|
|
1930
1945
|
const footerSuppressed = () => {
|
|
@@ -1935,7 +1950,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1935
1950
|
groupedChangesOwner = (width) => belowFloat(width, !footerSuppressed()) !== undefined;
|
|
1936
1951
|
const bottomBar = {
|
|
1937
1952
|
...bottom,
|
|
1938
|
-
render: (width: number) => belowFloat(width, true)?.rows ?? (statusOwner() === STATUS_OWNER.BOTTOM ? renderShellBottomOnlyBar(footerModel(), theme, width, usageShortcutKey, visualSettings) : bottom.render(width)),
|
|
1953
|
+
render: (width: number) => belowFloat(width, true)?.rows ?? (statusOwner() === STATUS_OWNER.BOTTOM ? renderShellBottomOnlyBar(footerModel(), theme, width, usageShortcutKey, visualSettings, undefined, gaugePaint(ctx)) : bottom.render(width)),
|
|
1939
1954
|
handleMouse(event: Parameters<NonNullable<SidebarRail["handleMouse"]>>[0]) {
|
|
1940
1955
|
if (footerSuppressed() || event.type !== "click" || event.button !== "left") return undefined;
|
|
1941
1956
|
const chrome = belowFloat(event.width, true);
|
|
@@ -1952,7 +1967,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
|
|
|
1952
1967
|
// The header row carries everything that ticks every frame (model,
|
|
1953
1968
|
// effort, context, cost, usage) plus session identity and the jobs count
|
|
1954
1969
|
// when Status is absent. Opaque statuses and working state stay excluded.
|
|
1955
|
-
const headerBar = (width: number) => renderShellHeaderChrome(buildShellHeaderModel(footerModel(), !statusCardVisible() && (statusOwner() === STATUS_OWNER.HEADER || visualSettings.statusPlacement === "hidden")), theme, width, usageShortcutKey, visualSettings);
|
|
1970
|
+
const headerBar = (width: number) => renderShellHeaderChrome(buildShellHeaderModel(footerModel(), !statusCardVisible() && (statusOwner() === STATUS_OWNER.HEADER || visualSettings.statusPlacement === "hidden")), theme, width, usageShortcutKey, visualSettings, gaugePaint(ctx));
|
|
1956
1971
|
const disposeHeader = sidebarHeader(tui, {
|
|
1957
1972
|
digest: () => JSON.stringify([footerModel(), tracker.model, visualSettings, cardStyle()]),
|
|
1958
1973
|
render: (width) => headerBar(width).rows,
|
|
@@ -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;
|
|
@@ -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,365 @@
|
|
|
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_VERSION,
|
|
19
|
+
ledgerPath,
|
|
20
|
+
listLedgers,
|
|
21
|
+
liveSubagentScratch,
|
|
22
|
+
processStartToken,
|
|
23
|
+
readLedger,
|
|
24
|
+
realTempRoot,
|
|
25
|
+
removeArtifact,
|
|
26
|
+
removeSessionScratch,
|
|
27
|
+
scratchRefusal,
|
|
28
|
+
SWEEP_GRACE_MS,
|
|
29
|
+
type ArtifactCheck,
|
|
30
|
+
type ArtifactTarget,
|
|
31
|
+
type ScratchArtifact,
|
|
32
|
+
type ScratchLedger,
|
|
33
|
+
type StoredLedger,
|
|
34
|
+
writeLedger,
|
|
35
|
+
} from "../lib/session-scratch.ts";
|
|
36
|
+
|
|
37
|
+
// Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md):
|
|
38
|
+
// the primary session gets its own TMPDIR, the runtime records the clones and
|
|
39
|
+
// worktrees its bash commands create, removes the scratch at shutdown (and the
|
|
40
|
+
// scratch of crashed sessions at the next start), and /gentle:scratch lists or
|
|
41
|
+
// cleans the rest. Delegated children (GENTLE_PI_AGENTS_CHILD=1) do none of it:
|
|
42
|
+
// their own TMPDIR comes from the runner and nests under the parent's scratch.
|
|
43
|
+
|
|
44
|
+
export const SCRATCH_COMMAND_USAGE = [
|
|
45
|
+
"Usage: /gentle:scratch [list|clean]",
|
|
46
|
+
" list this session's scratch dir with its size, and the clones and worktrees recorded by this and ended sessions",
|
|
47
|
+
" clean remove scratch dirs; after one confirmation, remove recorded worktrees and clones with no uncommitted changes and no unpushed commits",
|
|
48
|
+
].join("\n");
|
|
49
|
+
|
|
50
|
+
export interface SessionScratchOptions {
|
|
51
|
+
env?: NodeJS.ProcessEnv;
|
|
52
|
+
configHome?: string;
|
|
53
|
+
now?: () => number;
|
|
54
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
55
|
+
/** When a process started (epoch ms), or undefined when it cannot be read. */
|
|
56
|
+
processStartToken?: (pid: number) => string | undefined;
|
|
57
|
+
graceMs?: number;
|
|
58
|
+
platform?: NodeJS.Platform;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ActiveSession {
|
|
62
|
+
sessionId: string;
|
|
63
|
+
path: string;
|
|
64
|
+
ledger: ScratchLedger;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Keyed by the environment the session scratch was applied to. A /reload that
|
|
68
|
+
// re-runs this factory in the same module instance finds the live session here;
|
|
69
|
+
// one that re-evaluates the module (Pi's loader) recovers it from the ledger.
|
|
70
|
+
const activeSessions = new WeakMap<NodeJS.ProcessEnv, ActiveSession>();
|
|
71
|
+
|
|
72
|
+
export function createSessionScratchExtension(options: SessionScratchOptions = {}): (pi: ExtensionAPI) => void {
|
|
73
|
+
const env = options.env ?? process.env;
|
|
74
|
+
const now = options.now ?? Date.now;
|
|
75
|
+
const alive = options.isProcessAlive ?? isProcessAlive;
|
|
76
|
+
const startToken = options.processStartToken ?? processStartToken;
|
|
77
|
+
// A live pid is the ledger's process only if it has the start token the ledger
|
|
78
|
+
// recorded: the OS may have reused it. A token that is missing or cannot be
|
|
79
|
+
// read keeps the ledger live.
|
|
80
|
+
const isLive = (ledger: ScratchLedger) => {
|
|
81
|
+
if (!alive(ledger.pid)) return false;
|
|
82
|
+
if (ledger.processStartToken === undefined) return true;
|
|
83
|
+
let current: string | undefined;
|
|
84
|
+
try { current = startToken(ledger.pid); } catch { current = undefined; }
|
|
85
|
+
return current === undefined || current === ledger.processStartToken;
|
|
86
|
+
};
|
|
87
|
+
// This process's own token, or undefined (omitted from the ledger JSON) when it cannot be read.
|
|
88
|
+
const safeStartToken = () => { try { return startToken(process.pid); } catch { return undefined; } };
|
|
89
|
+
// Real paths of this process's live and quarantined subagent TMPDIRs (lib/agents-runner.ts).
|
|
90
|
+
const subagentDirs = () => liveSubagentScratch().flatMap((dir) => realTempRoot(dir) ?? []);
|
|
91
|
+
const graceMs = options.graceMs ?? SWEEP_GRACE_MS;
|
|
92
|
+
const tempVariables = (options.platform ?? process.platform) === "win32" ? ["TMPDIR", "TMP", "TEMP"] : ["TMPDIR"];
|
|
93
|
+
const configHome = () => options.configHome ?? gentlePiConfigHome(env);
|
|
94
|
+
|
|
95
|
+
const persist = (path: string, ledger: ScratchLedger) => {
|
|
96
|
+
try {
|
|
97
|
+
if (ledger.scratchDir === null && ledger.artifacts.length === 0) deleteLedger(path);
|
|
98
|
+
else writeLedger(path, ledger);
|
|
99
|
+
} catch { /* Best effort: an unwritable config home keeps the session running. */ }
|
|
100
|
+
};
|
|
101
|
+
// Paths the runtime never records or removes, whatever a ledger says.
|
|
102
|
+
const protectedDirs = (ledger: ScratchLedger) => [configHome(), ledger.cwd, process.cwd()];
|
|
103
|
+
|
|
104
|
+
// The temp root the asking session trusts inside the home: its own, as recorded.
|
|
105
|
+
const ownRoot = () => activeSessions.get(env)?.ledger.tempRoot ?? realTempRoot(env.TMPDIR?.trim() || tmpdir());
|
|
106
|
+
|
|
107
|
+
// S9: a scratch name no longer carries its session id, so a scratch dir that a
|
|
108
|
+
// live session's ledger records stays out of reach of every other ledger. This
|
|
109
|
+
// session is live whatever the process check says. Returns, per ledger path,
|
|
110
|
+
// what removal through that ledger must keep: its artifacts and others' live scratch.
|
|
111
|
+
const heldBy = (ledgers: readonly StoredLedger[]) => {
|
|
112
|
+
const own = activeSessions.get(env);
|
|
113
|
+
const live = ledgers.filter(({ ledger }) => ledger.scratchDir !== null && isLive(ledger)).map(({ path, ledger }) => ({ path, dir: ledger.scratchDir! }));
|
|
114
|
+
if (own?.ledger.scratchDir) live.push({ path: own.path, dir: own.ledger.scratchDir });
|
|
115
|
+
return (path: string, ledger: ScratchLedger) => [
|
|
116
|
+
...ledger.artifacts.map((artifact) => artifact.path),
|
|
117
|
+
...live.filter((entry) => entry.path !== path).map((entry) => entry.dir),
|
|
118
|
+
];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const endSession = (session: ActiveSession) => {
|
|
122
|
+
const { ledger } = session;
|
|
123
|
+
// A running or quarantined subagent's TMPDIR stays; the scratch then stays in the ledger for a later sweep.
|
|
124
|
+
const removal = removeSessionScratch(ledger, heldBy(listLedgers(configHome()))(session.path, ledger), { ownRoot: ledger.tempRoot ?? undefined, liveSubagentDirs: subagentDirs() });
|
|
125
|
+
if (removal === "removed" || removal === "absent") ledger.scratchDir = null;
|
|
126
|
+
for (const [key, value] of Object.entries(ledger.inherited)) {
|
|
127
|
+
if (value === null) delete env[key];
|
|
128
|
+
else env[key] = value;
|
|
129
|
+
}
|
|
130
|
+
persist(session.path, ledger);
|
|
131
|
+
activeSessions.delete(env);
|
|
132
|
+
pending.clear();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// S5(b): scratch of ended sessions past the grace period; their artifacts stay for the command.
|
|
136
|
+
const sweep = (ownPath: string) => {
|
|
137
|
+
const ledgers = listLedgers(configHome());
|
|
138
|
+
const held = heldBy(ledgers);
|
|
139
|
+
for (const { path, ledger, mtimeMs } of ledgers) {
|
|
140
|
+
if (path === ownPath || isLive(ledger) || now() - mtimeMs < graceMs) continue;
|
|
141
|
+
const artifacts = ledger.artifacts.filter((artifact) => { try { lstatSync(artifact.path); return true; } catch { return false; } });
|
|
142
|
+
const removal = ledger.scratchDir === null ? "absent" : removeSessionScratch(ledger, held(path, { ...ledger, artifacts }), { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
|
|
143
|
+
persist(path, { ...ledger, scratchDir: removal === "removed" || removal === "absent" ? null : ledger.scratchDir, artifacts });
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// The live scratch of this process: its ledger names this pid and a scratch it
|
|
148
|
+
// created that TMPDIR still points into, as after a reload that lost module state.
|
|
149
|
+
const isLiveScratch = (ledger: ScratchLedger | undefined): boolean => {
|
|
150
|
+
if (!ledger?.scratchDir || ledger.pid !== process.pid || scratchRefusal(ledger, ledger.tempRoot ?? undefined) !== undefined) return false;
|
|
151
|
+
const current = env.TMPDIR?.trim();
|
|
152
|
+
return !!current && [resolve(current), realTempRoot(current)].some((dir) => dir !== undefined && isWithin(dir, ledger.scratchDir!));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const startSession = (ctx: ExtensionContext, reason: string) => {
|
|
156
|
+
const sessionId = ctx.sessionManager.getSessionId?.();
|
|
157
|
+
if (!sessionId) return;
|
|
158
|
+
const current = activeSessions.get(env);
|
|
159
|
+
if (current?.sessionId === sessionId && reason === "reload") return;
|
|
160
|
+
if (current) endSession(current);
|
|
161
|
+
const path = ledgerPath(configHome(), sessionId);
|
|
162
|
+
const previous = readLedger(path);
|
|
163
|
+
if (previous && isLiveScratch(previous)) {
|
|
164
|
+
activeSessions.set(env, { sessionId, path, ledger: previous });
|
|
165
|
+
persist(path, previous);
|
|
166
|
+
sweep(path);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// A reload that could not recover the scratch finds TMPDIR pointing at it:
|
|
170
|
+
// the inherited values are the ones this process's ledger recorded, else
|
|
171
|
+
// TMPDIR as it is unless it is a session scratch, whose parent it was made in.
|
|
172
|
+
const reloaded = reason === "reload" && previous?.pid === process.pid ? previous.inherited : undefined;
|
|
173
|
+
const inheritedValue = (key: string) => {
|
|
174
|
+
if (reloaded && key in reloaded) return reloaded[key];
|
|
175
|
+
const value = env[key];
|
|
176
|
+
if (reason === "reload" && value?.trim() && isSessionScratchName(value.trim())) return dirname(value.trim());
|
|
177
|
+
return value ?? null;
|
|
178
|
+
};
|
|
179
|
+
const inherited = Object.fromEntries(tempVariables.map((key) => [key, inheritedValue(key)]));
|
|
180
|
+
// Restored first, so tmpdir() never answers with the old scratch.
|
|
181
|
+
for (const [key, value] of Object.entries(inherited)) {
|
|
182
|
+
if (value === null) delete env[key];
|
|
183
|
+
else env[key] = value;
|
|
184
|
+
}
|
|
185
|
+
const inheritedTmp = inherited.TMPDIR?.trim();
|
|
186
|
+
const created = createSessionScratch(inheritedTmp ? inheritedTmp : tmpdir());
|
|
187
|
+
// TMPDIR keeps the inherited spelling (S9: socket path length); the ledger keeps real paths.
|
|
188
|
+
if (created) for (const key of tempVariables) env[key] = created.path;
|
|
189
|
+
const scratchDir = created?.realPath;
|
|
190
|
+
// A resumed session keeps the artifacts an earlier run of it recorded.
|
|
191
|
+
const ledger: ScratchLedger = {
|
|
192
|
+
version: LEDGER_VERSION,
|
|
193
|
+
sessionId,
|
|
194
|
+
pid: process.pid,
|
|
195
|
+
processStartedAt: new Date(now() - process.uptime() * 1000).toISOString(),
|
|
196
|
+
processStartToken: safeStartToken(),
|
|
197
|
+
cwd: ctx.cwd,
|
|
198
|
+
tempRoot: scratchDir ? dirname(scratchDir) : null,
|
|
199
|
+
inherited,
|
|
200
|
+
scratchDir: scratchDir ?? null,
|
|
201
|
+
artifacts: previous?.artifacts ?? [],
|
|
202
|
+
};
|
|
203
|
+
// A scratch of this process is never removed at startup: it may still be in use.
|
|
204
|
+
if (previous?.scratchDir && previous.scratchDir !== scratchDir && previous.pid !== process.pid && !isLive(previous)) {
|
|
205
|
+
const held = heldBy(listLedgers(configHome()))(path, ledger);
|
|
206
|
+
removeSessionScratch(previous, scratchDir ? [...held, scratchDir] : held, { ownRoot: ownRoot(), liveSubagentDirs: subagentDirs() });
|
|
207
|
+
}
|
|
208
|
+
activeSessions.set(env, { sessionId, path, ledger });
|
|
209
|
+
persist(path, ledger);
|
|
210
|
+
sweep(path);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// S4: destinations a foreground bash command is about to create, by tool call
|
|
214
|
+
// id. A destination that already exists belongs to someone else.
|
|
215
|
+
const pending = new Map<string, ArtifactTarget[]>();
|
|
216
|
+
|
|
217
|
+
const note = (toolCallId: string, command: string, ctx: ExtensionContext) => {
|
|
218
|
+
if (!activeSessions.get(env)) return;
|
|
219
|
+
const absent = gitArtifactTargets(command, ctx.cwd).filter((target) => {
|
|
220
|
+
try { lstatSync(target.path); return false; } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
|
|
221
|
+
});
|
|
222
|
+
if (absent.length > 0) pending.set(toolCallId, absent);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// S4: the noted destinations that a successful command created.
|
|
226
|
+
const capture = (targets: readonly ArtifactTarget[], command: string) => {
|
|
227
|
+
const session = activeSessions.get(env);
|
|
228
|
+
if (!session) return;
|
|
229
|
+
const { ledger } = session;
|
|
230
|
+
let changed = false;
|
|
231
|
+
for (const target of targets) {
|
|
232
|
+
let path: string;
|
|
233
|
+
try {
|
|
234
|
+
if (!lstatSync(target.path).isDirectory()) continue;
|
|
235
|
+
path = realpathSync(target.path);
|
|
236
|
+
} catch { continue; }
|
|
237
|
+
if (isProtectedPath(path, protectedDirs(ledger)) || ledger.artifacts.some((artifact) => artifact.path === path)) continue;
|
|
238
|
+
ledger.artifacts.push({ kind: target.kind, path, command, recordedAt: new Date(now()).toISOString() });
|
|
239
|
+
changed = true;
|
|
240
|
+
}
|
|
241
|
+
if (changed) persist(session.path, ledger);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const endedLedgers = (ownPath: string | undefined) =>
|
|
245
|
+
listLedgers(configHome()).filter(({ path, ledger }) => path !== ownPath && !isLive(ledger));
|
|
246
|
+
|
|
247
|
+
const describe = (artifact: ScratchArtifact) => `${artifact.kind} ${artifact.path}`;
|
|
248
|
+
|
|
249
|
+
const list = (): string => {
|
|
250
|
+
const session = activeSessions.get(env);
|
|
251
|
+
const lines: string[] = [];
|
|
252
|
+
const own = session?.ledger;
|
|
253
|
+
lines.push(own?.scratchDir
|
|
254
|
+
? `Session scratch: ${own.scratchDir} (${formatSize(directorySize(own.scratchDir))})`
|
|
255
|
+
: `Session scratch: none (TMPDIR ${env.TMPDIR ?? tmpdir()})`);
|
|
256
|
+
const ended = endedLedgers(session?.path);
|
|
257
|
+
for (const { ledger } of ended) if (ledger.scratchDir) lines.push(`Ended-session scratch: ${ledger.scratchDir} (session ${ledger.sessionId})`);
|
|
258
|
+
const artifacts = [
|
|
259
|
+
...(own?.artifacts ?? []).map((artifact) => ` ${describe(artifact)} (this session)`),
|
|
260
|
+
...ended.flatMap(({ ledger }) => ledger.artifacts.map((artifact) => ` ${describe(artifact)} (session ${ledger.sessionId}, ended)`)),
|
|
261
|
+
];
|
|
262
|
+
lines.push(artifacts.length > 0 ? "Recorded clones and worktrees:" : "Recorded clones and worktrees: none", ...artifacts);
|
|
263
|
+
return lines.join("\n");
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const clean = async (ctx: ExtensionContext): Promise<string> => {
|
|
267
|
+
// Entries of the live scratch changed since this moment may belong to a running command.
|
|
268
|
+
const recentSince = now() - CLEAN_RECENT_MS;
|
|
269
|
+
const session = activeSessions.get(env);
|
|
270
|
+
const groups = [
|
|
271
|
+
...(session ? [{ path: session.path, ledger: session.ledger, own: true }] : []),
|
|
272
|
+
...endedLedgers(session?.path).map(({ path, ledger }) => ({ path, ledger, own: false })),
|
|
273
|
+
];
|
|
274
|
+
const lines: string[] = [];
|
|
275
|
+
const entries = groups.flatMap((group) => group.ledger.artifacts.map((artifact) => ({ group, artifact })));
|
|
276
|
+
const drop = (entry: (typeof entries)[number]) => {
|
|
277
|
+
entry.group.ledger.artifacts = entry.group.ledger.artifacts.filter((artifact) => artifact !== entry.artifact);
|
|
278
|
+
};
|
|
279
|
+
if (entries.length > 0 && !ctx.hasUI) {
|
|
280
|
+
lines.push("Recorded clones and worktrees kept (no UI to confirm their removal):", ...entries.map(({ artifact }) => ` ${describe(artifact)}`));
|
|
281
|
+
} else if (entries.length > 0) {
|
|
282
|
+
const checked = await Promise.all(entries.map(async (entry) => ({ ...entry, check: await checkArtifact(entry.artifact, protectedDirs(entry.group.ledger)) })));
|
|
283
|
+
const removable: Array<(typeof entries)[number] & { check: Extract<ArtifactCheck, { state: "removable" }> }> = [];
|
|
284
|
+
for (const entry of checked) {
|
|
285
|
+
if (entry.check.state === "missing") { drop(entry); lines.push(`Already gone: ${describe(entry.artifact)}`); }
|
|
286
|
+
else if (entry.check.state === "kept") lines.push(`Kept ${describe(entry.artifact)}: ${entry.check.reason}`);
|
|
287
|
+
else removable.push(entry as (typeof removable)[number]);
|
|
288
|
+
}
|
|
289
|
+
if (removable.length > 0) {
|
|
290
|
+
const confirmed = await ctx.ui.confirm(
|
|
291
|
+
`Remove ${removable.length} recorded clone${removable.length === 1 ? "" : "s"}/worktree${removable.length === 1 ? "" : "s"}?`,
|
|
292
|
+
`No uncommitted changes and no unpushed commits; ignored files inside these paths are deleted too:\n${removable.map(({ artifact }) => ` ${describe(artifact)}`).join("\n")}`,
|
|
293
|
+
);
|
|
294
|
+
for (const entry of removable) {
|
|
295
|
+
if (!confirmed) { lines.push(`Kept ${describe(entry.artifact)}: not confirmed`); continue; }
|
|
296
|
+
// The clone may have changed while the confirmation was open: check it again.
|
|
297
|
+
const recheck = await checkArtifact(entry.artifact, protectedDirs(entry.group.ledger));
|
|
298
|
+
if (recheck.state === "missing") { drop(entry); lines.push(`Already gone: ${describe(entry.artifact)}`); continue; }
|
|
299
|
+
if (recheck.state === "kept") { lines.push(`Kept ${describe(entry.artifact)}: ${recheck.reason}`); continue; }
|
|
300
|
+
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; }
|
|
301
|
+
const failure = await removeArtifact(entry.artifact, recheck);
|
|
302
|
+
if (failure) lines.push(`Kept ${describe(entry.artifact)}: ${failure}`);
|
|
303
|
+
else { drop(entry); lines.push(`Removed ${describe(entry.artifact)}`); }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Scratch goes without asking; the live session keeps its TMPDIR and empties it.
|
|
308
|
+
const held = heldBy(listLedgers(configHome()));
|
|
309
|
+
for (const group of groups) {
|
|
310
|
+
const { ledger } = group;
|
|
311
|
+
if (!ledger.scratchDir) continue;
|
|
312
|
+
const dir = ledger.scratchDir;
|
|
313
|
+
const removal = removeSessionScratch(ledger, held(group.path, ledger), { contentsOnly: group.own, ownRoot: ownRoot(), liveSubagentDirs: subagentDirs(), keepChangedSince: group.own ? recentSince : undefined });
|
|
314
|
+
if (!group.own && (removal === "removed" || removal === "absent")) ledger.scratchDir = null;
|
|
315
|
+
if (removal === "refused") lines.push(`Kept scratch ${dir}: not a directory this runtime created`);
|
|
316
|
+
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"}`);
|
|
317
|
+
else lines.push(`${group.own ? "Emptied" : "Removed"} scratch ${dir}`);
|
|
318
|
+
}
|
|
319
|
+
for (const group of groups) persist(group.path, group.ledger);
|
|
320
|
+
return lines.length > 0 ? lines.join("\n") : "Nothing to clean.";
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
return (pi: ExtensionAPI) => {
|
|
324
|
+
if (env.GENTLE_PI_AGENTS_CHILD === "1") return;
|
|
325
|
+
pi.on("session_start", (event, ctx) => {
|
|
326
|
+
try { startSession(ctx, (event as { reason?: string }).reason ?? "startup"); } catch { /* The session keeps the inherited TMPDIR. */ }
|
|
327
|
+
});
|
|
328
|
+
pi.on("session_shutdown", (event) => {
|
|
329
|
+
// A reload continues the same session and keeps its scratch.
|
|
330
|
+
if ((event as { reason?: string }).reason === "reload") return;
|
|
331
|
+
const session = activeSessions.get(env);
|
|
332
|
+
try { if (session) endSession(session); } catch { /* best effort */ }
|
|
333
|
+
});
|
|
334
|
+
pi.on("tool_call", (event, ctx) => {
|
|
335
|
+
// Only foreground bash: a background job's result arrives before its clone exists.
|
|
336
|
+
const command = (event.input as { command?: unknown } | undefined)?.command;
|
|
337
|
+
if (event.toolName !== "bash" || typeof command !== "string") return undefined;
|
|
338
|
+
try { note(event.toolCallId, command, ctx); } catch { /* Capture never blocks a tool call. */ }
|
|
339
|
+
return undefined;
|
|
340
|
+
});
|
|
341
|
+
pi.on("tool_result", (event) => {
|
|
342
|
+
const targets = pending.get(event.toolCallId);
|
|
343
|
+
if (!targets) return undefined;
|
|
344
|
+
pending.delete(event.toolCallId);
|
|
345
|
+
const command = (event.input as { command?: unknown } | undefined)?.command;
|
|
346
|
+
if (event.toolName !== "bash" || event.isError !== false || typeof command !== "string") return undefined;
|
|
347
|
+
try { capture(targets, command); } catch { /* Capture never changes a tool result. */ }
|
|
348
|
+
return undefined;
|
|
349
|
+
});
|
|
350
|
+
pi.registerCommand("gentle:scratch", {
|
|
351
|
+
description: "List or clean this session's scratch dir and the recorded clones and worktrees (list|clean).",
|
|
352
|
+
getArgumentCompletions: (prefix) => ["list", "clean"].filter((action) => action.startsWith(prefix.trim())).map((action) => ({ value: action, label: action })),
|
|
353
|
+
handler: async (args, ctx) => {
|
|
354
|
+
const action = args.trim() || "list";
|
|
355
|
+
if (action === "list") ctx.ui.notify(list(), "info");
|
|
356
|
+
else if (action === "clean") ctx.ui.notify(await clean(ctx), "info");
|
|
357
|
+
else ctx.ui.notify(SCRATCH_COMMAND_USAGE, "warning");
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export default function sessionScratch(pi: ExtensionAPI): void {
|
|
364
|
+
createSessionScratchExtension()(pi);
|
|
365
|
+
}
|