mixdog 1.4.0 → 1.4.1

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 (236) hide show
  1. package/package.json +1 -1
  2. package/scripts/check-voice-runtime-inputs.mjs +25 -0
  3. package/scripts/gate-local.mjs +12 -3
  4. package/scripts/latest-guard.mjs +21 -9
  5. package/scripts/lib/isolated-test-env.mjs +10 -5
  6. package/scripts/lib/run-node-tests.mjs +37 -37
  7. package/scripts/lib/scoped-temp.d.mts +16 -0
  8. package/scripts/lib/scoped-temp.mjs +123 -0
  9. package/scripts/lib/stale-temp.mjs +44 -0
  10. package/scripts/post-release-check.mjs +1 -6
  11. package/scripts/pptx-kit-index.mjs +17 -4
  12. package/scripts/suite-health.mjs +10 -1
  13. package/scripts/test-data-dir.mjs +10 -7
  14. package/src/app.mjs +4 -0
  15. package/src/defaults/shipped-history.json +5 -0
  16. package/src/defaults/skills/browser-use/SKILL.md +6 -4
  17. package/src/defaults/skills/computer-use/SKILL.md +4 -2
  18. package/src/lib/rules-builder.cjs +29 -12
  19. package/src/output-styles/common.md +2 -2
  20. package/src/output-styles/simple.md +1 -1
  21. package/src/rules/agent/AGENT.md +5 -4
  22. package/src/rules/lead/LEAD.md +10 -5
  23. package/src/rules/shared/10-tool-workflow.md +3 -1
  24. package/src/rules/shared/45-destructive.md +0 -2
  25. package/src/rules/shared/50-execution.md +2 -0
  26. package/src/rules/shared/70-delivery.md +7 -9
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -6
  28. package/src/runtime/agent/orchestrator/cache-break-trace.mjs +2 -0
  29. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +3 -1
  31. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +5 -2
  32. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +11 -0
  33. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +7 -3
  35. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +8 -2
  36. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +6 -2
  37. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +3 -1
  39. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -3
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +6 -0
  41. package/src/runtime/agent/orchestrator/providers/anthropic-turn-result.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +1 -10
  43. package/src/runtime/agent/orchestrator/providers/cursor.mjs +3 -0
  44. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/providers/devin.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +210 -20
  47. package/src/runtime/agent/orchestrator/providers/lib/anthropic-prefix-trace.mjs +76 -11
  48. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +45 -6
  49. package/src/runtime/agent/orchestrator/providers/lib/first-output-window.mjs +58 -0
  50. package/src/runtime/agent/orchestrator/providers/lib/listen-on.mjs +14 -0
  51. package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +1 -10
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -0
  53. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/chunk-events.mjs +9 -2
  54. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +7 -0
  55. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +14 -3
  56. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +4 -2
  57. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +1 -0
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +5 -3
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +16 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +1 -0
  61. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/output-items.mjs +5 -2
  62. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events.mjs +13 -3
  63. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +13 -2
  64. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +81 -42
  65. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +8 -0
  66. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +136 -5
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +20 -12
  68. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +7 -1
  69. package/src/runtime/agent/orchestrator/providers/openai-ws-watchdogs.mjs +48 -22
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +12 -0
  71. package/src/runtime/agent/orchestrator/runtime-core/ask-user-tool-defs.mjs +41 -13
  72. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +7 -6
  73. package/src/runtime/agent/orchestrator/runtime-core/session-text.mjs +7 -3
  74. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +3 -1
  75. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +5 -2
  76. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +1 -3
  77. package/src/runtime/agent/orchestrator/session/cache-warm.mjs +8 -2
  78. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +4 -1
  79. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +1 -8
  80. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +23 -0
  81. package/src/runtime/agent/orchestrator/session/compact/observation-supersession.mjs +103 -23
  82. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +160 -36
  83. package/src/runtime/agent/orchestrator/session/context-utils.mjs +13 -1
  84. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +163 -22
  85. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +132 -17
  86. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +38 -7
  87. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +3 -0
  88. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +90 -22
  89. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +33 -3
  90. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +7 -2
  91. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -1
  92. package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +13 -5
  93. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +14 -0
  94. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +13 -1
  95. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +2 -2
  96. package/src/runtime/agent/orchestrator/session/loop/tool-exposure-migration.mjs +39 -21
  97. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +3 -3
  98. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +3 -1
  99. package/src/runtime/agent/orchestrator/session/manager/owner-route-fallbacks.mjs +2 -2
  100. package/src/runtime/agent/orchestrator/session/manager/route-fallback-chain.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +27 -9
  102. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +61 -6
  103. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +19 -8
  104. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +12 -5
  105. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +3 -3
  106. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-context.mjs +4 -0
  107. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +3 -0
  108. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +9 -0
  109. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +5 -1
  110. package/src/runtime/agent/orchestrator/session/prompt-cache-touch-store.mjs +30 -7
  111. package/src/runtime/agent/orchestrator/session/restart-prefix-parity.fixture.mjs +154 -0
  112. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +7 -0
  113. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +4 -0
  114. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +15 -0
  115. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +37 -8
  116. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +9 -1
  117. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +61 -2
  118. package/src/runtime/agent/orchestrator/stall-policy.mjs +23 -0
  119. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
  120. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -2
  121. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +6 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -20
  123. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +7 -1
  124. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +31 -5
  125. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +22 -8
  126. package/src/runtime/agent/orchestrator/tools/builtin/lib/scoped-search-flight.mjs +9 -5
  127. package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +7 -1
  128. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +6 -3
  129. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +1 -1
  130. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +23 -12
  131. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +6 -1
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -8
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/federation.mjs +3 -1
  134. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  135. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  136. package/src/runtime/agent/orchestrator/tools/lib/isolated-test-env.mjs +4 -3
  137. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +26 -4
  138. package/src/runtime/channels/lib/webhook/signature.mjs +4 -1
  139. package/src/runtime/channels/lib/whisper-port.mjs +10 -1
  140. package/src/runtime/computer-bridge/action-schema.mjs +2 -2
  141. package/src/runtime/computer-bridge/core-actions.mjs +2 -2
  142. package/src/runtime/computer-bridge/error-recovery.mjs +5 -4
  143. package/src/runtime/computer-bridge/linux-action-capabilities.mjs +24 -5
  144. package/src/runtime/computer-bridge/linux-readiness.d.mts +7 -2
  145. package/src/runtime/local-provider/server-process.mjs +2 -1
  146. package/src/runtime/media/adapters/gemini-video.mjs +4 -1
  147. package/src/runtime/media/adapters/xai-media.mjs +2 -1
  148. package/src/runtime/memory/lib/embedding-worker.mjs +6 -3
  149. package/src/runtime/memory/lib/memory-action-handlers/cycle-backfill-action.mjs +1 -7
  150. package/src/runtime/memory/lib/memory-chunk-quality.mjs +1 -6
  151. package/src/runtime/office/authoring/pdf-author-action.mjs +1 -6
  152. package/src/runtime/office/design/design-art-direction.mjs +29 -27
  153. package/src/runtime/office/pdf/pdf-writer.mjs +6 -1
  154. package/src/runtime/office/portable/portable-snapshot-docx.mjs +7 -1
  155. package/src/runtime/office/portable/portable-snapshot-shared.mjs +6 -1
  156. package/src/runtime/office/quality/assurance-rendered.mjs +9 -2
  157. package/src/runtime/office/quality/assurance-structure-docx.mjs +3 -1
  158. package/src/runtime/office/quality/design-review.mjs +1 -5
  159. package/src/runtime/office/quality/document-brief.mjs +1 -5
  160. package/src/runtime/office/quality/forced-break-pages.mjs +9 -6
  161. package/src/runtime/office/quality/quality-pipeline.mjs +1 -1
  162. package/src/runtime/shared/bot-owned-work.mjs +56 -0
  163. package/src/runtime/shared/defaults-separation.mjs +1 -4
  164. package/src/runtime/shared/fetch-safe-listen.d.mts +16 -0
  165. package/src/runtime/shared/fetch-safe-listen.mjs +50 -0
  166. package/src/runtime/shared/handoff-artifacts.d.mts +2 -1
  167. package/src/runtime/shared/handoff-artifacts.mjs +18 -8
  168. package/src/runtime/shared/idle-auto-clear-gate.mjs +29 -7
  169. package/src/runtime/shared/llm/cost.mjs +5 -1
  170. package/src/runtime/shared/loopback-listener.mjs +25 -2
  171. package/src/runtime/shared/model-route-chain.d.mts +7 -2
  172. package/src/runtime/shared/model-route-chain.mjs +22 -4
  173. package/src/runtime/shared/open-url.mjs +10 -0
  174. package/src/runtime/shared/session-activity-signal.mjs +14 -0
  175. package/src/runtime/shared/session-scratch.mjs +37 -0
  176. package/src/runtime/shared/tool-card-model/agent-surface.mjs +1 -3
  177. package/src/runtime/shared/tool-card-model/card-status.mjs +15 -2
  178. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  179. package/src/runtime/shared/tool-surface.mjs +3 -0
  180. package/src/runtime/shared/tool-work-units.mjs +2 -0
  181. package/src/runtime/shared/turn-snapshot-store.mjs +13 -6
  182. package/src/runtime/shared/worktrees/contract.mjs +4 -1
  183. package/src/runtime/shared/worktrees/git.mjs +15 -0
  184. package/src/runtime/shared/worktrees/manager.mjs +87 -38
  185. package/src/runtime/shared/worktrees/test-support.mjs +6 -10
  186. package/src/runtime/visualize-bridge/client.mjs +2 -0
  187. package/src/session-runtime/agent-home-delivery.mjs +41 -0
  188. package/src/session-runtime/agent-task-sessions.mjs +152 -63
  189. package/src/session-runtime/agent-tool-routing.mjs +8 -41
  190. package/src/session-runtime/boot/tools.mjs +5 -1
  191. package/src/session-runtime/bot-status.mjs +12 -6
  192. package/src/session-runtime/bot-target.mjs +38 -0
  193. package/src/session-runtime/channel-config-api.mjs +6 -1
  194. package/src/session-runtime/conversation-import/codex-titles.mjs +88 -0
  195. package/src/session-runtime/conversation-import/discovery.mjs +271 -25
  196. package/src/session-runtime/conversation-import/service.mjs +26 -5
  197. package/src/session-runtime/handoff-attachments.mjs +82 -31
  198. package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +12 -13
  199. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +1 -0
  200. package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +4 -1
  201. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +8 -2
  202. package/src/session-runtime/lifecycle/cold-heir.mjs +11 -6
  203. package/src/session-runtime/lifecycle/session-catalog.mjs +9 -1
  204. package/src/session-runtime/model-route/set-route.mjs +11 -2
  205. package/src/session-runtime/provider-catalog-cache.mjs +82 -36
  206. package/src/session-runtime/provider-models/catalog-load.mjs +23 -6
  207. package/src/session-runtime/provider-models.mjs +4 -2
  208. package/src/session-runtime/resource-recall-api.mjs +6 -1
  209. package/src/session-runtime/runtime-feature-gates.mjs +5 -1
  210. package/src/session-runtime/services/agent-tool/execute.mjs +14 -9
  211. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +20 -4
  212. package/src/session-runtime/services/agent-tool/model-select.mjs +48 -7
  213. package/src/session-runtime/services/agent-tool/render.mjs +71 -17
  214. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +6 -2
  215. package/src/session-runtime/services/agent-tool/tool-def.mjs +9 -9
  216. package/src/session-runtime/services/provider-admin.mjs +2 -1
  217. package/src/session-runtime/tool-policy/policy-stage.mjs +1 -0
  218. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +5 -1
  219. package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -4
  220. package/src/session-runtime/workflow-agents-api/onboarding.mjs +3 -3
  221. package/src/session-runtime/workspace/transitions.mjs +25 -0
  222. package/src/session-runtime/workspace/worktree-tool.mjs +26 -9
  223. package/src/standalone/daemon-session-control.mjs +18 -2
  224. package/src/standalone/session-service/agent-tree/agent-turns.mjs +16 -12
  225. package/src/standalone/session-service/session-calls/session-action.mjs +10 -3
  226. package/src/standalone/session-transport/client-registry/client-grace.mjs +12 -9
  227. package/src/tui/app/app-view/floating-panel.jsx +7 -4
  228. package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +1 -3
  229. package/src/tui/app/model-picker/route-selection.mjs +5 -6
  230. package/src/tui/app/provider-setup/oauth-actions.mjs +38 -20
  231. package/src/tui/app/provider-setup/provider-items.mjs +8 -2
  232. package/src/tui/app/provider-setup-picker.mjs +9 -5
  233. package/src/tui/components/QuestionPanel.jsx +13 -4
  234. package/src/tui/dist/index.mjs +75 -28
  235. package/src/tui/session/session-flow/queue/take-entries.mjs +11 -0
  236. package/src/ui/statusline-agents.mjs +1 -3
@@ -236,34 +236,36 @@ export function resolveOfficeArtDirection(
236
236
  String(format || '').toLowerCase() === 'xlsx'
237
237
  ? DIRECTION_BLUEPRINTS.filter((blueprint) => blueprint.backgroundMode !== 'dark')
238
238
  : DIRECTION_BLUEPRINTS;
239
- const candidates = blueprints.map((blueprint) => {
240
- const palette = directionPalette(baseHue, blueprint);
241
- const motif = rawSubject ? `${blueprint.motif} · ${rawSubject}` : blueprint.motif;
242
- return {
243
- id: blueprint.id,
244
- label: blueprint.label,
245
- rationale: `${blueprint.label} fits ${purpose} through ${blueprint.motif}.`,
246
- motif,
247
- palette,
248
- typography: { ...blueprint.typography },
249
- style: { ...blueprint.style, pageRhythm: [...blueprint.style.pageRhythm] },
250
- creativeSystem: creativeSystemOf(blueprint),
251
- deck: {
252
- backgroundMode: blueprint.backgroundMode,
239
+ const candidates = blueprints
240
+ .map((blueprint) => {
241
+ const palette = directionPalette(baseHue, blueprint);
242
+ const motif = rawSubject ? `${blueprint.motif} · ${rawSubject}` : blueprint.motif;
243
+ return {
244
+ id: blueprint.id,
245
+ label: blueprint.label,
246
+ rationale: `${blueprint.label} fits ${purpose} through ${blueprint.motif}.`,
253
247
  motif,
254
- imageTreatment: blueprint.imageTreatment,
255
- layoutBias: [...blueprint.layoutBias],
256
- ...creativeSystemOf(blueprint),
257
- },
258
- score: directionScore(blueprint, {
259
- purpose,
260
- expressionMode,
261
- tone: String(request.tone || ''),
262
- requestedId: String(requested.id || ''),
263
- seed,
264
- }),
265
- };
266
- }).sort((left, right) => right.score - left.score || left.id.localeCompare(right.id));
248
+ palette,
249
+ typography: { ...blueprint.typography },
250
+ style: { ...blueprint.style, pageRhythm: [...blueprint.style.pageRhythm] },
251
+ creativeSystem: creativeSystemOf(blueprint),
252
+ deck: {
253
+ backgroundMode: blueprint.backgroundMode,
254
+ motif,
255
+ imageTreatment: blueprint.imageTreatment,
256
+ layoutBias: [...blueprint.layoutBias],
257
+ ...creativeSystemOf(blueprint),
258
+ },
259
+ score: directionScore(blueprint, {
260
+ purpose,
261
+ expressionMode,
262
+ tone: String(request.tone || ''),
263
+ requestedId: String(requested.id || ''),
264
+ seed,
265
+ }),
266
+ };
267
+ })
268
+ .sort((left, right) => right.score - left.score || left.id.localeCompare(right.id));
267
269
  const selected = candidates[0];
268
270
  const hasSubject = Boolean(rawSubject || requested.seed || requested.id);
269
271
  const disabled = request.artDirection === false || requested.enabled === false;
@@ -890,7 +890,12 @@ function drawChartBlock(flow, block, following) {
890
890
  });
891
891
  flow.y -= rowH;
892
892
  });
893
- page.drawLine({ start: { x: left, y: top }, end: { x: left, y: flow.y }, thickness: 0.6, color: color(flow.ink.line) });
893
+ page.drawLine({
894
+ start: { x: left, y: top },
895
+ end: { x: left, y: flow.y },
896
+ thickness: 0.6,
897
+ color: color(flow.ink.line),
898
+ });
894
899
  } else {
895
900
  const baseline = flow.y - size * 1.6 - layout.plotH;
896
901
  values.forEach((value, index) => {
@@ -12,7 +12,13 @@ import {
12
12
  xmlDecode,
13
13
  } from './portable-xml.mjs';
14
14
  import { countDocxPropertyChanges, docxRevisionTree, flattenDocxRevisions } from './docx-revisions.mjs';
15
- import { docxParagraphSpacing, docxRunFont, nextPageOffset, softBreakFacts, statedRunFont } from './portable-snapshot-shared.mjs';
15
+ import {
16
+ docxParagraphSpacing,
17
+ docxRunFont,
18
+ nextPageOffset,
19
+ softBreakFacts,
20
+ statedRunFont,
21
+ } from './portable-snapshot-shared.mjs';
16
22
 
17
23
  const REVISION_TYPES = Object.freeze({
18
24
  ins: 'insertion',
@@ -123,7 +123,12 @@ export function populatedCellPagination({ options, page, selectedSheets, sheets,
123
123
 
124
124
  export function statedRunFont(xml) {
125
125
  const direct = docxRunFont(xml);
126
- return direct.size || direct.bold || direct.name || direct.color || direct.caps !== undefined || direct.letterSpacing !== undefined
126
+ return direct.size ||
127
+ direct.bold ||
128
+ direct.name ||
129
+ direct.color ||
130
+ direct.caps !== undefined ||
131
+ direct.letterSpacing !== undefined
127
132
  ? { font: direct }
128
133
  : {};
129
134
  }
@@ -152,7 +152,13 @@ export async function reviewRenderedOfficePages(
152
152
  // The cover/title page is short by design, and a page followed by a deliberate forced break ends where the
153
153
  // author ended it.
154
154
  const endsBeforeForcedBreak = forcedBreaks?.has(metric.page + 1) === true;
155
- if (flowing && metric.page > 1 && !endsBeforeForcedBreak && metric.page < lastPage && metric.bodyEnd < PAGE_BODY_END_MIN) {
155
+ if (
156
+ flowing &&
157
+ metric.page > 1 &&
158
+ !endsBeforeForcedBreak &&
159
+ metric.page < lastPage &&
160
+ metric.bodyEnd < PAGE_BODY_END_MIN
161
+ ) {
156
162
  const empty = Math.round((1 - metric.bodyEnd) * 100);
157
163
  issues.push(
158
164
  issue(
@@ -166,7 +172,8 @@ export async function reviewRenderedOfficePages(
166
172
  continue;
167
173
  }
168
174
  // A short chapter is the author's: the page ends before a forced break, or it is the last page and opens with one.
169
- const deliberateShort = endsBeforeForcedBreak || (forcedBreaks?.has(metric.page) === true && metric.page >= lastPage);
175
+ const deliberateShort =
176
+ endsBeforeForcedBreak || (forcedBreaks?.has(metric.page) === true && metric.page >= lastPage);
170
177
  if (
171
178
  ['docx', 'pdf'].includes(normalized) &&
172
179
  metric.page > 1 &&
@@ -12,7 +12,9 @@ function paragraphSize(paragraph) {
12
12
  }
13
13
 
14
14
  const median = (values) => {
15
- const sorted = values.filter((value) => value !== '' && value !== undefined && value !== null).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
15
+ const sorted = values
16
+ .filter((value) => value !== '' && value !== undefined && value !== null)
17
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
16
18
  return sorted.length ? sorted[Math.floor(sorted.length / 2)] : undefined;
17
19
  };
18
20
  // Automatic colour is the effective default, black.
@@ -12,11 +12,7 @@ import {
12
12
  saturatedHueFamilies,
13
13
  } from '../design/design-discipline.mjs';
14
14
  import { authoredBackgroundLadder, isCardGridSlide, isOrnamentalStripe, slideSize } from './design-review-authored.mjs';
15
- import {
16
- reviewBriefPromises,
17
- reviewFactCoverage,
18
- reviewSourceGrounding,
19
- } from '../authoring/pptx-brief.mjs';
15
+ import { reviewBriefPromises, reviewFactCoverage, reviewSourceGrounding } from '../authoring/pptx-brief.mjs';
20
16
  import { isAdvisoryOfficeIssue } from './quality-pipeline.mjs';
21
17
  import { issue } from './assurance-issue.mjs';
22
18
  import { isPptxSpecimenSlide, isPptxStatementSlide } from './pptx-slide-roles.mjs';
@@ -1,11 +1,7 @@
1
1
  // A Word document or workbook held to its brief, as an authored deck is: every figure its words show has a fact with
2
2
  // a source, and a fact cites where a reader can open it. The brief is the deck's (`design.brief`, key: value lines);
3
3
  // the readings are the deck's own (pptx-brief.mjs), over the document's text instead of its slides.
4
- import {
5
- parseAuthoringBrief,
6
- reviewFactCoverage,
7
- reviewSourceGrounding,
8
- } from '../authoring/pptx-brief.mjs';
4
+ import { parseAuthoringBrief, reviewFactCoverage, reviewSourceGrounding } from '../authoring/pptx-brief.mjs';
9
5
  import { htmlBriefScript } from '../authoring/pptx-html-runner.mjs';
10
6
 
11
7
  /** The brief a docx/xlsx call names in design.brief (the HTML BRIEF's key: value lines), or null. */
@@ -25,9 +25,7 @@ const visibleText = (html) =>
25
25
 
26
26
  // A media query list reaches the printed page unless every query is screen-only.
27
27
  const appliesToPrint = (queries) =>
28
- queries
29
- .split(',')
30
- .some((query) => !/^\s*(?:only\s+)?screen\b/i.test(query) && !/^\s*not\s+print\b/i.test(query));
28
+ queries.split(',').some((query) => !/^\s*(?:only\s+)?screen\b/i.test(query) && !/^\s*not\s+print\b/i.test(query));
31
29
 
32
30
  // Style rules in source order, flattened out of @media blocks that do not apply in print.
33
31
  function cssRules(css, active, rules) {
@@ -38,7 +36,10 @@ function cssRules(css, active, rules) {
38
36
  let depth = 1;
39
37
  let close = open + 1;
40
38
  for (; close < css.length && depth > 0; close += 1) depth += css[close] === '{' ? 1 : css[close] === '}' ? -1 : 0;
41
- const head = css.slice(index, open).replace(/^(?:\s*@[^{;]*;)+/, '').trim();
39
+ const head = css
40
+ .slice(index, open)
41
+ .replace(/^(?:\s*@[^{;]*;)+/, '')
42
+ .trim();
42
43
  const inner = css.slice(open + 1, close - 1);
43
44
  if (/^@media\b/i.test(head)) cssRules(inner, active && appliesToPrint(head.slice(6)), rules);
44
45
  else if (active && head && !head.startsWith('@')) rules.push({ head, body: inner });
@@ -64,7 +65,8 @@ function breakDeclaration(body, rank) {
64
65
  }
65
66
 
66
67
  const beats = (a, b) => {
67
- for (let index = 0; index < a.rank.length; index += 1) if (a.rank[index] !== b.rank[index]) return a.rank[index] > b.rank[index];
68
+ for (let index = 0; index < a.rank.length; index += 1)
69
+ if (a.rank[index] !== b.rank[index]) return a.rank[index] > b.rank[index];
68
70
  return true;
69
71
  };
70
72
 
@@ -80,7 +82,8 @@ function breakSelectors(html) {
80
82
  const classes = match[3].split('.').filter(Boolean);
81
83
  const specificity = (match[2] ? 100 : 0) + classes.length * 10 + (match[1] ? 1 : 0);
82
84
  const declaration = breakDeclaration(body, [0, specificity, order]);
83
- if (declaration) selectors.push({ tag: (match[1] || '').toLowerCase(), id: match[2] || '', classes, declaration });
85
+ if (declaration)
86
+ selectors.push({ tag: (match[1] || '').toLowerCase(), id: match[2] || '', classes, declaration });
84
87
  }
85
88
  }
86
89
  }
@@ -289,7 +289,7 @@ const POLISH_GUIDANCE = Object.freeze({
289
289
  theme_background_drift:
290
290
  'Use only the ladder backgrounds (paper, paperAlt, dark, darkAlt); recolor the drifting slide.',
291
291
  theme_body_backgrounds:
292
- 'Advisory: body slides use several background colours; keep them to the concept\'s declared palette ladder, or declare the concept or palette in the brief when the colour fields are the design.',
292
+ "Advisory: body slides use several background colours; keep them to the concept's declared palette ladder, or declare the concept or palette in the brief when the colour fields are the design.",
293
293
  excessive_slide_text: "Cut the copy to the reading mode's budget (composition.md §4) or split the slide.",
294
294
  decorative_stripe: 'Remove the ornamental bar; a stripe that encodes nothing is decoration (composition.md §10).',
295
295
  // Visual critique contract (finalize).
@@ -0,0 +1,56 @@
1
+ // Bot-owned work: the live work in a session that a bot sent. A bot's accepted
2
+ // `session` send or create (session-runtime/agent-task-sessions.mjs) is the
3
+ // bot's work in that session from its acceptance until it settles or is
4
+ // cancelled — whether or not it reports back (`notify: false`, a report watch
5
+ // that could not be stored). A turn nobody sent (the user typed it in the
6
+ // task session) is owned by no bot, so it never reads as a bot being busy.
7
+ // Process-wide and dependency-free like delegated-approval-host.mjs. The
8
+ // registry is only this process's cache: what keeps the work owned across a
9
+ // restart is stored apart (a reporting send's watch, any other send's record
10
+ // of its work, both in agent-home-delivery.mjs) and re-noted by
11
+ // agent-task-sessions.mjs recover. A change of which bots own a session's work
12
+ // is announced (session-activity-signal.mjs onSessionBotWorkChange), so the
13
+ // session catalog, which stamps them on each row, re-lists.
14
+ import { emitSessionBotWorkChange } from './session-activity-signal.mjs';
15
+
16
+ const KEY = Symbol.for('mixdog.botOwnedWork.v1');
17
+
18
+ /** Map run id → `{ sessionId, agentId }`. */
19
+ function registry() {
20
+ globalThis[KEY] ??= new Map();
21
+ return globalThis[KEY];
22
+ }
23
+
24
+ /** How many owned works of `agentId` run in `sessionId`. */
25
+ function worksOf(sessionId, agentId) {
26
+ let count = 0;
27
+ for (const work of registry().values()) if (work.sessionId === sessionId && work.agentId === agentId) count += 1;
28
+ return count;
29
+ }
30
+
31
+ /** Every session with owned work and the bots owning it (each once, in noting
32
+ * order): Map session → agent ids. */
33
+ export function botWorkBySession() {
34
+ const bySession = new Map();
35
+ for (const { sessionId, agentId } of registry().values()) {
36
+ const agents = bySession.get(sessionId) ?? [];
37
+ if (!agents.includes(agentId)) agents.push(agentId);
38
+ bySession.set(sessionId, agents);
39
+ }
40
+ return bySession;
41
+ }
42
+
43
+ /** Agent `agentId`'s submission `runId` to `sessionId` was accepted. */
44
+ export function noteBotWork({ runId, sessionId, agentId }) {
45
+ if (!runId || !sessionId || !agentId || registry().has(String(runId))) return;
46
+ registry().set(String(runId), { sessionId: String(sessionId), agentId: String(agentId) });
47
+ if (worksOf(String(sessionId), String(agentId)) === 1) emitSessionBotWorkChange(sessionId);
48
+ }
49
+
50
+ /** Submission `runId` settled or was cancelled: its work is nobody's now. */
51
+ export function endBotWork(runId) {
52
+ const work = registry().get(String(runId));
53
+ if (!work) return;
54
+ registry().delete(String(runId));
55
+ if (worksOf(work.sessionId, work.agentId) === 0) emitSessionBotWorkChange(work.sessionId);
56
+ }
@@ -176,10 +176,7 @@ function removeLegacyKeys(root, dataDir) {
176
176
  * not set the marker. `stripRouteDefaults` drops shipped presets and
177
177
  * maintenance routes (their defaults live with the agent config).
178
178
  */
179
- function separateDefaultsInConfig(
180
- root,
181
- { stripRouteDefaults = (agent) => agent, dataDir = resolvePluginData() } = {}
182
- ) {
179
+ function separateDefaultsInConfig(root, { stripRouteDefaults = (agent) => agent, dataDir = resolvePluginData() } = {}) {
183
180
  const from = Number(root?.[DEFAULTS_VERSION_KEY]) || 0;
184
181
  let next = { ...(root || {}) };
185
182
  if (from < 1) {
@@ -0,0 +1,16 @@
1
+ import type { Server } from 'node:net';
2
+
3
+ export const FETCH_BLOCKED_PORTS: ReadonlySet<number>;
4
+
5
+ export const FETCH_SAFE_ATTEMPTS: number;
6
+
7
+ export function listenOnFetchSafePort(
8
+ server: Server,
9
+ host: string,
10
+ onListening: () => void,
11
+ options?: {
12
+ attempts?: number;
13
+ isCurrent?: () => boolean;
14
+ portOf?: (server: Server) => number;
15
+ }
16
+ ): void;
@@ -0,0 +1,50 @@
1
+ // Ports the WHATWG Fetch spec blocks ("bad port"): fetch (undici) and Chromium
2
+ // refuse to connect to them, so a loopback server that a fetch client or a
3
+ // browser context must reach cannot live on one. https://fetch.spec.whatwg.org/#port-blocking
4
+ export const FETCH_BLOCKED_PORTS = new Set([
5
+ 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110,
6
+ 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
7
+ 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061,
8
+ 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
9
+ ]);
10
+
11
+ /** How many more OS-assigned ports a listener takes after a blocked one. */
12
+ export const FETCH_SAFE_ATTEMPTS = 16;
13
+
14
+ const assignedPort = (server) => {
15
+ const address = server.address();
16
+ return address && typeof address === 'object' ? address.port : 0;
17
+ };
18
+
19
+ /**
20
+ * `server.listen(0, host)` that never settles on a fetch-blocked port: when the
21
+ * OS assigns one, the server is closed and listened again, at most `attempts`
22
+ * more times. `onListening` runs once, on a safe port. When the attempts run
23
+ * out the server is closed and an 'error' is emitted, like a failed listen.
24
+ * `isCurrent` lets a caller that stopped the server meanwhile cancel the retry;
25
+ * `portOf` reads the assigned port.
26
+ */
27
+ export function listenOnFetchSafePort(
28
+ server,
29
+ host,
30
+ onListening,
31
+ { attempts = FETCH_SAFE_ATTEMPTS, isCurrent = () => true, portOf = assignedPort } = {}
32
+ ) {
33
+ let retries = attempts;
34
+ const attempt = () => {
35
+ server.listen(0, host, () => {
36
+ if (!FETCH_BLOCKED_PORTS.has(portOf(server))) {
37
+ onListening();
38
+ return;
39
+ }
40
+ const exhausted = retries <= 0;
41
+ retries -= 1;
42
+ server.close(() => {
43
+ if (!isCurrent()) return;
44
+ if (exhausted) server.emit('error', new Error('no fetch-safe loopback port was assigned'));
45
+ else attempt();
46
+ });
47
+ });
48
+ };
49
+ attempt();
50
+ }
@@ -8,7 +8,8 @@ export interface HandoffFile {
8
8
  id?: string;
9
9
  }
10
10
  export function normalizeHandoffFiles(files: unknown): HandoffFile[];
11
- export function artifactLines(files: unknown, omitted?: number, options?: { ids?: boolean }): string[];
11
+ export function artifactLines(files: unknown, omitted?: number): string[];
12
+ export function producedFileLines(files: unknown, omitted?: number): string[];
12
13
  export interface HandoffRecord {
13
14
  id: string;
14
15
  attachmentRef: string;
@@ -31,20 +31,30 @@ export function normalizeHandoffFiles(files) {
31
31
  }));
32
32
  }
33
33
 
34
- /** The model-facing list of `files`, empty when there are none. `ids`: the
35
- * row also records the stored files as its attachments (a task report), so
36
- * their ids can be forwarded; a plain-text handoff (an agent completion)
37
- * lists paths only — an id named in text is never trusted. */
38
- export function artifactLines(files, omitted = 0, { ids = true } = {}) {
34
+ /** An agent completion's compact list of files it produced (name and path
35
+ * only; the Lead forwards or links by path), empty when there are none. */
36
+ export function producedFileLines(files, omitted = 0) {
39
37
  const list = normalizeHandoffFiles(files);
40
38
  if (!list.length) return [];
41
- const attachable = (file) => (ids ? ` [attachment:${file.id}]` : ' (attachable: forward it by path)');
42
39
  return [
43
- `Files from this turn (forward one with attachments: [${ids ? 'id or path' : 'path'}]; link a path to show it):`,
40
+ 'Files produced:',
41
+ ...list.map((file) => `- ${oneLine(file.name)} — ${oneLine(file.path)}`),
42
+ ...(omitted > 0 ? [`- …and ${omitted} more`] : []),
43
+ ];
44
+ }
45
+
46
+ /** The model-facing list of a bot task report's `files`, empty when there are
47
+ * none; the row also records the stored files as its attachments, so their
48
+ * ids can be forwarded. */
49
+ export function artifactLines(files, omitted = 0) {
50
+ const list = normalizeHandoffFiles(files);
51
+ if (!list.length) return [];
52
+ return [
53
+ 'Files from this turn (forward one with attachments: [id or path]; link a path to show it):',
44
54
  ...list.map(
45
55
  (file) =>
46
56
  `- ${oneLine(file.name)} (${oneLine(file.mimeType)}, ${sizeLabel(file.size)}) — ${oneLine(file.path)}${
47
- file.id ? attachable(file) : ' (too large or unsupported to attach; link the path)'
57
+ file.id ? ` [attachment:${file.id}]` : ' (too large or unsupported to attach; link the path)'
48
58
  }`
49
59
  ),
50
60
  ...(omitted > 0 ? [`- …and ${omitted} more file(s)`] : []),
@@ -44,15 +44,37 @@ function hasModelVisibleConversation(session) {
44
44
  });
45
45
  }
46
46
 
47
- // When a session's prompt cache was last used by a successful cache warm (a
48
- // read with no write), registered by the warmer (cache-warm.mjs, from its
49
- // durable touch store); 0 when none.
50
- let lastCacheWarmUse = () => 0;
47
+ // A session's last successful cache-warm use (a read with no write),
48
+ // registered by the warmer (cache-warm.mjs, from its durable touch store):
49
+ // `{ at, provider, model, accountId }` — when, and the route and account whose
50
+ // cached prefix it read — or a bare epoch-ms time; null/0 when none.
51
+ let lastCacheWarmUse = () => null;
51
52
 
52
53
  /** Register the source of a session's last successful cache-warm use
53
- * (`(sessionId) => epoch ms | 0`); null restores none. */
54
+ * (`(sessionId) => { at, provider, model, accountId } | epoch ms | null`);
55
+ * null restores none. */
54
56
  export function setCacheWarmUseSource(source) {
55
- lastCacheWarmUse = typeof source === 'function' ? source : () => 0;
57
+ lastCacheWarmUse = typeof source === 'function' ? source : () => null;
58
+ }
59
+
60
+ /** When a successful cache warm last used the session's prompt cache (epoch
61
+ * ms), or 0: the warm read and refreshed the cached prefix without moving
62
+ * any of the session's own timestamps. */
63
+ export function lastCacheWarmUseAt(sessionId) {
64
+ const use = sessionId ? lastCacheWarmUse(sessionId) : null;
65
+ return timestampMs(use && typeof use === 'object' ? use.at : use);
66
+ }
67
+
68
+ /** When a successful cache warm last used the prefix a request on `provider`
69
+ * / `model` / `accountId` reads (epoch ms), or 0: a warm on another route or
70
+ * account refreshed an entry this request cannot read, and a use without a
71
+ * recorded identity matches nothing. */
72
+ export function cacheWarmUseFor(sessionId, { provider, model, accountId = '' } = {}) {
73
+ const use = sessionId ? lastCacheWarmUse(sessionId) : null;
74
+ if (!use || typeof use !== 'object' || !use.provider) return 0;
75
+ const matches =
76
+ use.provider === provider && use.model === model && String(use.accountId || '') === String(accountId || '');
77
+ return matches ? timestampMs(use.at) : 0;
56
78
  }
57
79
 
58
80
  /**
@@ -65,7 +87,7 @@ export function setCacheWarmUseSource(source) {
65
87
  export function sessionActivityTimestamp(session, fallback = 0) {
66
88
  if (!hasModelVisibleConversation(session)) return 0;
67
89
  const own = timestampMs(session?.lastUsedAt) || timestampMs(session?.updatedAt) || timestampMs(fallback);
68
- return Math.max(own, timestampMs(lastCacheWarmUse(session?.id)));
90
+ return Math.max(own, lastCacheWarmUseAt(session?.id));
69
91
  }
70
92
 
71
93
  // A zero is often an unavailable/stale meter field, not an authoritative
@@ -100,7 +100,11 @@ export function priceUsage(args) {
100
100
  // A provider-billed figure for a media request (e.g. xAI cost_in_usd_ticks)
101
101
  // is the price, with or without a catalog row.
102
102
  const media = args.media || null;
103
- if (typeof media?.reportedCostUsd === 'number' && Number.isFinite(media.reportedCostUsd) && media.reportedCostUsd >= 0)
103
+ if (
104
+ typeof media?.reportedCostUsd === 'number' &&
105
+ Number.isFinite(media.reportedCostUsd) &&
106
+ media.reportedCostUsd >= 0
107
+ )
104
108
  return {
105
109
  input,
106
110
  costUsd: Number(media.reportedCostUsd.toFixed(6)),
@@ -1,3 +1,5 @@
1
+ import { FETCH_BLOCKED_PORTS, FETCH_SAFE_ATTEMPTS } from './fetch-safe-listen.mjs';
2
+
1
3
  // One loopback listener owns each bind attempt, its event handlers, and stop.
2
4
  export function createLoopbackListener({
3
5
  server,
@@ -24,7 +26,15 @@ export function createLoopbackListener({
24
26
 
25
27
  const { promise, resolve, reject } = Promise.withResolvers();
26
28
  const controller = new AbortController();
27
- const attempt = { promise, reject, controller, port: basePort, bound: false, detach };
29
+ const attempt = {
30
+ promise,
31
+ reject,
32
+ controller,
33
+ port: basePort,
34
+ bound: false,
35
+ blockedRetries: FETCH_SAFE_ATTEMPTS,
36
+ detach,
37
+ };
28
38
  current = attempt;
29
39
 
30
40
  function detach() {
@@ -35,6 +45,19 @@ export function createLoopbackListener({
35
45
  function onListening() {
36
46
  if (current !== attempt) return;
37
47
  const boundPort = server.address().port;
48
+ // An OS-assigned port can be one fetch clients refuse ("bad port"):
49
+ // close it and take another, a bounded number of times.
50
+ if (attempt.port === 0 && FETCH_BLOCKED_PORTS.has(boundPort)) {
51
+ const exhausted = attempt.blockedRetries <= 0;
52
+ attempt.blockedRetries -= 1;
53
+ server.close(() => {
54
+ if (current !== attempt) return;
55
+ if (exhausted) onError(new Error('no fetch-safe loopback port was assigned'));
56
+ else listen();
57
+ });
58
+ return;
59
+ }
60
+ server.off('listening', onListening);
38
61
  attempt.bound = true;
39
62
  reportListening(boundPort);
40
63
  resolve(boundPort);
@@ -67,7 +90,7 @@ export function createLoopbackListener({
67
90
  }
68
91
 
69
92
  server.on('error', onError);
70
- server.once('listening', onListening);
93
+ server.on('listening', onListening);
71
94
  listen();
72
95
  return promise;
73
96
  }
@@ -1,8 +1,13 @@
1
1
  export function canonicalRouteProvider(value: unknown): string;
2
2
  export function routeChain(route: Record<string, unknown>): Record<string, unknown>[];
3
- export function validateRouteChainProviders(routes: Record<string, unknown>[]): void;
3
+ export function executionFallbacks(route: Record<string, unknown> | null | undefined): Record<string, unknown>[];
4
+ export function validateRouteChainProviders(
5
+ routes: Record<string, unknown>[],
6
+ options?: { allowDuplicates?: boolean }
7
+ ): void;
4
8
  export function normalizeRouteChain(
5
9
  route: Record<string, unknown>,
6
10
  normalizeLeaf: (route: Record<string, unknown>, fallback: Record<string, unknown>) => Record<string, unknown> | null,
7
- fallback?: Record<string, unknown>
11
+ fallback?: Record<string, unknown>,
12
+ options?: { strict?: boolean }
8
13
  ): Record<string, unknown> | null;
@@ -22,18 +22,36 @@ export function routeChain(route) {
22
22
  return [primary, ...fallbacks];
23
23
  }
24
24
 
25
- export function validateRouteChainProviders(routes) {
25
+ /** The fallbacks a chain runs: those on a provider not already used by the
26
+ * primary or an earlier fallback. The stored list is never changed; a fallback
27
+ * on the main model's own provider (left there by a later main-model change)
28
+ * is simply inactive. */
29
+ export function executionFallbacks(route) {
30
+ const [primary, ...fallbacks] = routeChain(route);
31
+ if (!primary) return [];
32
+ const seen = new Set([canonicalRouteProvider(primary.provider)]);
33
+ return fallbacks.filter((entry) => {
34
+ const provider = canonicalRouteProvider(entry.provider);
35
+ if (seen.has(provider)) return false;
36
+ seen.add(provider);
37
+ return true;
38
+ });
39
+ }
40
+
41
+ /** `allowDuplicates` accepts a stored chain whose fallbacks repeat a provider
42
+ * (see executionFallbacks); an explicit save validates without it. */
43
+ export function validateRouteChainProviders(routes, { allowDuplicates = false } = {}) {
26
44
  const providers = new Set();
27
45
  for (const route of routes) {
28
46
  const provider = canonicalRouteProvider(route.provider);
29
47
  if (!provider || !String(route.model || '').trim()) throw new Error('route requires provider and model');
30
- if (providers.has(provider)) throw new Error(`duplicate route provider "${provider}"`);
48
+ if (!allowDuplicates && providers.has(provider)) throw new Error(`duplicate route provider "${provider}"`);
31
49
  if (providers.size && provider === 'default') throw new Error('follow Main is only allowed as the primary route');
32
50
  providers.add(provider);
33
51
  }
34
52
  }
35
53
 
36
- export function normalizeRouteChain(route, normalizeLeaf, fallback = {}) {
54
+ export function normalizeRouteChain(route, normalizeLeaf, fallback = {}, { strict = true } = {}) {
37
55
  const raw = routeChain({ ...fallback, ...route });
38
56
  const primary = normalizeLeaf(route, fallback);
39
57
  if (!primary) return null;
@@ -42,7 +60,7 @@ export function normalizeRouteChain(route, normalizeLeaf, fallback = {}) {
42
60
  if (!normalized) throw new Error('fallback route requires provider and model');
43
61
  return { ...normalized, provider: canonicalRouteProvider(normalized.provider) };
44
62
  });
45
- validateRouteChainProviders(routes);
63
+ validateRouteChainProviders(routes, { allowDuplicates: !strict });
46
64
  const [first, ...rest] = routes;
47
65
  return rest.length ? { ...first, fallbacks: rest } : first;
48
66
  }
@@ -2,6 +2,16 @@ import { spawn } from 'node:child_process';
2
2
  import { isWSL } from './wsl.mjs';
3
3
  import { detachedSpawnOpts } from './spawn-flags.mjs';
4
4
 
5
+ /**
6
+ * Whether a browser can plausibly open on this machine: not inside an SSH
7
+ * session, and on non-WSL Linux a DISPLAY or WAYLAND_DISPLAY exists.
8
+ */
9
+ export function canOpenBrowser(env = process.env, platform = process.platform, wsl = isWSL()) {
10
+ if (env.SSH_TTY || env.SSH_CONNECTION) return false;
11
+ if (platform !== 'linux' || wsl) return true;
12
+ return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
13
+ }
14
+
5
15
  /**
6
16
  * Open a URL in the user's default browser. Best-effort and non-blocking.
7
17
  * Callers also print the URL, so opener failure should never block OAuth.
@@ -45,6 +45,20 @@ export function emitSessionApprovalCardChange(sessionId = '') {
45
45
  emit(approvalCardListeners, sessionId);
46
46
  }
47
47
 
48
+ // The bots whose sends are in flight in a session changed (bot-owned-work.mjs):
49
+ // the session catalog stamps them on each row (`handedBy`) and re-reads.
50
+ const botWorkListeners = new Set();
51
+
52
+ export function onSessionBotWorkChange(listener) {
53
+ if (typeof listener !== 'function') return () => {};
54
+ botWorkListeners.add(listener);
55
+ return () => botWorkListeners.delete(listener);
56
+ }
57
+
58
+ export function emitSessionBotWorkChange(sessionId = '') {
59
+ emit(botWorkListeners, sessionId);
60
+ }
61
+
48
62
  function emit(targets, sessionId) {
49
63
  for (const listener of [...targets]) {
50
64
  try {