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.
Files changed (288) hide show
  1. package/README.md +1 -0
  2. package/package.json +2 -1
  3. package/scripts/lib/run-node-tests.mjs +9 -0
  4. package/scripts/prune-desktop-runtime.mjs +3 -0
  5. package/scripts/test-direct.mjs +4 -2
  6. package/src/defaults/shipped-history.json +6 -0
  7. package/src/defaults/skills/setup/references/actions.md +5 -2
  8. package/src/defaults/skills/visualize/SKILL.md +157 -0
  9. package/src/defaults/skills/visualize/references/craft.md +88 -0
  10. package/src/headless-command.mjs +56 -3
  11. package/src/headless-exec.mjs +17 -1
  12. package/src/help.mjs +2 -0
  13. package/src/rules/shared/70-delivery.md +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/preset.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +1 -0
  16. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -0
  17. package/src/runtime/agent/orchestrator/config-presets.mjs +2 -0
  18. package/src/runtime/agent/orchestrator/config-providers-merge.mjs +17 -11
  19. package/src/runtime/agent/orchestrator/config-storage.mjs +1 -0
  20. package/src/runtime/agent/orchestrator/config.mjs +9 -1
  21. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +47 -7
  22. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +51 -8
  23. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +25 -5
  24. package/src/runtime/agent/orchestrator/mcp/client.mjs +348 -122
  25. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +4 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +13 -1
  29. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -0
  30. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +2 -0
  31. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +2 -1
  32. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +12 -4
  33. package/src/runtime/agent/orchestrator/providers/effort-rejection.mjs +244 -0
  34. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  36. package/src/runtime/agent/orchestrator/providers/mimo-oauth-profile.mjs +8 -40
  37. package/src/runtime/agent/orchestrator/providers/mimo-oauth-tokens.mjs +2 -0
  38. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +51 -12
  39. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +37 -2
  40. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.d.mts +67 -0
  41. package/src/runtime/agent/orchestrator/providers/model-proxy-channel.mjs +521 -0
  42. package/src/runtime/agent/orchestrator/providers/model-proxy-provider.mjs +251 -0
  43. package/src/runtime/agent/orchestrator/providers/muse-code-oauth-tokens.mjs +2 -0
  44. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +69 -102
  45. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog-store.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/providers/openai-oauth-catalog.mjs +5 -2
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +3 -0
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +15 -1
  51. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +10 -0
  52. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +36 -3
  53. package/src/runtime/agent/orchestrator/runtime-core/effort.mjs +12 -11
  54. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +16 -1
  55. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +11 -0
  56. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +10 -3
  57. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +10 -0
  58. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +9 -5
  59. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +18 -8
  60. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +10 -3
  61. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +18 -11
  62. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +13 -5
  63. package/src/runtime/agent/orchestrator/session/compaction-archive-capture.mjs +362 -0
  64. package/src/runtime/agent/orchestrator/session/compaction-archive.mjs +253 -0
  65. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +1 -1
  66. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/diagnostic.mjs +1 -7
  67. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/refusal.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +3 -2
  69. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +2 -1
  70. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +1 -0
  71. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -2
  74. package/src/runtime/agent/orchestrator/session/manager/ask-turn-auto-effort.mjs +2 -0
  75. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +5 -3
  76. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -16
  78. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +4 -0
  79. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +90 -0
  80. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -12
  81. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +32 -3
  82. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +27 -0
  83. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +16 -1
  84. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -0
  85. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  86. package/src/runtime/agent/orchestrator/session/message-ids.mjs +123 -0
  87. package/src/runtime/agent/orchestrator/session/message-ids.test-support.mjs +29 -0
  88. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +29 -19
  89. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +3 -0
  90. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-finish.mjs +10 -3
  91. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +11 -0
  92. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +29 -0
  93. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +5 -1
  94. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +23 -4
  95. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +20 -3
  96. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +28 -8
  97. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +140 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +52 -2
  99. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -22
  100. package/src/runtime/agent/orchestrator/tools/builtin/git-repo-rw-lock.mjs +10 -3
  101. package/src/runtime/agent/orchestrator/tools/builtin/read-cache-policy.mjs +8 -0
  102. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +31 -12
  103. package/src/runtime/agent/orchestrator/tools/builtin/read-single-fast-paths.mjs +32 -35
  104. package/src/runtime/agent/orchestrator/tools/builtin/read-single-render.mjs +11 -12
  105. package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +9 -6
  106. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -66
  107. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +8 -3
  108. package/src/runtime/agent/orchestrator/tools/builtin/read-source-windows.mjs +5 -1
  109. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +16 -7
  110. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
  111. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +137 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +11 -10
  113. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +21 -11
  114. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +12 -1
  115. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +56 -13
  116. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +58 -0
  117. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +25 -1
  118. package/src/runtime/agent/orchestrator/tools/patch/ui-diff.mjs +78 -5
  119. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +2 -0
  120. package/src/runtime/attachments/store.mjs +38 -0
  121. package/src/runtime/shared/atomic-file.mjs +8 -2
  122. package/src/runtime/shared/best-effort-stderr.mjs +8 -0
  123. package/src/runtime/shared/code-graph-state.mjs +12 -0
  124. package/src/runtime/shared/defaults-separation.mjs +2 -0
  125. package/src/runtime/shared/git-runner-worker.mjs +3 -2
  126. package/src/runtime/shared/git-runner.mjs +4 -3
  127. package/src/runtime/shared/llm/default-context-window.mjs +6 -0
  128. package/src/runtime/shared/llm/model-catalog-projection.mjs +5 -4
  129. package/src/runtime/shared/llm/model-catalog.mjs +240 -118
  130. package/src/runtime/shared/llm/provider-catalog-cache.mjs +36 -28
  131. package/src/runtime/shared/llm/provider-catalog-registry.mjs +156 -0
  132. package/src/runtime/shared/llm/provider-seed-rosters.mjs +130 -0
  133. package/src/runtime/shared/model-proxy-mode.d.mts +3 -0
  134. package/src/runtime/shared/model-proxy-mode.mjs +19 -0
  135. package/src/runtime/shared/provider-accounts.mjs +2 -2
  136. package/src/runtime/shared/provider-api-key.mjs +21 -1
  137. package/src/runtime/shared/schedule-model-ref.mjs +11 -2
  138. package/src/runtime/shared/session-usage-totals.mjs +19 -0
  139. package/src/runtime/shared/sharp-electron-linux.mjs +25 -0
  140. package/src/runtime/shared/singleton-owner.mjs +36 -6
  141. package/src/runtime/shared/statusline/statusline-segments.mjs +68 -14
  142. package/src/runtime/shared/tool-surface.mjs +7 -0
  143. package/src/runtime/shared/tool-work-units.mjs +14 -0
  144. package/src/runtime/shared/transcript-metadata.mjs +17 -1
  145. package/src/runtime/shared/turn-changes.mjs +791 -0
  146. package/src/runtime/shared/turn-snapshot-store.mjs +346 -12
  147. package/src/runtime/shared/turn-snapshot.mjs +690 -81
  148. package/src/runtime/shared/turn-worktree-snapshot.mjs +161 -11
  149. package/src/runtime/shared/worktrees/cache-cleanups.mjs +52 -0
  150. package/src/runtime/shared/worktrees/contract.mjs +551 -0
  151. package/src/runtime/shared/worktrees/disk.mjs +57 -0
  152. package/src/runtime/shared/worktrees/git.mjs +247 -0
  153. package/src/runtime/shared/worktrees/include.mjs +480 -0
  154. package/src/runtime/shared/worktrees/lock.mjs +46 -0
  155. package/src/runtime/shared/worktrees/maintenance.mjs +96 -0
  156. package/src/runtime/shared/worktrees/manager.mjs +1215 -0
  157. package/src/runtime/shared/worktrees/merge.mjs +182 -0
  158. package/src/runtime/shared/worktrees/owner.mjs +46 -0
  159. package/src/runtime/shared/worktrees/paths.mjs +62 -0
  160. package/src/runtime/shared/worktrees/registry.mjs +110 -0
  161. package/src/runtime/shared/worktrees/settings.mjs +84 -0
  162. package/src/runtime/shared/worktrees/slug.mjs +62 -0
  163. package/src/runtime/shared/worktrees/test-support.mjs +96 -0
  164. package/src/runtime/visualize-bridge/client.mjs +181 -0
  165. package/src/runtime/visualize-bridge/tool-defs.mjs +97 -0
  166. package/src/session-runtime/boot/apis.mjs +35 -0
  167. package/src/session-runtime/boot/config.mjs +2 -0
  168. package/src/session-runtime/boot/facade.mjs +20 -0
  169. package/src/session-runtime/boot/infrastructure.mjs +1 -0
  170. package/src/session-runtime/boot/session.mjs +1 -0
  171. package/src/session-runtime/boot/tools.mjs +4 -0
  172. package/src/session-runtime/config-lifecycle.mjs +10 -1
  173. package/src/session-runtime/cwd-plugins/cwd-apply.mjs +11 -1
  174. package/src/session-runtime/global-extensions.mjs +2 -2
  175. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +27 -2
  176. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +32 -1
  177. package/src/session-runtime/internal-tool-executor.mjs +9 -1
  178. package/src/session-runtime/lifecycle/session-catalog.mjs +8 -1
  179. package/src/session-runtime/lifecycle/session-switch/context-switch.mjs +5 -0
  180. package/src/session-runtime/lifecycle/session-switch/resume.mjs +21 -3
  181. package/src/session-runtime/lifecycle/session-switch/session-delete.mjs +16 -0
  182. package/src/session-runtime/mcp-glue/connect.mjs +11 -6
  183. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -3
  184. package/src/session-runtime/mcp-glue/server-input.mjs +29 -0
  185. package/src/session-runtime/model-route/route-persist.mjs +3 -0
  186. package/src/session-runtime/model-route/set-route.mjs +15 -2
  187. package/src/session-runtime/notification-bus.mjs +9 -1
  188. package/src/session-runtime/provider-models/row-hydration.mjs +2 -0
  189. package/src/session-runtime/resource-extension-sync.mjs +30 -7
  190. package/src/session-runtime/resource-mcp-api.mjs +20 -7
  191. package/src/session-runtime/route-resolution.mjs +10 -1
  192. package/src/session-runtime/route-state.mjs +16 -2
  193. package/src/session-runtime/runtime-core.mjs +5 -0
  194. package/src/session-runtime/runtime-facade.mjs +8 -0
  195. package/src/session-runtime/runtime-review-api.mjs +9 -0
  196. package/src/session-runtime/services/agent-tool/helpers.mjs +5 -0
  197. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +3 -0
  198. package/src/session-runtime/services/agent-tool/job-views/job-lookup.mjs +1 -0
  199. package/src/session-runtime/services/agent-tool/job-views/spawn-meta.mjs +1 -0
  200. package/src/session-runtime/services/agent-tool/lead-worker-index.mjs +15 -0
  201. package/src/session-runtime/services/agent-tool/send-flow.mjs +1 -0
  202. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +10 -2
  203. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +19 -7
  204. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +3 -0
  205. package/src/session-runtime/services/agent-tool/tag-registry.mjs +2 -1
  206. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +5 -0
  207. package/src/session-runtime/services/agent-tool/worker-rows.mjs +1 -0
  208. package/src/session-runtime/services/agent-tool.mjs +1 -0
  209. package/src/session-runtime/services/daemon-candidate.mjs +3 -2
  210. package/src/session-runtime/services/daemon-crash-capture/spawn-capture.mjs +55 -0
  211. package/src/session-runtime/session-create/session-options.mjs +10 -1
  212. package/src/session-runtime/session-turn-api.mjs +1 -0
  213. package/src/session-runtime/settings-api.mjs +4 -2
  214. package/src/session-runtime/settings-builtin-tools-api.mjs +9 -2
  215. package/src/session-runtime/settings-worktree-api.mjs +17 -0
  216. package/src/session-runtime/setup-tool/executor.mjs +11 -4
  217. package/src/session-runtime/setup-tool/settings-contract.mjs +3 -1
  218. package/src/session-runtime/setup-tool/tool-defs.mjs +31 -3
  219. package/src/session-runtime/turn/agent-ops.mjs +12 -1
  220. package/src/session-runtime/turn/turn-run/ask-callbacks.mjs +13 -3
  221. package/src/session-runtime/turn/turn-run/snapshot-tracker.mjs +20 -15
  222. package/src/session-runtime/turn/turn-run/turn-open.mjs +4 -5
  223. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +29 -1
  224. package/src/session-runtime/turn/turn-run/turn-settle.mjs +6 -1
  225. package/src/session-runtime/turn/turn-run.mjs +6 -7
  226. package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -3
  227. package/src/session-runtime/workspace/admission-gate.mjs +197 -0
  228. package/src/session-runtime/workspace/project-branches.mjs +136 -0
  229. package/src/session-runtime/workspace/transition-steps.mjs +364 -0
  230. package/src/session-runtime/workspace/transitions.mjs +651 -0
  231. package/src/session-runtime/workspace/workspace-state.mjs +112 -0
  232. package/src/standalone/channel-transport/http-routes.mjs +17 -0
  233. package/src/standalone/channel-transport.mjs +13 -1
  234. package/src/standalone/daemon-desktop-runtime.mjs +1 -0
  235. package/src/standalone/daemon-owner.mjs +372 -0
  236. package/src/standalone/daemon-stored-session-views.mjs +4 -1
  237. package/src/standalone/daemon.mjs +139 -5
  238. package/src/standalone/desktop-service-registry.mjs +20 -0
  239. package/src/standalone/local-session-runtime.mjs +11 -0
  240. package/src/standalone/session-client/attachment-calls.mjs +3 -1
  241. package/src/standalone/session-client/daemon-unavailable.mjs +62 -0
  242. package/src/standalone/session-client/lease-heartbeat.mjs +47 -0
  243. package/src/standalone/session-client.mjs +104 -13
  244. package/src/standalone/session-protocol.mjs +16 -0
  245. package/src/standalone/session-proxy.mjs +3 -0
  246. package/src/standalone/session-service/agent-tree/agent-child.mjs +4 -0
  247. package/src/standalone/session-service/agent-tree/agent-rehydrate.mjs +1 -0
  248. package/src/standalone/session-service/entries/lifecycle.mjs +6 -0
  249. package/src/standalone/session-service/projection/revisions.mjs +2 -0
  250. package/src/standalone/session-service/projection/transcript-window.mjs +149 -19
  251. package/src/standalone/session-service/projection/workspace.mjs +14 -0
  252. package/src/standalone/session-service/session-calls/session-action.mjs +11 -3
  253. package/src/standalone/session-transport/call-route.mjs +13 -0
  254. package/src/standalone/session-transport/client-registry/client-grace.mjs +45 -3
  255. package/src/standalone/session-transport/client-registry/registration-replays.mjs +1 -0
  256. package/src/standalone/session-transport/client-registry.mjs +20 -3
  257. package/src/standalone/session-transport/routes.mjs +79 -2
  258. package/src/standalone/session-transport/stack.mjs +2 -0
  259. package/src/standalone/session-transport.mjs +24 -1
  260. package/src/tui/app/message-selector.mjs +2 -1
  261. package/src/tui/app/model-options.mjs +35 -5
  262. package/src/tui/app/model-picker/provider-models-list.mjs +2 -2
  263. package/src/tui/app/model-picker.mjs +4 -2
  264. package/src/tui/dist/index.mjs +332 -36
  265. package/src/tui/index.jsx +10 -2
  266. package/src/tui/session/compaction-view.mjs +147 -0
  267. package/src/tui/session/context-state.mjs +6 -0
  268. package/src/tui/session/initial-state.mjs +3 -0
  269. package/src/tui/session/quota-resume.mjs +2 -1
  270. package/src/tui/session/session-api/commands.mjs +10 -2
  271. package/src/tui/session/session-api/controls.mjs +4 -0
  272. package/src/tui/session/session-api/intake/rewind.mjs +2 -1
  273. package/src/tui/session/session-api/integrations.mjs +17 -0
  274. package/src/tui/session/session-api/lifecycle/dispose.mjs +6 -1
  275. package/src/tui/session/session-api/lifecycle/inheritance.mjs +2 -0
  276. package/src/tui/session/session-api/lifecycle/resume.mjs +3 -0
  277. package/src/tui/session/session-api/routes.mjs +10 -1
  278. package/src/tui/session/session-api/settings.mjs +2 -0
  279. package/src/tui/session/session-api/worktree.mjs +60 -0
  280. package/src/tui/session/session-api.mjs +2 -0
  281. package/src/tui/session/session-flow/auto-clear.mjs +31 -12
  282. package/src/tui/session/session-flow/drain.mjs +15 -1
  283. package/src/tui/session/session-flow/session-reset.mjs +4 -3
  284. package/src/tui/session/session-flow.mjs +2 -0
  285. package/src/tui/session/transcript-intake.mjs +14 -5
  286. package/src/tui/session/turn-stream.mjs +32 -4
  287. package/src/tui/session/turn-tool-cards.mjs +10 -2
  288. package/src/tui/session/turn.mjs +39 -10
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Session workspace record helpers: reading the durable `workspace` field,
3
+ * keeping it consistent when the cwd tool switches Projects, restoring it on
4
+ * resume (missing worktree → recovery), and carrying the Project's memory
5
+ * identity into a new worktree.
6
+ */
7
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { isWorktreeBound, nextRevision, normalizeSessionWorkspace } from '../../runtime/shared/worktrees/contract.mjs';
10
+ import { isWithin, samePath } from '../../runtime/shared/worktrees/paths.mjs';
11
+ import { resolveProjectId } from '../../runtime/memory/lib/project-id-resolver.mjs';
12
+ import { setWorkspaceCwdLock } from './admission-gate.mjs';
13
+
14
+ /** The session's persisted workspace, or null when absent or malformed. */
15
+ export function readSessionWorkspace(session) {
16
+ if (!session?.workspace) return null;
17
+ try {
18
+ return normalizeSessionWorkspace(session.workspace);
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * A manual Project selection (cwd tool / setCwd) while a workspace record
26
+ * exists. Moving inside the bound worktree keeps the record; any other
27
+ * directory is a Project switch and leaves the worktree (still retained) at a
28
+ * new revision. Returns the record to store and the Project path the desktop
29
+ * metadata must carry.
30
+ * @returns {{ workspace: object|null, projectPath: string }}
31
+ */
32
+ export function workspaceForProjectSelection(session, cwd) {
33
+ const current = readSessionWorkspace(session);
34
+ if (!current) return { workspace: session?.workspace ?? null, projectPath: cwd };
35
+ if (isWorktreeBound(current) && isWithin(current.cwd, cwd)) {
36
+ return { workspace: session.workspace, projectPath: current.projectPath };
37
+ }
38
+ if (!isWorktreeBound(current) && samePath(current.cwd, cwd)) {
39
+ return { workspace: session.workspace, projectPath: cwd };
40
+ }
41
+ return {
42
+ workspace: normalizeSessionWorkspace({ projectPath: cwd, cwd, revision: nextRevision(current) }),
43
+ projectPath: cwd,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Where a session that replaces `session` (/new, /clear) starts: the Project
49
+ * folder when `session` is bound to a worktree — that worktree belongs to the
50
+ * session being left — else null (keep the current cwd).
51
+ * @returns {string|null}
52
+ */
53
+ export function replacementSessionCwd(session) {
54
+ const workspace = readSessionWorkspace(session);
55
+ return isWorktreeBound(workspace) ? workspace.projectPath : null;
56
+ }
57
+
58
+ /**
59
+ * Execution root of a worktree-bound session for resume: the worktree when it
60
+ * exists, else the Project folder flagged `missing`. Null when not bound.
61
+ * @returns {{ cwd: string, missing: boolean }|null}
62
+ */
63
+ export function boundWorkspaceCwd(session) {
64
+ const workspace = readSessionWorkspace(session);
65
+ if (!isWorktreeBound(workspace)) return null;
66
+ if (existsSync(workspace.cwd)) return { cwd: workspace.cwd, missing: false };
67
+ return { cwd: workspace.projectPath, missing: true };
68
+ }
69
+
70
+ /**
71
+ * After a resume: a bound session whose worktree vanished enters `recovery`
72
+ * (turns are refused with WORKTREE_MISSING until the user picks a branch or
73
+ * removes the worktree); an intact one re-locks its workers to the worktree.
74
+ */
75
+ export function restoreWorkspaceAfterResume(session) {
76
+ const workspace = readSessionWorkspace(session);
77
+ if (!session?.id) return workspace;
78
+ if (!isWorktreeBound(workspace)) {
79
+ setWorkspaceCwdLock(session.id, null);
80
+ return workspace;
81
+ }
82
+ if (existsSync(workspace.cwd)) {
83
+ setWorkspaceCwdLock(session.id, workspace.cwd);
84
+ if (workspace.transition.state !== 'idle') {
85
+ session.workspace = normalizeSessionWorkspace({ ...workspace, transition: { state: 'idle', txId: null } });
86
+ }
87
+ return session.workspace;
88
+ }
89
+ setWorkspaceCwdLock(session.id, null);
90
+ session.workspace = normalizeSessionWorkspace({
91
+ ...workspace,
92
+ transition: { state: 'recovery', txId: `recovery:${workspace.worktreeId}` },
93
+ });
94
+ return session.workspace;
95
+ }
96
+
97
+ /**
98
+ * Carry the Project's memory identity into a worktree that lives outside the
99
+ * Project tree. Runs before anything resolves memory for the worktree (the
100
+ * resolver memoizes per root). No-op when the checkout already has the file
101
+ * or the Project has no identity.
102
+ * @returns {boolean} Whether a file was written.
103
+ */
104
+ export function copyProjectIdentity(projectPath, worktreePath) {
105
+ const target = join(worktreePath, '.mixdog', 'project.id');
106
+ if (existsSync(target)) return false;
107
+ const id = resolveProjectId(projectPath);
108
+ if (!id) return false;
109
+ mkdirSync(dirname(target), { recursive: true });
110
+ writeFileSync(target, `${id}\n`, 'utf8');
111
+ return true;
112
+ }
@@ -20,6 +20,7 @@ export function createChannelRoutes({
20
20
  onClientsEmpty,
21
21
  registry,
22
22
  dispatch,
23
+ admitWork = () => true,
23
24
  }) {
24
25
  const { clients } = state;
25
26
 
@@ -27,6 +28,16 @@ export function createChannelRoutes({
27
28
  sendError(res, `daemon is draining: ${state.drainingReason}`, 503);
28
29
  }
29
30
 
31
+ // Checked on every register/call/agent dispatch, first and again right
32
+ // before the work is handed on (the lease may have been fenced while the
33
+ // body was still arriving): a daemon that lost (or cannot re-verify) its
34
+ // owner lease serves nothing.
35
+ function refuseUnadmitted(res) {
36
+ if (admitWork()) return false;
37
+ sendError(res, 'daemon owner lease is not held', 503);
38
+ return true;
39
+ }
40
+
30
41
  function health(res) {
31
42
  sendJson(res, {
32
43
  status: 'ok',
@@ -43,8 +54,10 @@ export function createChannelRoutes({
43
54
  }
44
55
 
45
56
  async function register(req, res) {
57
+ if (refuseUnadmitted(res)) return;
46
58
  if (state.drainCommitted) return sendDraining(res);
47
59
  const body = await readChannelBody(req);
60
+ if (refuseUnadmitted(res)) return;
48
61
  const clientToken = registry.registerClient({
49
62
  leadPid: body.leadPid,
50
63
  cwd: body.cwd,
@@ -83,9 +96,11 @@ export function createChannelRoutes({
83
96
  // channels client registry alive. The broker itself owns a parallel fair
84
97
  // scheduler and per-call cancellation.
85
98
  async function agentDispatch(req, res) {
99
+ if (refuseUnadmitted(res)) return;
86
100
  if (state.drainCommitted) return sendDraining(res);
87
101
  if (!agentBroker?.dispatch) return sendError(res, 'agent broker unavailable', 503);
88
102
  const body = await readChannelBody(req);
103
+ if (refuseUnadmitted(res)) return;
89
104
  const callId = String(body.callId || '').trim();
90
105
  if (!callId) return sendError(res, 'callId required', 400);
91
106
  res.on('close', () => {
@@ -115,8 +130,10 @@ export function createChannelRoutes({
115
130
  }
116
131
 
117
132
  async function call(req, res) {
133
+ if (refuseUnadmitted(res)) return;
118
134
  if (state.drainCommitted) return sendDraining(res);
119
135
  const body = await readChannelBody(req);
136
+ if (refuseUnadmitted(res)) return;
120
137
  const clientToken = body.token || null;
121
138
  const c = clientToken ? clients.get(clientToken) : null;
122
139
  if (!c) return sendError(res, 'unknown client token', 404);
@@ -41,10 +41,21 @@ export function createChannelTransport({
41
41
  onClientRegistered = null,
42
42
  onRemoteStateChange = null,
43
43
  agentBroker = null,
44
+ // Asked before every register/call/agent dispatch; false answers 503 (the
45
+ // daemon's owner lease is not held — daemon-owner.mjs).
46
+ admitWork = () => true,
44
47
  } = {}) {
45
48
  if (typeof handleCall !== 'function') throw new Error('handleCall is required');
46
49
 
47
- const remoteIntent = readRemoteIntent(remoteIntentPath);
50
+ let remoteIntent = null;
51
+ try {
52
+ remoteIntent = readRemoteIntent(remoteIntentPath);
53
+ } catch (error) {
54
+ // An unreadable pin (permissions, an antivirus lock) only costs the channel
55
+ // its restored session; it must never keep the whole daemon from booting.
56
+ // The file stays in place.
57
+ log(`channel remote intent unreadable, starting unpinned: ${error?.message || error}`);
58
+ }
48
59
  const state = {
49
60
  // token -> { token, leadPid, cwd, sse, lastSeen, registeredAt }
50
61
  clients: new Map(),
@@ -111,6 +122,7 @@ export function createChannelTransport({
111
122
  onClientsEmpty,
112
123
  registry,
113
124
  dispatch,
125
+ admitWork,
114
126
  });
115
127
 
116
128
  function beginDrain(reason = 'daemon replacement') {
@@ -16,6 +16,7 @@ export function createDesktopRuntime({ getLocalSessionBridge }) {
16
16
  return bridge.attach(options);
17
17
  },
18
18
  loadProjects: () => import('../runtime/shared/projects.mjs'),
19
+ loadWorktrees: () => import('../runtime/shared/worktrees/manager.mjs'),
19
20
  loadSessionStore: () => import('../runtime/agent/orchestrator/session/store-summary-reader.mjs'),
20
21
  loadSessionSearch: () => import('../runtime/session-search/session-search-db.mjs'),
21
22
  loadStatuslineSegments: () => import('../runtime/shared/statusline/statusline-segments.mjs'),
@@ -0,0 +1,372 @@
1
+ // Whether the daemon-owner.json record still names a daemon, and the lease a
2
+ // daemon holds on it.
3
+ //
4
+ // A live pid proves nothing on its own. A daemon terminated without running
5
+ // its exit hooks (Windows shutdown, an installer or Task Manager ending the
6
+ // process tree) leaves its owner record behind, and the OS soon hands that pid
7
+ // to an unrelated process. A pid-only check then reported a live owner for as
8
+ // long as that process ran: every launcher waited for it to publish, every
9
+ // spawned candidate yielded to it, and the desktop failed every call with
10
+ // "daemon session endpoint is unavailable".
11
+ //
12
+ // Single-owner safety never rests on the wall clock:
13
+ // - Holder: keeps its lease in monotonic time since its last successful
14
+ // refresh. An expired lease fences it (refuse work, shut down) instead of
15
+ // being renewed, and every register/call asks `admit()` first. After an
16
+ // event-loop stall nothing is served until a refresh under the claim lock
17
+ // proves the record still names it — a contender's claim takes that lock.
18
+ // - Holder's record names `livenessPort` from its very first claim: a tiny
19
+ // loopback listener started BEFORE the claim (startOwnerLivenessListener)
20
+ // answers /health with the pid, through boot and for the process lifetime.
21
+ // - Contender: may reclaim a live-pid record only when its heartbeat looks
22
+ // stale (wall clock: a reused pid never beats) AND that port, probed for the
23
+ // full OWNER_PROBE_TIMEOUT_MS, does not answer with that pid — never on
24
+ // wall-clock staleness alone, so a clock jump never reclaims a holder that
25
+ // answers, booting or not; a holder silent for that long has stalled past
26
+ // `stallMs` and re-verifies before serving again.
27
+ //
28
+ // Records without a heartbeat come from v1.1.2 and older (and records with a
29
+ // heartbeat but no liveness port only from unreleased intermediate builds,
30
+ // judged the same way). Their pid matching the leftover daemon.json proves
31
+ // nothing — a crashed daemon leaves both files behind — so only the endpoint
32
+ // daemon.json publishes, answering with that pid, proves them alive.
33
+ import http from 'node:http';
34
+ import { readFileSync } from 'node:fs';
35
+ import { isPidAlive } from '../runtime/shared/pid-liveness.mjs';
36
+ import { readSingletonOwner, refreshSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
37
+
38
+ export const DAEMON_OWNER_STALE_MS = 60_000;
39
+ export const OWNER_PROBE_TIMEOUT_MS = 3_000;
40
+ const PROBE_BODY_MAX_BYTES = 1024 * 1024;
41
+
42
+ /**
43
+ * Lease timings. MIXDOG_DAEMON_OWNER_STALE_MS may only shorten the stale
44
+ * threshold (tests); every process on one data dir must see the same value.
45
+ * Six beats per threshold; fenced at two thirds of it (40 s of 60 s, leaving
46
+ * the 15 s shutdown budget inside it); the lease is checked every `tickMs`
47
+ * and a turn gap over `stallMs` (< OWNER_PROBE_TIMEOUT_MS) counts as a stall.
48
+ */
49
+ export function daemonOwnerTimings(env = process.env) {
50
+ const configured = Number(env.MIXDOG_DAEMON_OWNER_STALE_MS);
51
+ const staleMs =
52
+ Number.isFinite(configured) && configured > 0
53
+ ? Math.min(DAEMON_OWNER_STALE_MS, Math.max(600, configured))
54
+ : DAEMON_OWNER_STALE_MS;
55
+ const heartbeatMs = Math.round(staleMs / 6);
56
+ const tickMs = Math.min(250, Math.round(heartbeatMs / 4));
57
+ return { staleMs, heartbeatMs, fenceMs: Math.round((staleMs * 2) / 3), tickMs, stallMs: tickMs * 4 };
58
+ }
59
+
60
+ /** daemon.json's pid and session endpoint, or null when missing/unreadable. */
61
+ export function readDiscovery(discoveryPath) {
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(discoveryPath, 'utf8'));
64
+ const session = parsed?.endpoints?.session;
65
+ return { pid: Number(parsed?.pid) || 0, port: Number(session?.port) || 0, token: session?.token || '' };
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /** The pid daemon.json names, or 0 when it is missing or unreadable. */
72
+ export function discoveryPid(discoveryPath) {
73
+ return readDiscovery(discoveryPath)?.pid || 0;
74
+ }
75
+
76
+ /**
77
+ * The pid a session endpoint's /health reports (0 when it does not answer
78
+ * with one), bounded by `timeoutMs` of TOTAL duration — a response trickling
79
+ * in byte by byte cannot stretch it. `timedOut` tells a probe cut off by the
80
+ * bound from one refused or answered.
81
+ */
82
+ export function probeDaemonPid({ port, token = '', timeoutMs = OWNER_PROBE_TIMEOUT_MS }) {
83
+ return new Promise((resolve) => {
84
+ if (!(Number.isInteger(port) && port > 0 && port < 65_536)) {
85
+ resolve({ pid: 0, timedOut: false });
86
+ return;
87
+ }
88
+ let settled = false;
89
+ let timer = null;
90
+ let req = null;
91
+ const finish = (pid, timedOut = false) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ clearTimeout(timer);
95
+ resolve({ pid, timedOut });
96
+ req?.destroy();
97
+ };
98
+ timer = setTimeout(() => finish(0, true), Math.max(1, timeoutMs));
99
+ req = http.get(
100
+ {
101
+ hostname: '127.0.0.1',
102
+ port,
103
+ path: '/health',
104
+ agent: false,
105
+ headers: token ? { 'X-Mixdog-Daemon-Token': token } : {},
106
+ },
107
+ (res) => {
108
+ let body = '';
109
+ res.setEncoding('utf8');
110
+ res.on('data', (chunk) => {
111
+ body += chunk;
112
+ if (body.length > PROBE_BODY_MAX_BYTES) finish(0);
113
+ });
114
+ res.on('end', () => {
115
+ let health = null;
116
+ try {
117
+ health = JSON.parse(body);
118
+ } catch {}
119
+ finish(res.statusCode === 200 && health?.status === 'ok' ? Number(health.pid) || 0 : 0);
120
+ });
121
+ res.on('error', () => finish(0));
122
+ }
123
+ );
124
+ req.on('error', () => finish(0));
125
+ });
126
+ }
127
+
128
+ function olderThan(stamp, now, staleMs) {
129
+ const at = Date.parse(String(stamp ?? ''));
130
+ // A clock step in either direction must not keep a dead record fresh.
131
+ return !Number.isFinite(at) || Math.abs(now - at) > staleMs;
132
+ }
133
+
134
+ function validPort(value) {
135
+ const port = Number(value);
136
+ return Number.isInteger(port) && port > 0 && port < 65_536 ? port : 0;
137
+ }
138
+
139
+ /** The liveness port of a leased record (heartbeat + port), else 0. */
140
+ function leasedPort(owner) {
141
+ return owner?.heartbeatAt !== undefined ? validPort(owner.livenessPort) : 0;
142
+ }
143
+
144
+ /**
145
+ * The holder's owner-liveness listener: loopback /health answering this pid
146
+ * (unauthenticated, like the session /health). Started before the claim so
147
+ * the very first record names its port; it answers whenever this process's
148
+ * event loop runs. Resolves null when loopback cannot be bound — the record
149
+ * then carries no port and is judged like a legacy one.
150
+ */
151
+ export function startOwnerLivenessListener({ pid = process.pid, log = () => {} } = {}) {
152
+ return new Promise((resolve) => {
153
+ const server = http.createServer((req, res) => {
154
+ if (req.method === 'GET' && req.url === '/health') {
155
+ res.writeHead(200, { 'Content-Type': 'application/json' });
156
+ res.end(JSON.stringify({ status: 'ok', pid }));
157
+ } else {
158
+ res.writeHead(404);
159
+ res.end();
160
+ }
161
+ });
162
+ server.once('error', (error) => {
163
+ log(`owner liveness listener unavailable: ${error?.message || error}`);
164
+ resolve(null);
165
+ });
166
+ server.listen(0, '127.0.0.1', () => {
167
+ // Never what keeps the process alive.
168
+ server.unref();
169
+ resolve({
170
+ port: server.address().port,
171
+ close: () => new Promise((done) => server.close(() => done())),
172
+ });
173
+ });
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Liveness proof for a live-pid owner record that may be reclaimable.
179
+ * Leased record (heartbeat + liveness port): null while the heartbeat looks
180
+ * fresh, else a probe of that port. Any other record (v1.1.2 and older, or an
181
+ * intermediate build's record without a port): a probe of the endpoint
182
+ * daemon.json publishes for that pid. Verdicts: `live` (answered with the
183
+ * pid), `dead` (refused, answered otherwise, or silent for the full probe
184
+ * timeout), `unknown` (silent for a probe cut short by `budgetMs`),
185
+ * `unpublished` (legacy only: no endpoint to probe).
186
+ */
187
+ export async function proveDaemonOwner(
188
+ owner,
189
+ {
190
+ discoveryPath,
191
+ now = Date.now(),
192
+ staleMs = daemonOwnerTimings().staleMs,
193
+ probe = probeDaemonPid,
194
+ timeoutMs = OWNER_PROBE_TIMEOUT_MS,
195
+ budgetMs = Number.POSITIVE_INFINITY,
196
+ } = {}
197
+ ) {
198
+ const pid = Number(owner?.pid) || 0;
199
+ if (!pid || !isPidAlive(pid)) return null;
200
+ const limit = Math.max(1, Math.min(timeoutMs, budgetMs));
201
+ const verdictOf = (answer) => {
202
+ if (answer.pid === pid) return 'live';
203
+ return answer.timedOut && limit < timeoutMs ? 'unknown' : 'dead';
204
+ };
205
+ const livenessPort = leasedPort(owner);
206
+ if (livenessPort) {
207
+ if (!olderThan(owner.heartbeatAt, now, staleMs)) return null;
208
+ return { pid, verdict: verdictOf(await probe({ port: livenessPort, timeoutMs: limit })) };
209
+ }
210
+ const discovery = readDiscovery(discoveryPath);
211
+ if (discovery?.pid !== pid || !validPort(discovery.port)) return { pid, verdict: 'unpublished' };
212
+ return { pid, verdict: verdictOf(await probe({ port: discovery.port, token: discovery.token, timeoutMs: limit })) };
213
+ }
214
+
215
+ /** proveDaemonOwner for the record currently at `ownerPath`. */
216
+ export function proveDaemonOwnerRecord(ownerPath, options) {
217
+ return proveDaemonOwner(readSingletonOwner(ownerPath).owner, options);
218
+ }
219
+
220
+ /**
221
+ * Whether a live-pid owner record may be reclaimed (the pid check itself is
222
+ * the caller's: readSingletonOwner / claimSingletonOwner), given `proof` from
223
+ * proveDaemonOwner for its pid. Without that proof it stands.
224
+ */
225
+ export function daemonOwnerIsStale(
226
+ owner,
227
+ { proof = null, now = Date.now(), staleMs = daemonOwnerTimings().staleMs } = {}
228
+ ) {
229
+ const pid = Number(owner?.pid) || 0;
230
+ if (!pid || proof?.pid !== pid) return false;
231
+ if (leasedPort(owner)) {
232
+ // Only a failed probe of its liveness port, never staleness alone. The
233
+ // heartbeat is re-checked: under the claim lock the holder may just have
234
+ // beaten.
235
+ return olderThan(owner.heartbeatAt, now, staleMs) && proof.verdict === 'dead';
236
+ }
237
+ if (proof.verdict === 'dead') return true;
238
+ return proof.verdict === 'unpublished' && olderThan(owner.claimedAt, now, staleMs);
239
+ }
240
+
241
+ /**
242
+ * The holder's side of the lease, in monotonic time (`monoNow`). Every
243
+ * `tickMs` it checks the lease, notices stalls, and every `heartbeatMs`
244
+ * rewrites `owner` with a fresh wall-clock `heartbeatAt` under the claim lock
245
+ * (recreating a deleted record), then calls `onBeat`.
246
+ * - No successful refresh for `fenceMs`: fenced, never renewed (`onFenced`).
247
+ * - The record names another pid: `onSuperseded(record)`.
248
+ * - A turn gap over `stallMs`: `admit()` refuses work until a refresh succeeds.
249
+ * Both callbacks must make this process stop serving.
250
+ */
251
+ export function createOwnerHeartbeat({
252
+ ownerPath,
253
+ owner,
254
+ heartbeatMs,
255
+ fenceMs,
256
+ tickMs,
257
+ stallMs,
258
+ now = Date.now,
259
+ monoNow = () => performance.now(),
260
+ refresh = refreshSingletonOwner,
261
+ log = () => {},
262
+ onBeat = () => {},
263
+ onSuperseded,
264
+ onFenced,
265
+ }) {
266
+ const record = { ...owner };
267
+ let state = 'active';
268
+ let leaseFrom = monoNow();
269
+ let lastRefreshAt = record.heartbeatAt ?? null;
270
+ let lastBeatAt = leaseFrom;
271
+ let lastTurnAt = leaseFrom;
272
+ let needsVerify = false;
273
+ let timer = null;
274
+
275
+ function halt(next) {
276
+ state = next;
277
+ if (timer) clearInterval(timer);
278
+ timer = null;
279
+ }
280
+
281
+ function leaseExpired(t) {
282
+ return t - leaseFrom >= fenceMs;
283
+ }
284
+
285
+ function fence(t) {
286
+ if (state !== 'active') return;
287
+ halt('fenced');
288
+ log(`owner lease not refreshed for ${Math.round(t - leaseFrom)}ms (last ${lastRefreshAt}) — fencing`);
289
+ onFenced({ lastRefreshAt, sinceMs: Math.round(t - leaseFrom) });
290
+ }
291
+
292
+ function noteTurn(t) {
293
+ if (t - lastTurnAt > stallMs && !needsVerify) {
294
+ needsVerify = true;
295
+ log(`event loop stalled ${Math.round(t - lastTurnAt)}ms — owner lease must be re-verified before serving`);
296
+ }
297
+ lastTurnAt = t;
298
+ }
299
+
300
+ function beat() {
301
+ if (state !== 'active') return null;
302
+ const t = monoNow();
303
+ lastBeatAt = t;
304
+ // Checked before every renewal: an overdue beat after a stall must fence,
305
+ // not extend a lease a contender may already have reclaimed.
306
+ if (leaseExpired(t)) {
307
+ fence(t);
308
+ return 'fenced';
309
+ }
310
+ const heartbeatAt = new Date(now()).toISOString();
311
+ let result;
312
+ try {
313
+ result = refresh(ownerPath, { ...record, heartbeatAt });
314
+ } catch (error) {
315
+ log(`owner heartbeat failed: ${error?.message || error}`);
316
+ result = { status: 'failed' };
317
+ }
318
+ if (result.status === 'refreshed') {
319
+ leaseFrom = t;
320
+ lastRefreshAt = heartbeatAt;
321
+ needsVerify = false;
322
+ onBeat();
323
+ } else if (result.status === 'superseded') {
324
+ halt('superseded');
325
+ onSuperseded(result.owner);
326
+ } else if (result.status === 'busy') {
327
+ log('owner heartbeat skipped: claim lock busy');
328
+ }
329
+ return result.status;
330
+ }
331
+
332
+ function tick() {
333
+ if (state !== 'active') return;
334
+ const t = monoNow();
335
+ noteTurn(t);
336
+ if (leaseExpired(t)) fence(t);
337
+ else if (needsVerify ? t - lastBeatAt >= tickMs : t - lastBeatAt >= heartbeatMs) beat();
338
+ }
339
+
340
+ return {
341
+ start() {
342
+ if (timer || state !== 'active') return;
343
+ timer = setInterval(tick, tickMs);
344
+ timer.unref?.();
345
+ },
346
+ /** Gate for every register/call: true while the lease is held and verified. */
347
+ admit() {
348
+ if (state === 'fenced' || state === 'superseded') return false;
349
+ const t = monoNow();
350
+ // Shutting down: no ticks run to measure stalls; the lease still bounds it.
351
+ if (state === 'stopped') return !leaseExpired(t);
352
+ noteTurn(t);
353
+ if (leaseExpired(t)) {
354
+ fence(t);
355
+ return false;
356
+ }
357
+ // Re-verified at most once per tick: a busy claim lock must not block
358
+ // the loop on every request.
359
+ if (needsVerify && t - lastBeatAt >= tickMs) beat();
360
+ return state === 'active' && !needsVerify;
361
+ },
362
+ beat,
363
+ tick,
364
+ /** Stop beating (shutdown). admit() keeps refusing once the lease runs out. */
365
+ stop() {
366
+ if (state === 'active') halt('stopped');
367
+ },
368
+ get state() {
369
+ return state;
370
+ },
371
+ };
372
+ }
@@ -37,7 +37,10 @@ export function createStoredSessionViews({ desktopRuntime, dataDir }) {
37
37
  const { createRuntimeReviewApi } = await import('../session-runtime/runtime-review-api.mjs');
38
38
  const review = createRuntimeReviewApi({ getCwd: () => metadata.cwd || '', getSessionId: () => sessionId });
39
39
  // The runtime review API answers `known`/`summary` itself (turnReviewReply).
40
- return action === 'getSessionReviewDiff' ? review.getSessionReviewDiff() : review.getTurnReviewDiff(args[0]);
40
+ if (action === 'getSessionReviewDiff') return review.getSessionReviewDiff();
41
+ // A cold session runs no turn: its change record is final.
42
+ if (action === 'getTurnChanges') return review.getTurnChanges(args[0]);
43
+ return review.getTurnReviewDiff(args[0]);
41
44
  },
42
45
  forgetStoredSession: async (sessionId) => {
43
46
  const store = await desktopRuntime.loadSessionStore();