mixdog 1.3.2 → 1.3.4

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.
Files changed (182) hide show
  1. package/package.json +8 -8
  2. package/scripts/verify-runtime-dependency-versions.mjs +158 -0
  3. package/src/agents/reviewer/AGENT.md +5 -7
  4. package/src/agents/reviewer/agent.json +1 -1
  5. package/src/defaults/shipped-history.json +4 -0
  6. package/src/defaults/skills/browser-use/SKILL.md +6 -3
  7. package/src/defaults/skills/local-provider/SKILL.md +33 -6
  8. package/src/rules/lead/LEAD.md +5 -8
  9. package/src/rules/shared/45-destructive.md +5 -1
  10. package/src/rules/shared/60-verification.md +6 -4
  11. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +4 -1
  12. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +38 -11
  13. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +15 -3
  14. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +17 -4
  16. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  17. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  18. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +14 -1
  19. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/providers/registry.mjs +10 -0
  21. package/src/runtime/agent/orchestrator/providers/wire-gateway.mjs +13 -1
  22. package/src/runtime/agent/orchestrator/runtime-core/goal-runtime.mjs +5 -0
  23. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +10 -1
  24. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +40 -5
  25. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +32 -11
  26. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +6 -3
  27. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +1 -1
  28. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +13 -0
  29. package/src/runtime/agent/orchestrator/session/agent-room.mjs +68 -12
  30. package/src/runtime/agent/orchestrator/session/compact/continuation.mjs +15 -6
  31. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +31 -8
  32. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +44 -0
  33. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +117 -19
  34. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/session/compact/user-message-kind.mjs +3 -0
  36. package/src/runtime/agent/orchestrator/session/human-gate/delegation.mjs +147 -137
  37. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +37 -74
  38. package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +0 -2
  39. package/src/runtime/agent/orchestrator/session/human-gate/run-policy.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +13 -7
  41. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +5 -0
  42. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +2 -0
  43. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/empty-nudge.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/output-limit.mjs +62 -13
  45. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +22 -6
  46. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +7 -0
  47. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +12 -20
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec/call-prep.mjs +5 -0
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +11 -5
  50. package/src/runtime/agent/orchestrator/session/manager/agent-listing-notice.mjs +102 -0
  51. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +2 -0
  52. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +13 -1
  53. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +24 -15
  54. package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +8 -0
  55. package/src/runtime/agent/orchestrator/session/manager/ask-turn-steering-drain.mjs +1 -0
  56. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +32 -1
  57. package/src/runtime/agent/orchestrator/session/manager/orchestration-mode-switch-notice.mjs +17 -1
  58. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +6 -0
  59. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +36 -0
  60. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -9
  61. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +1 -1
  62. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +7 -5
  63. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +7 -3
  64. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +10 -1
  65. package/src/runtime/agent/orchestrator/session/routine-result.mjs +7 -4
  66. package/src/runtime/agent/orchestrator/session/runtime-user-context.mjs +3 -1
  67. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +11 -2
  68. package/src/runtime/agent/orchestrator/session/store/sweep/stale-sweep.mjs +5 -4
  69. package/src/runtime/agent/orchestrator/session/store/sweep/sub-agent-retention.mjs +140 -0
  70. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-plan.mjs +5 -0
  71. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-record.mjs +7 -2
  72. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-row.mjs +13 -0
  73. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +76 -0
  74. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +33 -1
  75. package/src/runtime/agent/orchestrator/session/store-summary-visibility.d.mts +5 -0
  76. package/src/runtime/agent/orchestrator/session/store-summary-visibility.mjs +37 -0
  77. package/src/runtime/agent/orchestrator/session/store.mjs +1 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +18 -7
  79. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/backgrounded-result.mjs +2 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +127 -20
  81. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +557 -0
  82. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +6 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +8 -2
  84. package/src/runtime/attachments/legacy-office.mjs +110 -0
  85. package/src/runtime/attachments/prompt-restore.mjs +70 -0
  86. package/src/runtime/attachments/store.mjs +60 -3
  87. package/src/runtime/effort-judge/judge-client.mjs +61 -14
  88. package/src/runtime/effort-judge/model-install.mjs +34 -2
  89. package/src/runtime/memory/lib/memory-chunk-quality.mjs +14 -2
  90. package/src/runtime/office/portable/portable-soffice.mjs +132 -30
  91. package/src/runtime/shared/agent-avatar-sprites.mjs +23 -8
  92. package/src/runtime/shared/agent-avatar.mjs +3 -2
  93. package/src/runtime/shared/agent-room-host.d.mts +31 -0
  94. package/src/runtime/shared/agent-room-host.mjs +58 -0
  95. package/src/runtime/shared/bot-hops.mjs +16 -11
  96. package/src/runtime/shared/llm/usage-rollup.mjs +4 -383
  97. package/src/runtime/shared/redact-directories.mjs +18 -0
  98. package/src/runtime/shared/remove-directory.mjs +28 -0
  99. package/src/runtime/shared/session-sidebar-host.d.mts +11 -2
  100. package/src/runtime/shared/session-sidebar-host.mjs +16 -3
  101. package/src/runtime/shared/tool-execution-contract.mjs +4 -0
  102. package/src/runtime/shared/tool-work-units.mjs +12 -9
  103. package/src/runtime/shared/turn-snapshot.mjs +233 -122
  104. package/src/runtime/shared/user-data-guard.mjs +51 -19
  105. package/src/runtime/shared/worktrees/activity-gate.mjs +209 -227
  106. package/src/runtime/shared/worktrees/contract.mjs +11 -3
  107. package/src/runtime/shared/worktrees/maintenance.mjs +11 -2
  108. package/src/runtime/shared/worktrees/manager.mjs +419 -52
  109. package/src/runtime/shared/worktrees/registry.mjs +3 -1
  110. package/src/runtime/shared/worktrees/tool-host.mjs +39 -0
  111. package/src/runtime/tidy/process.mjs +41 -8
  112. package/src/runtime/tidy/resolve.mjs +10 -7
  113. package/src/session-runtime/agent-home-delivery.mjs +73 -91
  114. package/src/session-runtime/agent-profile-changes.mjs +16 -0
  115. package/src/session-runtime/agent-room-delivery.mjs +2 -63
  116. package/src/session-runtime/agent-task-sessions.mjs +194 -64
  117. package/src/session-runtime/agent-tool-routing.mjs +49 -4
  118. package/src/session-runtime/boot/apis.mjs +3 -0
  119. package/src/session-runtime/boot/facade.mjs +5 -6
  120. package/src/session-runtime/boot/tools.mjs +23 -1
  121. package/src/session-runtime/held-requests.mjs +20 -25
  122. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +5 -22
  123. package/src/session-runtime/model-recency.mjs +1 -0
  124. package/src/session-runtime/provider-models/row-hydration.mjs +8 -1
  125. package/src/session-runtime/quick-model-rows/model-meta.mjs +2 -0
  126. package/src/session-runtime/quick-model-rows/provider-rows.mjs +7 -1
  127. package/src/session-runtime/runtime-review-api.mjs +4 -1
  128. package/src/session-runtime/services/agent-tool/close-flow.mjs +14 -2
  129. package/src/session-runtime/services/agent-tool/execute.mjs +6 -1
  130. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +44 -20
  131. package/src/session-runtime/services/agent-tool/render.mjs +3 -1
  132. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +3 -0
  133. package/src/session-runtime/services/agent-tool/tool-def.mjs +1 -1
  134. package/src/session-runtime/services/provider-admin.mjs +12 -2
  135. package/src/session-runtime/services/worktree-access.mjs +27 -0
  136. package/src/session-runtime/session-hooks.mjs +9 -17
  137. package/src/session-runtime/settings-builtin-tools-api.mjs +2 -0
  138. package/src/session-runtime/setup-tool/executor.mjs +4 -0
  139. package/src/session-runtime/setup-tool/settings-contract.mjs +3 -0
  140. package/src/session-runtime/tool-policy/agent-profile-fold.mjs +98 -14
  141. package/src/session-runtime/tool-policy/policy-stage.mjs +23 -5
  142. package/src/session-runtime/tool-policy-refresh.mjs +1 -0
  143. package/src/session-runtime/tool-surface.mjs +9 -5
  144. package/src/session-runtime/turn/session-ops.mjs +8 -0
  145. package/src/session-runtime/turn/turn-run.mjs +5 -1
  146. package/src/session-runtime/usage-stats-api.mjs +1 -1
  147. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +10 -2
  148. package/src/session-runtime/workflow-agents-api/agent-route.mjs +39 -17
  149. package/src/session-runtime/workspace/transition-steps.mjs +1 -18
  150. package/src/session-runtime/workspace/transitions.mjs +279 -67
  151. package/src/session-runtime/workspace/worktree-tool.mjs +194 -0
  152. package/src/standalone/daemon-lag-profiler.mjs +3 -1
  153. package/src/standalone/daemon-session-control.mjs +140 -23
  154. package/src/standalone/daemon-telemetry.mjs +9 -0
  155. package/src/standalone/daemon.mjs +5 -0
  156. package/src/standalone/local-session-runtime.mjs +11 -2
  157. package/src/standalone/session-protocol.mjs +1 -0
  158. package/src/standalone/session-runtime-inline-host/boot-loaders.mjs +2 -0
  159. package/src/standalone/session-service/agent-route-follow.mjs +69 -0
  160. package/src/standalone/session-service/session-calls/session-turns.mjs +33 -4
  161. package/src/standalone/session-service.mjs +10 -0
  162. package/src/standalone/session-tool-output.mjs +1 -0
  163. package/src/standalone/staged-import.mjs +47 -0
  164. package/src/tui/App.jsx +2 -0
  165. package/src/tui/app/message-selector.mjs +15 -2
  166. package/src/tui/app/model-options.mjs +16 -2
  167. package/src/tui/app/model-picker.mjs +2 -1
  168. package/src/tui/app/route-pickers.mjs +3 -1
  169. package/src/tui/dist/index.mjs +403 -210
  170. package/src/tui/paste-attachments.mjs +0 -1
  171. package/src/tui/paste-text-policy.mjs +4 -5
  172. package/src/tui/session/agent-job-feed/execution-delivery.mjs +4 -1
  173. package/src/tui/session/notice-surface.mjs +3 -0
  174. package/src/tui/session/session-api/intake/abort.mjs +33 -17
  175. package/src/tui/session/session-api/intake/rewind.mjs +39 -4
  176. package/src/tui/session/session-api/routes-agent-definitions.mjs +1 -0
  177. package/src/tui/session/tool-approval.mjs +11 -48
  178. package/src/vendor/statusline/src/gateway/claude-current.mjs +0 -58
  179. package/src/vendor/statusline/src/gateway/route-meta.mjs +1 -361
  180. package/src/vendor/statusline/src/gateway/session-routes.mjs +0 -55
  181. package/src/session-runtime/room-member-gate.mjs +0 -61
  182. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +0 -285
@@ -1,16 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { existsSync, readFileSync } from 'node:fs';
2
+ import { readFileSync } from 'node:fs';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { isAbsolute, join } from 'node:path';
5
- import {
6
- parseJsonText,
7
- quarantinePath,
8
- readJsonStateSync,
9
- unsupportedState,
10
- updateJsonAtomicSync,
11
- writeFileAtomicSync,
12
- writeJsonAtomicSync,
13
- } from '../atomic-file.mjs';
5
+ import { parseJsonText, quarantinePath, withFileLockSync, writeFileAtomicSync } from '../atomic-file.mjs';
14
6
  import { isPidAliveOrUnknown } from '../pid-liveness.mjs';
15
7
  import { findGitLayout } from './git-head.mjs';
16
8
  import { canonicalPath, isWithin, samePath } from './paths.mjs';
@@ -43,10 +35,19 @@ export function isExternalCheckout(folder) {
43
35
  );
44
36
  }
45
37
 
46
- const VERSION = 1;
47
- const UNRECORDED = Symbol('unrecorded');
48
- const emptyLedger = () => ({ version: VERSION, leases: [] });
49
- const isNewer = (state) => Number.isSafeInteger(state?.version) && state.version > VERSION;
38
+ /*
39
+ * The admission ledger lists every turn, command and management lease of a
40
+ * repository. It is kept in two copies with the same content in the Git
41
+ * common directory: `mixdog-workspace-admission.json` and its `.management`
42
+ * mirror. A grant returns its release only once both copies hold it, and a
43
+ * release is forgotten only once both copies dropped it, so damage to either
44
+ * copy alone never loses an acknowledged lease. `recoveredAt` marks a ledger
45
+ * that may be missing leases because no complete copy survived: readers still
46
+ * run, management refuses, and only a quiescent reset that removes both
47
+ * copies clears it (docs/git-github-integration.md). Time, healing and
48
+ * releases never do.
49
+ */
50
+ const VERSION = 2;
50
51
  const isLease = (lease) =>
51
52
  !!lease &&
52
53
  ['turn', 'command', 'management'].includes(lease.kind) &&
@@ -55,256 +56,245 @@ const isLease = (lease) =>
55
56
  lease.pid > 0 &&
56
57
  typeof lease.path === 'string' &&
57
58
  isAbsolute(lease.path);
58
- const hasLedgerShape = (state) => state?.version === VERSION && Array.isArray(state.leases);
59
-
60
- // The whole-file check: a newer build's ledger is intact data in a format this
61
- // build cannot honour (left in place), anything else off-shape is damage. One
62
- // bad lease record is not a damaged file; mutate() drops just that record.
63
- function validateLedger(state) {
64
- if (isNewer(state)) throw unsupportedState('Workspace admission ledger was written by a newer build.');
65
- if (!hasLedgerShape(state)) throw new TypeError('Workspace admission ledger is malformed.');
66
- return state;
67
- }
68
-
69
- const refuseUnreadable = () => {
70
- throw worktreeRefusal('workspace-admission-unreadable');
59
+ // Only a pid PROVEN dead (ESRCH) drops its lease, whatever its kind; an
60
+ // unprobeable pid still counts as running. A live runtime keeps a command
61
+ // whose tree it cannot verify reserved under its own (live) pid until the
62
+ // tree is confirmed gone, so a lease that outlives every pid belongs to a
63
+ // restarted runtime that can no longer release it.
64
+ const aliveLease = (lease) => isPidAliveOrUnknown(lease.pid);
65
+ // A management hold shares one token across its folders, so a lease is
66
+ // identified by token and path.
67
+ const leaseKey = (lease) => `${lease.token}\0${lease.path}`;
68
+ const unverifiable = () => worktreeRefusal('workspace-admission-unreadable');
69
+ const copyPaths = (commonDir) => {
70
+ const primary = join(commonDir, FILE);
71
+ return [primary, `${primary}.management`];
71
72
  };
72
73
  const envMs = (name, fallback) => {
73
74
  const value = Number(process.env[name]);
74
75
  return Number.isFinite(value) && value > 0 ? value : fallback;
75
76
  };
76
- // How long the leases of a recovered ledger stay unknown. Every process
77
- // holding a lease republishes it within one heal period, so after this window
78
- // the ledger is whole again and destructive operations resume on their own.
79
- const recoveryWindowMs = () => envMs('MIXDOG_ADMISSION_RECOVERY_MS', 15_000);
80
77
  const healIntervalMs = () => envMs('MIXDOG_ADMISSION_HEAL_MS', 2_000);
81
78
 
82
- // A path that cannot be read at all (access denied, in use, a directory) is an
83
- // I/O failure, not damage: it refuses everything and is never touched.
84
- function assertLedgerReadable(path) {
85
- try {
86
- readFileSync(path);
87
- } catch (error) {
88
- if (error?.code !== 'ENOENT') throw worktreeRefusal('workspace-admission-unreadable');
89
- }
90
- }
79
+ // The leases this process holds, put back into a ledger that lost them, and
80
+ // the tokens it released or failed to grant whose removal from both copies is
81
+ // not confirmed yet (token -> common dir), retried until it is.
82
+ const held = new Map();
83
+ const pending = new Map();
84
+ let healTimer = null;
91
85
 
92
86
  /**
93
- * A damaged ledger (NUL, truncated, malformed) must not block every prompt and
94
- * shell command of the workspace, nor may it make a destructive operation fail
95
- * open. Called under the ledger lock: its bytes are COPIED aside as
96
- * `<ledger>.corrupt-<ts>` and the same locked update replaces it with a ledger
97
- * carrying `recoveredAt`. The ledger is never moved away: a missing ledger
98
- * reads as "no leases", so any gap between the move and the publish (another
99
- * process taking the lock, or a crash) would admit management beside a live
100
- * foreign turn. A crash before the publish leaves the damaged file in place,
101
- * which the next writer recovers the same way. If the copy cannot be kept the
102
- * ledger is left untouched and everything refuses.
87
+ * One copy as read under the ledger lock. A copy that cannot be read (access
88
+ * denied, in use, a directory) or that a newer build wrote refuses everything
89
+ * and is never written: neither may pass for an empty ledger. Anything else
90
+ * that is not a ledger, or holds an invalid lease record, is damaged; its
91
+ * valid records still count. A copy is complete when it accounts for every
92
+ * acknowledged lease: intact, not uncertain, and version 2 — or the version 1
93
+ * primary, since the version 1 mirror held management leases only.
103
94
  */
104
- function keepDamagedAside(path) {
105
- const aside = quarantinePath(path);
95
+ function readCopy(path, primary) {
96
+ let bytes;
106
97
  try {
107
- writeFileAtomicSync(aside, readFileSync(path));
108
- } catch {
109
- throw worktreeRefusal('workspace-admission-unreadable');
98
+ bytes = readFileSync(path);
99
+ } catch (error) {
100
+ if (error?.code === 'ENOENT') return { path, leases: [] };
101
+ throw unverifiable();
110
102
  }
111
- process.stderr.write(`[state] ${path} is damaged; kept as ${aside}\n`);
112
- }
113
-
114
- function keepAside(path, value) {
115
- const aside = quarantinePath(path);
103
+ let ledger = null;
116
104
  try {
117
- writeFileAtomicSync(aside, JSON.stringify(value));
118
- process.stderr.write(`[state] ${path} held invalid lease records; kept as ${aside}\n`);
105
+ ledger = parseJsonText(bytes.toString('utf8'));
119
106
  } catch {
120
- // Best effort: the valid records carry on either way.
107
+ // Unparseable: damaged below.
108
+ }
109
+ if (Number.isSafeInteger(ledger?.version) && ledger.version > VERSION) throw unverifiable();
110
+ if (![1, VERSION].includes(ledger?.version) || !Array.isArray(ledger.leases)) {
111
+ return { path, bytes, leases: [], damaged: true };
121
112
  }
113
+ const leases = ledger.leases.filter(isLease);
114
+ const damaged = leases.length !== ledger.leases.length;
115
+ const uncertain = Object.hasOwn(ledger, 'recoveredAt');
116
+ const complete = !damaged && !uncertain && (ledger.version === VERSION || primary);
117
+ return { path, bytes, leases, damaged, uncertain, complete, recoveredAt: ledger.recoveredAt };
122
118
  }
123
119
 
124
- const aliveLease = (lease) => isPidAliveOrUnknown(lease.pid);
125
-
126
- // Management leases live in a mirror file as well as the ledger: damage to
127
- // one never hides an operation in progress from the readers.
128
- function readMirror(mirror) {
129
- try {
130
- const state = readJsonStateSync(mirror, { validate: validateLedger, strictOwner: true });
131
- return (state?.leases ?? []).filter((lease) => isLease(lease) && lease.kind === 'management' && aliveLease(lease));
132
- } catch {
133
- return [];
120
+ /**
121
+ * The ledger recovered from both copies: the union of their valid lease
122
+ * records (one copy lacking a lease never drops it), less proven-dead pids
123
+ * and this process's pending removals, plus the leases this process holds.
124
+ * It stays uncertain when either copy is, and becomes so when a copy exists
125
+ * but none is complete. Both copies missing reads as a new repository.
126
+ */
127
+ function recover(commonDir, copies) {
128
+ const own = [...held.values()].filter((entry) => entry.commonDir === commonDir).map((entry) => entry.lease);
129
+ const leases = new Map();
130
+ for (const lease of [...copies.flatMap((copy) => copy.leases), ...own]) {
131
+ const key = leaseKey(lease);
132
+ if (!leases.has(key) && pending.get(lease.token) !== commonDir && aliveLease(lease)) leases.set(key, lease);
134
133
  }
134
+ const ledger = { version: VERSION, leases: [...leases.values()] };
135
+ const uncertain =
136
+ copies.some((copy) => copy.uncertain) ||
137
+ (copies.some((copy) => copy.bytes) && !copies.some((copy) => copy.complete));
138
+ if (uncertain) ledger.recoveredAt = copies.map((copy) => copy.recoveredAt).find(Number.isFinite) ?? Date.now();
139
+ return ledger;
135
140
  }
136
141
 
137
- function writeMirror(mirror, leases) {
138
- try {
139
- writeJsonAtomicSync(mirror, { version: VERSION, leases }, { compact: true });
140
- } catch (error) {
141
- process.stderr.write(`[state] ${mirror} could not be written (${error?.message})\n`);
142
+ /**
143
+ * #117: the bytes of a damaged copy are copied aside as `<copy>.corrupt-<ts>`
144
+ * before it is rewritten. If they cannot be kept, nothing is written and
145
+ * everything refuses.
146
+ */
147
+ function keepDamagedAside(copies) {
148
+ for (const copy of copies.filter((each) => each.damaged)) {
149
+ const aside = quarantinePath(copy.path);
150
+ try {
151
+ writeFileAtomicSync(aside, copy.bytes);
152
+ } catch {
153
+ throw unverifiable();
154
+ }
155
+ process.stderr.write(`[state] ${copy.path} is damaged; kept as ${aside}\n`);
142
156
  }
143
157
  }
144
158
 
145
- // Leases this process holds, republished into any ledger that lost them.
146
- const held = new Map();
147
- let healTimer = null;
148
-
149
- // A management hold shares one token across its folders, so a lease is
150
- // identified by token and path.
151
- const leaseKey = (lease) => `${lease.token}\0${lease.path}`;
159
+ /** Writes the primary, then the mirror; a copy that already holds the ledger is left alone. */
160
+ function publish(copies, ledger) {
161
+ const text = Buffer.from(`${JSON.stringify(ledger, null, 2)}\n`);
162
+ for (const copy of copies) {
163
+ if (copy.bytes?.equals(text)) continue;
164
+ copy.bytes = null; // Unknown until the write lands.
165
+ writeFileAtomicSync(copy.path, text);
166
+ copy.bytes = text;
167
+ }
168
+ }
152
169
 
153
- function track(commonDir, lease) {
154
- held.set(leaseKey(lease), { commonDir, lease });
155
- healTimer ??= setInterval(healHeld, healIntervalMs());
156
- healTimer.unref?.();
170
+ function settle(tokens) {
171
+ for (const token of tokens) pending.delete(token);
172
+ syncHealTimer();
157
173
  }
158
174
 
159
- function untrack(token) {
160
- for (const [key, { lease }] of held) if (lease.token === token) held.delete(key);
161
- if (!held.size && healTimer) {
162
- clearInterval(healTimer);
163
- healTimer = null;
164
- }
175
+ /**
176
+ * Runs `update(leases, { unknown })` on the recovered ledger under the
177
+ * primary copy's lock and publishes the result to both copies before
178
+ * returning. `unknown`: the ledger may be missing leases. A refused update
179
+ * still publishes the repaired ledger. A grant that cannot be published is
180
+ * refused: its leases become pending removals and the ledger without them is
181
+ * published again; whatever that leaves behind is withdrawn by the next
182
+ * mutation or heal tick, which also blocks every later grant until it lands.
183
+ */
184
+ function mutate(commonDir, update = () => {}) {
185
+ const paths = copyPaths(commonDir);
186
+ return withFileLockSync(
187
+ `${paths[0]}.lock`,
188
+ () => {
189
+ const copies = paths.map((path, index) => readCopy(path, index === 0));
190
+ keepDamagedAside(copies);
191
+ const withdrawn = [...pending].filter(([, dir]) => dir === commonDir).map(([token]) => token);
192
+ const base = recover(commonDir, copies);
193
+ const leases = [...base.leases];
194
+ let result;
195
+ let refusal = null;
196
+ try {
197
+ result = update(leases, { unknown: Object.hasOwn(base, 'recoveredAt') });
198
+ } catch (error) {
199
+ refusal = error;
200
+ }
201
+ const granted = refusal ? [] : leases.filter((lease) => !base.leases.includes(lease));
202
+ try {
203
+ publish(copies, refusal ? base : { ...base, leases });
204
+ settle(withdrawn);
205
+ } catch (error) {
206
+ process.stderr.write(`[state] ${paths[0]} could not be published (${error?.message})\n`);
207
+ if (refusal) throw refusal;
208
+ for (const lease of granted) pending.set(lease.token, commonDir);
209
+ if (granted.length) {
210
+ try {
211
+ publish(copies, base);
212
+ settle([...withdrawn, ...granted.map((lease) => lease.token)]);
213
+ } catch {
214
+ syncHealTimer();
215
+ }
216
+ }
217
+ throw unverifiable();
218
+ }
219
+ if (refusal) throw refusal;
220
+ return result;
221
+ },
222
+ { strictOwner: true }
223
+ );
165
224
  }
166
225
 
167
- function ledgerLacks(commonDir, keys) {
168
- let state;
226
+ // Without the lock, a cheap check that both copies are the same current
227
+ // ledger holding every live lease this process holds there and none of the
228
+ // tokens it is withdrawing. Repair, never proof that the ledger is complete.
229
+ function inSync(commonDir) {
230
+ let ledger;
169
231
  try {
170
- state = parseJsonText(readFileSync(join(commonDir, FILE), 'utf8'));
171
- } catch (error) {
172
- return error?.code === 'ENOENT' || error instanceof SyntaxError;
232
+ const [primary, mirror] = copyPaths(commonDir).map((path) => readFileSync(path));
233
+ if (!primary.equals(mirror)) return false;
234
+ ledger = parseJsonText(primary.toString('utf8'));
235
+ } catch {
236
+ return false;
173
237
  }
174
- if (isNewer(state)) return false;
175
- if (!hasLedgerShape(state)) return true;
176
- const stored = new Set(state.leases.filter(Boolean).map(leaseKey));
177
- return keys.some((key) => !stored.has(key));
238
+ if (ledger?.version !== VERSION || !Array.isArray(ledger.leases)) return false;
239
+ const stored = new Set(ledger.leases.filter(isLease).map(leaseKey));
240
+ return (
241
+ [...held].every(([key, entry]) => entry.commonDir !== commonDir || stored.has(key) || !aliveLease(entry.lease)) &&
242
+ !ledger.leases.some((lease) => pending.get(lease?.token) === commonDir)
243
+ );
178
244
  }
179
245
 
180
246
  function healHeld() {
181
- const byDir = new Map();
182
- for (const [key, { commonDir }] of held) byDir.set(commonDir, [...(byDir.get(commonDir) ?? []), key]);
183
- for (const [commonDir, keys] of byDir) {
184
- if (!ledgerLacks(commonDir, keys)) continue;
247
+ const dirs = new Set([...[...held.values()].map((entry) => entry.commonDir), ...pending.values()]);
248
+ for (const commonDir of dirs) {
249
+ if (inSync(commonDir)) continue;
185
250
  try {
186
- mutate(
187
- commonDir,
188
- () => {},
189
- () => {}
190
- );
251
+ mutate(commonDir);
191
252
  } catch {
192
253
  // Retried on the next tick.
193
254
  }
194
255
  }
256
+ syncHealTimer();
195
257
  }
196
258
 
197
- /**
198
- * Runs `update(leases, { unknown })` under the ledger lock. `unknown`: the
199
- * ledger was recovered recently and may be missing other processes' leases.
200
- * `onNewer` answers for a ledger of a newer build, which is left untouched:
201
- * it refuses by default; readers pass an answer that admits them unrecorded.
202
- */
203
- function mutate(commonDir, update, onNewer = refuseUnreadable) {
204
- const path = join(commonDir, FILE);
205
- const mirror = `${path}.management`;
206
- assertLedgerReadable(path);
207
- let result;
208
- let refusal = null;
209
- updateJsonAtomicSync(
210
- path,
211
- (current) => {
212
- if (isNewer(current)) {
213
- result = onNewer();
214
- return undefined;
215
- }
216
- const now = Date.now();
217
- let state;
218
- let recovered = false;
219
- if (current === null) {
220
- state = emptyLedger();
221
- // `keepWithoutBackup` leaves a ledger whose text does not parse in
222
- // place, so null with a file present is damage; no file is a new ledger.
223
- if (existsSync(path)) {
224
- keepDamagedAside(path);
225
- recovered = true;
226
- }
227
- } else if (!hasLedgerShape(current)) {
228
- keepAside(path, current);
229
- state = emptyLedger();
230
- recovered = true;
231
- } else {
232
- state = { ...current, leases: current.leases.filter(isLease) };
233
- if (state.leases.length !== current.leases.length) {
234
- keepAside(path, current);
235
- recovered = true;
236
- }
237
- }
238
- if (recovered) state.recoveredAt = now;
239
- // Only a pid PROVEN dead (ESRCH) drops its lease, whatever its kind; an
240
- // unprobeable pid still counts as running. A live runtime keeps a command
241
- // whose tree it cannot verify reserved under its own (live) pid until the
242
- // tree is confirmed gone, so a lease that outlives every pid belongs to a
243
- // restarted runtime that can no longer release it.
244
- state.leases = state.leases.filter(aliveLease);
245
- const present = new Set(state.leases.map(leaseKey));
246
- const restore = [
247
- ...(recovered ? readMirror(mirror) : []),
248
- ...[...held.values()].filter((own) => own.commonDir === commonDir).map((own) => own.lease),
249
- ];
250
- for (const lease of restore) {
251
- if (present.has(leaseKey(lease)) || !aliveLease(lease)) continue;
252
- present.add(leaseKey(lease));
253
- state.leases.push(lease);
254
- }
255
- const unknown = Number.isFinite(state.recoveredAt) && now - state.recoveredAt < recoveryWindowMs();
256
- if (!unknown) delete state.recoveredAt;
257
- const managementTokens = () =>
258
- state.leases
259
- .filter((lease) => lease.kind === 'management')
260
- .map((lease) => lease.token)
261
- .join();
262
- const before = managementTokens();
263
- const unchanged = [...state.leases];
264
- try {
265
- result = update(state.leases, { unknown });
266
- } catch (error) {
267
- // A refusal still publishes the recovery: dropping the write would
268
- // leave the damaged file, and every retry would recover it again and
269
- // restart the window, so management would never resume.
270
- if (!recovered) throw error;
271
- refusal = error;
272
- state.leases = unchanged;
273
- }
274
- if (recovered || managementTokens() !== before) {
275
- writeMirror(
276
- mirror,
277
- state.leases.filter((lease) => lease.kind === 'management')
278
- );
279
- }
280
- return state;
281
- },
282
- // No backup restore: an older ledger would resurrect leases long released.
283
- { strictOwner: true, keepWithoutBackup: true, restoreBackup: false }
284
- );
285
- if (refusal) throw refusal;
286
- return result;
259
+ function syncHealTimer() {
260
+ if (held.size || pending.size) {
261
+ healTimer ??= setInterval(healHeld, healIntervalMs());
262
+ healTimer.unref?.();
263
+ } else if (healTimer) {
264
+ clearInterval(healTimer);
265
+ healTimer = null;
266
+ }
267
+ }
268
+
269
+ function track(commonDir, lease) {
270
+ held.set(leaseKey(lease), { commonDir, lease });
271
+ syncHealTimer();
287
272
  }
288
273
 
274
+ // Forgotten as held first, so nothing puts the lease back; its token stays
275
+ // pending until both copies dropped it.
289
276
  function release(commonDir, token) {
290
277
  let done = false;
291
278
  return () => {
292
279
  if (done) return;
293
- mutate(
294
- commonDir,
295
- (leases) => {
296
- for (let i = leases.length - 1; i >= 0; i--) if (leases[i].token === token) leases.splice(i, 1);
297
- },
298
- () => {}
299
- );
300
280
  done = true;
301
- untrack(token);
281
+ for (const [key, entry] of held) if (entry.lease.token === token) held.delete(key);
282
+ pending.set(token, commonDir);
283
+ try {
284
+ mutate(commonDir);
285
+ } catch {
286
+ // Retried by the next mutation or heal tick.
287
+ }
288
+ syncHealTimer();
302
289
  };
303
290
  }
304
291
 
305
292
  /**
306
293
  * Readers share a checkout freely. Null means a management writer currently
307
- * owns it; a reader in an external checkout is never held back.
294
+ * owns it; a reader in an external checkout is never held back. Throws
295
+ * `workspace-admission-unreadable` when the ledger cannot be read, was
296
+ * written by a newer build, or the grant could not be made durable in both
297
+ * copies: no work may run unrecorded.
308
298
  */
309
299
  export function tryAdmitWorkspaceActivity(sessionId, cwd, { pid = process.pid, kind = 'turn' } = {}) {
310
300
  if (!['turn', 'command'].includes(kind)) throw new TypeError('Invalid workspace reader kind.');
@@ -314,18 +304,11 @@ export function tryAdmitWorkspaceActivity(sessionId, cwd, { pid = process.pid, k
314
304
  const path = canonicalPath(cwd);
315
305
  const token = randomUUID();
316
306
  const lease = { kind, sessionId, pid, token, path };
317
- const admitted = mutate(
318
- layout.commonDir,
319
- (leases) => {
320
- if (!external && leases.some((other) => other.kind === 'management' && overlaps(other.path, path))) return false;
321
- leases.push(lease);
322
- return true;
323
- },
324
- // A newer build's ledger cannot be written: the reader goes unrecorded
325
- // (prompts never wait on it) while management refuses on it.
326
- () => UNRECORDED
327
- );
328
- if (admitted === UNRECORDED) return () => {};
307
+ const admitted = mutate(layout.commonDir, (leases) => {
308
+ if (!external && leases.some((other) => other.kind === 'management' && overlaps(other.path, path))) return false;
309
+ leases.push(lease);
310
+ return true;
311
+ });
329
312
  if (!admitted) return null;
330
313
  track(layout.commonDir, lease);
331
314
  return release(layout.commonDir, token);
@@ -374,9 +357,8 @@ export function holdWorkspaceActivity(folders, { sessionId = null } = {}) {
374
357
  paths.some(({ path }) => overlaps(path, lease.path))
375
358
  );
376
359
  if (conflicts.length) throw worktreeRefusal('folder-in-use');
377
- // A recovered ledger may be missing a live foreign turn: wait for the
378
- // holders to republish (bounded by the recovery window).
379
- if (unknown) throw worktreeRefusal('workspace-admission-unreadable');
360
+ // The ledger may be missing a live foreign lease: refused until reset.
361
+ if (unknown) throw unverifiable();
380
362
  for (const { path, external } of paths) {
381
363
  if (external) continue;
382
364
  const lease = { kind: 'management', pid: process.pid, token, path };
@@ -265,13 +265,19 @@ export function nextRevision(workspace) {
265
265
  * @property {string} repoCommonDir
266
266
  * @property {string} projectPath
267
267
  * @property {string} path Absolute worktree folder (see managedWorktreePath).
268
- * @property {string} branch Full branch name, e.g. "mixdog/fix-login".
268
+ * @property {string|null} branch Full branch name, e.g. "mixdog/fix-login"; null only for a snapshot.
269
269
  * @property {string|null} baseCommit
270
270
  * @property {number} createdAt Epoch milliseconds.
271
- * @property {string|null} ownerSessionId Lead session that retains it; null once released.
271
+ * @property {string|null} ownerSessionId Lead session that retains it; null once released (and for a snapshot).
272
272
  * @property {WorktreeEntryState} state
273
+ * @property {'snapshot'} [kind] A read-only detached checkout of `baseCommit`
274
+ * (WORKTREE_SNAPSHOT_KIND): no branch, bound to no session, removed with
275
+ * the turn that made it.
273
276
  */
274
277
 
278
+ /** `kind` of a managed read-only snapshot: a detached checkout no session works in. */
279
+ export const WORKTREE_SNAPSHOT_KIND = 'snapshot';
280
+
275
281
  /**
276
282
  * @param {unknown} input
277
283
  * @returns {WorktreeEntry}
@@ -284,16 +290,18 @@ export function normalizeWorktreeEntry(input) {
284
290
  const id = text(value.id, 'worktree.id');
285
291
  const idError = worktreeNameError(id);
286
292
  if (idError) throw new TypeError(`worktree.id is invalid: ${idError}`);
293
+ const snapshot = value.kind === WORKTREE_SNAPSHOT_KIND;
287
294
  return {
288
295
  id,
289
296
  repoCommonDir: text(value.repoCommonDir, 'worktree.repoCommonDir'),
290
297
  projectPath: text(value.projectPath, 'worktree.projectPath'),
291
298
  path: text(value.path, 'worktree.path'),
292
- branch: text(value.branch, 'worktree.branch'),
299
+ branch: snapshot ? nullableText(value.branch, 'worktree.branch') : text(value.branch, 'worktree.branch'),
293
300
  baseCommit: nullableCommit(value.baseCommit, 'worktree.baseCommit'),
294
301
  createdAt: value.createdAt,
295
302
  ownerSessionId: nullableText(value.ownerSessionId, 'worktree.ownerSessionId'),
296
303
  state: oneOf(value.state, WORKTREE_ENTRY_STATES, 'worktree.state'),
304
+ ...(snapshot ? { kind: WORKTREE_SNAPSHOT_KIND } : {}),
297
305
  };
298
306
  }
299
307
 
@@ -72,14 +72,21 @@ export async function listWorktreesWithRetention({
72
72
  * The manual prune (an explicit request, so independent of autoCleanup):
73
73
  * the startup reconcile plus removal of clean, merged worktrees that no
74
74
  * stored or live session retains. Dirty or unmerged ones are reported kept.
75
- * @param {{ liveSessionIds?: string[], listSessionIds?: () => Promise<string[]>, manager?: object }} [options]
75
+ * `repoCommonDir` limits the prune to that repository's worktrees.
76
+ * @param {{ liveSessionIds?: string[], listSessionIds?: () => Promise<string[]>, manager?: object, repoCommonDir?: string|null }} [options]
76
77
  */
77
- export async function pruneWorktrees({ liveSessionIds = [], listSessionIds = storedSessionIds, manager = null } = {}) {
78
+ export async function pruneWorktrees({
79
+ liveSessionIds = [],
80
+ listSessionIds = storedSessionIds,
81
+ manager = null,
82
+ repoCommonDir = null,
83
+ } = {}) {
78
84
  const backend = manager || (await loadManager());
79
85
  return backend.reconcileManagedWorktrees({
80
86
  liveSessionIds,
81
87
  retainedSessionIds: await listSessionIds(),
82
88
  removeOrphans: true,
89
+ ...(repoCommonDir ? { repoCommonDir } : {}),
83
90
  });
84
91
  }
85
92
 
@@ -93,6 +100,8 @@ export async function pruneWorktrees({ liveSessionIds = [], listSessionIds = sto
93
100
  export async function cleanupDeletedSessionWorktree(sessionId, { manager = null, autoCleanup } = {}) {
94
101
  const backend = manager || (await loadManager());
95
102
  await backend.forgetSharedWorktreeSession?.(sessionId);
103
+ // Its read-only snapshots go whatever autoCleanup says (they never outlive their holder).
104
+ await backend.removeSessionSnapshots?.(sessionId);
96
105
  const entry = await backend.getSessionWorktree(sessionId);
97
106
  if (!entry) return null;
98
107
  const enabled = autoCleanup ?? (await backend.currentWorktreeSettings()).autoCleanup;