mixdog 1.3.3 → 1.3.5

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 (210) hide show
  1. package/LICENSES/oh-my-pi-MIT.txt +23 -0
  2. package/NOTICE.md +15 -0
  3. package/README.md +11 -0
  4. package/package.json +3 -2
  5. package/scripts/embed-google-oauth-client.mjs +49 -0
  6. package/scripts/lib/test-spawn-ledger.mjs +46 -1
  7. package/scripts/test.mjs +5 -0
  8. package/src/defaults/shipped-history.json +8 -0
  9. package/src/defaults/skills/browser-use/SKILL.md +21 -9
  10. package/src/defaults/skills/setup/SKILL.md +7 -2
  11. package/src/defaults/skills/setup/references/actions.md +82 -27
  12. package/src/defaults/skills/setup/references/surfaces.md +67 -34
  13. package/src/defaults/skills/setup-guide/SKILL.md +3 -3
  14. package/src/lib/keychain-cjs.cjs +359 -29
  15. package/src/rules/agent-mode/AGENT_MODE.md +10 -2
  16. package/src/rules/agent-mode/MIXY.md +12 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +193 -0
  18. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  19. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +2 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  21. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +31 -2
  22. package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +16 -3
  23. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +42 -7
  24. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +21 -2
  25. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +23 -0
  26. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +11 -1
  27. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +68 -23
  28. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +22 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +18 -5
  30. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -3
  32. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +52 -8
  33. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +22 -6
  35. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +15 -4
  36. package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +17 -2
  37. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +10 -4
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire-protobuf.mjs +10 -217
  41. package/src/runtime/agent/orchestrator/providers/cursor.mjs +7 -0
  42. package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +409 -0
  43. package/src/runtime/agent/orchestrator/providers/devin-wire-families.mjs +187 -0
  44. package/src/runtime/agent/orchestrator/providers/devin-wire-identity.mjs +342 -0
  45. package/src/runtime/agent/orchestrator/providers/devin-wire-models.mjs +541 -0
  46. package/src/runtime/agent/orchestrator/providers/devin-wire-protobuf.mjs +305 -0
  47. package/src/runtime/agent/orchestrator/providers/devin-wire-request.mjs +558 -0
  48. package/src/runtime/agent/orchestrator/providers/devin-wire-schema.mjs +880 -0
  49. package/src/runtime/agent/orchestrator/providers/devin-wire-session.mjs +190 -0
  50. package/src/runtime/agent/orchestrator/providers/devin-wire-tool-args.mjs +351 -0
  51. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +560 -0
  52. package/src/runtime/agent/orchestrator/providers/devin.mjs +491 -0
  53. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/gemini-output-limit.mjs +46 -0
  55. package/src/runtime/agent/orchestrator/providers/gemini-reasoning-relay.mjs +30 -0
  56. package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +7 -11
  57. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +61 -22
  58. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -0
  59. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -4
  60. package/src/runtime/agent/orchestrator/providers/gemini.mjs +25 -2
  61. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  62. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +96 -5
  63. package/src/runtime/agent/orchestrator/providers/lib/tail-reminder-split.mjs +23 -5
  64. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +17 -0
  65. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +39 -3
  66. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +3 -1
  67. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +2 -0
  68. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +6 -1
  69. package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +1 -0
  70. package/src/runtime/agent/orchestrator/providers/provider-availability.mjs +19 -0
  71. package/src/runtime/agent/orchestrator/providers/registry.mjs +17 -3
  72. package/src/runtime/agent/orchestrator/providers/wire-protobuf-codec.mjs +368 -0
  73. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +2 -0
  74. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +1 -1
  75. package/src/runtime/agent/orchestrator/runtime-core/reaction-tool-defs.mjs +133 -0
  76. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +23 -5
  78. package/src/runtime/agent/orchestrator/runtime-core/tool-profile.mjs +35 -10
  79. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +57 -10
  80. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +9 -6
  81. package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -1
  82. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  83. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +0 -3
  84. package/src/runtime/agent/orchestrator/session/agent-room.mjs +10 -4
  85. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +15 -3
  86. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -0
  87. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +36 -4
  88. package/src/runtime/agent/orchestrator/session/human-gate/outbound-policy.mjs +6 -0
  89. package/src/runtime/agent/orchestrator/session/human-gate/store.mjs +15 -3
  90. package/src/runtime/agent/orchestrator/session/loop/assistant-commit.mjs +8 -0
  91. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
  92. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +50 -2
  94. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +3 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +11 -2
  96. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +11 -2
  97. package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +60 -0
  98. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +2 -0
  99. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +14 -0
  100. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +13 -0
  102. package/src/runtime/agent/orchestrator/session/message-ids.mjs +5 -3
  103. package/src/runtime/agent/orchestrator/session/message-reactions.mjs +89 -0
  104. package/src/runtime/agent/orchestrator/session/message-reactions.test-support.mjs +75 -0
  105. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +0 -2
  106. package/src/runtime/agent/orchestrator/session/routine-result.mjs +11 -2
  107. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +20 -2
  108. package/src/runtime/agent/orchestrator/session/store-agent-worker-header-cache.mjs +85 -0
  109. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +125 -12
  110. package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +11 -5
  111. package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +4 -0
  112. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -0
  113. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +7 -1
  114. package/src/runtime/channels/lib/schedule-errors.mjs +51 -0
  115. package/src/runtime/channels/lib/scheduler.mjs +109 -14
  116. package/src/runtime/channels/lib/schedules-db.mjs +49 -0
  117. package/src/runtime/channels/lib/tool-dispatch.mjs +13 -0
  118. package/src/runtime/channels/lib/worker-main.mjs +9 -0
  119. package/src/runtime/office/com/com-adapter.mjs +17 -1
  120. package/src/runtime/office/com/office-com-host.ps1 +51 -1
  121. package/src/runtime/office/pdf/cfb-fixture.mjs +328 -0
  122. package/src/runtime/office/pdf/cfb-protection.mjs +311 -0
  123. package/src/runtime/office/pdf/document-preview.mjs +326 -8
  124. package/src/runtime/office/pdf/ooxml-crypto.mjs +469 -0
  125. package/src/runtime/office/pdf/ooxml-key-worker.mjs +14 -0
  126. package/src/runtime/office/portable/portable-soffice.mjs +45 -5
  127. package/src/runtime/shared/agent-room-host.mjs +1 -1
  128. package/src/runtime/shared/bot-hops.mjs +62 -2
  129. package/src/runtime/shared/config.mjs +49 -22
  130. package/src/runtime/shared/connector-catalog.d.mts +4 -2
  131. package/src/runtime/shared/connector-catalog.mjs +16 -8
  132. package/src/runtime/shared/developer-options.mjs +32 -1
  133. package/src/runtime/shared/google-oauth-client.generated.json +4 -0
  134. package/src/runtime/shared/google-oauth-client.mjs +34 -9
  135. package/src/runtime/shared/llm/anthropic-betas.mjs +7 -0
  136. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +12 -0
  137. package/src/runtime/shared/llm/provider-catalog-registry.mjs +1 -0
  138. package/src/runtime/shared/llm/usage-rollup.mjs +4 -383
  139. package/src/runtime/shared/llm/well-formed.mjs +66 -0
  140. package/src/runtime/shared/provider-accounts.mjs +224 -58
  141. package/src/runtime/shared/provider-api-key.mjs +19 -8
  142. package/src/runtime/shared/routine-trigger.mjs +42 -0
  143. package/src/runtime/shared/tool-execution-contract.mjs +2 -0
  144. package/src/runtime/shared/transcript-metadata.mjs +38 -0
  145. package/src/runtime/shared/user-data-guard.mjs +30 -2
  146. package/src/runtime/shared/worktrees/activity-gate.mjs +12 -5
  147. package/src/session-runtime/agent-home-delivery.mjs +57 -13
  148. package/src/session-runtime/agent-room-delivery.mjs +28 -1
  149. package/src/session-runtime/agent-task-sessions.mjs +292 -79
  150. package/src/session-runtime/agent-tool-routing.mjs +49 -4
  151. package/src/session-runtime/boot/begin.mjs +3 -2
  152. package/src/session-runtime/boot/tools.mjs +4 -0
  153. package/src/session-runtime/bot-chain.mjs +32 -0
  154. package/src/session-runtime/bot-status.mjs +7 -4
  155. package/src/session-runtime/channel-config-api.mjs +63 -17
  156. package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +45 -0
  157. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +72 -5
  158. package/src/session-runtime/internal-tool-executor.mjs +4 -0
  159. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -1
  160. package/src/session-runtime/mixy-heartbeat.mjs +807 -0
  161. package/src/session-runtime/model-route/route-tuning.mjs +10 -6
  162. package/src/session-runtime/owned-automation-run.mjs +33 -12
  163. package/src/session-runtime/provider-auth/login.mjs +7 -0
  164. package/src/session-runtime/provider-models/row-hydration.mjs +13 -2
  165. package/src/session-runtime/routine-home-turn.mjs +33 -6
  166. package/src/session-runtime/schedule-session-run.mjs +7 -3
  167. package/src/session-runtime/services/agent-tool/close-flow.mjs +14 -2
  168. package/src/session-runtime/services/agent-tool/execute.mjs +6 -1
  169. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +11 -2
  170. package/src/session-runtime/services/agent-tool/render.mjs +3 -1
  171. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +8 -12
  172. package/src/session-runtime/services/agent-tool/tool-def.mjs +1 -1
  173. package/src/session-runtime/services/channel-admin.mjs +66 -18
  174. package/src/session-runtime/services/channel-worker.mjs +1 -1
  175. package/src/session-runtime/services/provider-admin.mjs +128 -25
  176. package/src/session-runtime/services/schedule-access.mjs +14 -0
  177. package/src/session-runtime/services/usage-dashboard-model.mjs +1 -0
  178. package/src/session-runtime/session-create/session-options.mjs +7 -4
  179. package/src/session-runtime/settings-system-api.mjs +16 -1
  180. package/src/session-runtime/setup-tool/executor.mjs +49 -14
  181. package/src/session-runtime/setup-tool/extended-actions.mjs +29 -3
  182. package/src/session-runtime/setup-tool/settings-contract.mjs +65 -4
  183. package/src/session-runtime/setup-tool/tool-defs.mjs +30 -3
  184. package/src/session-runtime/task-session-route.mjs +99 -0
  185. package/src/session-runtime/tool-policy/policy-stage.mjs +5 -2
  186. package/src/session-runtime/tool-policy/surface-helpers.mjs +2 -3
  187. package/src/session-runtime/tool-surface.mjs +4 -0
  188. package/src/session-runtime/usage-stats-api.mjs +1 -1
  189. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +98 -21
  190. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +6 -1
  191. package/src/session-runtime/workspace/transitions.mjs +10 -2
  192. package/src/standalone/daemon-session-control.mjs +88 -20
  193. package/src/standalone/daemon.mjs +8 -0
  194. package/src/standalone/session-protocol.mjs +5 -0
  195. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +6 -2
  196. package/src/standalone/session-service/session-calls/session-turns.mjs +6 -0
  197. package/src/standalone/session-service.mjs +13 -0
  198. package/src/standalone/session-tool-output.mjs +8 -5
  199. package/src/tui/app/model-options.mjs +2 -0
  200. package/src/tui/dist/index.mjs +335 -172
  201. package/src/tui/session/notice-surface.mjs +17 -1
  202. package/src/tui/session/session-api/integrations.mjs +1 -0
  203. package/src/tui/session/session-api/lifecycle.mjs +8 -0
  204. package/src/tui/session/session-api/settings.mjs +14 -2
  205. package/src/tui/session/turn-stream.mjs +17 -1
  206. package/src/tui/session/turn.mjs +22 -2
  207. package/src/vendor/statusline/src/gateway/claude-current.mjs +0 -58
  208. package/src/vendor/statusline/src/gateway/route-meta.mjs +1 -361
  209. package/src/vendor/statusline/src/gateway/session-routes.mjs +0 -55
  210. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +0 -285
@@ -11,6 +11,7 @@ export const ACCOUNT_PROVIDERS = Object.freeze([
11
11
  'command-code-oauth',
12
12
  'mimo-oauth',
13
13
  'cursor-oauth',
14
+ 'devin-oauth',
14
15
  'antigravity-oauth',
15
16
  'opencode-go-oauth',
16
17
  ]);
@@ -95,16 +96,32 @@ export function providerAccountPolicy(pool) {
95
96
  return pool?.policy === 'headroom' ? 'headroom' : 'limit';
96
97
  }
97
98
 
98
- function normalizePool(pool) {
99
+ // A reading stored before per-model gating is stale: it is dropped on read so
100
+ // it steers nothing until the account is measured again.
101
+ function dropStaleUsage(provider, row) {
102
+ if (!staleQuotaWindows(provider, row.usage?.windows)) return row;
103
+ const fresh = { ...row };
104
+ delete fresh.usage;
105
+ return fresh;
106
+ }
107
+
108
+ function normalizePool(provider, pool) {
99
109
  if (!pool) return pool;
100
110
  const policy = providerAccountPolicy(pool);
101
- return { ...normalizeLabels(pool), policy, auto: policy !== 'manual' };
111
+ const labelled = normalizeLabels(pool);
112
+ const accounts = labelled.accounts.map((row) => dropStaleUsage(provider, row));
113
+ return { ...labelled, accounts, policy, auto: policy !== 'manual' };
102
114
  }
103
115
 
104
116
  export function readProviderAccountPool(provider, dataDir = resolvePluginData()) {
105
117
  requireAccountProvider(provider);
106
118
  return (
107
- normalizePool(read(dataDir).providers[provider]) || { accounts: [], selectedId: null, auto: true, policy: 'limit' }
119
+ normalizePool(provider, read(dataDir).providers[provider]) || {
120
+ accounts: [],
121
+ selectedId: null,
122
+ auto: true,
123
+ policy: 'limit',
124
+ }
108
125
  );
109
126
  }
110
127
 
@@ -124,7 +141,7 @@ async function update(provider, mutate) {
124
141
  },
125
142
  { mode: 0o600, secret: true, fsyncDir: true, backup: true }
126
143
  );
127
- return normalizePool(result.providers[provider]);
144
+ return normalizePool(provider, result.providers[provider]);
128
145
  }
129
146
 
130
147
  /**
@@ -256,21 +273,55 @@ export async function recordProviderAccountUsage(provider, id, snapshot) {
256
273
  label: String(row.label || '').slice(0, 40),
257
274
  usedPct: typeof row.usedPct === 'number' && Number.isFinite(row.usedPct) ? row.usedPct : null,
258
275
  resetAt: typeof row.resetAt === 'number' && Number.isFinite(row.resetAt) ? row.resetAt : null,
276
+ ...windowScopeFields(row),
259
277
  }));
260
278
  if (!readProviderAccountPool(provider).accounts.some((row) => row.id === id)) return;
261
279
  await update(provider, (pool) => {
262
280
  const row = pool.accounts.find((entry) => entry.id === id);
263
281
  if (!row) return;
264
282
  row.usage = { windows, checkedAt: Date.now() };
265
- // A fresh observation of available quota releases a prior typed refusal.
266
- // Only a MEASURED window can testify: a provider that reports a window
267
- // without a percentage says nothing about that meter, and requiring a
268
- // number from it kept accounts refused that every real reading had freed.
269
- const measured = windows.filter((window) => window.usedPct !== null);
270
- if (measured.length && measured.every((window) => window.usedPct < 100)) clearAccountBlock(row);
283
+ // A fresh observation of available quota releases a prior refusal. Only
284
+ // the meters the refusal drew on testify (a full Claude/GPT bucket says
285
+ // nothing about a Gemini refusal), and only a MEASURED window: a provider
286
+ // that reports a window without a percentage says nothing about that
287
+ // meter, and requiring a number from it kept accounts refused that every
288
+ // real reading had freed. Each bucket's block is weighed on its own.
289
+ const lifted = (block) => {
290
+ const measured = blockEvidenceWindows(block).filter((window) => window.usedPct !== null);
291
+ return measured.length > 0 && measured.every((window) => window.usedPct < 100);
292
+ };
293
+ setAccountBlocks(
294
+ row,
295
+ accountBlocks(row).filter((block) => !lifted(block))
296
+ );
271
297
  });
272
298
  }
273
299
 
300
+ // The model-id tokens a window's gate names, as recorded: lowercased and bounded.
301
+ function modelTokens(value) {
302
+ if (!Array.isArray(value)) return null;
303
+ return value
304
+ .slice(0, 12)
305
+ .map((token) =>
306
+ String(token || '')
307
+ .trim()
308
+ .toLowerCase()
309
+ .slice(0, 80)
310
+ )
311
+ .filter(Boolean);
312
+ }
313
+
314
+ function windowScopeFields(row) {
315
+ const models = modelTokens(row.models);
316
+ const except = modelTokens(row.except);
317
+ const limit = typeof row.limit === 'string' ? row.limit.trim().slice(0, 80) : '';
318
+ const scope = {};
319
+ if (models) scope.models = models;
320
+ else if (except?.length) scope.except = except;
321
+ if (limit) scope.limit = limit;
322
+ return scope;
323
+ }
324
+
274
325
  // A refusal window is an AVOIDANCE hint for the roster, not the retry schedule —
275
326
  // the request-level retry honours Retry-After on its own. Generous enough to
276
327
  // cover a full five-hour burst window, tight enough that a header asking for
@@ -283,18 +334,29 @@ const MAX_BLOCK_MS = 6 * 60 * 60_000;
283
334
  // early can tell us anything.
284
335
  // `options.quota` records that the block came from a typed quota refusal or an
285
336
  // exhausted usage window, so later sends still count it as quota evidence.
337
+ // `options.limit` is the server's id of the limit the refusal named; the block
338
+ // then covers that limit's windows. `options.reset` marks `until` as the
339
+ // quota reset the server named: it is kept as given (a fresh reading showing
340
+ // room still lifts it early), while advice and inferred delays stay capped.
341
+ // A new refusal of the same model replaces that model's block; every other
342
+ // block still running keeps its own deadline, so refusing one bucket never
343
+ // frees another.
286
344
  export async function blockProviderAccount(provider, id, until, model = '', source = 'inferred', options = {}) {
287
345
  return update(provider, (pool) => {
288
346
  const row = pool.accounts.find((entry) => entry.id === id);
289
347
  if (!row) return;
290
- row.blockedUntil = Math.min(until, Date.now() + MAX_BLOCK_MS);
348
+ const now = Date.now();
349
+ const block = { blockedUntil: options.reset === true ? until : Math.min(until, now + MAX_BLOCK_MS) };
291
350
  const scope = String(model || '').trim();
292
- if (scope) row.blockedModel = scope;
293
- else delete row.blockedModel;
294
- if (source === 'retry-after') row.blockedSource = source;
295
- else delete row.blockedSource;
296
- if (options.quota === true) row.blockedQuota = true;
297
- else delete row.blockedQuota;
351
+ if (scope) block.blockedModel = scope;
352
+ const limit = String(options.limit || '').trim();
353
+ if (limit) block.blockedLimit = limit;
354
+ if (source === 'retry-after') block.blockedSource = source;
355
+ if (options.quota === true) block.blockedQuota = true;
356
+ const running = accountBlocks(row).filter(
357
+ (entry) => entry.blockedUntil > now && String(entry.blockedModel || '') !== scope
358
+ );
359
+ setAccountBlocks(row, [...running, block]);
298
360
  });
299
361
  }
300
362
 
@@ -312,41 +374,136 @@ export async function clearProviderAccountQuotaState(provider, id) {
312
374
  });
313
375
  }
314
376
 
377
+ const BLOCK_FIELDS = Object.freeze(['blockedUntil', 'blockedModel', 'blockedLimit', 'blockedSource', 'blockedQuota']);
378
+
315
379
  function clearAccountBlock(row) {
316
- delete row.blockedUntil;
317
- delete row.blockedModel;
318
- delete row.blockedSource;
319
- delete row.blockedQuota;
380
+ for (const key of BLOCK_FIELDS) delete row[key];
381
+ delete row.blockedBuckets;
382
+ }
383
+
384
+ const blockOf = (source) =>
385
+ Object.fromEntries(BLOCK_FIELDS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]));
386
+
387
+ // Every block the account holds, oldest first, each read beside the account's
388
+ // usage. The row's own fields carry the latest; earlier blocks of other
389
+ // buckets still running sit in `blockedBuckets`.
390
+ function accountBlocks(row) {
391
+ const blocks = [
392
+ ...(Array.isArray(row?.blockedBuckets) ? row.blockedBuckets : []),
393
+ ...(row?.blockedUntil === undefined ? [] : [blockOf(row)]),
394
+ ];
395
+ return blocks.map((block) => ({ ...block, usage: row.usage }));
396
+ }
397
+
398
+ function setAccountBlocks(row, blocks) {
399
+ clearAccountBlock(row);
400
+ if (blocks.length) Object.assign(row, blockOf(blocks.at(-1)));
401
+ if (blocks.length > 1) row.blockedBuckets = blocks.slice(0, -1).map(blockOf);
402
+ return row;
403
+ }
404
+
405
+ // The row holding only the blocks `keep` accepts (none by default), so a check
406
+ // can weigh the usage windows, or only the quota refusals, on their own.
407
+ export function providerAccountWithBlocks(row, keep = () => false) {
408
+ return row && setAccountBlocks({ ...row }, accountBlocks(row).filter(keep));
320
409
  }
321
410
 
322
- // Single-token weekly labels ("7D Fable", "7D Opus") belong to that Claude
323
- // family only. A full Fable window must not idle an Opus request. Unscoped
324
- // 5H/7D meters and multi-word surfaces ("7D OAuth apps") still apply to every
325
- // model.
411
+ // Labels every current reading scopes with `models`/`except`. A stored window
412
+ // carrying one of them without a scope predates per-model gating and would
413
+ // read as account-wide, so its whole reading is stale.
414
+ const SCOPED_WINDOW_LABELS = Object.freeze({
415
+ 'anthropic-oauth': ['EXTRA'],
416
+ 'antigravity-oauth': ['7D Argon', '5H Claude/GPT', '7D Claude/GPT'],
417
+ 'cursor-oauth': ['Basic', 'API'],
418
+ });
419
+
420
+ export function staleQuotaWindows(provider, windows) {
421
+ const labels = SCOPED_WINDOW_LABELS[provider] || [];
422
+ return (Array.isArray(windows) ? windows : []).some(
423
+ (window) =>
424
+ labels.includes(String(window?.label || '').trim()) &&
425
+ !Array.isArray(window.models) &&
426
+ !Array.isArray(window.except)
427
+ );
428
+ }
429
+
430
+ // Which models one quota window gates — the single rule every window and every
431
+ // block follows. The usage reading scopes a window to the server's quota
432
+ // bucket: `models` gates only model ids naming one of those tokens (an empty
433
+ // list gates nothing: a display-only meter such as paid extra usage), `except`
434
+ // gates every model but those. A window without either keeps the older label
435
+ // rule: a single-token weekly label ("7D Fable", "7D Opus") belongs to that
436
+ // family only, and every other window (5H/7D, multi-word surfaces such as
437
+ // "7D OAuth apps") gates every model.
326
438
  const FAMILY_QUOTA_WINDOW = /^7D\s+([A-Za-z][A-Za-z0-9]*)$/i;
327
439
 
440
+ function windowScope(window) {
441
+ if (Array.isArray(window?.models)) return { models: window.models };
442
+ if (Array.isArray(window?.except) && window.except.length) return { except: window.except };
443
+ const family = FAMILY_QUOTA_WINDOW.exec(String(window?.label || '').trim())?.[1];
444
+ return family ? { models: [family.toLowerCase()] } : null;
445
+ }
446
+
447
+ const displayOnly = (window) => Array.isArray(window?.models) && window.models.length === 0;
448
+
449
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
450
+
451
+ function namesModel(model, tokens) {
452
+ const id = String(model).toLowerCase();
453
+ return tokens.some((token) => new RegExp(`(?:^|[-_/.@])${escapeRegExp(token)}(?:[-_/.@]|$)`).test(id));
454
+ }
455
+
328
456
  export function quotaWindowAppliesToModel(window, model) {
329
- const match = FAMILY_QUOTA_WINDOW.exec(String(window?.label || '').trim());
330
- if (!match) return true;
331
- const family = match[1].toLowerCase();
332
- const id = String(model || '').toLowerCase();
333
- if (!id) return false;
334
- return new RegExp(`(?:^|[-_/.@])${family}(?:[-_/.@]|$)`).test(id);
457
+ const scope = windowScope(window);
458
+ if (!scope) return true;
459
+ if (!model) return false;
460
+ return scope.models ? namesModel(model, scope.models) : !namesModel(model, scope.except);
461
+ }
462
+
463
+ // The meters a block stands for: the windows of the limit the refusal named,
464
+ // else the scoped windows that gate the model that earned it.
465
+ function blockWindows(row) {
466
+ const windows = row?.usage?.windows || [];
467
+ const limit = String(row?.blockedLimit || '');
468
+ const named = limit ? windows.filter((window) => window.limit === limit) : [];
469
+ if (named.length) return named;
470
+ const earned = String(row?.blockedModel || '').trim();
471
+ return earned ? windows.filter((window) => windowScope(window) && quotaWindowAppliesToModel(window, earned)) : [];
472
+ }
473
+
474
+ // The windows a fresh reading must show below 100% to lift a block: the
475
+ // block's own meters plus every meter, scoped or shared, the model that earned
476
+ // it (or, for an account-wide block, any model) draws on.
477
+ function blockEvidenceWindows(row) {
478
+ const earned = String(row?.blockedModel || '').trim();
479
+ const gating = (row?.usage?.windows || []).filter((window) =>
480
+ earned ? quotaWindowAppliesToModel(window, earned) : !displayOnly(window)
481
+ );
482
+ return [...new Set([...blockWindows(row), ...gating])];
335
483
  }
336
484
 
337
485
  // A refusal earned by one model must not idle a model that draws on a
338
- // different meter — the same rule the usage windows already follow. Two models
339
- // share a block when one scoped window covers both; when the account exposes no
340
- // scoped meter at all (a plain 5H/7D subscription) the block stays account-wide,
341
- // because there is no narrower category to attribute it to.
486
+ // different meter — the same rule the usage windows follow. Two models share a
487
+ // block when one of the block's windows gates both; when the account exposes
488
+ // no scoped meter for the model (a plain 5H/7D subscription) the block stays
489
+ // account-wide, because there is no narrower category to attribute it to.
342
490
  function blockAppliesToModel(row, model) {
343
- const scope = String(row?.blockedModel || '').trim();
344
- if (!scope || !model || scope === String(model)) return true;
345
- const scoped = (row?.usage?.windows || []).filter((window) =>
346
- FAMILY_QUOTA_WINDOW.test(String(window?.label || '').trim())
347
- );
348
- if (!scoped.length) return true;
349
- return scoped.some((window) => quotaWindowAppliesToModel(window, scope) && quotaWindowAppliesToModel(window, model));
491
+ const earned = String(row?.blockedModel || '').trim();
492
+ if (!earned || !model || earned === String(model)) return true;
493
+ const windows = blockWindows(row);
494
+ if (!windows.length) return true;
495
+ return windows.some((window) => quotaWindowAppliesToModel(window, model));
496
+ }
497
+
498
+ // When the blocks holding `model` lift (every block when no model is named):
499
+ // the latest of their deadlines, or null when none holds it. Past deadlines
500
+ // are returned as they are, so a caller compares the result with its clock.
501
+ export function providerAccountBlockedUntil(row, model = '') {
502
+ const ends = accountBlocks(row)
503
+ .filter((block) => blockAppliesToModel(block, model))
504
+ .map((block) => Number(block.blockedUntil))
505
+ .filter(Number.isFinite);
506
+ return ends.length ? Math.max(...ends) : null;
350
507
  }
351
508
 
352
509
  // Earliest moment any of the given exhausted accounts can serve `model` again,
@@ -360,8 +517,9 @@ export function providerAccountsResetAt(rows, now = Date.now(), model = '', opti
360
517
  if (!providerAccountExhausted(row, now, model, options)) continue;
361
518
  const times = [];
362
519
  let unknown = false;
363
- if (row.blockedUntil > now && blockAppliesToModel(row, model)) {
364
- if (row.blockedSource === 'retry-after') times.push(row.blockedUntil);
520
+ for (const block of accountBlocks(row)) {
521
+ if (!(block.blockedUntil > now) || !blockAppliesToModel(block, model)) continue;
522
+ if (block.blockedSource === 'retry-after') times.push(block.blockedUntil);
365
523
  else unknown = true;
366
524
  }
367
525
  if (options.useCredits !== true) {
@@ -381,39 +539,47 @@ function windowFull(row, window, now) {
381
539
  }
382
540
 
383
541
  export function providerAccountExhausted(row, now = Date.now(), model = '', options = {}) {
384
- if (row?.blockedUntil > now && blockAppliesToModel(row, model)) return true;
542
+ if (accountBlocks(row).some((block) => block.blockedUntil > now && blockAppliesToModel(block, model))) return true;
385
543
  if (options.useCredits === true) return false;
386
544
  return (row?.usage?.windows || []).some(
387
545
  (window) => quotaWindowAppliesToModel(window, model) && windowFull(row, window, now)
388
546
  );
389
547
  }
390
548
 
549
+ // The display name of a scoped limit: the family of a legacy "7D Fable" label,
550
+ // else the label without its window prefix ("5H Claude/GPT" → "Claude/GPT").
391
551
  function quotaWindowFamily(window) {
392
- return FAMILY_QUOTA_WINDOW.exec(String(window?.label || '').trim())?.[1] || '';
552
+ const label = String(window?.label || '').trim();
553
+ return FAMILY_QUOTA_WINDOW.exec(label)?.[1] || label.replace(/^(?:5H|7D)\s+/i, '') || label;
393
554
  }
394
555
 
395
556
  // Which limit currently holds the account, independent of any one model:
396
557
  // `{ scope: 'account' }` when every request is refused (an unscoped window is
397
558
  // full, or a block with no narrower meter to attribute it to), `{ scope:
398
- // 'model', families }` when only model-family meters ("7D Fable") or a block
399
- // attributed to one are spent, so other models still run — else null.
559
+ // 'model', families }` when only scoped meters ("7D Fable", "5H Claude/GPT")
560
+ // or a block attributed to one are spent, so other models still run — else null.
400
561
  export function providerAccountLimit(row, now = Date.now(), options = {}) {
401
562
  if (!row) return null;
402
563
  const windows = row.usage?.windows || [];
403
- const blocked = row.blockedUntil > now;
404
- const scope = String(row.blockedModel || '').trim();
405
- const scopedWindows = windows.filter((window) => quotaWindowFamily(window));
406
- // A block counts as family-scoped only when a family meter covers the model
407
- // that earned it; any other refusal is treated as the account's.
408
- const covering = scope ? scopedWindows.filter((window) => quotaWindowAppliesToModel(window, scope)) : [];
409
- if (blocked && !covering.length) return { scope: 'account' };
564
+ // A block counts as scoped only when scoped meters cover it; any other
565
+ // refusal is treated as the account's.
566
+ const covering = accountBlocks(row)
567
+ .filter((block) => block.blockedUntil > now)
568
+ .map(blockWindows);
569
+ if (covering.some((meters) => !meters.length || meters.some((window) => !windowScope(window)))) {
570
+ return { scope: 'account' };
571
+ }
410
572
  const credits = options.useCredits === true;
411
- if (!credits && windows.some((window) => !quotaWindowFamily(window) && windowFull(row, window, now))) {
573
+ if (!credits && windows.some((window) => !windowScope(window) && windowFull(row, window, now))) {
412
574
  return { scope: 'account' };
413
575
  }
414
- const families = new Set(blocked ? covering.map(quotaWindowFamily) : []);
576
+ const families = new Set(covering.flat().map(quotaWindowFamily));
415
577
  if (!credits) {
416
- for (const window of scopedWindows) if (windowFull(row, window, now)) families.add(quotaWindowFamily(window));
578
+ for (const window of windows) {
579
+ if (windowScope(window) && !displayOnly(window) && windowFull(row, window, now)) {
580
+ families.add(quotaWindowFamily(window));
581
+ }
582
+ }
417
583
  }
418
584
  return families.size ? { scope: 'model', families: [...families] } : null;
419
585
  }
@@ -26,16 +26,27 @@ function providerSecretEnv(provider) {
26
26
  .toUpperCase()}_APIKEY`;
27
27
  }
28
28
 
29
- function environmentApiKey(provider) {
30
- const id = String(provider || '').trim();
29
+ // The environment variable that supplies a provider's key, in the resolver's
30
+ // precedence (standard name, MIXDOG_AGENT_<ID>_APIKEY, then aliases); null when none is set.
31
+ function environmentKeyName(id) {
31
32
  const standardEnv = AGENT_PROVIDER_ENV[id];
32
- if (standardEnv && process.env[standardEnv]) return process.env[standardEnv];
33
+ if (standardEnv && process.env[standardEnv]) return standardEnv;
33
34
  const mixdogEnv = providerSecretEnv(id);
34
- if (process.env[mixdogEnv]) return process.env[mixdogEnv];
35
- for (const alias of AGENT_PROVIDER_ENV_ALIASES[id] || []) {
36
- if (process.env[alias]) return process.env[alias];
37
- }
38
- return null;
35
+ if (process.env[mixdogEnv]) return mixdogEnv;
36
+ return (AGENT_PROVIDER_ENV_ALIASES[id] || []).find((alias) => process.env[alias]) ?? null;
37
+ }
38
+
39
+ function environmentApiKey(provider) {
40
+ const name = environmentKeyName(String(provider || '').trim());
41
+ return name ? process.env[name] : null;
42
+ }
43
+
44
+ /** Name of the environment variable getAgentApiKey would return for `provider`
45
+ * (it wins over any stored key), or null when the effective key is not an
46
+ * environment one. Provenance for the setup views; never the value. */
47
+ export function agentApiKeyEnvName(provider) {
48
+ if (modelProxyClientMode()) return null;
49
+ return environmentKeyName(String(provider || '').trim());
39
50
  }
40
51
 
41
52
  /** Presence-only credential probe. On Windows this is a cheap DPAPI-file
@@ -28,6 +28,48 @@ export function routineTriggerHeader({ kind, name, project = '', now = new Date(
28
28
  ].join(' ');
29
29
  }
30
30
 
31
+ /** The whole reply of a heartbeat turn that has nothing to tell the user. */
32
+ export const HEARTBEAT_QUIET = 'HEARTBEAT_QUIET';
33
+ /** The routine name a heartbeat turn runs under and its hidden session's source. */
34
+ export const HEARTBEAT_SOURCE = 'heartbeat';
35
+ /** A heartbeat turn's bounds: its tool calls; when it is told to reply with no
36
+ * more tools (session/loop/turn-limits.mjs); when it is cut off without a
37
+ * reply (session-runtime/routine-home-turn.mjs). */
38
+ export const HEARTBEAT_TURN_LIMITS = Object.freeze({ maxToolCalls: 8, wrapUpMs: 60_000, wallClockMs: 90_000 });
39
+
40
+ // Mixy's hidden heartbeat sessions (mixy-heartbeat.mjs wakeMixy), marked in
41
+ // this process from creation until their close lands. Only the mark counts:
42
+ // stored fields (source, owner) can be supplied by any session create.
43
+ const heartbeatSessions = new Set();
44
+
45
+ /** Mark `sessionId` as a heartbeat session; `unmark` ends it. */
46
+ export function markHeartbeatSession(sessionId, unmark = false) {
47
+ const id = String(sessionId || '');
48
+ if (unmark) heartbeatSessions.delete(id);
49
+ else if (id) heartbeatSessions.add(id);
50
+ }
51
+
52
+ /** Whether `sessionId` is a marked heartbeat session. */
53
+ export function isHeartbeatSession(sessionId) {
54
+ return heartbeatSessions.has(String(sessionId || ''));
55
+ }
56
+
57
+ /** The first line of a heartbeat turn (session-runtime/mixy-heartbeat.mjs):
58
+ * a system check-in, never the user, and it grants no approval. */
59
+ export function heartbeatTriggerHeader({ now = new Date() } = {}) {
60
+ const at = localTimestampParts(now);
61
+ return [
62
+ `${HEADER_PREFIX} Heartbeat: a system check-in from Mixdog, not a message from the user; it grants no approval.`,
63
+ ...(at ? [`Local time: ${at.date} ${at.time.slice(0, 5)} ${at.timeZone} (${at.offset}).`] : []),
64
+ 'Nobody is watching this turn and nothing asks for approval: act within your permissions.',
65
+ 'Payments and permanent deletes are refused here.',
66
+ 'Do not start agents, task sessions, session sends, Goals or background commands here: this session closes when the turn ends, so their results would reach nobody; propose them instead.',
67
+ 'Handle the items below as your heartbeat instructions say, finishing in this turn:',
68
+ 'only its final reply is posted in your chat with the user, as your message.',
69
+ `If nothing is worth telling the user, reply with exactly ${HEARTBEAT_QUIET} and nothing else.`,
70
+ ].join(' ');
71
+ }
72
+
31
73
  /** `text` without the header line a routine turn opens with. */
32
74
  export function stripRoutineTriggerHeader(text) {
33
75
  const value = String(text ?? '');
@@ -195,6 +195,8 @@ const TRANSCRIPT_HIDDEN_CONTROL_TOOL_NAMES = new Set([
195
195
  'update_goal',
196
196
  'load_tool',
197
197
  'tool_search',
198
+ // A bot's reaction shows as its emoji under the user's message, not as a card.
199
+ 'react',
198
200
  ]);
199
201
  // Skill loads are visible cards EXCEPT for built-in skills: those ride a
200
202
  // built-in feature whose own card (Browser Use, Document work, ...) follows
@@ -2,6 +2,7 @@
2
2
  // These pure helpers keep live rows, persisted messages and restored rows on
3
3
  // the same field contract without rewriting older display-only transcripts.
4
4
  import { displayModelName } from './model-display.mjs';
5
+ import { botChainOf } from './bot-hops.mjs';
5
6
  import { routineTriggerField } from './routine-trigger.mjs';
6
7
 
7
8
  export function transcriptRouteMetadataFields(value) {
@@ -90,6 +91,11 @@ export function persistedUserTranscriptMetadata(value) {
90
91
  ...transcriptSourceFields(value),
91
92
  // Bot-to-bot chain position of a bot's message (session-runtime/bot-hops.mjs).
92
93
  ...(Number.isInteger(value.hop) && value.hop > 0 ? { hop: value.hop } : {}),
94
+ // The bots that handed this message on, sender last (bot-hops.mjs botChainOf).
95
+ ...botChainField(value.botChain),
96
+ // A group-room member turn: the newest user row of the room log its prompt
97
+ // shows (message-reactions.mjs counts a reaction only on that row).
98
+ ...(typeof value.roomRow === 'string' && value.roomRow ? { roomRow: value.roomRow } : {}),
93
99
  // The turn's change-record key (the outer prompt id), so a restored user
94
100
  // row still addresses its turn's Undo/Reapply.
95
101
  ...(typeof value.checkpointId === 'string' && value.checkpointId ? { checkpointId: value.checkpointId } : {}),
@@ -104,6 +110,11 @@ export function persistedUserTranscriptMetadata(value) {
104
110
  };
105
111
  }
106
112
 
113
+ function botChainField(value) {
114
+ const bots = botChainOf(value);
115
+ return bots.length ? { botChain: bots } : {};
116
+ }
117
+
107
118
  function submissionIdsField(value) {
108
119
  const ids = Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : [];
109
120
  return ids.length ? { submissionIds: ids } : {};
@@ -125,6 +136,33 @@ export function transcriptAttachmentFields(value) {
125
136
  return attachmentsField(value?.attachments);
126
137
  }
127
138
 
139
+ const REACTOR_ID = /^[A-Za-z0-9._-]{1,80}$/;
140
+ const REACTION_EMOJI_MAX_LENGTH = 16;
141
+
142
+ /**
143
+ * The bots' reactions on a user message (`meta.transcript.reactions`):
144
+ * `[{ agentId, emoji, at }]`, one per bot, in the order they were left.
145
+ * Malformed entries and a bot's repeats are dropped.
146
+ */
147
+ export function normalizeMessageReactions(value) {
148
+ if (!Array.isArray(value)) return [];
149
+ const out = [];
150
+ for (const raw of value) {
151
+ const agentId = typeof raw?.agentId === 'string' ? raw.agentId : '';
152
+ const emoji = typeof raw?.emoji === 'string' ? raw.emoji : '';
153
+ if (!REACTOR_ID.test(agentId) || !emoji || emoji.length > REACTION_EMOJI_MAX_LENGTH) continue;
154
+ if (out.some((entry) => entry.agentId === agentId)) continue;
155
+ out.push({ agentId, emoji, ...(Number.isFinite(raw.at) ? { at: raw.at } : {}) });
156
+ }
157
+ return out;
158
+ }
159
+
160
+ /** The `reactions` field of a transcript row (absent when there are none). */
161
+ export function transcriptReactionsField(value) {
162
+ const reactions = normalizeMessageReactions(value?.reactions);
163
+ return reactions.length ? { reactions } : {};
164
+ }
165
+
128
166
  export function persistedAssistantTranscriptMetadata(value, fallbackAt = Date.now()) {
129
167
  if (!value || typeof value !== 'object') return null;
130
168
  const candidateAt = Number(value.assistantAt);
@@ -438,13 +438,41 @@ const ensuredDataDirs = new Set();
438
438
  * data dir per process.
439
439
  */
440
440
  export function ensureUserDataInitialized(dataDir) {
441
- if (!dataDir) return;
441
+ if (!dataDir || skipBackups()) return;
442
442
  const key = normalizeDataDir(dataDir);
443
443
  if (ensuredDataDirs.has(key)) return;
444
- if (!hasUserDataInitMarker(dataDir)) backupUserData(dataDir, 'initial-snapshot');
444
+ // The marker alone is no proof: a first snapshot whose manifest failed left
445
+ // it behind. Only a published backup of this incarnation counts, so a failed
446
+ // or interrupted one is taken again by the next read or restart.
447
+ if (!hasPublishedBackup(dataDir)) {
448
+ backupUserData(dataDir, 'initial-snapshot');
449
+ if (!hasPublishedBackup(dataDir)) return;
450
+ }
445
451
  ensuredDataDirs.add(key);
446
452
  }
447
453
 
454
+ /** Whether this data dir's scope holds a published backup of its current
455
+ * incarnation (marker instanceId). A transient manifest read failure throws. */
456
+ function hasPublishedBackup(dataDir) {
457
+ const instanceId = readInitInstanceId(dataDir);
458
+ if (!instanceId) return false;
459
+ const key = normalizeDataDir(dataDir);
460
+ const scopeDir = scopeDirFor(dataDir);
461
+ return listBackupDirsNewestFirst(scopeDir).some((name) => {
462
+ try {
463
+ const manifest = JSON.parse(readFileSync(manifestPath(scopeDir, name), 'utf8'));
464
+ return (
465
+ typeof manifest?.dataDir === 'string' &&
466
+ normalizeDataDir(manifest.dataDir) === key &&
467
+ manifest.instanceId === instanceId
468
+ );
469
+ } catch (error) {
470
+ if (isAbsentBackupError(error)) return false;
471
+ throw error;
472
+ }
473
+ });
474
+ }
475
+
448
476
  function skipBackups() {
449
477
  return process.env.MIXDOG_SKIP_USER_DATA_BACKUP === '1' || process.env.MIXDOG_SKIP_USER_DATA_BACKUP === 'true';
450
478
  }
@@ -15,9 +15,11 @@ const overlaps = (a, b) => isWithin(a, b) || isWithin(b, a);
15
15
  /**
16
16
  * The admission policy's external exception: a linked checkout Mixdog did not
17
17
  * create (not a project's main folder, not a registered managed worktree) is
18
- * never lock-blocked. Management takes no lease on it and its readers are
19
- * admitted whatever is held elsewhere; its running readers still refuse a
20
- * management action that touches it.
18
+ * not lock-blocked by what is held elsewhere. Management takes no lease on it
19
+ * and its readers are admitted whatever is held outside it; its running
20
+ * readers still refuse a management action that touches it. A management lease
21
+ * published inside the checkout itself (another profile, or a registry
22
+ * recovered empty) still refuses its readers.
21
23
  */
22
24
  export function isExternalCheckout(folder) {
23
25
  const layout = findGitLayout(folder, { strict: true });
@@ -291,7 +293,8 @@ function release(commonDir, token) {
291
293
 
292
294
  /**
293
295
  * Readers share a checkout freely. Null means a management writer currently
294
- * owns it; a reader in an external checkout is never held back. Throws
296
+ * owns it; a reader in an external checkout is admitted whatever is held
297
+ * outside that checkout, but refused by a management lease inside it. Throws
295
298
  * `workspace-admission-unreadable` when the ledger cannot be read, was
296
299
  * written by a newer build, or the grant could not be made durable in both
297
300
  * copies: no work may run unrecorded.
@@ -304,8 +307,12 @@ export function tryAdmitWorkspaceActivity(sessionId, cwd, { pid = process.pid, k
304
307
  const path = canonicalPath(cwd);
305
308
  const token = randomUUID();
306
309
  const lease = { kind, sessionId, pid, token, path };
310
+ // External is this profile's registry view: management another profile (or
311
+ // a registry recovered empty) published inside this very checkout still holds.
312
+ const checkout = canonicalPath(layout.topLevel);
313
+ const held = (other) => (external ? isWithin(checkout, other.path) : overlaps(other.path, path));
307
314
  const admitted = mutate(layout.commonDir, (leases) => {
308
- if (!external && leases.some((other) => other.kind === 'management' && overlaps(other.path, path))) return false;
315
+ if (leases.some((other) => other.kind === 'management' && held(other))) return false;
309
316
  leases.push(lease);
310
317
  return true;
311
318
  });