mixdog 1.4.1 → 1.4.2

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 (326) hide show
  1. package/package.json +3 -2
  2. package/scripts/build-shipped-history.mjs +115 -33
  3. package/scripts/gate-local.mjs +172 -61
  4. package/scripts/lib/gate-scheduler.mjs +138 -32
  5. package/scripts/lib/gate-timing-reporter.mjs +16 -0
  6. package/scripts/lib/run-node-tests.mjs +54 -29
  7. package/scripts/lib/scoped-temp.mjs +1 -0
  8. package/scripts/lib/test-failure-records.mjs +14 -1
  9. package/scripts/lib/test-failure-summary.mjs +16 -4
  10. package/scripts/lib/test-spawn-ledger.mjs +12 -3
  11. package/scripts/pptx-kit-index.mjs +5 -6
  12. package/scripts/suite-health.mjs +110 -28
  13. package/scripts/test.mjs +9 -14
  14. package/src/agents/advisor/AGENT.md +4 -4
  15. package/src/agents/advisor/agent.json +1 -1
  16. package/src/agents/explore/AGENT.md +22 -0
  17. package/src/agents/explore/agent.json +6 -0
  18. package/src/cli.mjs +7 -0
  19. package/src/defaults/agents.json +7 -0
  20. package/src/defaults/shipped-history.json +18 -2
  21. package/src/defaults/skills/browser-use/SKILL.md +4 -1
  22. package/src/defaults/skills/code-tidy/SKILL.md +71 -102
  23. package/src/defaults/skills/code-tidy/references/agent-cleanup.md +66 -114
  24. package/src/defaults/skills/code-tidy/references/cleanup-report.md +19 -35
  25. package/src/defaults/skills/code-tidy/references/dead-code.md +3 -9
  26. package/src/defaults/skills/code-tidy/references/test-suite-slop.md +16 -27
  27. package/src/defaults/skills/computer-use/SKILL.md +12 -2
  28. package/src/defaults/skills/goal-management/SKILL.md +1 -1
  29. package/src/defaults/skills/setup/references/actions.md +12 -6
  30. package/src/defaults/skills/setup/references/surfaces.md +2 -2
  31. package/src/defaults/skills/setup-guide/SKILL.md +5 -2
  32. package/src/defaults/skills/setup-guide/model-recommendations.json +13 -0
  33. package/src/defaults/skills/skill-creator/SKILL.md +85 -190
  34. package/src/defaults/skills/skill-creator/references/design.md +70 -162
  35. package/src/defaults/skills/skill-creator/references/evaluation.md +33 -92
  36. package/src/defaults/skills/test-suite/SKILL.md +194 -0
  37. package/src/defaults/skills/test-suite/references/case-management.md +51 -0
  38. package/src/defaults/skills/test-suite/references/flaky-policy.md +39 -0
  39. package/src/defaults/skills/test-suite/references/reliability.md +49 -0
  40. package/src/defaults/skills/test-suite/references/suite-design.md +78 -0
  41. package/src/defaults/skills/test-suite/scripts/inventory.mjs +335 -0
  42. package/src/defaults/skills/visualize/SKILL.md +6 -2
  43. package/src/lib/rules-builder.cjs +6 -30
  44. package/src/rules/lead/LEAD.md +3 -3
  45. package/src/rules/shared/20-research.md +3 -6
  46. package/src/rules/shared/40-editing.md +1 -0
  47. package/src/rules/shared/60-verification.md +10 -9
  48. package/src/rules/shared/70-delivery.md +4 -3
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch/abort-link.mjs +5 -1
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +10 -1
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +130 -21
  52. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +13 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +4 -1
  54. package/src/runtime/agent/orchestrator/config.mjs +12 -7
  55. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +7 -7
  56. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +5 -26
  57. package/src/runtime/agent/orchestrator/internal-agents.mjs +3 -14
  58. package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +122 -12
  59. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +5 -3
  60. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +13 -2
  61. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +152 -58
  62. package/src/runtime/agent/orchestrator/mcp/client.mjs +51 -18
  63. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +83 -52
  64. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +42 -26
  65. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +43 -8
  66. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +30 -28
  67. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +31 -21
  69. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-login.mjs +3 -1
  70. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-tokens.mjs +2 -2
  71. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  72. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +1 -1
  73. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +40 -0
  74. package/src/runtime/agent/orchestrator/providers/custom-provider-keys.mjs +34 -0
  75. package/src/runtime/agent/orchestrator/providers/custom-provider.mjs +3 -0
  76. package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +14 -7
  77. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +3 -2
  78. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +2 -2
  79. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +4 -1
  80. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +48 -31
  81. package/src/runtime/agent/orchestrator/providers/lib/oauth-page.mjs +0 -18
  82. package/src/runtime/agent/orchestrator/providers/lib/oauth-token-utils.mjs +138 -6
  83. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +1 -1
  84. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +100 -33
  85. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/providers/oauth-secret-fields.fixtures.mjs +45 -0
  87. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +26 -28
  88. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +3 -1
  89. package/src/runtime/agent/orchestrator/providers/openai-ws-watchdogs.mjs +1 -0
  90. package/src/runtime/agent/orchestrator/providers/opencode-go-oauth.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +3 -1
  92. package/src/runtime/agent/orchestrator/providers/registry.mjs +33 -2
  93. package/src/runtime/agent/orchestrator/providers/vertex-ai-auth.mjs +622 -0
  94. package/src/runtime/agent/orchestrator/providers/vertex-ai-maas.mjs +185 -0
  95. package/src/runtime/agent/orchestrator/providers/vertex-ai-models.mjs +445 -0
  96. package/src/runtime/agent/orchestrator/providers/vertex-ai.mjs +567 -0
  97. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +2 -0
  98. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +2 -1
  99. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  100. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +3 -0
  101. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +14 -5
  102. package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -0
  103. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +30 -5
  104. package/src/runtime/agent/orchestrator/session/agent-room.mjs +171 -37
  105. package/src/runtime/agent/orchestrator/session/cache-warm.mjs +94 -10
  106. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +3 -3
  107. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +10 -5
  108. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +5 -5
  109. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +11 -1
  110. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -3
  111. package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +36 -6
  112. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +5 -3
  113. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +21 -2
  114. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +2 -0
  115. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +23 -1
  116. package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +13 -8
  117. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +50 -28
  118. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +1 -0
  119. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +3 -4
  120. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +2 -0
  121. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +7 -0
  122. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +15 -0
  123. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +25 -14
  124. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +17 -14
  125. package/src/runtime/agent/orchestrator/session/manager/session-scratch-environment.mjs +70 -0
  126. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +0 -3
  127. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -3
  128. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +47 -29
  129. package/src/runtime/agent/orchestrator/session/manager/turn-reap.mjs +125 -0
  130. package/src/runtime/agent/orchestrator/session/mixy-name.mjs +11 -5
  131. package/src/runtime/agent/orchestrator/session/restart-prefix-parity.fixture.mjs +1 -1
  132. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +243 -38
  133. package/src/runtime/agent/orchestrator/session/store-transcript-cache.mjs +163 -64
  134. package/src/runtime/agent/orchestrator/session/store-transcript-projection.mjs +164 -4
  135. package/src/runtime/agent/orchestrator/session/store-transcript-worker.mjs +142 -18
  136. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/completed-result.mjs +6 -2
  137. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +36 -26
  138. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +3 -5
  139. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +58 -20
  140. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +20 -15
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +96 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +59 -10
  143. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  144. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +99 -2
  145. package/src/runtime/agent/orchestrator/tools/lib/owned-shell-trees.mjs +2 -0
  146. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +103 -13
  147. package/src/runtime/agent/orchestrator/tools/lib/shell-run-background/promote-job.mjs +3 -1
  148. package/src/runtime/agent/orchestrator/tools/lib/shell-run-settle.mjs +49 -15
  149. package/src/runtime/agent/orchestrator/tools/lib/shell-run-spawn.mjs +45 -27
  150. package/src/runtime/agent/orchestrator/tools/lib/shell-run-state.mjs +34 -10
  151. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +2 -0
  152. package/src/runtime/agent/orchestrator/tools/lib/wait-until-fixture.mjs +26 -0
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -2
  154. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +13 -13
  155. package/src/runtime/channels/lib/whisper-port.mjs +19 -10
  156. package/src/runtime/channels/lib/whisper-server.mjs +10 -1
  157. package/src/runtime/connectors/connector-accounts-mcp.mjs +236 -103
  158. package/src/runtime/connectors/gmail-app-password.mjs +3 -3
  159. package/src/runtime/explore/explorer.mjs +93 -0
  160. package/src/runtime/explore/tool-defs.mjs +35 -0
  161. package/src/runtime/explore/tool.mjs +123 -0
  162. package/src/runtime/memory/index.mjs +4 -0
  163. package/src/runtime/memory/lib/embedding-provider.mjs +4 -1
  164. package/src/runtime/memory/lib/embedding-worker.mjs +4 -0
  165. package/src/runtime/office/quality/assurance-structure-docx.mjs +1 -1
  166. package/src/runtime/shared/agent-route-config.mjs +17 -2
  167. package/src/runtime/shared/atomic-file.mjs +103 -2
  168. package/src/runtime/shared/background-tasks.mjs +15 -6
  169. package/src/runtime/shared/connector-accounts.mjs +5 -5
  170. package/src/runtime/shared/connector-catalog.d.mts +11 -4
  171. package/src/runtime/shared/connector-catalog.mjs +60 -52
  172. package/src/runtime/shared/custom-provider-keys.mjs +20 -0
  173. package/src/runtime/shared/file-has-data.mjs +26 -2
  174. package/src/runtime/shared/file-lock.mjs +13 -15
  175. package/src/runtime/shared/git-launch.d.mts +9 -0
  176. package/src/runtime/shared/git-launch.mjs +213 -0
  177. package/src/runtime/shared/git-runner-worker.mjs +2 -1
  178. package/src/runtime/shared/llm/cost.mjs +6 -1
  179. package/src/runtime/shared/llm/http-agent.mjs +313 -29
  180. package/src/runtime/shared/llm/model-catalog-projection.mjs +3 -2
  181. package/src/runtime/shared/llm/model-catalog.mjs +46 -6
  182. package/src/runtime/shared/llm/provider-catalog-registry.mjs +3 -0
  183. package/src/runtime/shared/llm/system-proxy.mjs +452 -0
  184. package/src/runtime/shared/provider-accounts.mjs +52 -2
  185. package/src/runtime/shared/staged-install-worker.mjs +5 -0
  186. package/src/runtime/shared/staged-update.mjs +47 -2
  187. package/src/runtime/shared/tool-surface.mjs +110 -36
  188. package/src/runtime/shared/tool-work-units.mjs +4 -0
  189. package/src/runtime/shared/turn-worktree-snapshot.mjs +414 -54
  190. package/src/runtime/shared/worktrees/activity-gate.mjs +215 -318
  191. package/src/runtime/shared/worktrees/activity-ledger-worker.mjs +56 -0
  192. package/src/runtime/shared/worktrees/activity-ledger.mjs +391 -0
  193. package/src/runtime/shared/worktrees/branch-management.mjs +9 -3
  194. package/src/runtime/shared/worktrees/git.mjs +57 -0
  195. package/src/runtime/shared/worktrees/manager.mjs +205 -137
  196. package/src/runtime/tidy/install.mjs +95 -15
  197. package/src/runtime/tidy/languages.mjs +17 -15
  198. package/src/runtime/tidy/process.mjs +7 -4
  199. package/src/runtime/tidy/resolve.mjs +19 -16
  200. package/src/runtime/tidy/runners/biome.mjs +3 -3
  201. package/src/runtime/tidy/tool.mjs +1 -1
  202. package/src/runtime/tidy/worktree.mjs +10 -6
  203. package/src/runtime/visualize-bridge/client.mjs +4 -1
  204. package/src/runtime/visualize-bridge/tool-defs.mjs +7 -2
  205. package/src/runtime/web-search/index.mjs +1 -1
  206. package/src/session-runtime/agent-task-sessions.mjs +410 -353
  207. package/src/session-runtime/boot/tools.mjs +2 -0
  208. package/src/session-runtime/bot-status.mjs +103 -103
  209. package/src/session-runtime/connector-merge-migration.mjs +18 -15
  210. package/src/session-runtime/conversation-import/discovery.mjs +159 -40
  211. package/src/session-runtime/conversation-import/service.mjs +9 -3
  212. package/src/session-runtime/handoff-attachments.mjs +1 -1
  213. package/src/session-runtime/held-requests.mjs +3 -1
  214. package/src/session-runtime/internal-tool-executor/feature-tools.mjs +2 -0
  215. package/src/session-runtime/internal-tool-executor/knowledge-tools.mjs +8 -0
  216. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +143 -116
  217. package/src/session-runtime/lifecycle/teardown/session-close.mjs +6 -1
  218. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  219. package/src/session-runtime/mcp-glue/server-catalog.mjs +16 -10
  220. package/src/session-runtime/mcp-glue/server-input.mjs +4 -1
  221. package/src/session-runtime/mixy-heartbeat.mjs +1 -1
  222. package/src/session-runtime/provider-auth/login.mjs +2 -2
  223. package/src/session-runtime/resource-mcp-api.mjs +98 -35
  224. package/src/session-runtime/resource-mcp-connector-accounts.mjs +44 -31
  225. package/src/session-runtime/resource-mcp-secrets.mjs +28 -11
  226. package/src/session-runtime/resource-mcp-servers.mjs +5 -4
  227. package/src/session-runtime/services/agent-tool/send-flow.mjs +10 -2
  228. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-prep.mjs +8 -3
  229. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +4 -1
  230. package/src/session-runtime/services/agent-watchdog-registry.mjs +32 -4
  231. package/src/session-runtime/services/channel-worker/daemon-spawn.mjs +2 -0
  232. package/src/session-runtime/services/daemon-candidate.mjs +4 -1
  233. package/src/session-runtime/services/memory-runtime-proxy/daemon-spawn.mjs +4 -0
  234. package/src/session-runtime/services/plugin-admin.mjs +10 -6
  235. package/src/session-runtime/services/provider-admin.mjs +281 -10
  236. package/src/session-runtime/session-turn-api.mjs +2 -0
  237. package/src/session-runtime/settings-builtin-tools-api.mjs +4 -1
  238. package/src/session-runtime/settings-system-api.mjs +1 -1
  239. package/src/session-runtime/setup-tool/agent-settings-actions.mjs +13 -3
  240. package/src/session-runtime/setup-tool/connector-widen-approval.mjs +96 -0
  241. package/src/session-runtime/setup-tool/executor.mjs +6 -2
  242. package/src/session-runtime/setup-tool/tool-defs.mjs +2 -1
  243. package/src/session-runtime/turn/agent-ops.mjs +23 -0
  244. package/src/session-runtime/workflow-agents-api/agent-connectors.mjs +49 -11
  245. package/src/session-runtime/workflow-agents-api/agent-route.mjs +10 -1
  246. package/src/session-runtime/workspace/activity-guards.mjs +176 -0
  247. package/src/session-runtime/workspace/session-view.mjs +149 -0
  248. package/src/session-runtime/workspace/side-checkouts.mjs +203 -0
  249. package/src/session-runtime/workspace/transition-runner.mjs +188 -0
  250. package/src/session-runtime/workspace/transitions.mjs +108 -1066
  251. package/src/session-runtime/workspace/workspace-merge.mjs +132 -0
  252. package/src/session-runtime/workspace/worktree-intents.mjs +242 -0
  253. package/src/session-runtime/workspace/worktree-maintenance.mjs +80 -0
  254. package/src/session-runtime/workspace/worktree-setup.mjs +85 -0
  255. package/src/session-runtime/workspace/worktree-tool.mjs +54 -54
  256. package/src/standalone/daemon-desktop-runtime.mjs +2 -0
  257. package/src/standalone/daemon-session-control.mjs +151 -136
  258. package/src/standalone/daemon.mjs +7 -0
  259. package/src/standalone/desktop-service-registry.mjs +9 -0
  260. package/src/standalone/local-session-runtime.mjs +5 -1
  261. package/src/standalone/session-client/daemon-unavailable.mjs +3 -0
  262. package/src/standalone/session-client.mjs +5 -1
  263. package/src/standalone/session-frame-stream.mjs +83 -11
  264. package/src/standalone/session-protocol.mjs +4 -0
  265. package/src/standalone/session-service/agent-tree.mjs +45 -9
  266. package/src/standalone/session-service/projection/transcript-window.mjs +700 -64
  267. package/src/standalone/session-service/session-calls/session-views.mjs +160 -104
  268. package/src/standalone/session-service/stored-reader.mjs +127 -40
  269. package/src/tui/App.jsx +154 -164
  270. package/src/tui/app/app-view/floating-panel.jsx +3 -0
  271. package/src/tui/app/app-view/text-entry-labels.mjs +1 -0
  272. package/src/tui/app/clipboard.mjs +21 -10
  273. package/src/tui/app/prompt-handlers/use-prompt-history-nav.mjs +1 -1
  274. package/src/tui/app/prompt-handlers/use-prompt-interrupt.mjs +3 -0
  275. package/src/tui/app/prompt-submit/provider-prompts.mjs +3 -1
  276. package/src/tui/app/provider-setup/api-key-actions.mjs +31 -1
  277. package/src/tui/app/provider-setup/oauth-actions.mjs +25 -6
  278. package/src/tui/app/provider-setup-picker.mjs +20 -13
  279. package/src/tui/app/shell-layout/row-budgets.mjs +12 -10
  280. package/src/tui/app/slash-commands.mjs +1 -0
  281. package/src/tui/app/slash-dispatch/session-commands.mjs +46 -0
  282. package/src/tui/app/slash-dispatch.mjs +1 -1
  283. package/src/tui/app/use-btw-panel.mjs +29 -0
  284. package/src/tui/app/use-context-panel-refresh.mjs +31 -0
  285. package/src/tui/app/use-model-catalog-cache.mjs +13 -10
  286. package/src/tui/app/use-mouse-input.mjs +11 -0
  287. package/src/tui/app/use-onboarding-start.mjs +8 -3
  288. package/src/tui/app/use-panel-transition.mjs +10 -1
  289. package/src/tui/app/use-pasted-buffers.mjs +28 -20
  290. package/src/tui/app/use-prompt-draft-flow.mjs +7 -0
  291. package/src/tui/app/use-prompt-layout-rows.mjs +3 -3
  292. package/src/tui/app/use-prompt-queue-history.mjs +3 -2
  293. package/src/tui/app/use-selection-focus.mjs +11 -2
  294. package/src/tui/app/use-selection-geometry.mjs +18 -9
  295. package/src/tui/app/use-selection-paint.mjs +4 -4
  296. package/src/tui/app/use-selection-stitch.mjs +3 -3
  297. package/src/tui/app/use-slash-palette.mjs +62 -0
  298. package/src/tui/app/use-terminal-chrome.mjs +2 -2
  299. package/src/tui/app/use-transcript-activity.mjs +47 -32
  300. package/src/tui/app/use-transcript-follow.mjs +12 -4
  301. package/src/tui/app/use-transcript-scroll.mjs +46 -17
  302. package/src/tui/app/use-transcript-swap-reset.mjs +27 -0
  303. package/src/tui/app/use-transcript-window/geometry.mjs +19 -5
  304. package/src/tui/app/use-transcript-window/instance-state.mjs +4 -0
  305. package/src/tui/app/use-transcript-window/selection-shift.mjs +5 -1
  306. package/src/tui/app/use-transcript-window.mjs +21 -11
  307. package/src/tui/app/use-tui-ready.mjs +18 -0
  308. package/src/tui/app/use-welcome-prompt-hint.mjs +5 -0
  309. package/src/tui/app/use-workflow-tab-cycle.mjs +5 -3
  310. package/src/tui/components/Picker.jsx +27 -3
  311. package/src/tui/components/SignInUrl.jsx +68 -0
  312. package/src/tui/components/TextEntryPanel.jsx +76 -46
  313. package/src/tui/components/sign-in-url-layout.mjs +24 -0
  314. package/src/tui/dist/index.mjs +1896 -1341
  315. package/src/tui/session/goal-turn-state.mjs +6 -2
  316. package/src/tui/session/session-api/controls.mjs +4 -0
  317. package/src/tui/session/session-api/extensions.mjs +8 -0
  318. package/src/tui/session/session-api/intake/abort.mjs +2 -1
  319. package/src/tui/session/session-api/intake.mjs +2 -0
  320. package/src/tui/session/session-api/integrations.mjs +2 -2
  321. package/src/tui/session/session-api/routes-agent-definitions.mjs +1 -0
  322. package/src/tui/session/session-api/settings.mjs +2 -2
  323. package/src/tui/session/transcript-spill/spill-writer.mjs +15 -0
  324. package/src/tui/session/transcript-spill.mjs +92 -4
  325. package/src/tui/session/transcript-store.mjs +2 -0
  326. package/src/defaults/skills/skill-creator/references/source-review.md +0 -94
@@ -104,9 +104,10 @@ async function runGit(
104
104
  maxBytes = COMMAND_MAX_BYTES,
105
105
  allowFailure = false,
106
106
  stdoutEncoding = 'utf8',
107
+ env = null,
107
108
  } = {}
108
109
  ) {
109
- const result = await runGitOffThread(args, { cwd, input, timeoutMs, maxBytes, stdoutEncoding });
110
+ const result = await runGitOffThread(args, { cwd, input, env, timeoutMs, maxBytes, stdoutEncoding });
110
111
  if (result.code !== 0 && !allowFailure) throw commandError(args, result.stderr, result.code);
111
112
  return result;
112
113
  }
@@ -142,6 +143,7 @@ function stateForRoot(root) {
142
143
  gitDir,
143
144
  initialized: false,
144
145
  sourceIndexPath: null,
146
+ sourcePaths: null,
145
147
  sourceIndexIdentity: null,
146
148
  sourceTracked: new Set(),
147
149
  lineEndingArgs: [],
@@ -233,7 +235,7 @@ function assertStateLive(state) {
233
235
  throw error;
234
236
  }
235
237
 
236
- function shadowArgs(state, args) {
238
+ function shadowArgs(state, args, workTree = state.root) {
237
239
  assertStateLive(state);
238
240
  return [
239
241
  ...state.lineEndingArgs,
@@ -245,7 +247,7 @@ function shadowArgs(state, args) {
245
247
  '--git-dir',
246
248
  state.gitDir,
247
249
  '--work-tree',
248
- state.root,
250
+ workTree,
249
251
  ...args,
250
252
  ];
251
253
  }
@@ -259,21 +261,62 @@ async function sourceGitPath(root, suffix) {
259
261
  return result.code === 0 && value ? resolve(root, value) : null;
260
262
  }
261
263
 
262
- async function syncSourceExclude(state) {
263
- const source = await sourceGitPath(state.root, 'info/exclude');
264
- if (!source || !(await exists(source))) return;
265
- const target = join(state.gitDir, 'info', 'exclude');
266
- await mkdir(dirname(target), { recursive: true });
267
- await copyFile(source, target).catch(() => {});
264
+ /** The source repository's info/exclude, index and common directory paths
265
+ * from one `rev-parse`, kept for the state's lifetime once answered (the
266
+ * index path already was). An unanswered or unexpected reply falls back to
267
+ * the per-path reads, uncached, so a failure is retried as before. */
268
+ async function sourceGitPaths(state) {
269
+ if (state.sourcePaths) return state.sourcePaths;
270
+ const result = await runGit(
271
+ [
272
+ 'rev-parse',
273
+ '--path-format=absolute',
274
+ '--git-path',
275
+ 'info/exclude',
276
+ '--git-path',
277
+ 'index',
278
+ '--git-common-dir',
279
+ ],
280
+ { cwd: state.root, allowFailure: true }
281
+ );
282
+ const lines = String(result.stdout || '')
283
+ .split(/\r?\n/)
284
+ .filter((line) => line !== '');
285
+ if (result.code === 0 && lines.length === 3 && lines.every((line) => clean(line))) {
286
+ const [exclude, index, commonDir] = lines.map((line) => clean(line));
287
+ state.sourcePaths = {
288
+ exclude: resolve(state.root, exclude),
289
+ index: resolve(state.root, index),
290
+ commonDir,
291
+ };
292
+ return state.sourcePaths;
293
+ }
294
+ const [exclude, index, commonDir] = await Promise.all([
295
+ sourceGitPath(state.root, 'info/exclude'),
296
+ sourceGitPath(state.root, 'index'),
297
+ sourceCommonDir(state.root),
298
+ ]);
299
+ return { exclude, index, commonDir };
268
300
  }
269
301
 
270
- async function syncAlternates(state) {
302
+ async function sourceCommonDir(root) {
271
303
  const result = await runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], {
272
- cwd: state.root,
304
+ cwd: root,
273
305
  allowFailure: true,
274
306
  });
275
307
  const commonDir = clean(result.stdout);
276
- if (result.code !== 0 || !commonDir) return;
308
+ return result.code === 0 && commonDir ? commonDir : null;
309
+ }
310
+
311
+ async function syncSourceExclude(state, source) {
312
+ if (!source || !(await exists(source))) return;
313
+ const target = join(state.gitDir, 'info', 'exclude');
314
+ await mkdir(dirname(target), { recursive: true });
315
+ await copyFile(source, target).catch(() => {});
316
+ }
317
+
318
+ async function syncAlternates(state, commonDir) {
319
+ if (!commonDir) return;
277
320
  const sourceObjects = join(resolve(state.root, commonDir), 'objects');
278
321
  if (!(await exists(sourceObjects))) return;
279
322
  const alternates = [sourceObjects];
@@ -294,7 +337,7 @@ async function syncAlternates(state) {
294
337
  }
295
338
 
296
339
  async function sourceIndexIdentity(state) {
297
- state.sourceIndexPath ??= await sourceGitPath(state.root, 'index');
340
+ state.sourceIndexPath ??= (await sourceGitPaths(state)).index;
298
341
  if (!state.sourceIndexPath) return 'missing';
299
342
  try {
300
343
  const stat = await lstat(state.sourceIndexPath);
@@ -349,10 +392,14 @@ async function ensureState(state) {
349
392
  'utf8'
350
393
  );
351
394
  }
395
+ // One layout read serves both syncs below. Every (re)initialisation reads the
396
+ // source layout afresh, so a changed `.git` indirection is never served stale.
397
+ state.sourcePaths = null;
398
+ const paths = await sourceGitPaths(state);
352
399
  const [lineEndingArgs] = await Promise.all([
353
400
  sourceLineEndingArgs(state.root),
354
- syncSourceExclude(state),
355
- syncAlternates(state),
401
+ syncSourceExclude(state, paths.exclude),
402
+ syncAlternates(state, paths.commonDir),
356
403
  ]);
357
404
  state.lineEndingArgs = lineEndingArgs;
358
405
  // Seed once per process, then keep this index synchronized with the live
@@ -366,7 +413,7 @@ async function ensureState(state) {
366
413
 
367
414
  async function resetShadowIndex(state) {
368
415
  state.lastCapture = null;
369
- state.sourceIndexPath ??= await sourceGitPath(state.root, 'index');
416
+ state.sourceIndexPath ??= (await sourceGitPaths(state)).index;
370
417
  const sourceIndex = state.sourceIndexPath;
371
418
  const target = join(state.gitDir, 'index');
372
419
  if (sourceIndex && (await exists(sourceIndex))) {
@@ -545,7 +592,9 @@ function withOmittedBaselines(snapshot, review) {
545
592
  return { ...review, files, patch, diffTooLarge: reviewDiffTooLarge(files, patch) };
546
593
  }
547
594
 
548
- async function applyBaselineFilesUnlocked(state, tree, allEntries) {
595
+ /** `tree` with `allEntries` (recorded first states) applied. `absent`: keys of
596
+ * paths known not to be in `tree`, whose removal is a no-op and runs no git. */
597
+ async function applyBaselineFilesUnlocked(state, tree, allEntries, { absent = null } = {}) {
549
598
  // Omitted baselines change no tree: they are carried as metadata only.
550
599
  const files = allEntries
551
600
  .filter((entry) => entry?.omitted === true)
@@ -555,37 +604,257 @@ async function applyBaselineFilesUnlocked(state, tree, allEntries) {
555
604
  omitted: true,
556
605
  size: Number(entry.size) || 0,
557
606
  }));
558
- const entries = allEntries.filter((entry) => entry?.omitted !== true);
559
- if (entries.length === 0) return { tree, files };
560
- const saved = clean((await runGit(shadowArgs(state, ['write-tree']), { cwd: state.root })).stdout);
561
- try {
562
- await runGit(shadowArgs(state, ['read-tree', tree]), { cwd: state.root });
563
- for (const entry of entries) {
607
+ const entries = allEntries
608
+ .filter((entry) => entry?.omitted !== true)
609
+ .map((entry) => {
564
610
  const path = safeRelativePath(state.root, entry.path);
565
- let oid = entry.oid || null;
566
- if (entry.content !== undefined && entry.content !== null) {
567
- oid = clean(
568
- (
569
- await runGit(shadowArgs(state, ['hash-object', '-w', '--stdin', '--path', path]), {
570
- cwd: state.root,
571
- input: entry.content,
572
- })
573
- ).stdout
574
- );
611
+ const content = entry.content !== undefined && entry.content !== null ? entry.content : null;
612
+ const noop = content === null && !entry.oid && Boolean(absent?.has(pathKey(path)));
613
+ return { path, content, oid: entry.oid || null, noop };
614
+ });
615
+ if (entries.every((entry) => entry.noop)) {
616
+ return { tree, files: [...files, ...entries.map(({ path }) => ({ path, oid: null }))] };
617
+ }
618
+ try {
619
+ return await applyInIndex(state, tree, entries, files, temporaryIndexEnv(state), { batch: true });
620
+ } catch {
621
+ // The original sequence in the shadow index: it fails, if at all, as before.
622
+ return await applyInShadowIndex(state, tree, entries, files);
623
+ }
624
+ }
625
+
626
+ let temporaryIndexSeq = 0;
627
+
628
+ /** An environment whose git index is a fresh file in the shadow repository. */
629
+ function temporaryIndexEnv(state) {
630
+ const index = join(state.gitDir, `index.baseline-${process.pid}-${++temporaryIndexSeq}`);
631
+ return { ...process.env, GIT_INDEX_FILE: index };
632
+ }
633
+
634
+ /** `content` written as a blob, converted as the file at `path` would be. */
635
+ async function hashBlob(state, path, content, env) {
636
+ const result = await runGit(shadowArgs(state, ['hash-object', '-w', '--stdin', '--path', path]), {
637
+ cwd: state.root,
638
+ input: content,
639
+ env,
640
+ });
641
+ return clean(result.stdout);
642
+ }
643
+
644
+ let blobWorkSeq = 0;
645
+ // A path component a scratch worktree file could not carry unchanged: a
646
+ // character or name the platform rewrites (Windows device names, trailing dots
647
+ // and spaces, 8.3 aliases) or git treats specially (`.git`, `.gitattributes`).
648
+ const UNBATCHABLE_COMPONENT =
649
+ /[\u0000-\u001f"*:<>?|\\]|[. ]$|~\d|^(?:con|prn|aux|nul|(?:com|lpt)[0-9\u00b9\u00b2\u00b3])(?:\..*)?$|^\.(?:git|gitattributes)$/i;
650
+ // Longest scratch file path written: below the classic Windows MAX_PATH.
651
+ const MAX_BATCH_PATH = 240;
652
+
653
+ function batchKey(path) {
654
+ return path.normalize('NFC').toLowerCase();
655
+ }
656
+
657
+ /** Blob ids of `items` (`{ path, content }`) as `hash-object --stdin --path`
658
+ * gives them, in a constant number of git processes. hash-object takes its
659
+ * attributes, `core.autocrlf` and eol conversion from the file's path, so the
660
+ * contents are written to a scratch worktree at the same relative paths, with
661
+ * the real worktree's `.gitattributes` files beside them, and hashed there by
662
+ * one `hash-object --stdin-paths`: the same attribute stack, the same
663
+ * conversion code. A path with a `filter=` attribute (a clean driver may read
664
+ * the worktree) and a path the scratch worktree cannot represent exactly are
665
+ * hashed one by one as before. Any failure throws and the caller falls back. */
666
+ async function hashBlobs(state, items, env) {
667
+ const ids = new Map();
668
+ const batched = [];
669
+ const single = [];
670
+ const keys = new Set();
671
+ const scratch = join(state.gitDir, `baseline-work-${process.pid}-${++blobWorkSeq}`);
672
+ for (const item of items) {
673
+ const key = batchKey(item.path);
674
+ const fits =
675
+ !keys.has(key) &&
676
+ scratch.length + 1 + item.path.length <= MAX_BATCH_PATH &&
677
+ !item.path.split('/').some((part) => UNBATCHABLE_COMPONENT.test(part));
678
+ if (fits) {
679
+ keys.add(key);
680
+ batched.push(item);
681
+ } else {
682
+ single.push(item);
683
+ }
684
+ }
685
+ if (batched.length > 0) {
686
+ try {
687
+ const directories = new Set(['']);
688
+ for (const { path } of batched) {
689
+ for (let dir = dirname(path); dir !== '.'; dir = dirname(dir)) directories.add(dir);
690
+ }
691
+ await mkdir(scratch, { recursive: true });
692
+ await Promise.all([...directories].map((dir) => mkdir(join(scratch, dir), { recursive: true })));
693
+ await Promise.all([
694
+ ...batched.map(({ path, content }) => writeFile(join(scratch, path), content)),
695
+ // git ignores a symbolic-link .gitattributes, so only a regular file is carried over.
696
+ ...[...directories].map(async (dir) => {
697
+ const source = join(state.root, dir, '.gitattributes');
698
+ if ((await lstat(source).catch(() => null))?.isFile()) await copyFile(source, join(scratch, dir, '.gitattributes'));
699
+ }),
700
+ ]);
701
+ const options = { cwd: scratch, env };
702
+ const filtered = await runGit(shadowArgs(state, ['check-attr', '-z', '--stdin', 'filter'], scratch), {
703
+ ...options,
704
+ input: `${batched.map(({ path }) => path).join('\0')}\0`,
705
+ });
706
+ const fields = filtered.stdout.split('\0');
707
+ const driven = new Set();
708
+ for (let index = 0; index + 2 < fields.length; index += 3) {
709
+ if (fields[index + 2] !== 'unspecified') driven.add(fields[index]);
710
+ }
711
+ const plain = batched.filter((item) => {
712
+ if (!driven.has(item.path)) return true;
713
+ single.push(item);
714
+ return false;
715
+ });
716
+ if (plain.length > 0) {
717
+ const hashed = await runGit(shadowArgs(state, ['hash-object', '-w', '--stdin-paths'], scratch), {
718
+ ...options,
719
+ input: `${plain.map(({ path }) => path).join('\n')}\n`,
720
+ });
721
+ const oids = hashed.stdout.split('\n').filter(Boolean);
722
+ if (oids.length !== plain.length) throw new Error('hash-object answered a different number of blobs');
723
+ plain.forEach((item, index) => ids.set(item, oids[index].trim()));
724
+ }
725
+ } finally {
726
+ await rm(scratch, { recursive: true, force: true }).catch(() => {});
727
+ }
728
+ }
729
+ for (const item of single) ids.set(item, await hashBlob(state, item.path, item.content, env));
730
+ return ids;
731
+ }
732
+
733
+ // Longest ls-tree command line the directory/file probe sends.
734
+ const MAX_PROBE_CHARS = 24_000;
735
+
736
+ /** Every proper parent directory of `path`, outermost last. */
737
+ function parentDirectories(path) {
738
+ const parents = [];
739
+ for (let dir = dirname(path); dir !== '.' && dir !== '/'; dir = dirname(dir)) parents.push(dir);
740
+ return parents;
741
+ }
742
+
743
+ /** Could `update-index --index-info` take `addPaths` on top of `tree` without
744
+ * replacing anything? Unlike `--cacheinfo`, it replaces a file by a directory
745
+ * of the same name and the reverse; the per-file sequence refuses both and
746
+ * the caller must see that refusal. False for a pair of added paths where one
747
+ * is a directory of the other, or an added path whose parent is a file (or
748
+ * submodule) in `tree`, or that is a directory in `tree`. A path the probe
749
+ * cannot ask about exactly (a leading ':' is pathspec magic) or too many to
750
+ * ask at once counts as unsafe, so the caller runs the per-file sequence. */
751
+ async function addsReplaceNothing(state, tree, addPaths, env) {
752
+ if (addPaths.length === 0) return true;
753
+ if (addPaths.some((path) => path.startsWith(':'))) return false;
754
+ const added = new Set(addPaths);
755
+ const parents = new Set(addPaths.flatMap(parentDirectories));
756
+ if ([...parents].some((dir) => added.has(dir))) return false;
757
+ const asked = [...new Set([...added, ...parents])];
758
+ if (asked.reduce((total, path) => total + path.length + 1, 0) > MAX_PROBE_CHARS) return false;
759
+ const listed = await runGit(shadowArgs(state, ['ls-tree', '-z', tree, '--', ...asked]), {
760
+ cwd: state.root,
761
+ env,
762
+ });
763
+ for (const record of listed.stdout.split('\0')) {
764
+ const tab = record.indexOf('\t');
765
+ if (tab < 0) continue;
766
+ const isTree = record.slice(0, tab).split(' ')[1] === 'tree';
767
+ const path = record.slice(tab + 1);
768
+ if (isTree ? added.has(path) : parents.has(path)) return false;
769
+ }
770
+ return true;
771
+ }
772
+
773
+ /** The entries applied on top of `tree` in an index of their own, removed
774
+ * afterwards: the shadow index is never touched, so it needs no save and
775
+ * restore. Every command (hash-object's attribute lookup included) sees the
776
+ * same index content the original sequence does: `tree` plus the entries so
777
+ * far. Null `env`: the shadow index itself (the caller saves and restores).
778
+ * `batch`: when it spawns fewer git processes and no entry swaps a file for a
779
+ * directory (addsReplaceNothing), every blob is hashed together (hashBlobs)
780
+ * and every add and removal is one `update-index --index-info`, instead of a
781
+ * `hash-object` and an `update-index` per entry. */
782
+ async function applyInIndex(state, tree, entries, baseFiles, env, { batch = false } = {}) {
783
+ const files = [...baseFiles];
784
+ try {
785
+ const live = entries.filter(({ noop }) => !noop);
786
+ const contents = live.filter(({ content }) => content !== null).length;
787
+ const adds = live.filter(({ content, oid }) => content !== null || oid).map(({ path }) => path);
788
+ // Per entry: a hash (content) plus an update-index, or an update-index. In
789
+ // batch: check-attr and hash-object for all contents, an ls-tree probe
790
+ // when anything is added, and one update-index.
791
+ const worthBatching =
792
+ batch && (contents > 0 ? 2 : 0) + 1 + (adds.length > 0 ? 1 : 0) < 2 * contents + (live.length - contents);
793
+ const [, replacesNothing] = await Promise.all([
794
+ runGit(shadowArgs(state, ['read-tree', tree]), { cwd: state.root, env }),
795
+ worthBatching ? addsReplaceNothing(state, tree, adds, env) : false,
796
+ ]);
797
+ if (replacesNothing) {
798
+ const hashed = await hashBlobs(
799
+ state,
800
+ live.filter(({ content }) => content !== null),
801
+ env
802
+ );
803
+ const nullOid = '0'.repeat(tree.length);
804
+ let info = '';
805
+ for (const entry of entries) {
806
+ if (entry.noop) {
807
+ files.push({ path: entry.path, oid: null });
808
+ continue;
809
+ }
810
+ const oid = entry.content !== null ? hashed.get(entry) : entry.oid;
811
+ // Mode 0 removes the path, as `update-index --force-remove` did.
812
+ info += oid ? `100644 ${oid}\t${entry.path}\0` : `0 ${nullOid}\t${entry.path}\0`;
813
+ files.push({ path: entry.path, oid });
814
+ }
815
+ await runGit(shadowArgs(state, ['update-index', '-z', '--index-info']), {
816
+ cwd: state.root,
817
+ env,
818
+ input: info,
819
+ });
820
+ entries = [];
821
+ }
822
+ for (const { path, content, oid: recorded, noop } of entries) {
823
+ if (noop) {
824
+ files.push({ path, oid: null });
825
+ continue;
826
+ }
827
+ let oid = recorded;
828
+ if (content !== null) {
829
+ oid = await hashBlob(state, path, content, env);
575
830
  }
576
831
  if (oid) {
577
832
  await runGit(shadowArgs(state, ['update-index', '--add', '--cacheinfo', '100644', oid, path]), {
578
833
  cwd: state.root,
834
+ env,
579
835
  });
580
836
  } else {
581
- await runGit(shadowArgs(state, ['update-index', '--force-remove', '--', path]), { cwd: state.root });
837
+ await runGit(shadowArgs(state, ['update-index', '--force-remove', '--', path]), { cwd: state.root, env });
582
838
  }
583
839
  files.push({ path, oid });
584
840
  }
585
841
  return {
586
- tree: clean((await runGit(shadowArgs(state, ['write-tree']), { cwd: state.root })).stdout),
842
+ tree: clean((await runGit(shadowArgs(state, ['write-tree']), { cwd: state.root, env })).stdout),
587
843
  files,
588
844
  };
845
+ } finally {
846
+ if (env) {
847
+ await rm(env.GIT_INDEX_FILE, { force: true }).catch(() => {});
848
+ await rm(`${env.GIT_INDEX_FILE}.lock`, { force: true }).catch(() => {});
849
+ }
850
+ }
851
+ }
852
+
853
+ /** The original sequence: the shadow index saved as a tree, used, restored. */
854
+ async function applyInShadowIndex(state, tree, entries, files) {
855
+ const saved = clean((await runGit(shadowArgs(state, ['write-tree']), { cwd: state.root })).stdout);
856
+ try {
857
+ return await applyInIndex(state, tree, entries, files, null);
589
858
  } finally {
590
859
  await runGit(shadowArgs(state, ['read-tree', saved]), { cwd: state.root });
591
860
  }
@@ -603,13 +872,26 @@ async function materializeToolBaselines(snapshot) {
603
872
  }
604
873
  );
605
874
  const present = new Set(splitNulList(listed.stdout).map(pathKey));
606
- const ignoredResult = await runGit(['check-ignore', '--no-index', '-z', '--stdin'], {
607
- cwd: state.root,
608
- input: `${paths.join('\0')}\0`,
609
- allowFailure: true,
610
- });
611
- if (ignoredResult.code > 1) throw commandError(['check-ignore'], ignoredResult.stderr, ignoredResult.code);
612
- const ignored = new Set(splitNulList(ignoredResult.stdout).map(pathKey));
875
+ // A leading ':' is pathspec magic to ls-tree and check-ignore: such a path
876
+ // may not have been read literally, so every read below runs as before.
877
+ const literal = paths.every((path) => !path.startsWith(':'));
878
+ // The ignore answer is only consulted for bytes kept for a path that is
879
+ // neither in the baseline tree nor tracked, and small enough to keep.
880
+ const asksIgnored = ([path, content]) =>
881
+ Buffer.isBuffer(content) &&
882
+ !present.has(pathKey(path)) &&
883
+ !state.sourceTracked.has(pathKey(path)) &&
884
+ content.length <= MAX_UNTRACKED_FILE_BYTES;
885
+ let ignored = new Set();
886
+ if (!literal || pending.some(asksIgnored)) {
887
+ const ignoredResult = await runGit(['check-ignore', '--no-index', '-z', '--stdin'], {
888
+ cwd: state.root,
889
+ input: `${paths.join('\0')}\0`,
890
+ allowFailure: true,
891
+ });
892
+ if (ignoredResult.code > 1) throw commandError(['check-ignore'], ignoredResult.stderr, ignoredResult.code);
893
+ ignored = new Set(splitNulList(ignoredResult.stdout).map(pathKey));
894
+ }
613
895
  const missing = pending
614
896
  .filter(
615
897
  ([path, content]) =>
@@ -624,7 +906,9 @@ async function materializeToolBaselines(snapshot) {
624
906
  .map(([path, content]) =>
625
907
  isOmittedBaseline(content) ? { path, omitted: true, size: content.size } : { path, content }
626
908
  );
627
- const applied = await applyBaselineFilesUnlocked(state, snapshot.baselineTree, missing);
909
+ // A path ls-tree read literally and did not list is not in the tree.
910
+ const absent = literal ? new Set(paths.map(pathKey).filter((key) => !present.has(key))) : null;
911
+ const applied = await applyBaselineFilesUnlocked(state, snapshot.baselineTree, missing, { absent });
628
912
  snapshot.baselineTree = applied.tree;
629
913
  for (const entry of applied.files) snapshot.baselineFiles.set(pathKey(entry.path), entry);
630
914
  for (const [path] of pending) snapshot.pendingBaselines.delete(path);
@@ -710,14 +994,68 @@ async function patchSectionsByPath(state, baselineTree, currentTree, pathspec, m
710
994
  if (error?.code === 'EMAXBUFFER') return null;
711
995
  throw error;
712
996
  }
997
+ return sectionsByPath(result.stdout);
998
+ }
999
+
1000
+ function sectionsByPath(patchText) {
713
1001
  const sections = new Map();
714
- for (const section of splitPatchSections(Buffer.from(result.stdout, 'utf8'))) {
1002
+ for (const section of splitPatchSections(Buffer.from(patchText, 'utf8'))) {
715
1003
  const path = section.newPath ?? section.oldPath;
716
1004
  if (path) sections.set(pathKey(path), section.patch);
717
1005
  }
718
1006
  return sections;
719
1007
  }
720
1008
 
1009
+ /** A range's name-status, numstat and whole patch from one `git diff`
1010
+ * (`--raw --numstat --patch -z`: raw records, numstat records, a NUL, then
1011
+ * the same patch bytes a patch-only read prints). Null when that read fails
1012
+ * or passes the patch read's byte cap, or its output is not that shape: the
1013
+ * caller then takes the separate reads, which fail or bound exactly as
1014
+ * before. */
1015
+ async function combinedTreeDiff(state, rangeArgs) {
1016
+ let result;
1017
+ try {
1018
+ result = await runGit(
1019
+ shadowArgs(state, ['diff', '--raw', '--numstat', '--patch', '--no-ext-diff', '--unified=3', '-z', ...rangeArgs]),
1020
+ { cwd: state.root, maxBytes: COMMAND_MAX_BYTES + PATCH_OUTPUT_SLACK_BYTES, allowFailure: true }
1021
+ );
1022
+ } catch (error) {
1023
+ if (error?.code === 'EMAXBUFFER') return null;
1024
+ throw error;
1025
+ }
1026
+ if (result.code !== 0) return null;
1027
+ const text = String(result.stdout || '');
1028
+ if (!text) return { statuses: new Map(), stats: new Map(), sections: new Map() };
1029
+ // Raw records (each starts with ':') re-shaped as name-status tokens.
1030
+ const nameStatus = [];
1031
+ let cursor = 0;
1032
+ const token = () => {
1033
+ const end = text.indexOf('\0', cursor);
1034
+ if (end < 0) return null;
1035
+ const value = text.slice(cursor, end);
1036
+ cursor = end + 1;
1037
+ return value;
1038
+ };
1039
+ while (text[cursor] === ':') {
1040
+ const header = token();
1041
+ const status = header?.slice(header.lastIndexOf(' ') + 1);
1042
+ if (!status) return null;
1043
+ const paths = [token()];
1044
+ if (status[0] === 'R' || status[0] === 'C') paths.push(token());
1045
+ if (paths.some((path) => !path)) return null;
1046
+ nameStatus.push(status, ...paths);
1047
+ }
1048
+ // Numstat records never hold an empty token after a NUL, so the first NUL
1049
+ // pair ends them; the separator NUL follows.
1050
+ const numstatEnd = text[cursor] === '\0' ? cursor : text.indexOf('\0\0', cursor) + 1;
1051
+ if (numstatEnd <= 0) return null;
1052
+ return {
1053
+ statuses: parseNameStatus(`${nameStatus.join('\0')}\0`),
1054
+ stats: parseNumstat(text.slice(cursor, numstatEnd)),
1055
+ sections: sectionsByPath(text.slice(numstatEnd + 1)),
1056
+ };
1057
+ }
1058
+
721
1059
  /** After the whole range overflowed: patches read in path batches, the
722
1060
  * smallest changes first, until the reply's total is spent. A batch that
723
1061
  * overflows is halved; a single file that does is too large. */
@@ -761,6 +1099,12 @@ async function boundedTreePatch(state, baselineTree, currentTree, pathspec, file
761
1099
  sections = new Map();
762
1100
  await batchedPatchSections(state, baselineTree, currentTree, files, sections);
763
1101
  }
1102
+ return boundedTreePatchFromSections(files, sections, whole);
1103
+ }
1104
+
1105
+ /** `files` with the patch `sections` that fit the reply caps; `whole`: the
1106
+ * sections are a complete read, so a file without one has no patch. */
1107
+ function boundedTreePatchFromSections(files, sections, whole) {
764
1108
  const budget = createReviewPatchBudget();
765
1109
  const kept = [];
766
1110
  const bounded = files.map((file) => {
@@ -783,12 +1127,35 @@ async function diffTreesUnlocked(state, baselineTree, currentTree, paths = null)
783
1127
  // baseline describes the whole worktree, concurrent sessions included.
784
1128
  const pathspec = Array.isArray(paths) && paths.length > 0 ? paths : ['.'];
785
1129
  const rangeArgs = ['--find-renames', baselineTree, currentTree, '--', ...pathspec];
1130
+ const combined = await combinedTreeDiff(state, rangeArgs);
1131
+ if (combined) {
1132
+ const files = reviewFiles(combined.statuses, combined.stats);
1133
+ const bounded = boundedTreePatchFromSections(files, combined.sections, true);
1134
+ return {
1135
+ patch: bounded.patch,
1136
+ files: bounded.files,
1137
+ patchTruncated: false,
1138
+ diffTooLarge: reviewDiffTooLarge(bounded.files, bounded.patch),
1139
+ currentTree,
1140
+ };
1141
+ }
786
1142
  const [nameStatus, numstat] = await Promise.all([
787
1143
  runGit(shadowArgs(state, ['diff', '--name-status', '-z', ...rangeArgs]), { cwd: state.root }),
788
1144
  runGit(shadowArgs(state, ['diff', '--numstat', '-z', ...rangeArgs]), { cwd: state.root }),
789
1145
  ]);
790
- const statuses = parseNameStatus(nameStatus.stdout);
791
- const stats = parseNumstat(numstat.stdout);
1146
+ const files = reviewFiles(parseNameStatus(nameStatus.stdout), parseNumstat(numstat.stdout));
1147
+ const bounded = await boundedTreePatch(state, baselineTree, currentTree, pathspec, files);
1148
+ return {
1149
+ patch: bounded.patch,
1150
+ files: bounded.files,
1151
+ patchTruncated: false,
1152
+ diffTooLarge: reviewDiffTooLarge(bounded.files, bounded.patch),
1153
+ currentTree,
1154
+ };
1155
+ }
1156
+
1157
+ /** Review rows from the name-status and numstat reads of one range. */
1158
+ function reviewFiles(statuses, stats) {
792
1159
  const keys = [...new Set([...statuses.keys(), ...stats.keys()])];
793
1160
  const files = keys
794
1161
  .map((key) => {
@@ -804,14 +1171,7 @@ async function diffTreesUnlocked(state, baselineTree, currentTree, paths = null)
804
1171
  };
805
1172
  })
806
1173
  .sort((left, right) => left.path.localeCompare(right.path));
807
- const bounded = await boundedTreePatch(state, baselineTree, currentTree, pathspec, files);
808
- return {
809
- patch: bounded.patch,
810
- files: bounded.files,
811
- patchTruncated: false,
812
- diffTooLarge: reviewDiffTooLarge(bounded.files, bounded.patch),
813
- currentTree,
814
- };
1174
+ return files;
815
1175
  }
816
1176
 
817
1177
  /** One file's patch between two trees (a rename read with its old path), up