mixdog 1.1.2 → 1.2.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 +1 -0
- package/package.json +2 -1
- package/scripts/lib/run-node-tests.mjs +9 -0
- package/scripts/prune-desktop-runtime.mjs +3 -0
- package/scripts/test-direct.mjs +4 -2
- package/src/defaults/shipped-history.json +6 -0
- package/src/defaults/skills/setup/references/actions.md +5 -2
- package/src/defaults/skills/visualize/SKILL.md +157 -0
- package/src/defaults/skills/visualize/references/craft.md +88 -0
- package/src/headless-command.mjs +56 -3
- package/src/headless-exec.mjs +17 -1
- package/src/help.mjs +2 -0
- package/src/rules/shared/70-delivery.md +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
- package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
- package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
- package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
- package/src/runtime/agent/orchestrator/config.mjs +9 -1
- package/src/runtime/agent/orchestrator/mcp/client-config.mjs +47 -7
- package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +51 -8
- package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +25 -5
- package/src/runtime/agent/orchestrator/mcp/client.mjs +348 -122
- package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +13 -1
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
- package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
- package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
- package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +67 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +521 -0
- package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +15 -1
- package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
- package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +12 -11
- package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
- package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +11 -0
- package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
- package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
- package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +18 -11
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
- package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
- package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +17 -7
- package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +5 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
- package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
- package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +29 -19
- package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
- package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +23 -4
- package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
- package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +28 -8
- package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +140 -0
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
- package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +31 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -66
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
- package/src/runtime/attachments/store.mjs +38 -0
- package/src/runtime/shared/atomic-file.mjs +8 -2
- package/src/runtime/shared/best-effort-stderr.mjs +8 -0
- package/src/runtime/shared/code-graph-state.mjs +12 -0
- package/src/runtime/shared/defaults-separation.mjs +2 -0
- package/src/runtime/shared/git-runner-worker.mjs +3 -2
- package/src/runtime/shared/git-runner.mjs +4 -3
- package/src/runtime/shared/llm/default-context-window.mjs +6 -0
- package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
- package/src/runtime/shared/llm/model-catalog.mjs +240 -118
- package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
- package/src/runtime/shared/llm/provider-catalog-registry.mjs +156 -0
- package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
- package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
- package/src/runtime/shared/model-proxy-mode.mjs +19 -0
- package/src/runtime/shared/provider-accounts.mjs +2 -2
- package/src/runtime/shared/provider-api-key.mjs +21 -1
- package/src/runtime/shared/schedule-model-ref.mjs +11 -2
- package/src/runtime/shared/session-usage-totals.mjs +19 -0
- package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
- package/src/runtime/shared/singleton-owner.mjs +36 -6
- package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
- package/src/runtime/shared/tool-surface.mjs +7 -0
- package/src/runtime/shared/tool-work-units.mjs +14 -0
- package/src/runtime/shared/transcript-metadata.mjs +17 -1
- package/src/runtime/shared/turn-changes.mjs +791 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
- package/src/runtime/shared/turn-snapshot.mjs +690 -81
- package/src/runtime/shared/turn-worktree-snapshot.mjs +161 -11
- package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
- package/src/runtime/shared/worktrees/contract.mjs +551 -0
- package/src/runtime/shared/worktrees/disk.mjs +57 -0
- package/src/runtime/shared/worktrees/git.mjs +247 -0
- package/src/runtime/shared/worktrees/include.mjs +480 -0
- package/src/runtime/shared/worktrees/lock.mjs +46 -0
- package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
- package/src/runtime/shared/worktrees/manager.mjs +1215 -0
- package/src/runtime/shared/worktrees/merge.mjs +182 -0
- package/src/runtime/shared/worktrees/owner.mjs +46 -0
- package/src/runtime/shared/worktrees/paths.mjs +62 -0
- package/src/runtime/shared/worktrees/registry.mjs +110 -0
- package/src/runtime/shared/worktrees/settings.mjs +84 -0
- package/src/runtime/shared/worktrees/slug.mjs +62 -0
- package/src/runtime/shared/worktrees/test-support.mjs +96 -0
- package/src/runtime/visualize-bridge/client.mjs +181 -0
- package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
- package/src/session-runtime/boot/apis.mjs +35 -0
- package/src/session-runtime/boot/config.mjs +2 -0
- package/src/session-runtime/boot/facade.mjs +20 -0
- package/src/session-runtime/boot/infrastructure.mjs +1 -0
- package/src/session-runtime/boot/session.mjs +1 -0
- package/src/session-runtime/boot/tools.mjs +4 -0
- package/src/session-runtime/config-lifecycle.mjs +10 -1
- package/src/session-runtime/cwd-plugins/cwd-apply.mjs +11 -1
- package/src/session-runtime/global-extensions.mjs +2 -2
- package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
- package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
- package/src/session-runtime/internal-tool-executor.mjs +9 -1
- package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
- package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
- package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
- package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
- package/src/session-runtime/mcp-glue/connect.mjs +11 -6
- package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -3
- package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
- package/src/session-runtime/model-route/route-persist.mjs +3 -0
- package/src/session-runtime/model-route/set-route.mjs +15 -2
- package/src/session-runtime/notification-bus.mjs +9 -1
- package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
- package/src/session-runtime/resource-extension-sync.mjs +30 -7
- package/src/session-runtime/resource-mcp-api.mjs +20 -7
- package/src/session-runtime/route-resolution.mjs +10 -1
- package/src/session-runtime/route-state.mjs +16 -2
- package/src/session-runtime/runtime-core.mjs +5 -0
- package/src/session-runtime/runtime-facade.mjs +8 -0
- package/src/session-runtime/runtime-review-api.mjs +9 -0
- package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
- package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
- package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
- package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
- package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
- package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
- package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
- package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
- package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
- package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
- package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
- package/src/session-runtime/services/agent-tool.mjs +1 -0
- package/src/session-runtime/services/daemon-candidate.mjs +3 -2
- package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
- package/src/session-runtime/session-create/session-options.mjs +10 -1
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/settings-api.mjs +4 -2
- package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
- package/src/session-runtime/settings-worktree-api.mjs +17 -0
- package/src/session-runtime/setup-tool/executor.mjs +11 -4
- package/src/session-runtime/setup-tool/settings-contract.mjs +3 -1
- package/src/session-runtime/setup-tool/tool-defs.mjs +31 -3
- package/src/session-runtime/turn/agent-ops.mjs +12 -1
- package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +13 -3
- package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
- package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
- package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
- package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
- package/src/session-runtime/turn/turn-run.mjs +6 -7
- package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
- package/src/session-runtime/workspace/admission-gate.mjs +197 -0
- package/src/session-runtime/workspace/project-branches.mjs +136 -0
- package/src/session-runtime/workspace/transition-steps.mjs +364 -0
- package/src/session-runtime/workspace/transitions.mjs +651 -0
- package/src/session-runtime/workspace/workspace-state.mjs +112 -0
- package/src/standalone/channel-transport/http-routes.mjs +17 -0
- package/src/standalone/channel-transport.mjs +13 -1
- package/src/standalone/daemon-desktop-runtime.mjs +1 -0
- package/src/standalone/daemon-owner.mjs +372 -0
- package/src/standalone/daemon-stored-session-views.mjs +4 -1
- package/src/standalone/daemon.mjs +139 -5
- package/src/standalone/desktop-service-registry.mjs +20 -0
- package/src/standalone/local-session-runtime.mjs +11 -0
- package/src/standalone/session-client/attachment-calls.mjs +3 -1
- package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
- package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
- package/src/standalone/session-client.mjs +104 -13
- package/src/standalone/session-protocol.mjs +16 -0
- package/src/standalone/session-proxy.mjs +3 -0
- package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
- package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
- package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
- package/src/standalone/session-service/projection/revisions.mjs +2 -0
- package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
- package/src/standalone/session-service/projection/workspace.mjs +14 -0
- package/src/standalone/session-service/session-calls/session-action.mjs +11 -3
- package/src/standalone/session-transport/call-route.mjs +13 -0
- package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
- package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
- package/src/standalone/session-transport/client-registry.mjs +20 -3
- package/src/standalone/session-transport/routes.mjs +79 -2
- package/src/standalone/session-transport/stack.mjs +2 -0
- package/src/standalone/session-transport.mjs +24 -1
- package/src/tui/app/message-selector.mjs +2 -1
- package/src/tui/app/model-options.mjs +35 -5
- package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
- package/src/tui/app/model-picker.mjs +4 -2
- package/src/tui/dist/index.mjs +332 -36
- package/src/tui/index.jsx +10 -2
- package/src/tui/session/compaction-view.mjs +147 -0
- package/src/tui/session/context-state.mjs +6 -0
- package/src/tui/session/initial-state.mjs +3 -0
- package/src/tui/session/quota-resume.mjs +2 -1
- package/src/tui/session/session-api/commands.mjs +10 -2
- package/src/tui/session/session-api/controls.mjs +4 -0
- package/src/tui/session/session-api/intake/rewind.mjs +2 -1
- package/src/tui/session/session-api/integrations.mjs +17 -0
- package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
- package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
- package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
- package/src/tui/session/session-api/routes.mjs +10 -1
- package/src/tui/session/session-api/settings.mjs +2 -0
- package/src/tui/session/session-api/worktree.mjs +60 -0
- package/src/tui/session/session-api.mjs +2 -0
- package/src/tui/session/session-flow/auto-clear.mjs +31 -12
- package/src/tui/session/session-flow/drain.mjs +15 -1
- package/src/tui/session/session-flow/session-reset.mjs +4 -3
- package/src/tui/session/session-flow.mjs +2 -0
- package/src/tui/session/transcript-intake.mjs +14 -5
- package/src/tui/session/turn-stream.mjs +32 -4
- package/src/tui/session/turn-tool-cards.mjs +10 -2
- package/src/tui/session/turn.mjs +39 -10
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
// Per-turn change records: replay-safe patches, their public reply shape and
|
|
2
|
+
// the `git apply` that undoes (reverse) or reapplies (forward) them.
|
|
3
|
+
//
|
|
4
|
+
// A record is frozen when a turn seals (turn-snapshot.mjs) and persisted by
|
|
5
|
+
// turn-snapshot-store.mjs. Every file carries its own complete patch, separate
|
|
6
|
+
// from the bounded display patch the review bar renders, so a historical turn
|
|
7
|
+
// stays undoable after later turns, cache eviction or a runtime restart.
|
|
8
|
+
import { lstat, open, realpath, stat } from 'node:fs/promises';
|
|
9
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
10
|
+
import { applyPatch, parsePatch, reversePatch, structuredPatch } from 'diff';
|
|
11
|
+
import { cleanString as clean } from './clean.mjs';
|
|
12
|
+
import { runGitOffThread } from './git-runner.mjs';
|
|
13
|
+
|
|
14
|
+
/** Largest single-file patch a record keeps replayable. */
|
|
15
|
+
export const TURN_CHANGE_FILE_PATCH_MAX_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
const APPLY_TIMEOUT_MS = 60_000;
|
|
17
|
+
const APPLY_OUTPUT_MAX_BYTES = 4 * 1024 * 1024;
|
|
18
|
+
const TRACKED_DIFF_TIMEOUT_MS = 2_000;
|
|
19
|
+
const STATUSES = new Set(['A', 'M', 'D', 'R']);
|
|
20
|
+
// Characters a patch header cannot carry unquoted; such a path is recorded but
|
|
21
|
+
// never replayed.
|
|
22
|
+
const UNSAFE_PATH_CHARACTERS = /[\t\n\r"\0]/;
|
|
23
|
+
|
|
24
|
+
export function changePathKey(value) {
|
|
25
|
+
const text = clean(value).replace(/\\/g, '/');
|
|
26
|
+
return process.platform === 'win32' ? text.toLowerCase() : text;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** git status letter → record status (copies create a new file). */
|
|
30
|
+
export function normalizeChangeStatus(code) {
|
|
31
|
+
const letter = String(code || 'M').charAt(0);
|
|
32
|
+
if (STATUSES.has(letter)) return letter;
|
|
33
|
+
return letter === 'C' ? 'A' : 'M';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A root-relative forward-slash path that a patch may name, or ''. */
|
|
37
|
+
function safeChangePath(value) {
|
|
38
|
+
const text = clean(value).replace(/\\/g, '/');
|
|
39
|
+
if (!text || text.startsWith('/') || isAbsolute(text) || /^[A-Za-z]:/.test(text)) return '';
|
|
40
|
+
if (UNSAFE_PATH_CHARACTERS.test(text)) return '';
|
|
41
|
+
const parts = text.split('/');
|
|
42
|
+
if (parts.some((part) => !part || part === '.' || part === '..' || part.toLowerCase() === '.git')) return '';
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Store form of a patch: UTF-8 text when that round-trips exactly, else base64. */
|
|
47
|
+
function encodeChangePatch(buffer) {
|
|
48
|
+
if (!buffer) return { patch: null };
|
|
49
|
+
const text = buffer.toString('utf8');
|
|
50
|
+
if (Buffer.from(text, 'utf8').equals(buffer)) return { patch: text, patchEncoding: 'utf8' };
|
|
51
|
+
return { patch: buffer.toString('base64'), patchEncoding: 'base64' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function changePatchBuffer(file) {
|
|
55
|
+
if (typeof file?.patch !== 'string') return null;
|
|
56
|
+
return file.patchEncoding === 'base64' ? Buffer.from(file.patch, 'base64') : Buffer.from(file.patch, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Patch sections ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** Decode one git C-quoted path ("a/\303\244.txt"); unquoted text passes through. */
|
|
62
|
+
function unquoteGitPath(text) {
|
|
63
|
+
if (!text.startsWith('"')) return text;
|
|
64
|
+
const bytes = [];
|
|
65
|
+
const chars = Array.from(text.slice(1));
|
|
66
|
+
const escapes = { n: 10, t: 9, a: 7, b: 8, f: 12, r: 13, v: 11, '\\': 92, '"': 34 };
|
|
67
|
+
for (let index = 0; index < chars.length; index += 1) {
|
|
68
|
+
const char = chars[index];
|
|
69
|
+
if (char === '"') break;
|
|
70
|
+
if (char !== '\\') {
|
|
71
|
+
bytes.push(...Buffer.from(char, 'utf8'));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const next = chars[++index] ?? '';
|
|
75
|
+
if (/^[0-7]$/.test(next)) {
|
|
76
|
+
bytes.push(Number.parseInt(chars.slice(index, index + 3).join(''), 8) & 0xff);
|
|
77
|
+
index += 2;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
bytes.push(escapes[next] ?? next.charCodeAt(0));
|
|
81
|
+
}
|
|
82
|
+
return Buffer.from(bytes).toString('utf8');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `---`/`+++` operand → path without its a/ or b/ prefix, or null for /dev/null. */
|
|
86
|
+
function headerName(raw, prefix) {
|
|
87
|
+
const value = raw.replace(/\t$/, '');
|
|
88
|
+
if (value === '/dev/null') return null;
|
|
89
|
+
const name = unquoteGitPath(value);
|
|
90
|
+
return name.startsWith(prefix) ? name.slice(prefix.length) : name;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The two operands of `diff --git a/X b/Y` when they can be told apart. */
|
|
94
|
+
function diffGitLinePaths(line) {
|
|
95
|
+
const rest = line.slice('diff --git '.length);
|
|
96
|
+
if (rest.startsWith('"')) {
|
|
97
|
+
const match = /^("(?:[^"\\]|\\.)*") (.*)$/.exec(rest);
|
|
98
|
+
if (!match) return null;
|
|
99
|
+
return [headerName(match[1], 'a/'), headerName(match[2], 'b/')];
|
|
100
|
+
}
|
|
101
|
+
if ((rest.length - 5) % 2 !== 0) return null;
|
|
102
|
+
const length = (rest.length - 5) / 2;
|
|
103
|
+
const left = rest.slice(0, length + 2);
|
|
104
|
+
const right = rest.slice(length + 3);
|
|
105
|
+
if (!left.startsWith('a/') || !right.startsWith('b/') || left.slice(2) !== right.slice(2)) return null;
|
|
106
|
+
return [left.slice(2), right.slice(2)];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Old/new path of one `diff --git` section (null for the /dev/null side). */
|
|
110
|
+
function patchSectionPaths(section) {
|
|
111
|
+
let end = section.length;
|
|
112
|
+
for (const marker of ['\n@@ ', '\nGIT binary patch', '\nBinary files ']) {
|
|
113
|
+
const index = section.indexOf(marker);
|
|
114
|
+
if (index >= 0 && index < end) end = index;
|
|
115
|
+
}
|
|
116
|
+
const lines = section.subarray(0, Math.min(end, 65_536)).toString('utf8').split('\n');
|
|
117
|
+
let oldPath;
|
|
118
|
+
let newPath;
|
|
119
|
+
let created = false;
|
|
120
|
+
let deleted = false;
|
|
121
|
+
for (const line of lines.slice(1)) {
|
|
122
|
+
if (line.startsWith('rename from ')) oldPath = unquoteGitPath(line.slice(12));
|
|
123
|
+
else if (line.startsWith('rename to ')) newPath = unquoteGitPath(line.slice(10));
|
|
124
|
+
else if (line.startsWith('new file mode')) created = true;
|
|
125
|
+
else if (line.startsWith('deleted file mode')) deleted = true;
|
|
126
|
+
else if (line.startsWith('--- ')) oldPath ??= headerName(line.slice(4), 'a/');
|
|
127
|
+
else if (line.startsWith('+++ ')) newPath ??= headerName(line.slice(4), 'b/');
|
|
128
|
+
}
|
|
129
|
+
if (oldPath === undefined || newPath === undefined) {
|
|
130
|
+
const pair = String(lines[0] || '').startsWith('diff --git ') ? diffGitLinePaths(lines[0]) : null;
|
|
131
|
+
if (pair) {
|
|
132
|
+
oldPath ??= pair[0];
|
|
133
|
+
newPath ??= pair[1];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
oldPath: created ? null : (oldPath ?? null),
|
|
138
|
+
newPath: deleted ? null : (newPath ?? null),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Split a multi-file git patch into its `diff --git` sections. A hunk line
|
|
143
|
+
* always starts with ' ', '+', '-' or '\\' and base85 has no space, so a line
|
|
144
|
+
* starting `diff --git ` only ever opens a section. */
|
|
145
|
+
export function splitPatchSections(buffer) {
|
|
146
|
+
if (!buffer?.length) return [];
|
|
147
|
+
const opener = Buffer.from('diff --git ');
|
|
148
|
+
const separator = Buffer.from('\ndiff --git ');
|
|
149
|
+
const starts = buffer.subarray(0, opener.length).equals(opener) ? [0] : [];
|
|
150
|
+
for (let index = buffer.indexOf(separator); index >= 0; index = buffer.indexOf(separator, index + 1)) {
|
|
151
|
+
starts.push(index + 1);
|
|
152
|
+
}
|
|
153
|
+
return starts.map((start, index) => {
|
|
154
|
+
const patch = buffer.subarray(start, starts[index + 1] ?? buffer.length);
|
|
155
|
+
return { ...patchSectionPaths(patch), patch };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Exact tracker (non-Git) patches ─────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
162
|
+
function decodeText(content) {
|
|
163
|
+
if (content == null) return '';
|
|
164
|
+
try {
|
|
165
|
+
const text = utf8Decoder.decode(content);
|
|
166
|
+
// A NUL byte marks binary content: it gets no text diff.
|
|
167
|
+
return text.includes('\0') ? null : text;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const headerTab = (path) => (path.includes(' ') ? '\t' : '');
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One git-format patch from exact before/after bytes (null = absent). Paths
|
|
177
|
+
* are root-relative. Returns the record file without its state.
|
|
178
|
+
*/
|
|
179
|
+
export function trackedChangeFile({ oldPath, newPath, before, after }) {
|
|
180
|
+
let status = 'M';
|
|
181
|
+
if (before === null) status = 'A';
|
|
182
|
+
else if (after === null) status = 'D';
|
|
183
|
+
else if (oldPath !== newPath) status = 'R';
|
|
184
|
+
const path = status === 'D' ? oldPath : newPath;
|
|
185
|
+
const base = { path, ...(status === 'R' ? { oldPath } : {}), status, additions: 0, deletions: 0 };
|
|
186
|
+
if (!safeChangePath(path) || (status === 'R' && !safeChangePath(oldPath))) {
|
|
187
|
+
return { ...base, revertable: false, reason: 'path cannot be represented in a patch' };
|
|
188
|
+
}
|
|
189
|
+
const oldText = decodeText(before);
|
|
190
|
+
const newText = decodeText(after);
|
|
191
|
+
if (oldText === null || newText === null) {
|
|
192
|
+
return {
|
|
193
|
+
...base,
|
|
194
|
+
binary: true,
|
|
195
|
+
revertable: false,
|
|
196
|
+
reason: 'binary content was not recorded as a replayable patch',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
let hunks = [];
|
|
200
|
+
if (oldText !== newText) {
|
|
201
|
+
const result = structuredPatch(`a/${oldPath}`, `b/${newPath}`, oldText, newText, '', '', {
|
|
202
|
+
context: 3,
|
|
203
|
+
timeout: TRACKED_DIFF_TIMEOUT_MS,
|
|
204
|
+
});
|
|
205
|
+
if (!result) return { ...base, revertable: false, reason: 'the diff took too long to compute' };
|
|
206
|
+
hunks = result.hunks;
|
|
207
|
+
}
|
|
208
|
+
const oldName = status === 'A' ? newPath : oldPath;
|
|
209
|
+
const newName = status === 'D' ? oldPath : newPath;
|
|
210
|
+
let patch = `diff --git a/${oldName} b/${newName}\n`;
|
|
211
|
+
if (status === 'A') patch += 'new file mode 100644\n';
|
|
212
|
+
if (status === 'D') patch += 'deleted file mode 100644\n';
|
|
213
|
+
if (status === 'R') {
|
|
214
|
+
patch += `${hunks.length ? '' : 'similarity index 100%\n'}rename from ${oldPath}\nrename to ${newPath}\n`;
|
|
215
|
+
}
|
|
216
|
+
let additions = 0;
|
|
217
|
+
let deletions = 0;
|
|
218
|
+
if (hunks.length) {
|
|
219
|
+
patch += `--- ${status === 'A' ? '/dev/null' : `a/${oldName}${headerTab(oldName)}`}\n`;
|
|
220
|
+
patch += `+++ ${status === 'D' ? '/dev/null' : `b/${newName}${headerTab(newName)}`}\n`;
|
|
221
|
+
for (const hunk of hunks) {
|
|
222
|
+
const oldStart = hunk.oldLines === 0 ? hunk.oldStart - 1 : hunk.oldStart;
|
|
223
|
+
const newStart = hunk.newLines === 0 ? hunk.newStart - 1 : hunk.newStart;
|
|
224
|
+
patch += `@@ -${oldStart},${hunk.oldLines} +${newStart},${hunk.newLines} @@\n`;
|
|
225
|
+
for (const line of hunk.lines) {
|
|
226
|
+
if (line.startsWith('+')) additions += 1;
|
|
227
|
+
else if (line.startsWith('-')) deletions += 1;
|
|
228
|
+
patch += `${line}\n`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { ...base, additions, deletions, revertable: true, patch: Buffer.from(patch, 'utf8') };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Bound a frozen file's patch and convert it to its stored form. */
|
|
236
|
+
export function storedChangeFile(file) {
|
|
237
|
+
const { patch, ...rest } = file;
|
|
238
|
+
if (rest.revertable === false || !patch) {
|
|
239
|
+
return { ...rest, revertable: false, reason: rest.reason || 'no replayable patch was recorded', patch: null };
|
|
240
|
+
}
|
|
241
|
+
if (patch.length > TURN_CHANGE_FILE_PATCH_MAX_BYTES) {
|
|
242
|
+
return {
|
|
243
|
+
...rest,
|
|
244
|
+
revertable: false,
|
|
245
|
+
reason: 'change is too large to keep for undo',
|
|
246
|
+
patchOmitted: true,
|
|
247
|
+
patch: null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
return { ...rest, revertable: true, ...encodeChangePatch(patch) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Reply ───────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
/** The record file the caller named: its current path first, then a rename's
|
|
256
|
+
* old path. Absolute paths are taken relative to the recorded root. */
|
|
257
|
+
export function findChangeFile(record, value) {
|
|
258
|
+
let target = clean(value).replace(/\\/g, '/');
|
|
259
|
+
if (!target) return null;
|
|
260
|
+
if (isAbsolute(target) && record?.root) target = relative(record.root, resolve(target)).replace(/\\/g, '/');
|
|
261
|
+
const key = changePathKey(target);
|
|
262
|
+
const files = record?.files || [];
|
|
263
|
+
return (
|
|
264
|
+
files.find((file) => changePathKey(file.path) === key) ||
|
|
265
|
+
files.find((file) => file.oldPath && changePathKey(file.oldPath) === key) ||
|
|
266
|
+
null
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function findOutsideChange(outside, value) {
|
|
271
|
+
const key = changePathKey(value);
|
|
272
|
+
return outside.find((entry) => changePathKey(entry.path) === key) || null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Why a file can no longer be undone or reapplied: a state change touched the
|
|
276
|
+
* worktree but could neither be recorded nor rolled back. */
|
|
277
|
+
export const CHANGE_STATE_UNKNOWN_REASON =
|
|
278
|
+
'its undo state could not be recorded, so the file on disk may not match this turn; check it manually';
|
|
279
|
+
|
|
280
|
+
/** The record file with its worktree state marked unknown. */
|
|
281
|
+
export function unknownStateChangeFile(file) {
|
|
282
|
+
return { ...file, revertable: false, reason: CHANGE_STATE_UNKNOWN_REASON };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function publicChangeFile(file) {
|
|
286
|
+
return {
|
|
287
|
+
path: file.path,
|
|
288
|
+
...(file.oldPath ? { oldPath: file.oldPath } : {}),
|
|
289
|
+
status: file.status,
|
|
290
|
+
additions: Number(file.additions) || 0,
|
|
291
|
+
deletions: Number(file.deletions) || 0,
|
|
292
|
+
...(file.binary ? { binary: true } : {}),
|
|
293
|
+
state: file.state === 'reverted' ? 'reverted' : 'applied',
|
|
294
|
+
revertable: file.revertable === true,
|
|
295
|
+
...(file.revertable !== true && file.reason ? { reason: file.reason } : {}),
|
|
296
|
+
// Its replay/preview patch was dropped for size (per-file ceiling or the
|
|
297
|
+
// session byte budget).
|
|
298
|
+
...(file.patchOmitted === true ? { patchOmitted: true } : {}),
|
|
299
|
+
// The record kept no counts for it: a viewer may complete them.
|
|
300
|
+
...(isUnmeasuredFile(file) ? { unmeasured: true } : {}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Why a whole-turn Undo is unavailable, or ''. */
|
|
305
|
+
function undoBlocker(files) {
|
|
306
|
+
const applied = files.filter((file) => file.state !== 'reverted');
|
|
307
|
+
if (applied.length === 0) return 'all changes in this turn are already undone';
|
|
308
|
+
const blocked = applied.find((file) => file.revertable !== true);
|
|
309
|
+
return blocked ? `${blocked.path} cannot be undone: ${blocked.reason || 'no replayable patch was recorded'}` : '';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The fixed TurnChangesReply contract (see contract-capabilities.ts). */
|
|
313
|
+
export function turnChangesReply({ status, checkpointId, record = null, patch = false, file = null }) {
|
|
314
|
+
const files = Array.isArray(record?.files) ? record.files : [];
|
|
315
|
+
// Edits outside a Git turn's root are listed beside its files but are
|
|
316
|
+
// view-only: Undo, Reapply and the blocker never see them.
|
|
317
|
+
const outside = Array.isArray(record?.outside) ? record.outside : [];
|
|
318
|
+
const ready = status === 'ready';
|
|
319
|
+
const blocker = undoBlocker(files);
|
|
320
|
+
let reason = '';
|
|
321
|
+
if (files.length > 0) {
|
|
322
|
+
if (status === 'pending') reason = 'the turn is still running';
|
|
323
|
+
else if (blocker) reason = blocker;
|
|
324
|
+
}
|
|
325
|
+
const reply = {
|
|
326
|
+
status,
|
|
327
|
+
checkpointId,
|
|
328
|
+
revision: Number(record?.revision) || 0,
|
|
329
|
+
root: clean(record?.root),
|
|
330
|
+
files: [...files, ...outside.map((entry) => ({ ...entry, revertable: false }))].map(publicChangeFile),
|
|
331
|
+
canUndo: ready && !blocker,
|
|
332
|
+
canReapply: ready && files.some((entry) => entry.state === 'reverted' && entry.revertable === true),
|
|
333
|
+
...(reason ? { reason } : {}),
|
|
334
|
+
};
|
|
335
|
+
if (patch === true) {
|
|
336
|
+
let selected = [...files, ...outside];
|
|
337
|
+
if (file) {
|
|
338
|
+
const match = findChangeFile(record, file) || findOutsideChange(outside, file);
|
|
339
|
+
if (!match && selected.length > 0) throw new Error(`${file} is not part of this turn's changes`);
|
|
340
|
+
selected = match ? [match] : [];
|
|
341
|
+
}
|
|
342
|
+
reply.patch = selected.map((entry) => patchOf(entry)).join('');
|
|
343
|
+
}
|
|
344
|
+
return reply;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Session review: net recorded changes ────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
export const NET_TEXT_MAX_BYTES = 1024 * 1024;
|
|
350
|
+
/** Budget of the merged session patch, in UTF-8 bytes. */
|
|
351
|
+
export const MERGED_PATCH_MAX_BYTES = 2 * 1024 * 1024;
|
|
352
|
+
const VIEW_PATCH_MAX_BYTES = 256 * 1024;
|
|
353
|
+
|
|
354
|
+
/** A git-format patch of exact before/after text under the names a viewer
|
|
355
|
+
* shows (absolute for a file outside the worktree). `before`/`after` are
|
|
356
|
+
* Buffers or null (absent). */
|
|
357
|
+
export function namedChangePatch({ before, after, oldName, newName, maxBytes = VIEW_PATCH_MAX_BYTES }) {
|
|
358
|
+
const renamed = oldName !== newName;
|
|
359
|
+
const measured = trackedChangeFile({
|
|
360
|
+
oldPath: renamed ? 'mx-old-file' : 'mx-file',
|
|
361
|
+
newPath: renamed ? 'mx-new-file' : 'mx-file',
|
|
362
|
+
before,
|
|
363
|
+
after,
|
|
364
|
+
});
|
|
365
|
+
const text = measured.patch ? measured.patch.toString('utf8') : '';
|
|
366
|
+
const split = text.search(/\n@@ /);
|
|
367
|
+
const head = (split >= 0 ? text.slice(0, split) : text)
|
|
368
|
+
.split('mx-old-file')
|
|
369
|
+
.join(oldName)
|
|
370
|
+
.split('mx-new-file')
|
|
371
|
+
.join(newName)
|
|
372
|
+
.split('mx-file')
|
|
373
|
+
.join(newName);
|
|
374
|
+
const patch = head + (split >= 0 ? text.slice(split) : '');
|
|
375
|
+
return {
|
|
376
|
+
status: measured.status,
|
|
377
|
+
additions: measured.additions,
|
|
378
|
+
deletions: measured.deletions,
|
|
379
|
+
...(measured.binary ? { binary: true } : {}),
|
|
380
|
+
patch: Buffer.byteLength(patch, 'utf8') > maxBytes ? '' : patch,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const slashPath = (value) => clean(value).replace(/\\/g, '/');
|
|
385
|
+
const isOutsidePath = (path) => isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path);
|
|
386
|
+
const absoluteKey = (root, path) => changePathKey(isOutsidePath(path) ? resolve(path) : resolve(root, path));
|
|
387
|
+
const patchOf = (file) =>
|
|
388
|
+
changePatchBuffer(file)?.toString('utf8') || (typeof file?.viewPatch === 'string' ? file.viewPatch : '');
|
|
389
|
+
|
|
390
|
+
const insideDirectory = (parent, child) => {
|
|
391
|
+
const rel = relative(parent, child);
|
|
392
|
+
return rel === '' || (!isAbsolute(rel) && rel.split(/[\\/]+/)[0] !== '..');
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/** At most NET_TEXT_MAX_BYTES of a regular file, read through one handle:
|
|
396
|
+
* undefined when it is larger (checked by size first, never read whole). */
|
|
397
|
+
async function readBounded(file) {
|
|
398
|
+
const handle = await open(file, 'r');
|
|
399
|
+
try {
|
|
400
|
+
const info = await handle.stat();
|
|
401
|
+
if (!info.isFile() || info.size > NET_TEXT_MAX_BYTES) return undefined;
|
|
402
|
+
const buffer = Buffer.alloc(NET_TEXT_MAX_BYTES + 1);
|
|
403
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
404
|
+
return bytesRead > NET_TEXT_MAX_BYTES ? undefined : buffer.subarray(0, bytesRead);
|
|
405
|
+
} finally {
|
|
406
|
+
await handle.close();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The file's current text: a string, null when absent, undefined when it
|
|
411
|
+
* cannot be netted (unreadable, binary, large, or reached through a link).
|
|
412
|
+
* An in-root file is followed only while it stays inside the recorded root;
|
|
413
|
+
* an outside file is read only at its exact recorded path, never through a
|
|
414
|
+
* link. */
|
|
415
|
+
export async function currentText(root, path) {
|
|
416
|
+
const outside = isOutsidePath(path);
|
|
417
|
+
const file = outside ? resolve(path) : resolve(root, path);
|
|
418
|
+
try {
|
|
419
|
+
const info = await lstat(file);
|
|
420
|
+
if (outside) {
|
|
421
|
+
if (info.isSymbolicLink() || !info.isFile()) return undefined;
|
|
422
|
+
// A link in any parent directory would also lead elsewhere: the real
|
|
423
|
+
// location must be exactly the recorded path.
|
|
424
|
+
if (changePathKey(await realpath(file)) !== changePathKey(file)) return undefined;
|
|
425
|
+
} else {
|
|
426
|
+
const [realRoot, realFile] = await Promise.all([realpath(root), realpath(file)]);
|
|
427
|
+
if (!insideDirectory(realRoot, realFile)) return undefined;
|
|
428
|
+
}
|
|
429
|
+
const bytes = await readBounded(file);
|
|
430
|
+
return bytes === undefined ? undefined : (decodeText(bytes) ?? undefined);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return error?.code === 'ENOENT' || error?.code === 'ENOTDIR' ? null : undefined;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A new file whose record kept neither counts nor a diff (an old outside-root
|
|
437
|
+
* edit): its numbers are unknown, not zero. */
|
|
438
|
+
function isUnmeasuredFile(file) {
|
|
439
|
+
return (
|
|
440
|
+
file?.status === 'A' &&
|
|
441
|
+
file.state !== 'reverted' &&
|
|
442
|
+
!file.binary &&
|
|
443
|
+
file.patchOmitted !== true &&
|
|
444
|
+
!patchOf(file) &&
|
|
445
|
+
(Number(file.additions) || 0) + (Number(file.deletions) || 0) === 0
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** A legacy record kept no counts or diff for a new file it could not replay
|
|
450
|
+
* (outside the root): measure the file as it is on disk now. Returns the
|
|
451
|
+
* file with its counts and display patch, or the file unchanged. */
|
|
452
|
+
export async function measureLegacyChangeFile(file, root) {
|
|
453
|
+
if (!isUnmeasuredFile(file)) return file;
|
|
454
|
+
const text = await currentText(root, file.path);
|
|
455
|
+
if (typeof text !== 'string' || !text) return file;
|
|
456
|
+
const name = slashPath(file.path);
|
|
457
|
+
const named = namedChangePatch({ before: null, after: Buffer.from(text, 'utf8'), oldName: name, newName: name });
|
|
458
|
+
if (!named.patch) return file;
|
|
459
|
+
return { ...file, additions: named.additions, deletions: named.deletions, viewPatch: named.patch };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The text a chain's file had before its first recorded turn, rebuilt by
|
|
463
|
+
* reverse-applying the recorded per-turn patches, newest first, onto the
|
|
464
|
+
* file as it is now (`current`: string, or null when absent). null = the file
|
|
465
|
+
* did not exist; undefined = a patch is missing or no longer matches, so the
|
|
466
|
+
* chain cannot be netted exactly. Nothing about the before-state is stored. */
|
|
467
|
+
function originalText(entries, current) {
|
|
468
|
+
let text = current;
|
|
469
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
470
|
+
const { file } = entries[index];
|
|
471
|
+
const patch = patchOf(file);
|
|
472
|
+
if (!patch || file.binary === true) return undefined;
|
|
473
|
+
let sections;
|
|
474
|
+
try {
|
|
475
|
+
sections = parsePatch(patch);
|
|
476
|
+
} catch {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
if (sections.length !== 1) return undefined;
|
|
480
|
+
const [section] = sections;
|
|
481
|
+
if (file.status === 'D') {
|
|
482
|
+
if (text !== null) return undefined;
|
|
483
|
+
text = '';
|
|
484
|
+
} else if (text === null) {
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
if (section.hunks.length) {
|
|
488
|
+
const result = applyPatch(text, reversePatch(section));
|
|
489
|
+
if (result === false) return undefined;
|
|
490
|
+
text = result;
|
|
491
|
+
}
|
|
492
|
+
if (file.status === 'A') {
|
|
493
|
+
if (text !== '') return undefined;
|
|
494
|
+
text = null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return text;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** A recorded patch re-headed with the names the merged review shows, so a
|
|
501
|
+
* disambiguated file keeps one identity in its list row and its diff. */
|
|
502
|
+
function retargetPatch(patch, { status, oldName, newName }) {
|
|
503
|
+
const split = patch.search(/\n@@ /);
|
|
504
|
+
const head = split >= 0 ? patch.slice(0, split) : patch;
|
|
505
|
+
const tail = split >= 0 ? patch.slice(split) : '';
|
|
506
|
+
const from = status === 'R' ? oldName : newName;
|
|
507
|
+
const tab = (name) => (name.includes(' ') ? '\t' : '');
|
|
508
|
+
const lines = head.split('\n').map((line) => {
|
|
509
|
+
if (line.startsWith('diff --git ')) return `diff --git a/${from} b/${newName}`;
|
|
510
|
+
if (line.startsWith('--- ') && !line.startsWith('--- /dev/null')) return `--- a/${from}${tab(from)}`;
|
|
511
|
+
if (line.startsWith('+++ ') && !line.startsWith('+++ /dev/null')) return `+++ b/${newName}${tab(newName)}`;
|
|
512
|
+
if (line.startsWith('rename from ')) return `rename from ${from}`;
|
|
513
|
+
if (line.startsWith('rename to ')) return `rename to ${newName}`;
|
|
514
|
+
return line;
|
|
515
|
+
});
|
|
516
|
+
return lines.join('\n') + tail;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** One path's turns, oldest first, as a single net change; null when the
|
|
520
|
+
* session left the file as it found it. The original is rebuilt from the
|
|
521
|
+
* recorded patches (see originalText) and compared with the disk, so undo,
|
|
522
|
+
* create→delete and renames fall out; when that fails the turns are merged
|
|
523
|
+
* per turn. `show` names a file for display. */
|
|
524
|
+
async function netChain(chain, show) {
|
|
525
|
+
const first = chain.entries[0];
|
|
526
|
+
const last = chain.entries.at(-1);
|
|
527
|
+
const path = show(last.root, last.file.path);
|
|
528
|
+
const firstRel = first.file.oldPath || first.file.path;
|
|
529
|
+
const oldName = absoluteKey(first.root, firstRel) === chain.key ? path : show(first.root, firstRel);
|
|
530
|
+
const identity = {
|
|
531
|
+
id: slashPath(isOutsidePath(last.file.path) ? resolve(last.file.path) : resolve(last.root, last.file.path)),
|
|
532
|
+
outside: isOutsidePath(last.file.path),
|
|
533
|
+
};
|
|
534
|
+
const after = await currentText(last.root, last.file.path);
|
|
535
|
+
if (after !== undefined) {
|
|
536
|
+
const before = originalText(chain.entries, after);
|
|
537
|
+
if (before !== undefined) {
|
|
538
|
+
if (before === after && oldName === path) return null;
|
|
539
|
+
const net = namedChangePatch({
|
|
540
|
+
before: before === null ? null : Buffer.from(before, 'utf8'),
|
|
541
|
+
after: after === null ? null : Buffer.from(after, 'utf8'),
|
|
542
|
+
oldName,
|
|
543
|
+
newName: path,
|
|
544
|
+
maxBytes: MERGED_PATCH_MAX_BYTES,
|
|
545
|
+
});
|
|
546
|
+
const omitted = !net.patch && net.additions + net.deletions > 0;
|
|
547
|
+
return {
|
|
548
|
+
path,
|
|
549
|
+
...identity,
|
|
550
|
+
...(oldName !== path ? { oldPath: oldName } : {}),
|
|
551
|
+
...net,
|
|
552
|
+
...(omitted ? { patchOmitted: true } : {}),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (first.file.status === 'A' && last.file.status === 'D') return null;
|
|
557
|
+
const files = await Promise.all(chain.entries.map((entry) => measureLegacyChangeFile(entry.file, entry.root)));
|
|
558
|
+
const patches = files
|
|
559
|
+
.map((file) => {
|
|
560
|
+
const patch = patchOf(file);
|
|
561
|
+
return patch ? retargetPatch(patch, { status: file.status, oldName, newName: path }) : '';
|
|
562
|
+
})
|
|
563
|
+
.filter(Boolean);
|
|
564
|
+
const additions = files.reduce((sum, file) => sum + (Number(file.additions) || 0), 0);
|
|
565
|
+
const deletions = files.reduce((sum, file) => sum + (Number(file.deletions) || 0), 0);
|
|
566
|
+
const binary = files.some((file) => file.binary === true);
|
|
567
|
+
const patch = patches.join('');
|
|
568
|
+
return {
|
|
569
|
+
path,
|
|
570
|
+
...identity,
|
|
571
|
+
...(oldName !== path ? { oldPath: oldName } : {}),
|
|
572
|
+
status: first.file.status === 'A' ? 'A' : last.file.status,
|
|
573
|
+
additions,
|
|
574
|
+
deletions,
|
|
575
|
+
...(binary ? { binary: true } : {}),
|
|
576
|
+
patch,
|
|
577
|
+
// A legacy record that measured nothing: the transcript may know more.
|
|
578
|
+
...(!patch && additions + deletions === 0 && !binary ? { unmeasured: true, reason: clean(last.file.reason) } : {}),
|
|
579
|
+
...(!patch && last.file.patchOmitted === true ? { patchOmitted: true } : {}),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Group the records' files into per-path chains (renames continue a chain).
|
|
584
|
+
* A chain is keyed by root + path, so two roots' `x.txt` stay two files. */
|
|
585
|
+
function recordChains(records, { inRootKinds }) {
|
|
586
|
+
const chains = new Map();
|
|
587
|
+
for (const record of records) {
|
|
588
|
+
const root = clean(record?.root);
|
|
589
|
+
const entries = [
|
|
590
|
+
...(inRootKinds.has(record?.kind) ? record.files || [] : []),
|
|
591
|
+
...(Array.isArray(record?.outside) ? record.outside : []),
|
|
592
|
+
];
|
|
593
|
+
for (const file of entries) {
|
|
594
|
+
if (!file?.path || file.state === 'reverted') continue;
|
|
595
|
+
const key = absoluteKey(root, file.path);
|
|
596
|
+
const from = file.oldPath ? absoluteKey(root, file.oldPath) : key;
|
|
597
|
+
const chain = chains.get(from) || chains.get(key) || { entries: [] };
|
|
598
|
+
chains.delete(chain.key);
|
|
599
|
+
chain.entries.push({ file, root });
|
|
600
|
+
chain.key = key;
|
|
601
|
+
chains.set(key, chain);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return [...chains.values()];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Records whose emptiness the session review may vouch for: a baseline that
|
|
608
|
+
* lists the files itself, or retained records (undo-aware). Without either
|
|
609
|
+
* the history is absent, never authoritative. */
|
|
610
|
+
const knownSources = new Set(['git', 'empty', 'tool']);
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The session-wide review completed with the turns' recorded changes — the
|
|
614
|
+
* source the transcript's turn-changes cards read — as ONE net change per
|
|
615
|
+
* file, identical before and after a runtime restart. `base.source` says what
|
|
616
|
+
* the baseline review could vouch for:
|
|
617
|
+
* - `git`: the baseline is authoritative; only edits outside its root are added.
|
|
618
|
+
* - `empty` / `tool`: no baseline lists files (a non-Git session, or a
|
|
619
|
+
* session whose tracker holds nothing yet): the `tracked` records and the
|
|
620
|
+
* running turn (`live`) supply them, never the tracker's single-turn patch.
|
|
621
|
+
* - `absent` / `unavailable`: no ownership metadata survived; every retained
|
|
622
|
+
* record supplies the files, and with no record the review is not complete.
|
|
623
|
+
* A file the user undid is never listed; Reapply lists it again. Each listed
|
|
624
|
+
* file carries an `id` (root + path); when two roots would show the same
|
|
625
|
+
* relative name both show their absolute path.
|
|
626
|
+
*/
|
|
627
|
+
export async function mergeRecordedSessionChanges(base, records, live = null) {
|
|
628
|
+
const source = base?.source || '';
|
|
629
|
+
const gitBacked = source === 'git';
|
|
630
|
+
const cleared = source === 'tool' ? { ...base, files: [], patch: '' } : base;
|
|
631
|
+
const retained = Array.isArray(records) ? records : [];
|
|
632
|
+
const withLive =
|
|
633
|
+
live && !retained.some((record) => record?.checkpointId === live.checkpointId) ? [...retained, live] : retained;
|
|
634
|
+
let inRootKinds = new Set(['tracked']);
|
|
635
|
+
if (gitBacked) inRootKinds = new Set();
|
|
636
|
+
else if (source === 'absent' || source === 'unavailable') inRootKinds = new Set(['git', 'tracked']);
|
|
637
|
+
const baseFiles = Array.isArray(cleared?.files) ? cleared.files : [];
|
|
638
|
+
const basePatch = typeof cleared?.patch === 'string' ? cleared.patch : '';
|
|
639
|
+
const baseRoot = clean(base?.root);
|
|
640
|
+
const known = new Set();
|
|
641
|
+
for (const file of baseFiles) {
|
|
642
|
+
if (file?.path) known.add(absoluteKey(baseRoot, file.path));
|
|
643
|
+
if (file?.oldPath) known.add(absoluteKey(baseRoot, file.oldPath));
|
|
644
|
+
}
|
|
645
|
+
const chains = recordChains(withLive, { inRootKinds }).filter((chain) => !known.has(chain.key));
|
|
646
|
+
// A relative name shared by different roots (or with a baseline file) is
|
|
647
|
+
// shown absolute, so each file stays distinct.
|
|
648
|
+
const owners = new Map();
|
|
649
|
+
const claim = (rel, key) => {
|
|
650
|
+
const name = changePathKey(rel);
|
|
651
|
+
owners.set(name, new Set([...(owners.get(name) || []), key]));
|
|
652
|
+
};
|
|
653
|
+
for (const file of baseFiles) if (file?.path) claim(file.path, absoluteKey(baseRoot, file.path));
|
|
654
|
+
for (const chain of chains) {
|
|
655
|
+
const entry = chain.entries.at(-1);
|
|
656
|
+
claim(entry.file.path, chain.key);
|
|
657
|
+
const origin = chain.entries[0];
|
|
658
|
+
if (origin.file.oldPath) claim(origin.file.oldPath, absoluteKey(origin.root, origin.file.oldPath));
|
|
659
|
+
}
|
|
660
|
+
const show = (root, rel) =>
|
|
661
|
+
isOutsidePath(rel) || (owners.get(changePathKey(rel))?.size || 0) <= 1
|
|
662
|
+
? slashPath(rel)
|
|
663
|
+
: slashPath(resolve(root, rel));
|
|
664
|
+
const nets = [];
|
|
665
|
+
for (const chain of chains) {
|
|
666
|
+
const net = await netChain(chain, show);
|
|
667
|
+
if (net) nets.push(net);
|
|
668
|
+
}
|
|
669
|
+
const { source: _source, reason: unavailableReason, ...kept } = cleared || {};
|
|
670
|
+
const complete = knownSources.has(source) || withLive.length > 0;
|
|
671
|
+
if (nets.length === 0) {
|
|
672
|
+
// Nothing recorded to add: the baseline answer stands (an unavailable
|
|
673
|
+
// baseline stays unavailable, never an invented empty set).
|
|
674
|
+
if (cleared?.supported === false) return { ...kept, reason: unavailableReason };
|
|
675
|
+
return { ...kept, ...(complete ? { complete: true } : {}) };
|
|
676
|
+
}
|
|
677
|
+
// The merged response stays bounded: past the budget a file is listed
|
|
678
|
+
// without its diff.
|
|
679
|
+
let budget = MERGED_PATCH_MAX_BYTES - Buffer.byteLength(basePatch, 'utf8');
|
|
680
|
+
let truncated = false;
|
|
681
|
+
const files = [];
|
|
682
|
+
const patches = [];
|
|
683
|
+
for (const { patch, ...file } of nets) {
|
|
684
|
+
const size = patch ? Buffer.byteLength(patch, 'utf8') : 0;
|
|
685
|
+
if (patch && size <= budget) {
|
|
686
|
+
budget -= size;
|
|
687
|
+
patches.push(patch);
|
|
688
|
+
} else if (patch) {
|
|
689
|
+
truncated = true;
|
|
690
|
+
files.push({ ...file, patchOmitted: true });
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
files.push(file);
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
...kept,
|
|
697
|
+
supported: true,
|
|
698
|
+
authoritative: true,
|
|
699
|
+
complete,
|
|
700
|
+
files: [...baseFiles, ...files],
|
|
701
|
+
patch: [basePatch, ...patches].filter(Boolean).join(basePatch.endsWith('\n') || !basePatch ? '' : '\n'),
|
|
702
|
+
patchTruncated: kept.patchTruncated === true || truncated,
|
|
703
|
+
snapshotKind: cleared?.snapshotKind || 'session',
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ── Apply ───────────────────────────────────────────────────────────────────
|
|
708
|
+
|
|
709
|
+
/** Every path a stored patch touches must be exactly the recorded file's. */
|
|
710
|
+
function assertPatchMatchesFile(file, buffer) {
|
|
711
|
+
const sections = splitPatchSections(buffer);
|
|
712
|
+
if (sections.length !== 1) throw new Error(`${file.path} has a malformed recorded patch`);
|
|
713
|
+
const { oldPath, newPath } = sections[0];
|
|
714
|
+
const expectedOld = file.status === 'A' ? null : file.oldPath || file.path;
|
|
715
|
+
const expectedNew = file.status === 'D' ? null : file.path;
|
|
716
|
+
for (const [actual, expected] of [
|
|
717
|
+
[oldPath, expectedOld],
|
|
718
|
+
[newPath, expectedNew],
|
|
719
|
+
]) {
|
|
720
|
+
if (expected === null ? actual !== null : actual !== expected || !safeChangePath(actual)) {
|
|
721
|
+
throw new Error(`${file.path} has a recorded patch for a different path`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** The recorded root must still be the directory (and, for a Git record, the
|
|
727
|
+
* repository top level) the patches were taken against. */
|
|
728
|
+
async function assertRecordedRoot(record) {
|
|
729
|
+
const root = clean(record?.root);
|
|
730
|
+
if (!root || !isAbsolute(root)) throw new Error('turn changes have no recorded root');
|
|
731
|
+
let info = null;
|
|
732
|
+
try {
|
|
733
|
+
info = await stat(root);
|
|
734
|
+
} catch {
|
|
735
|
+
info = null;
|
|
736
|
+
}
|
|
737
|
+
if (!info?.isDirectory()) throw new Error(`recorded root ${root} is unavailable`);
|
|
738
|
+
if (record.kind !== 'git') return root;
|
|
739
|
+
const result = await runGitOffThread(['rev-parse', '--show-toplevel'], {
|
|
740
|
+
cwd: root,
|
|
741
|
+
timeoutMs: APPLY_TIMEOUT_MS,
|
|
742
|
+
maxBytes: APPLY_OUTPUT_MAX_BYTES,
|
|
743
|
+
});
|
|
744
|
+
const top = clean(result.stdout);
|
|
745
|
+
if (result.code !== 0 || !top || changePathKey(resolve(top)) !== changePathKey(resolve(root))) {
|
|
746
|
+
throw new Error(`recorded root ${root} is no longer the repository top level`);
|
|
747
|
+
}
|
|
748
|
+
return root;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Apply (forward) or reverse-apply the given record files atomically in the
|
|
753
|
+
* recorded root: git checks every hunk of every file before it writes any, so
|
|
754
|
+
* a conflict leaves the worktree untouched. No --reject, no --unsafe-paths.
|
|
755
|
+
*/
|
|
756
|
+
export async function applyTurnChangeFiles(record, files, { reverse }) {
|
|
757
|
+
const root = await assertRecordedRoot(record);
|
|
758
|
+
const buffers = files.map((file) => {
|
|
759
|
+
if (!safeChangePath(file.path) || (file.oldPath && !safeChangePath(file.oldPath))) {
|
|
760
|
+
throw new Error(`${file.path} is not a safe recorded path`);
|
|
761
|
+
}
|
|
762
|
+
const buffer = changePatchBuffer(file);
|
|
763
|
+
if (!buffer?.length) throw new Error(`${file.path} has no recorded patch`);
|
|
764
|
+
assertPatchMatchesFile(file, buffer);
|
|
765
|
+
return buffer.at(-1) === 0x0a ? buffer : Buffer.concat([buffer, Buffer.from('\n')]);
|
|
766
|
+
});
|
|
767
|
+
// A Git record replays with the repository's own line-ending conversion
|
|
768
|
+
// (the semantics the shadow snapshot hashed with). An exact tracker patch
|
|
769
|
+
// holds raw bytes, so nothing may convert them.
|
|
770
|
+
const conversion = record.kind === 'git' ? [] : ['-c', 'core.autocrlf=false', '-c', 'core.eol=lf'];
|
|
771
|
+
const args = [
|
|
772
|
+
...conversion,
|
|
773
|
+
'-c',
|
|
774
|
+
'core.quotepath=false',
|
|
775
|
+
'apply',
|
|
776
|
+
...(reverse ? ['-R'] : []),
|
|
777
|
+
'--whitespace=nowarn',
|
|
778
|
+
'-',
|
|
779
|
+
];
|
|
780
|
+
const result = await runGitOffThread(args, {
|
|
781
|
+
cwd: root,
|
|
782
|
+
input: Buffer.concat(buffers),
|
|
783
|
+
timeoutMs: APPLY_TIMEOUT_MS,
|
|
784
|
+
maxBytes: APPLY_OUTPUT_MAX_BYTES,
|
|
785
|
+
});
|
|
786
|
+
if (result.code !== 0) {
|
|
787
|
+
const detail = clean(result.stderr) || clean(result.stdout) || `exit ${result.code}`;
|
|
788
|
+
throw new Error(`${reverse ? 'undo' : 'reapply'} failed; the worktree was not changed: ${detail}`);
|
|
789
|
+
}
|
|
790
|
+
return root;
|
|
791
|
+
}
|