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
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
7
|
+
import { directoryAfterSegment, SHELL_SEGMENT_SEPARATOR } from "./commit-slice-signal.ts";
|
|
8
|
+
|
|
9
|
+
// Session scratch and artifact ledger (odd/tasks/session-scratch-ledger.md).
|
|
10
|
+
// The runtime owns the parent session's TMPDIR and records the clones and
|
|
11
|
+
// worktrees the session creates, so cleanup reads a list instead of the model
|
|
12
|
+
// re-deducing what is garbage. Removal is limited to what the ledger recorded,
|
|
13
|
+
// and every check refuses rather than guesses.
|
|
14
|
+
|
|
15
|
+
export const LEDGER_VERSION = 1;
|
|
16
|
+
/** The ledger keeps the most recent artifacts, evicting the oldest first, so a long session's ledger file stays bounded. */
|
|
17
|
+
export const LEDGER_ARTIFACTS_CAP = 64;
|
|
18
|
+
// Short on purpose (S9): TMPDIR points at `gpXXXXXX` and a subagent's TMPDIR at
|
|
19
|
+
// `gaXXXXXX` under it, and Unix socket paths below them are capped near 104 bytes.
|
|
20
|
+
export const SCRATCH_PREFIX = "gp";
|
|
21
|
+
/** Subagent TMPDIRs (lib/agents-runner.ts) nest under the session scratch; their runner removes them. */
|
|
22
|
+
export const SUBAGENT_SCRATCH_PREFIX = "ga";
|
|
23
|
+
// mkdtemp appends six alphanumeric characters to the prefix.
|
|
24
|
+
const SCRATCH_NAME = new RegExp(`^${SCRATCH_PREFIX}[A-Za-z0-9]{6}$`);
|
|
25
|
+
const SUBAGENT_SCRATCH_NAME = new RegExp(`^${SUBAGENT_SCRATCH_PREFIX}[A-Za-z0-9]{6}$`);
|
|
26
|
+
/** Whether a path's last component is named as the runtime names a session scratch. */
|
|
27
|
+
export const isSessionScratchName = (path: string): boolean => SCRATCH_NAME.test(basename(path));
|
|
28
|
+
export const SWEEP_GRACE_MS = 60 * 60 * 1000;
|
|
29
|
+
/** `/gentle:scratch clean` keeps the live scratch's entries changed this long before it started. */
|
|
30
|
+
export const CLEAN_RECENT_MS = 60_000;
|
|
31
|
+
|
|
32
|
+
// The TMPDIRs of this process's live and quarantined subagents. On globalThis
|
|
33
|
+
// because Pi loads every extension as its own module instance, again on /reload,
|
|
34
|
+
// and a runner of an earlier instance may still hold a quarantined child.
|
|
35
|
+
const LIVE_SUBAGENT_SCRATCH = Symbol.for("gentle-pi.live-subagent-scratch");
|
|
36
|
+
const liveSubagentRegistry = (): Set<string> => {
|
|
37
|
+
const holder = globalThis as { [LIVE_SUBAGENT_SCRATCH]?: Set<string> };
|
|
38
|
+
return (holder[LIVE_SUBAGENT_SCRATCH] ??= new Set());
|
|
39
|
+
};
|
|
40
|
+
/** The runner holds a subagent TMPDIR from its creation until it removes it. */
|
|
41
|
+
export const holdSubagentScratch = (dir: string): void => { liveSubagentRegistry().add(dir); };
|
|
42
|
+
export const releaseSubagentScratch = (dir: string): void => { liveSubagentRegistry().delete(dir); };
|
|
43
|
+
export const liveSubagentScratch = (): string[] => [...liveSubagentRegistry()];
|
|
44
|
+
|
|
45
|
+
export type ArtifactKind = "clone" | "worktree";
|
|
46
|
+
|
|
47
|
+
export interface ScratchArtifact {
|
|
48
|
+
kind: ArtifactKind;
|
|
49
|
+
path: string;
|
|
50
|
+
command: string;
|
|
51
|
+
recordedAt: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ScratchLedger {
|
|
55
|
+
version: number;
|
|
56
|
+
sessionId: string;
|
|
57
|
+
pid: number;
|
|
58
|
+
processStartedAt: string;
|
|
59
|
+
/** The OS's opaque start token of the owning process (`processStartToken`); absent when it could not be read. */
|
|
60
|
+
processStartToken?: string;
|
|
61
|
+
cwd: string;
|
|
62
|
+
/** The real temp root the scratch was created directly under (the inherited TMPDIR or os.tmpdir()). */
|
|
63
|
+
tempRoot: string | null;
|
|
64
|
+
/** The temp variables before the session changed them (null: unset), restored at shutdown. */
|
|
65
|
+
inherited: Record<string, string | null>;
|
|
66
|
+
scratchDir: string | null;
|
|
67
|
+
artifacts: ScratchArtifact[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface StoredLedger { path: string; ledger: ScratchLedger; mtimeMs: number }
|
|
71
|
+
|
|
72
|
+
/** The session id as a file and directory name component. */
|
|
73
|
+
export function safeSessionId(sessionId: string): string {
|
|
74
|
+
return sessionId.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "session";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ledgerDirectory(configHome: string): string {
|
|
78
|
+
return join(configHome, "scratch");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function ledgerPath(configHome: string, sessionId: string): string {
|
|
82
|
+
return join(ledgerDirectory(configHome), `${safeSessionId(sessionId)}.json`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isArtifact(value: unknown): value is ScratchArtifact {
|
|
86
|
+
const artifact = value as Partial<ScratchArtifact> | null;
|
|
87
|
+
return typeof artifact === "object" && artifact !== null && (artifact.kind === "clone" || artifact.kind === "worktree") &&
|
|
88
|
+
typeof artifact.path === "string" && isAbsolute(artifact.path) && typeof artifact.command === "string" && typeof artifact.recordedAt === "string";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isLedger(value: unknown): value is ScratchLedger {
|
|
92
|
+
const ledger = value as Partial<ScratchLedger> | null;
|
|
93
|
+
return typeof ledger === "object" && ledger !== null && ledger.version === LEDGER_VERSION && typeof ledger.sessionId === "string" &&
|
|
94
|
+
typeof ledger.pid === "number" && typeof ledger.processStartedAt === "string" &&
|
|
95
|
+
(ledger.processStartToken === undefined || typeof ledger.processStartToken === "string") && typeof ledger.cwd === "string" &&
|
|
96
|
+
(ledger.tempRoot === null || typeof ledger.tempRoot === "string") && typeof ledger.inherited === "object" && ledger.inherited !== null &&
|
|
97
|
+
Object.values(ledger.inherited).every((value) => value === null || typeof value === "string") &&
|
|
98
|
+
(ledger.scratchDir === null || typeof ledger.scratchDir === "string") && Array.isArray(ledger.artifacts) && ledger.artifacts.every(isArtifact);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A valid ledger at `path`, or undefined for a missing, unreadable, or foreign file. */
|
|
102
|
+
export function readLedger(path: string): ScratchLedger | undefined {
|
|
103
|
+
try {
|
|
104
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
105
|
+
return isLedger(parsed) ? parsed : undefined;
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The fs functions the ledger file I/O uses. Tests spy on this object to pin the
|
|
112
|
+
// ledger I/O to the async fs API; the sync fs functions stay for the single-ledger
|
|
113
|
+
// read and the scratch-directory checks, which were already synchronous.
|
|
114
|
+
export const ledgerFs = { mkdir, readFile, readdir, rename, stat, unlink, writeFile };
|
|
115
|
+
|
|
116
|
+
/** A valid ledger read from `path`, or undefined for a missing, unreadable, or foreign file. */
|
|
117
|
+
async function readLedgerFile(path: string): Promise<ScratchLedger | undefined> {
|
|
118
|
+
try {
|
|
119
|
+
const parsed: unknown = JSON.parse(await ledgerFs.readFile(path, "utf8"));
|
|
120
|
+
return isLedger(parsed) ? parsed : undefined;
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Atomic and private: a temp file in the same directory, then a rename. */
|
|
127
|
+
export async function writeLedger(path: string, ledger: ScratchLedger): Promise<void> {
|
|
128
|
+
await ledgerFs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
129
|
+
const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
130
|
+
try {
|
|
131
|
+
await ledgerFs.writeFile(temp, `${JSON.stringify(ledger, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
132
|
+
await ledgerFs.rename(temp, path);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
try { await ledgerFs.unlink(temp); } catch { /* already renamed or never created */ }
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Synchronous `writeLedger`, for the one caller that cannot await it (the shutdown path, whose callers do not await the handler). */
|
|
140
|
+
export function writeLedgerSync(path: string, ledger: ScratchLedger): void {
|
|
141
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
142
|
+
const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(temp, `${JSON.stringify(ledger, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
145
|
+
renameSync(temp, path);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
try { unlinkSync(temp); } catch { /* already renamed or never created */ }
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function deleteLedger(path: string): void {
|
|
153
|
+
try { unlinkSync(path); } catch { /* already gone */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Every valid ledger in the config home; other files are left alone. */
|
|
157
|
+
export async function listLedgers(configHome: string): Promise<StoredLedger[]> {
|
|
158
|
+
const dir = ledgerDirectory(configHome);
|
|
159
|
+
let names: string[];
|
|
160
|
+
try { names = await ledgerFs.readdir(dir); } catch { return []; }
|
|
161
|
+
const result: StoredLedger[] = [];
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
if (!name.endsWith(".json")) continue;
|
|
164
|
+
const path = join(dir, name);
|
|
165
|
+
try {
|
|
166
|
+
const ledger = await readLedgerFile(path);
|
|
167
|
+
if (!ledger) continue;
|
|
168
|
+
result.push({ path, ledger, mtimeMs: (await ledgerFs.stat(path)).mtimeMs });
|
|
169
|
+
} catch { /* removed meanwhile */ }
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Synchronous `listLedgers`, for the one caller that cannot await it (the shutdown path, whose callers do not await the handler). */
|
|
175
|
+
export function listLedgersSync(configHome: string): StoredLedger[] {
|
|
176
|
+
const dir = ledgerDirectory(configHome);
|
|
177
|
+
let names: string[];
|
|
178
|
+
try { names = readdirSync(dir); } catch { return []; }
|
|
179
|
+
const result: StoredLedger[] = [];
|
|
180
|
+
for (const name of names) {
|
|
181
|
+
if (!name.endsWith(".json")) continue;
|
|
182
|
+
const path = join(dir, name);
|
|
183
|
+
const ledger = readLedger(path);
|
|
184
|
+
if (!ledger) continue;
|
|
185
|
+
try { result.push({ path, ledger, mtimeMs: statSync(path).mtimeMs }); } catch { /* removed meanwhile */ }
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `kill(pid, 0)`: EPERM still means a live process owned by someone else. */
|
|
191
|
+
export function isProcessAlive(pid: number): boolean {
|
|
192
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
193
|
+
try {
|
|
194
|
+
process.kill(pid, 0);
|
|
195
|
+
return true;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* An opaque token for when the process `pid` started, as the OS reports it, or
|
|
203
|
+
* undefined when it cannot be read: always on Windows, where only pid liveness
|
|
204
|
+
* is checked. Tokens are compared for equality only, never converted to wall
|
|
205
|
+
* clock time, so a clock step cannot make a live process look like another one.
|
|
206
|
+
*/
|
|
207
|
+
export function processStartToken(pid: number, platform: NodeJS.Platform = process.platform): string | undefined {
|
|
208
|
+
if (!Number.isInteger(pid) || pid <= 0) return undefined;
|
|
209
|
+
try {
|
|
210
|
+
if (platform === "linux") {
|
|
211
|
+
// Field 22 counts clock ticks since boot; the command name (field 2) may hold spaces and parentheses.
|
|
212
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
213
|
+
const ticks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
|
214
|
+
return ticks && /^\d+$/.test(ticks) ? `linux:${ticks}` : undefined;
|
|
215
|
+
}
|
|
216
|
+
if (platform === "win32") return undefined;
|
|
217
|
+
// macOS and the BSDs: the start time text as ps prints it, in a fixed locale.
|
|
218
|
+
const started = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 2_000, env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
219
|
+
return started ? `ps:${started}` : undefined;
|
|
220
|
+
} catch {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A private `gpXXXXXX` under `base`, or undefined when it cannot be created.
|
|
227
|
+
* `path` joins `base` as given, for TMPDIR (a real path can be longer, as
|
|
228
|
+
* macOS `/private/var/...`); `realPath` is what the ledger and checks use.
|
|
229
|
+
*/
|
|
230
|
+
export function createSessionScratch(base: string | undefined): { path: string; realPath: string } | undefined {
|
|
231
|
+
if (!base || !isAbsolute(base)) return undefined;
|
|
232
|
+
try {
|
|
233
|
+
const path = mkdtempSync(join(base, SCRATCH_PREFIX));
|
|
234
|
+
try { chmodSync(path, 0o700); } catch { /* best effort, as mkdtemp already creates 0700 on POSIX */ }
|
|
235
|
+
return { path, realPath: realpathSync(path) };
|
|
236
|
+
} catch {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export const isWithin = (path: string, dir: string) => path === dir || path.startsWith(dir.endsWith(sep) ? dir : `${dir}${sep}`);
|
|
242
|
+
|
|
243
|
+
/** The real path of a temp root, or undefined when it does not exist. */
|
|
244
|
+
export function realTempRoot(path: string | undefined): string | undefined {
|
|
245
|
+
if (!path || !isAbsolute(path)) return undefined;
|
|
246
|
+
try { return realpathSync(path); } catch { return undefined; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Why a recorded scratch dir is not ours to remove, "absent" when it is gone,
|
|
251
|
+
* or undefined when it is. `ownRoot` is the temp root of the session asking:
|
|
252
|
+
* a ledger's temp root inside the home is trusted only when it is that root.
|
|
253
|
+
*/
|
|
254
|
+
export function scratchRefusal(ledger: ScratchLedger, ownRoot: string | undefined): string | undefined {
|
|
255
|
+
const dir = ledger.scratchDir;
|
|
256
|
+
if (!dir || !isAbsolute(dir)) return "not recorded";
|
|
257
|
+
// Prefix and ledger match: only a directory named as the runtime names a session scratch.
|
|
258
|
+
if (!SCRATCH_NAME.test(basename(dir))) return "not a session scratch name";
|
|
259
|
+
// Created directly under the temp root recorded with it, which is a temp root.
|
|
260
|
+
const root = ledger.tempRoot;
|
|
261
|
+
if (!root || !isAbsolute(root) || dirname(dir) !== root) return "outside its temp root";
|
|
262
|
+
if (isWithin(root, resolve(homedir())) && root !== ownRoot) return "temp root inside the home";
|
|
263
|
+
let stat;
|
|
264
|
+
try { stat = lstatSync(dir); } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unreadable"; }
|
|
265
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return "not a directory";
|
|
266
|
+
// The ledger holds the real path made at creation; a swapped parent changes it.
|
|
267
|
+
try { if (realpathSync(dir) !== dir) return "moved behind a symlink"; } catch { return "unreadable"; }
|
|
268
|
+
const uid = process.getuid?.();
|
|
269
|
+
if (uid !== undefined && stat.uid !== uid) return "owned by another user";
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export type ScratchRemoval = "removed" | "absent" | "kept" | "refused";
|
|
274
|
+
|
|
275
|
+
/** The latest mtime or ctime under `path`, without following symlinks; Infinity when it cannot be told. */
|
|
276
|
+
function latestChange(path: string): number {
|
|
277
|
+
let latest = -Infinity;
|
|
278
|
+
const stack = [path];
|
|
279
|
+
for (let visited = 0; stack.length > 0; visited++) {
|
|
280
|
+
if (visited >= 200_000) return Infinity;
|
|
281
|
+
const current = stack.pop()!;
|
|
282
|
+
let stat;
|
|
283
|
+
try { stat = lstatSync(current); } catch (error) {
|
|
284
|
+
if (current !== path && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
285
|
+
return Infinity;
|
|
286
|
+
}
|
|
287
|
+
latest = Math.max(latest, stat.mtimeMs, stat.ctimeMs);
|
|
288
|
+
if (stat.isDirectory()) {
|
|
289
|
+
try { for (const name of readdirSync(current)) stack.push(join(current, name)); } catch { return Infinity; }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return latest;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface ScratchRemovalOptions {
|
|
296
|
+
/** Remove the entries and keep the directory, as for the live session's own scratch. */
|
|
297
|
+
contentsOnly?: boolean;
|
|
298
|
+
ownRoot: string | undefined;
|
|
299
|
+
/** Real paths of this process's live and quarantined subagent TMPDIRs: kept, and the rest of the scratch removed. */
|
|
300
|
+
liveSubagentDirs?: readonly string[];
|
|
301
|
+
/** With `contentsOnly`, an entry with anything changed at or after this time (epoch ms) is kept. */
|
|
302
|
+
keepChangedSince?: number;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Removes a ledger's scratch dir, or with `contentsOnly` its entries except
|
|
307
|
+
* subagent TMPDIRs. A dir or entry holding one of `heldPaths` is kept: a
|
|
308
|
+
* recorded artifact is removed only through its own checks, and the scratch dir
|
|
309
|
+
* of a live session only through that session's ledger. A scratch holding a
|
|
310
|
+
* live subagent TMPDIR loses its other entries only. Symlinks are never followed.
|
|
311
|
+
*/
|
|
312
|
+
export function removeSessionScratch(ledger: ScratchLedger, heldPaths: readonly string[], options: ScratchRemovalOptions): ScratchRemoval {
|
|
313
|
+
const refusal = scratchRefusal(ledger, options.ownRoot);
|
|
314
|
+
if (refusal === "absent") return "absent";
|
|
315
|
+
if (refusal) return "refused";
|
|
316
|
+
const dir = ledger.scratchDir!;
|
|
317
|
+
const holdsArtifact = (path: string) => heldPaths.some((held) => isWithin(held, path));
|
|
318
|
+
const holdsSubagent = (path: string) => (options.liveSubagentDirs ?? []).some((held) => isWithin(held, path));
|
|
319
|
+
try {
|
|
320
|
+
if (!options.contentsOnly) {
|
|
321
|
+
if (holdsArtifact(dir)) return "kept";
|
|
322
|
+
if (!holdsSubagent(dir)) {
|
|
323
|
+
rmSync(dir, { recursive: true, force: true });
|
|
324
|
+
return "removed";
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
let kept = false;
|
|
328
|
+
for (const name of readdirSync(dir)) {
|
|
329
|
+
const path = join(dir, name);
|
|
330
|
+
const recent = options.contentsOnly && options.keepChangedSince !== undefined && latestChange(path) >= options.keepChangedSince;
|
|
331
|
+
if (SUBAGENT_SCRATCH_NAME.test(name) || holdsArtifact(path) || holdsSubagent(path) || recent) { kept = true; continue; }
|
|
332
|
+
rmSync(path, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
return kept ? "kept" : "removed";
|
|
335
|
+
} catch {
|
|
336
|
+
return "refused";
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Total bytes of regular files under `dir`, without following symlinks. */
|
|
341
|
+
export function directorySize(dir: string): number {
|
|
342
|
+
let total = 0;
|
|
343
|
+
const stack = [dir];
|
|
344
|
+
for (let visited = 0; stack.length > 0 && visited < 200_000; visited++) {
|
|
345
|
+
const path = stack.pop()!;
|
|
346
|
+
try {
|
|
347
|
+
const stat = lstatSync(path);
|
|
348
|
+
if (stat.isDirectory()) for (const name of readdirSync(path)) stack.push(join(path, name));
|
|
349
|
+
else if (stat.isFile()) total += stat.size;
|
|
350
|
+
} catch { /* removed meanwhile */ }
|
|
351
|
+
}
|
|
352
|
+
return total;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function formatSize(bytes: number): string {
|
|
356
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
357
|
+
const units = ["KiB", "MiB", "GiB", "TiB"];
|
|
358
|
+
let value = bytes / 1024;
|
|
359
|
+
let unit = 0;
|
|
360
|
+
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; }
|
|
361
|
+
return `${value.toFixed(1)} ${units[unit]}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Command parsing (S4).
|
|
365
|
+
|
|
366
|
+
const CLONE_VALUE_OPTIONS = new Set(["-b", "--branch", "-o", "--origin", "-c", "--config", "--depth", "--reference", "--reference-if-able", "--separate-git-dir", "-u", "--upload-pack", "--template", "-j", "--jobs", "--filter", "--shallow-since", "--shallow-exclude", "--server-option", "--bundle-uri", "--revision"]);
|
|
367
|
+
const WORKTREE_ADD_VALUE_OPTIONS = new Set(["-b", "-B", "--reason"]);
|
|
368
|
+
// A pipe, background, or redirection token ends the git arguments.
|
|
369
|
+
const ARGUMENTS_END = /^(\||&|\d*>|\d*<|&>|<|>)/;
|
|
370
|
+
|
|
371
|
+
function tokens(segment: string): string[] {
|
|
372
|
+
return (segment.match(/"[^"]*"|'[^']*'|[^\s"']+/g) ?? []).map((token) => token.replace(/^(["'])(.*)\1$/s, "$2"));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function positionals(args: readonly string[], valueOptions: ReadonlySet<string>): string[] {
|
|
376
|
+
const result: string[] = [];
|
|
377
|
+
for (let i = 0; i < args.length; i++) {
|
|
378
|
+
const arg = args[i];
|
|
379
|
+
if (arg === "--") { result.push(...args.slice(i + 1)); break; }
|
|
380
|
+
if (arg.startsWith("-") && arg !== "-") { if (valueOptions.has(arg)) i++; continue; }
|
|
381
|
+
result.push(arg);
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The directory `git clone <repo>` creates without a destination, as git guesses it. */
|
|
387
|
+
function defaultCloneDirectory(repository: string): string | undefined {
|
|
388
|
+
const trimmed = repository.replace(/[\\/]+$/, "").replace(/[\\/]\.git$/, "");
|
|
389
|
+
const name = trimmed.slice(Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf(":"), trimmed.lastIndexOf("\\")) + 1).replace(/\.(git|bundle)$/, "");
|
|
390
|
+
return name && name !== "." && name !== ".." ? name : undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export interface ArtifactTarget { kind: ArtifactKind; path: string }
|
|
394
|
+
|
|
395
|
+
/** Clone and worktree destinations a shell command creates, resolved against its `cd` segments and `git -C`. */
|
|
396
|
+
export function gitArtifactTargets(command: string, cwd: string): ArtifactTarget[] {
|
|
397
|
+
const targets: ArtifactTarget[] = [];
|
|
398
|
+
let base = cwd;
|
|
399
|
+
for (const segment of command.split(SHELL_SEGMENT_SEPARATOR)) {
|
|
400
|
+
const words = tokens(segment);
|
|
401
|
+
let i = 0;
|
|
402
|
+
while (i < words.length && /^\w+=/.test(words[i])) i++;
|
|
403
|
+
if (words[i] === "git") {
|
|
404
|
+
let directory = base;
|
|
405
|
+
for (i++; i < words.length && words[i].startsWith("-"); i++) {
|
|
406
|
+
if (words[i] === "-C" && i + 1 < words.length) directory = resolve(directory, words[++i]);
|
|
407
|
+
else if (words[i] === "-c") i++;
|
|
408
|
+
}
|
|
409
|
+
const end = words.findIndex((word, index) => index >= i && ARGUMENTS_END.test(word));
|
|
410
|
+
const args = words.slice(i, end < 0 ? undefined : end);
|
|
411
|
+
if (args[0] === "clone") {
|
|
412
|
+
const [repository, destination] = positionals(args.slice(1), CLONE_VALUE_OPTIONS);
|
|
413
|
+
const target = destination ?? (repository ? defaultCloneDirectory(repository) : undefined);
|
|
414
|
+
if (target) targets.push({ kind: "clone", path: resolve(directory, target) });
|
|
415
|
+
} else if (args[0] === "worktree" && args[1] === "add") {
|
|
416
|
+
const [path] = positionals(args.slice(2), WORKTREE_ADD_VALUE_OPTIONS);
|
|
417
|
+
if (path) targets.push({ kind: "worktree", path: resolve(directory, path) });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
base = directoryAfterSegment(segment, base);
|
|
421
|
+
}
|
|
422
|
+
return targets;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** A path the runtime never records or removes: a filesystem root, or the home, config, or session directory or an ancestor of them. */
|
|
426
|
+
export function isProtectedPath(path: string, keep: readonly string[]): boolean {
|
|
427
|
+
return parse(path).root === path || [homedir(), ...keep].some((dir) => dir && isWithin(resolve(dir), path));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Artifact checks and removal (S6).
|
|
431
|
+
|
|
432
|
+
type Git = (args: string[], cwd: string) => Promise<string | undefined>;
|
|
433
|
+
|
|
434
|
+
// Inherited repository overrides would point git away from the recorded path.
|
|
435
|
+
const GIT_ENV_OVERRIDES = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY"];
|
|
436
|
+
|
|
437
|
+
const runGit: Git = (args, cwd) => new Promise((resolveResult) => {
|
|
438
|
+
const env = { ...process.env };
|
|
439
|
+
for (const key of GIT_ENV_OVERRIDES) delete env[key];
|
|
440
|
+
execFile("git", args, { cwd, env, encoding: "utf8", timeout: 10_000, windowsHide: true }, (error, stdout) => resolveResult(error ? undefined : stdout.trim()));
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
export type ArtifactCheck =
|
|
444
|
+
| { state: "missing" }
|
|
445
|
+
| { state: "kept"; reason: string }
|
|
446
|
+
| { state: "removable"; commonDir?: string; identity?: { dev: bigint; ino: bigint } };
|
|
447
|
+
|
|
448
|
+
const sameDirectory = (path: string) => {
|
|
449
|
+
try {
|
|
450
|
+
const stat = lstatSync(path);
|
|
451
|
+
return !stat.isSymbolicLink() && stat.isDirectory() && realpathSync(path) === path;
|
|
452
|
+
} catch {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const identityOf = (path: string) => {
|
|
458
|
+
try {
|
|
459
|
+
// bigint: some filesystems report inode numbers above Number.MAX_SAFE_INTEGER.
|
|
460
|
+
const { dev, ino } = lstatSync(path, { bigint: true });
|
|
461
|
+
return { dev, ino };
|
|
462
|
+
} catch {
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
/** Whether a recorded clone or worktree can be removed without losing work. */
|
|
468
|
+
export async function checkArtifact(artifact: ScratchArtifact, keep: readonly string[], git: Git = runGit): Promise<ArtifactCheck> {
|
|
469
|
+
const { path, kind } = artifact;
|
|
470
|
+
try { lstatSync(path); } catch (error) {
|
|
471
|
+
// ENOTDIR: a path component is no longer a directory, so the artifact cannot exist there.
|
|
472
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
473
|
+
if (code === "ENOENT" || code === "ENOTDIR") return { state: "missing" };
|
|
474
|
+
}
|
|
475
|
+
if (!sameDirectory(path)) return { state: "kept", reason: "not the recorded directory" };
|
|
476
|
+
// Taken before any Git call, so a directory swapped in during the checks is not the one removed.
|
|
477
|
+
const identity = identityOf(path);
|
|
478
|
+
if (isProtectedPath(path, keep)) return { state: "kept", reason: "protected directory" };
|
|
479
|
+
const top = await git(["rev-parse", "--show-toplevel"], path);
|
|
480
|
+
if (top === undefined || resolve(top) !== path) return { state: "kept", reason: "not a Git work tree root" };
|
|
481
|
+
let dotGit;
|
|
482
|
+
try { dotGit = lstatSync(join(path, ".git")); } catch { return { state: "kept", reason: "not a Git work tree root" }; }
|
|
483
|
+
if (kind === "clone" ? !dotGit.isDirectory() : !dotGit.isFile()) return { state: "kept", reason: kind === "clone" ? "not a standalone clone" : "not a linked worktree" };
|
|
484
|
+
// Override user settings that hide untracked files or submodule changes from status.
|
|
485
|
+
const status = await git(["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"], path);
|
|
486
|
+
if (status === undefined) return { state: "kept", reason: "Git status unavailable" };
|
|
487
|
+
if (status !== "") return { state: "kept", reason: "uncommitted changes" };
|
|
488
|
+
if (kind === "clone") {
|
|
489
|
+
// Removing the clone would orphan its linked worktrees; a prunable one has lost its directory already.
|
|
490
|
+
const worktrees = await git(["worktree", "list", "--porcelain"], path);
|
|
491
|
+
const present = worktrees?.split(/\n\s*\n/).filter((entry) => /^worktree /m.test(entry) && !/^prunable\b/m.test(entry));
|
|
492
|
+
if (!present?.length) return { state: "kept", reason: "Git worktrees unavailable" };
|
|
493
|
+
if (present.length > 1) return { state: "kept", reason: "has linked worktrees" };
|
|
494
|
+
// Every ref, stashes included, must already be on a remote.
|
|
495
|
+
const local = await git(["rev-list", "-n", "1", "--all", "--not", "--remotes"], path);
|
|
496
|
+
if (local === undefined) return { state: "kept", reason: "Git history unavailable" };
|
|
497
|
+
return local === "" ? { state: "removable", identity } : { state: "kept", reason: "commits not on any remote" };
|
|
498
|
+
}
|
|
499
|
+
const detached = await git(["rev-list", "-n", "1", "HEAD", "--not", "--branches", "--tags", "--remotes"], path);
|
|
500
|
+
if (detached === undefined) return { state: "kept", reason: "Git history unavailable" };
|
|
501
|
+
if (detached !== "") return { state: "kept", reason: "commits on no branch" };
|
|
502
|
+
const commonDir = await git(["rev-parse", "--path-format=absolute", "--git-common-dir"], path);
|
|
503
|
+
return commonDir ? { state: "removable", commonDir, identity } : { state: "kept", reason: "Git repository unavailable" };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Removes a checked artifact: a worktree through git (which refuses dirty ones), a clone by its recorded path only. */
|
|
507
|
+
export async function removeArtifact(artifact: ScratchArtifact, check: Extract<ArtifactCheck, { state: "removable" }>, git: Git = runGit): Promise<string | undefined> {
|
|
508
|
+
if (!sameDirectory(artifact.path)) return "not the recorded directory";
|
|
509
|
+
// The directory checked must be the one removed, not a replacement at the same path.
|
|
510
|
+
const now = identityOf(artifact.path);
|
|
511
|
+
if (!check.identity || !now || now.dev !== check.identity.dev || now.ino !== check.identity.ino) return "replaced after the check";
|
|
512
|
+
if (artifact.kind === "worktree") {
|
|
513
|
+
if (await git(["worktree", "remove", artifact.path], artifact.path) === undefined) return "git worktree remove failed";
|
|
514
|
+
if (check.commonDir) await git([`--git-dir=${check.commonDir}`, "worktree", "prune"], dirname(check.commonDir));
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
rmSync(artifact.path, { recursive: true, force: true });
|
|
519
|
+
return undefined;
|
|
520
|
+
} catch (error) {
|
|
521
|
+
return `removal failed: ${(error as Error).message}`;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
@@ -30,6 +30,13 @@ function canonicalDirectory(path: string, cwd: string): string {
|
|
|
30
30
|
return statSync(canonical).isDirectory() ? canonical : dirname(canonical);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// Windows accepts `/` as its separator and Git prints roots that way, while
|
|
34
|
+
// realpath answers with `\`. Only the separator is translated, so any other
|
|
35
|
+
// non-canonical spelling (dot segments, trailing separators) still differs.
|
|
36
|
+
export function nativeRootSpelling(path: string, platform: NodeJS.Platform = process.platform): string {
|
|
37
|
+
return platform === "win32" ? path.replaceAll("/", "\\") : path;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
export function resolveSessionWorktreeWithGit(path: string, cwd: string, run: typeof execFileSync = execFileSync): WorktreeIdentity | undefined {
|
|
34
41
|
try {
|
|
35
42
|
const directory = canonicalDirectory(path, cwd);
|