mixdog 0.9.44 → 0.9.46

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 (117) hide show
  1. package/package.json +5 -2
  2. package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
  3. package/scripts/agent-model-liveness-test.mjs +618 -0
  4. package/scripts/agent-trace-io-test.mjs +68 -4
  5. package/scripts/bench-run.mjs +30 -3
  6. package/scripts/compact-pressure-test.mjs +187 -4
  7. package/scripts/compact-prior-context-flatten-test.mjs +252 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +8 -6
  9. package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
  10. package/scripts/context-mcp-metering-test.mjs +75 -0
  11. package/scripts/explore-prompt-policy-test.mjs +15 -16
  12. package/scripts/explore-timeout-cancel-test.mjs +345 -0
  13. package/scripts/headless-pristine-execution-test.mjs +614 -0
  14. package/scripts/internal-comms-smoke.mjs +15 -6
  15. package/scripts/live-worker-smoke.mjs +1 -1
  16. package/scripts/memory-core-input-test.mjs +137 -0
  17. package/scripts/memory-rule-contract-test.mjs +5 -5
  18. package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
  19. package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
  20. package/scripts/parent-abort-link-test.mjs +22 -0
  21. package/scripts/provider-toolcall-test.mjs +22 -0
  22. package/scripts/reactive-compact-persist-smoke.mjs +8 -4
  23. package/scripts/session-bench.mjs +3 -70
  24. package/scripts/task-bench.mjs +0 -2
  25. package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
  26. package/scripts/tool-smoke.mjs +21 -21
  27. package/scripts/tool-tui-presentation-test.mjs +68 -0
  28. package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
  29. package/src/app.mjs +28 -103
  30. package/src/cli.mjs +17 -13
  31. package/src/headless-command.mjs +139 -0
  32. package/src/headless-role.mjs +121 -10
  33. package/src/help.mjs +4 -1
  34. package/src/rules/agent/00-common.md +3 -3
  35. package/src/rules/agent/00-core.md +8 -9
  36. package/src/rules/agent/20-skip-protocol.md +2 -3
  37. package/src/rules/agent/30-explorer.md +50 -56
  38. package/src/rules/agent/40-cycle1-agent.md +10 -12
  39. package/src/rules/agent/41-cycle2-agent.md +12 -9
  40. package/src/rules/agent/42-cycle3-agent.md +4 -6
  41. package/src/rules/lead/01-general.md +5 -6
  42. package/src/rules/lead/02-channels.md +1 -1
  43. package/src/rules/lead/lead-brief.md +14 -17
  44. package/src/rules/lead/lead-tool.md +3 -3
  45. package/src/rules/shared/01-tool.md +41 -43
  46. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  47. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  48. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  49. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  50. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  51. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  52. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  53. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  54. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  55. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  56. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  57. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  58. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  59. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  60. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  62. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  63. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  64. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  65. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  66. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  67. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  68. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  69. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  70. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  71. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  72. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  73. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  74. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  75. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  76. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  77. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  78. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  80. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  81. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  82. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  83. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  84. package/src/runtime/memory/index.mjs +0 -1
  85. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  86. package/src/runtime/memory/lib/http-router.mjs +0 -193
  87. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  88. package/src/runtime/memory/tool-defs.mjs +5 -6
  89. package/src/runtime/shared/config.mjs +11 -34
  90. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  91. package/src/runtime/shared/pristine-execution.mjs +356 -0
  92. package/src/runtime/shared/provider-api-key.mjs +43 -0
  93. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  94. package/src/runtime/shared/update-checker.mjs +40 -10
  95. package/src/session-runtime/context-status.mjs +61 -13
  96. package/src/session-runtime/mcp-glue.mjs +29 -2
  97. package/src/session-runtime/plugin-mcp.mjs +7 -0
  98. package/src/session-runtime/resource-api.mjs +38 -5
  99. package/src/session-runtime/runtime-core.mjs +5 -1
  100. package/src/session-runtime/session-turn-api.mjs +14 -2
  101. package/src/session-runtime/settings-api.mjs +5 -0
  102. package/src/session-runtime/tool-catalog.mjs +13 -2
  103. package/src/session-runtime/tool-defs.mjs +1 -3
  104. package/src/standalone/agent-task-status.mjs +50 -11
  105. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  106. package/src/standalone/explore-tool.mjs +257 -49
  107. package/src/standalone/seeds.mjs +1 -0
  108. package/src/tui/App.jsx +26 -16
  109. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  110. package/src/tui/app/use-transcript-window.mjs +12 -21
  111. package/src/tui/components/ContextPanel.jsx +19 -25
  112. package/src/tui/dist/index.mjs +89 -78
  113. package/src/tui/engine/agent-envelope.mjs +16 -5
  114. package/src/tui/engine/labels.mjs +1 -1
  115. package/src/workflows/default/WORKFLOW.md +21 -51
  116. package/src/workflows/solo/WORKFLOW.md +12 -17
  117. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -1,21 +1,18 @@
1
1
  # Lead Brief
2
2
 
3
- - One-line fragments. `Task:` is mandatory and lossless: include the intent,
4
- required outcomes, negative outcomes, and completion/stop boundary. Minimum
5
- chars, maximum info: omit role-known rules and repeated context/facts; split
6
- scope, don't pad or discard task requirements.
7
- - Preserve user-supplied exact targets and exact replacements/outputs in
8
- `Task:`. Never infer exactness from a task name, file count, or perceived
9
- difficulty.
10
- - All other fields are optional task-specific deltas: `Anchors:`
11
- `Allow/Forbid:` `Deliver:`. Omit any that add no information.
12
- - Anchors: `file:line` + one-line conclusion; never log/code bodies. Specify
13
- outcome, not method unless required. `Deliver:` gives shape/size, never a long
14
- handoff. The original request and official spec/test acceptance criteria beat
15
- their brief summary.
16
- - Each role independently constructs a role-appropriate, lossless `Task:` from
17
- the original request and official spec/test acceptance criteria.
18
- - Full brief only for fresh spawn/`respawned: true`; live follow-ups are delta.
19
- Dead-tag send is cold: re-supply anchors.
3
+ - Use one-line fragments. `Task:` is mandatory and lossless: preserve intent,
4
+ required and forbidden outcomes, completion/stop boundary, user-supplied
5
+ exact targets, and exact replacements/outputs. Never infer exactness from
6
+ task name, file count, or difficulty.
7
+ - Each role constructs its own `Task:` from the original request and official
8
+ spec/test acceptance criteria, preserving every requirement and boundary.
9
+ - Omit role-known rules, repeated context/facts, and padding; split scope
10
+ without discarding requirements.
11
+ - Other fields are task-specific deltas: `Anchors:`, `Allow/Forbid:`,
12
+ `Deliver:`. Omit empty fields. Anchors are `file:line` plus a one-line
13
+ conclusion, never log/code bodies. State outcomes, not methods, unless the
14
+ method is required. `Deliver:` sets handoff shape/size.
15
+ - Send a full brief only for a fresh spawn or `respawned: true`; live follow-ups
16
+ contain only the delta. A dead-tag send is cold and must re-supply anchors.
20
17
  - Never `send` mid-run; batch one follow-up after completion; interrupt only to
21
18
  cancel. Agent communication is English.
@@ -1,5 +1,5 @@
1
1
  # Lead Tools
2
2
 
3
- - Write-role agents self-verify via `shell`; Lead runs cross-scope
4
- verification, benches, and all git via it.
5
- - Use current project/workspace unless user request/tool need.
3
+ - Write-role agents self-verify with `shell`. Lead uses `shell` for cross-scope
4
+ verification, benches, and all git.
5
+ - Use the current project/workspace unless the request or tool requires another.
@@ -1,50 +1,48 @@
1
1
  # Tool Use
2
2
 
3
- - Before the first call, gather all presently-known facets in one tool message;
4
- for each facet choose exactly one shortest locator route: broad/uncertain→
5
- `explore`; partial path/name→`find`; verified root+wildcard→`glob`;
6
- quoted/non-identifier literal or regex→`grep`; exact code identifier/relation
7
- `code_graph` before grep (grep only for a requested literal occurrence or
8
- after graph zero/error). Batch compatible targets; parallelize distinct facets
9
- only, never alternative routes for one facet. Known file/span→`read`;
10
- verified directory→`list`;
11
- edit→`apply_patch`; program/state change→`shell`; web/current external
12
- info→`search`.
13
- - Put independent read-only calls in one turn; they may run concurrently
14
- regardless of tool. Later turns are only for targets dependent on prior
15
- results or unresolved facets. Shell/write calls are serial and dependencies
16
- are serialized. Combine variants, symbols, scopes, paths and queries in one
17
- call. After locator results, collect all known candidate files/regions before
18
- inspection; batch compatible reads, including same-file regions, in one
3
+ - Before the first call, gather every known facet in one tool message; for
4
+ each facet choose exactly one shortest locator route:
5
+ broad/uncertain→`explore` (roles without it: `find`); partial path/name
6
+ `find`; verified root+wildcard
7
+ `glob`; quoted/non-identifier literal or regex→`grep`; exact code
8
+ identifier/relation→`code_graph` before grep; known file/span→`read`
9
+ directly without `grep`; verified directory→`list`; edit→`apply_patch`;
10
+ program/state change→`shell`; web/current external info→`search`. Use grep
11
+ only for a requested literal occurrence or after graph zero/error.
12
+ - Batch compatible targets and combine variants, symbols, scopes, paths, and
13
+ queries. Parallelize distinct facets only, never alternative routes for one
14
+ facet. Put independent read-only calls in one turn; they may run
15
+ concurrently regardless of tool. Later turns are only for targets dependent
16
+ on prior results or unresolved facets. Shell/write calls are serial.
17
+ - After locator results, collect all known candidate files/regions before
18
+ inspection. Batch compatible reads, including same-file regions, in one
19
19
  `path[]` call and graph targets in arrays. Parallelize independent
20
- incompatible read-only inspections; do not start a singleton while a known
20
+ incompatible read-only inspections. Do not start a singleton while a known
21
21
  compatible candidate remains. Put all known edits in one patch.
22
22
  - Project root, session cwd, user-provided and tool-returned paths are
23
- verified. For a genuinely guessed path/name fragment, use `find` first
24
- (same turn as independent probes); do not find verified roots or use
25
- `path:"."` with guessed `src/**`. ENOENTfind the basename, never retry the
26
- guess. Unscoped root grep/glob/list needs no find.
27
- - At task start, `explore` facets (implementation, config, tests, errors, ...)
28
- go in one `query[]` call (max 8, parallel), never rephrased duplicates;
29
- on `EXPLORATION_FAILED`, retry once with changed tokens.
23
+ verified. Use `find` first for every genuinely guessed path/name fragment, in
24
+ the same turn as independent probes. Never find verified roots or use
25
+ `path:"."` with guessed `src/**`. On ENOENT, find the basename; never retry
26
+ the guess. Unscoped root grep/glob/list requires no find.
27
+ - At task start, batch all `explore` facets in one `query[]` call, maximum 8,
28
+ without rephrased duplicates. Retry `EXPLORATION_FAILED` once with changed
29
+ tokens.
30
30
  - Stop when evidence covers the deliverable; never re-locate, re-verify,
31
- refine or upgrade a sufficient anchor.
32
- Every returned requested `path:line` freezes the LOCATION only; read or
33
- code_graph detail inspection is valid when content was not returned, but
34
- never re-locate it. Content_with_context with sufficient context is
35
- actionable directly.
36
- - Grep content mode needs enough `-C` context to avoid rereads; sufficient
37
- contextual grep means no overlapping `read`. `files_with_matches`, `count`,
38
- capped, or insufficient context may inspect only missing content. Known
39
- file/span→`read` directly without `grep`; A nonzero `content_with_context`
40
- result resolves that search concept: act directly, without regex tweaks,
41
- narrowing or re-search. Only zero/error results may change tokens/scope.
42
- - `read` batches independent files/regions as real arrays (regions are
43
- `{path,offset,limit}`), and reads the whole logical unit; do not page or
44
- reread returned spans. A third window means the first was too narrow.
45
- - One carve-out to parallel-first: apply the edit, then verify it in a
46
- separate shell turn and consume results in order. Otherwise stay parallel.
31
+ refine, or upgrade a sufficient anchor. Every returned requested `path:line`
32
+ freezes the LOCATION only. When content was not returned, read or
33
+ code_graph detail inspection is valid; never re-locate it.
34
+ - Give grep content mode enough `-C` to avoid rereads; a sufficient contextual
35
+ grep means no overlapping `read`. After `files_with_matches`, `count`,
36
+ capped, or insufficient context, inspect only missing content. A nonzero
37
+ `content_with_context` result resolves the concept; act directly without
38
+ token changes, narrowing, or re-search. Only zero/error results permit token
39
+ or scope changes.
40
+ - Batch independent read files/regions as real arrays, using
41
+ `{path,offset,limit}` regions, and read the whole logical unit. Never page or
42
+ reread returned spans.
43
+ - Apply edits before verification, then verify in a separate shell turn and
44
+ consume results in order. Otherwise remain parallel.
47
45
  - A long-running command promoted to background is a decision point, not a
48
- cue to wait: estimate from its observed progress whether it can finish
49
- within the remaining budget; if not, diagnose the bottleneck and switch to
50
- an alternative route. Waiting is chosen, never assumed.
46
+ cue to wait. Estimate whether observed progress can finish within budget;
47
+ otherwise diagnose the bottleneck and switch routes. Choose waiting
48
+ explicitly.
@@ -256,6 +256,40 @@ function maintenanceRouteToPreset(routeOrName, agent) {
256
256
  * provider call tears down promptly (parent→child cascade).
257
257
  * @returns {(args: { prompt, preset?, sourceName? }) => Promise<string>}
258
258
  */
259
+ // runtime-liveness keeps one parent link per session because askSession swaps
260
+ // its controller at turn start. Agent dispatch can have several independent
261
+ // cancellation sources, so collapse them before installing that one link.
262
+ // The first already-aborted source wins (in declaration order), retaining its
263
+ // original reason instead of replacing it with a generic AbortError.
264
+ export function composeAgentDispatchAbortSignal(signals) {
265
+ const sources = (Array.isArray(signals) ? signals : [])
266
+ .filter((signal) => signal instanceof AbortSignal);
267
+ if (sources.length === 0) return { signal: null, dispose: () => {} };
268
+ const controller = new AbortController();
269
+ const listeners = [];
270
+ const abortFrom = (signal) => {
271
+ if (controller.signal.aborted) return;
272
+ try { controller.abort(signal.reason); } catch { try { controller.abort(); } catch { /* ignore */ } }
273
+ };
274
+ for (const signal of sources) {
275
+ if (signal.aborted) {
276
+ abortFrom(signal);
277
+ break;
278
+ }
279
+ const listener = () => abortFrom(signal);
280
+ signal.addEventListener('abort', listener, { once: true });
281
+ listeners.push([signal, listener]);
282
+ }
283
+ return {
284
+ signal: controller.signal,
285
+ dispose: () => {
286
+ for (const [signal, listener] of listeners) {
287
+ try { signal.removeEventListener('abort', listener); } catch { /* ignore */ }
288
+ }
289
+ },
290
+ };
291
+ }
292
+
259
293
  export function makeAgentDispatch(opts = {}) {
260
294
  if (!opts.agent || typeof opts.agent !== 'string') {
261
295
  throw new Error('[agent-dispatch] opts.agent is required');
@@ -375,14 +409,6 @@ export function makeAgentDispatch(opts = {}) {
375
409
  const _getProgressSnapshot = _managerMod?.getSessionProgressSnapshot;
376
410
  const _getLastProgressAt = _managerMod?.getSessionLastProgressAt;
377
411
  const _getSession = _managerMod?.getSession || getSession;
378
- if (_linkSignal) {
379
- if (opts.parentSignal instanceof AbortSignal) {
380
- try { _linkSignal(session.id, opts.parentSignal); } catch { /* ignore */ }
381
- }
382
- if (callParentSignal instanceof AbortSignal) {
383
- try { _linkSignal(session.id, callParentSignal); } catch { /* ignore */ }
384
- }
385
- }
386
412
  // Watchdog policy is split:
387
413
  // - firstResponseTimeoutMs cuts quickly only when the model produces no
388
414
  // first stream/tool activity at all.
@@ -396,8 +422,17 @@ export function makeAgentDispatch(opts = {}) {
396
422
  const _idleController = (agentWatchdogPolicyActive(_watchdogPolicy) && _linkSignal)
397
423
  ? new AbortController()
398
424
  : null;
399
- if (_idleController) {
400
- try { _linkSignal(session.id, _idleController.signal); } catch { /* ignore */ }
425
+ // Do not link factory parent, per-call explore cancellation, and the
426
+ // watchdog one at a time: each link replaces the previous listener in
427
+ // runtime-liveness. One composite survives askSession's controller
428
+ // swap and makes every source reach the provider call.
429
+ const _abortLink = composeAgentDispatchAbortSignal([
430
+ opts.parentSignal,
431
+ callParentSignal,
432
+ _idleController?.signal,
433
+ ]);
434
+ if (_linkSignal && _abortLink.signal) {
435
+ try { _linkSignal(session.id, _abortLink.signal); } catch { /* ignore */ }
401
436
  }
402
437
  // Watchdog blind spot guard: when the runtime snapshot is missing AND
403
438
  // no progress timestamp exists (pre-liveness hang, swept runtime), the
@@ -491,6 +526,7 @@ export function makeAgentDispatch(opts = {}) {
491
526
  try { closeSession(session.id, 'ephemeral-error'); } catch { /* ignore */ }
492
527
  throw err;
493
528
  } finally {
529
+ _abortLink.dispose();
494
530
  if (_idleTimer) {
495
531
  try { clearInterval(_idleTimer); } catch { /* ignore */ }
496
532
  }
@@ -11,7 +11,7 @@ import {
11
11
  resolveAgentToolThresholdSeconds,
12
12
  } from '../stall-policy.mjs';
13
13
 
14
- const WATCHDOG_ABORT_RE = /^agent (?:first response stale|task stale|tool running stale)\s*\(/;
14
+ const WATCHDOG_ABORT_RE = /^agent (?:first (?:transport|semantic response|response) stale|task stale|tool running stale)\s*\(/;
15
15
 
16
16
  /**
17
17
  * Typed abort error for the agent progress watchdog. Carrying a stable `name`
@@ -209,12 +209,12 @@ function resolveExplicitMs(value, fallback) {
209
209
  }
210
210
 
211
211
  export function resolveAgentWatchdogPolicy(agent, overrides = {}) {
212
- const firstResponseMs = resolveExplicitMs(
213
- overrides.firstResponseTimeoutMs,
212
+ const firstTransportMs = resolveExplicitMs(
213
+ overrides.firstTransportTimeoutMs ?? overrides.firstResponseTimeoutMs,
214
214
  DEFAULT_FIRST_RESPONSE_TIMEOUT_MS,
215
215
  );
216
- const firstVisibleCeilingMs = resolveExplicitMs(
217
- overrides.firstVisibleTimeoutMs,
216
+ const firstSemanticMs = resolveExplicitMs(
217
+ overrides.firstSemanticTimeoutMs ?? overrides.firstVisibleTimeoutMs,
218
218
  DEFAULT_FIRST_VISIBLE_CEILING_MS,
219
219
  );
220
220
 
@@ -245,8 +245,12 @@ export function resolveAgentWatchdogPolicy(agent, overrides = {}) {
245
245
  const toolRunningMs = Math.max(0, Math.floor(toolRunningSec * 1000));
246
246
 
247
247
  return {
248
- firstResponseMs,
249
- firstVisibleCeilingMs,
248
+ firstTransportMs,
249
+ firstSemanticMs,
250
+ // Compatibility aliases for persisted/background metadata and callers
251
+ // using the previous option names.
252
+ firstResponseMs: firstTransportMs,
253
+ firstVisibleCeilingMs: firstSemanticMs,
250
254
  idleStaleMs,
251
255
  toolRunningMs,
252
256
  };
@@ -255,19 +259,21 @@ export function resolveAgentWatchdogPolicy(agent, overrides = {}) {
255
259
  export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
256
260
  if (!snapshot || !policy) return null;
257
261
 
258
- if (snapshot.waitingForFirstActivity) {
259
- const startedAt = snapshot.modelRequestStartedAt || snapshot.askStartedAt;
260
- const hasTransport = Boolean(
261
- startedAt
262
- && snapshot.lastTransportAt
263
- && snapshot.lastTransportAt > startedAt
264
- );
265
- const ceilingMs = hasTransport
266
- ? policy.firstVisibleCeilingMs
267
- : policy.firstResponseMs;
268
- if (ceilingMs > 0 && startedAt && now - startedAt > ceilingMs) {
269
- return new AgentStallAbortError(`agent first response stale (${ceilingMs}ms)`);
270
- }
262
+ const startedAt = snapshot.modelRequestStartedAt || snapshot.askStartedAt;
263
+ const firstTransportMs = policy.firstTransportMs ?? policy.firstResponseMs ?? 0;
264
+ const firstSemanticMs = policy.firstSemanticMs ?? policy.firstVisibleCeilingMs ?? 0;
265
+ // Independent fixed deadlines from request start. Transport can satisfy
266
+ // only the transport deadline; it never switches, extends, or resets the
267
+ // semantic-response deadline.
268
+ if (snapshot.waitingForTransport && firstTransportMs > 0 && startedAt
269
+ && now - startedAt > firstTransportMs) {
270
+ return new AgentStallAbortError(`agent first transport stale (${firstTransportMs}ms)`);
271
+ }
272
+ if ((snapshot.waitingForFirstSemantic ?? snapshot.waitingForFirstActivity)
273
+ && firstSemanticMs > 0 && startedAt && now - startedAt > firstSemanticMs) {
274
+ return new AgentStallAbortError(`agent first semantic response stale (${firstSemanticMs}ms)`);
275
+ }
276
+ if (snapshot.waitingForFirstSemantic ?? snapshot.waitingForFirstActivity) {
271
277
  return null;
272
278
  }
273
279
 
@@ -290,15 +296,7 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
290
296
  // is still caught after the grace window. Unknown/missing self-deadline
291
297
  // (selfDeadlineMs <= 0) keeps the plain toolRunningMs behavior. All
292
298
  // other tools are unaffected.
293
- let ceilingMs = policy.toolRunningMs;
294
- const selfDeadlineMs = Number(snapshot.toolSelfDeadlineMs);
295
- if (
296
- isSelfDeadlineTool(snapshot.currentTool)
297
- && Number.isFinite(selfDeadlineMs)
298
- && selfDeadlineMs > 0
299
- ) {
300
- ceilingMs = Math.max(policy.toolRunningMs, selfDeadlineMs + TOOL_SELF_DEADLINE_GRACE_MS);
301
- }
299
+ const ceilingMs = resolveEffectiveToolRunningCeilingMs(snapshot, policy);
302
300
  if (now - snapshot.toolStartedAt > ceilingMs) {
303
301
  return new AgentStallAbortError(`agent tool running stale (${ceilingMs}ms)`);
304
302
  }
@@ -307,10 +305,25 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
307
305
  return null;
308
306
  }
309
307
 
308
+ /** The exact tool ceiling enforced by evaluateAgentWatchdogAbort. */
309
+ export function resolveEffectiveToolRunningCeilingMs(snapshot, policy) {
310
+ const policyMs = Number(policy?.toolRunningMs);
311
+ let ceilingMs = Number.isFinite(policyMs) && policyMs > 0 ? policyMs : 0;
312
+ const selfDeadlineMs = Number(snapshot?.toolSelfDeadlineMs);
313
+ if (
314
+ isSelfDeadlineTool(snapshot?.currentTool)
315
+ && Number.isFinite(selfDeadlineMs)
316
+ && selfDeadlineMs > 0
317
+ ) {
318
+ ceilingMs = Math.max(ceilingMs, selfDeadlineMs + TOOL_SELF_DEADLINE_GRACE_MS);
319
+ }
320
+ return ceilingMs;
321
+ }
322
+
310
323
  export function agentWatchdogPolicyActive(policy) {
311
324
  if (!policy) return false;
312
- return (policy.firstResponseMs > 0)
313
- || (policy.firstVisibleCeilingMs > 0)
325
+ return ((policy.firstTransportMs ?? policy.firstResponseMs) > 0)
326
+ || ((policy.firstSemanticMs ?? policy.firstVisibleCeilingMs) > 0)
314
327
  || (policy.idleStaleMs > 0)
315
328
  || (policy.toolRunningMs > 0);
316
329
  }
@@ -23,6 +23,7 @@ let _serviceUrl = null;
23
23
  let _serviceAdvertPort = null;
24
24
  let _flushInFlight = false;
25
25
  let _localTracePath = null;
26
+ let _localTraceIsExplicit = false;
26
27
  let _localTraceBuffer = [];
27
28
  let _localTraceTimer = null;
28
29
  let _localTraceFlushInFlight = false;
@@ -93,6 +94,12 @@ function _rotateLocalTraceIfNeeded(path) {
93
94
  }
94
95
  }
95
96
 
97
+ function _ensureLocalTraceDirectory(path) {
98
+ // The path may outlive an ephemeral runtime directory. Recreate its parent
99
+ // at flush time rather than relying on the one-time path resolution.
100
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
101
+ }
102
+
96
103
  function _resolveLocalTracePath() {
97
104
  if (process.env.MIXDOG_AGENT_TRACE_LOCAL_DISABLE === '1') return null;
98
105
  if (_localTracePath) return _localTracePath;
@@ -104,9 +111,10 @@ function _resolveLocalTracePath() {
104
111
  try {
105
112
  _localTracePath = explicit
106
113
  || join(getPluginData(), 'history', 'agent-trace.jsonl');
114
+ _localTraceIsExplicit = Boolean(explicit);
107
115
  // R4 data-at-rest: trace rows may carry tool payloads / prompts;
108
116
  // clamp dir to owner-only on POSIX (advisory on Windows).
109
- mkdirSync(dirname(_localTracePath), { recursive: true, mode: 0o700 });
117
+ _ensureLocalTraceDirectory(_localTracePath);
110
118
  return _localTracePath;
111
119
  } catch {
112
120
  return null;
@@ -142,10 +150,15 @@ function _flushLocalTrace() {
142
150
  const chunk = _localTraceBuffer.join('');
143
151
  _localTraceBuffer = [];
144
152
  try {
153
+ _ensureLocalTraceDirectory(path);
145
154
  // Throttle rotation stat checks to avoid unnecessary statSync calls
146
155
  // on every flush. First flush (_lastRotateCheckMs === 0) always checks.
147
156
  const now = Date.now();
148
- if (_lastRotateCheckMs === 0 || now - _lastRotateCheckMs >= MIXDOG_AGENT_TRACE_ROTATE_CHECK_MS) {
157
+ // Explicit sinks can have many short-lived headless writers. Keep that
158
+ // shared file append-only so no writer can rename it out from under a
159
+ // peer or from session-bench while scoring.
160
+ if (!_localTraceIsExplicit
161
+ && (_lastRotateCheckMs === 0 || now - _lastRotateCheckMs >= MIXDOG_AGENT_TRACE_ROTATE_CHECK_MS)) {
149
162
  _rotateLocalTraceIfNeeded(path);
150
163
  _lastRotateCheckMs = now;
151
164
  }
@@ -183,8 +196,10 @@ function _flushLocalTraceSync() {
183
196
  const chunk = _localTraceBuffer.join('');
184
197
  _localTraceBuffer = [];
185
198
  try {
199
+ _ensureLocalTraceDirectory(path);
186
200
  const now = Date.now();
187
- if (_lastRotateCheckMs === 0 || now - _lastRotateCheckMs >= MIXDOG_AGENT_TRACE_ROTATE_CHECK_MS) {
201
+ if (!_localTraceIsExplicit
202
+ && (_lastRotateCheckMs === 0 || now - _lastRotateCheckMs >= MIXDOG_AGENT_TRACE_ROTATE_CHECK_MS)) {
188
203
  _rotateLocalTraceIfNeeded(path);
189
204
  _lastRotateCheckMs = now;
190
205
  }
@@ -185,6 +185,29 @@ function hasKeys(value) {
185
185
  return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0;
186
186
  }
187
187
 
188
+ // Process-local write intent for project MCP overrides. A loaded config contains
189
+ // snapshots for every project, but only entries toggled by this process may
190
+ // override a newer value read from disk inside the save lock.
191
+ const dirtyMcpProjectOverrides = new Map();
192
+ let mcpProjectOverrideDirtySeq = 0;
193
+ function mcpProjectOverrideDirtyKey(projectKey, serverName) {
194
+ return `${String(projectKey)}\u0000${String(serverName)}`;
195
+ }
196
+ export function markMcpProjectOverrideDirty(projectKey, serverName, enabled) {
197
+ const pairKey = mcpProjectOverrideDirtyKey(projectKey, serverName);
198
+ dirtyMcpProjectOverrides.set(pairKey, {
199
+ enabled: enabled !== false,
200
+ seq: ++mcpProjectOverrideDirtySeq,
201
+ });
202
+ }
203
+ function clearDurablySavedMcpProjectOverrides(appliedDirty) {
204
+ for (const { pairKey, seq } of appliedDirty) {
205
+ if (dirtyMcpProjectOverrides.get(pairKey)?.seq === seq) {
206
+ dirtyMcpProjectOverrides.delete(pairKey);
207
+ }
208
+ }
209
+ }
210
+
188
211
  function normalizeSearchRoute(route) {
189
212
  if (!route || typeof route !== 'object' || Array.isArray(route))
190
213
  return null;
@@ -456,6 +479,9 @@ export function loadConfig(options = {}) {
456
479
  return {
457
480
  providers: mergedProviders,
458
481
  mcpServers,
482
+ mcpProjectOverrides: raw.mcpProjectOverrides && typeof raw.mcpProjectOverrides === 'object' && !Array.isArray(raw.mcpProjectOverrides)
483
+ ? raw.mcpProjectOverrides
484
+ : {},
459
485
  presets: normalizedPresets,
460
486
  default: raw.default || null,
461
487
  maintenance: { ...DEFAULT_MAINTENANCE, ...migratedMaint },
@@ -489,6 +515,7 @@ export function loadConfig(options = {}) {
489
515
  return {
490
516
  ...defaults,
491
517
  mcpServers: {},
518
+ mcpProjectOverrides: {},
492
519
  presets: DEFAULT_PRESETS.map(p => ({ ...p })),
493
520
  default: null,
494
521
  maintenance: { ...DEFAULT_MAINTENANCE },
@@ -556,7 +583,7 @@ export async function patchSkillsDisabledAsync(disabledNames) {
556
583
  return nextSkills;
557
584
  }
558
585
 
559
- function buildAgentSaveBuilder(config) {
586
+ function buildAgentSaveBuilder(config, appliedDirty) {
560
587
  // Strip ephemeral defaults from providers but preserve any unknown
561
588
  // per-provider subkey so future schema additions round-trip through the
562
589
  // setup UI without changes here. apiKey is intentionally omitted —
@@ -590,40 +617,79 @@ function buildAgentSaveBuilder(config) {
590
617
  // Build the replacement from `existingRaw` — the section read INSIDE the
591
618
  // file lock — not a snapshot taken before it, so unmanaged keys written by
592
619
  // a concurrent instance survive the save (lost-update guard).
593
- return (existingRaw) => ({
594
- ...existingRaw,
595
- guide: config.guide || existingRaw.guide || undefined,
596
- providers: persistedProviders,
597
- mcpServers: config.mcpServers || {},
598
- presets,
599
- default: config.default || null,
600
- maintenance: config.maintenance || {},
601
- workflowRoutes,
602
- searchRoute: normalizeSearchRoute(config.searchRoute),
603
- fastModels: config.fastModels || {},
604
- modelSettings: config.modelSettings || {},
605
- onboarding: config.onboarding || {},
606
- agents: config.agents || {},
607
- workflow: config.workflow || { active: 'default' },
608
- agentMaintenance: config.agentMaintenance || {},
609
- profile: normalizeProfileConfig(config.profile),
610
- skills: normalizeSkillsConfig(config.skills),
611
- autoClear: config.autoClear || {},
612
- compaction: config.compaction || {},
613
- trajectory: config.trajectory || {},
614
- runtime: config.runtime || {},
615
- shell: config.shell || {},
616
- update: config.update || {},
617
- recap: config.recap || {},
618
- modules: config.modules || existingRaw.modules || {},
619
- });
620
+ return (existingRaw) => {
621
+ const mcpServers = config.mcpServers || {};
622
+ const diskOverrides = existingRaw.mcpProjectOverrides && typeof existingRaw.mcpProjectOverrides === 'object'
623
+ ? existingRaw.mcpProjectOverrides
624
+ : {};
625
+ const memoryOverrides = config.mcpProjectOverrides && typeof config.mcpProjectOverrides === 'object'
626
+ ? config.mcpProjectOverrides
627
+ : {};
628
+ const mcpProjectOverrides = {};
629
+ for (const projectKey of new Set([...Object.keys(diskOverrides), ...Object.keys(memoryOverrides)])) {
630
+ const diskProject = diskOverrides[projectKey] && typeof diskOverrides[projectKey] === 'object'
631
+ ? diskOverrides[projectKey]
632
+ : {};
633
+ const memoryProject = memoryOverrides[projectKey] && typeof memoryOverrides[projectKey] === 'object'
634
+ ? memoryOverrides[projectKey]
635
+ : {};
636
+ // Disk wins for untouched snapshot entries. Only an override this
637
+ // process explicitly toggled may replace the in-lock disk value.
638
+ const mergedProject = { ...memoryProject, ...diskProject };
639
+ for (const serverName of Object.keys(memoryProject)) {
640
+ const pairKey = mcpProjectOverrideDirtyKey(projectKey, serverName);
641
+ const dirty = dirtyMcpProjectOverrides.get(pairKey);
642
+ if (dirty) {
643
+ const appliedValue = { enabled: dirty.enabled };
644
+ mergedProject[serverName] = appliedValue;
645
+ appliedDirty.push({ pairKey, seq: dirty.seq, appliedValue });
646
+ }
647
+ }
648
+ for (const serverName of Object.keys(mergedProject)) {
649
+ if (!Object.prototype.hasOwnProperty.call(mcpServers, serverName)) delete mergedProject[serverName];
650
+ }
651
+ if (Object.keys(mergedProject).length > 0) mcpProjectOverrides[projectKey] = mergedProject;
652
+ }
653
+ return {
654
+ ...existingRaw,
655
+ guide: config.guide || existingRaw.guide || undefined,
656
+ providers: persistedProviders,
657
+ mcpServers,
658
+ mcpProjectOverrides,
659
+ presets,
660
+ default: config.default || null,
661
+ maintenance: config.maintenance || {},
662
+ workflowRoutes,
663
+ searchRoute: normalizeSearchRoute(config.searchRoute),
664
+ fastModels: config.fastModels || {},
665
+ modelSettings: config.modelSettings || {},
666
+ onboarding: config.onboarding || {},
667
+ agents: config.agents || {},
668
+ workflow: config.workflow || { active: 'default' },
669
+ agentMaintenance: config.agentMaintenance || {},
670
+ profile: normalizeProfileConfig(config.profile),
671
+ skills: normalizeSkillsConfig(config.skills),
672
+ autoClear: config.autoClear || {},
673
+ compaction: config.compaction || {},
674
+ trajectory: config.trajectory || {},
675
+ runtime: config.runtime || {},
676
+ shell: config.shell || {},
677
+ update: config.update || {},
678
+ recap: config.recap || {},
679
+ modules: config.modules || existingRaw.modules || {},
680
+ };
681
+ };
620
682
  }
621
683
  export function saveConfig(config) {
622
- persistAgentConfig(buildAgentSaveBuilder(config));
684
+ const appliedDirty = [];
685
+ persistAgentConfig(buildAgentSaveBuilder(config, appliedDirty));
686
+ clearDurablySavedMcpProjectOverrides(appliedDirty);
623
687
  }
624
688
  // Async twin used by the debounced config-save flush timer.
625
689
  export async function saveConfigAsync(config) {
626
- await persistAgentConfigAsync(buildAgentSaveBuilder(config));
690
+ const appliedDirty = [];
691
+ await persistAgentConfigAsync(buildAgentSaveBuilder(config, appliedDirty));
692
+ clearDurablySavedMcpProjectOverrides(appliedDirty);
627
693
  }
628
694
  // --- Preset helpers ---
629
695
  // preset shape: { id, name, type: 'agent', provider, model, effort?, fast?, tools? }
@@ -9,6 +9,10 @@ import {
9
9
  } from '../../../shared/markdown-frontmatter.mjs';
10
10
  import { loadConfig, normalizeSkillsConfig } from '../config.mjs';
11
11
 
12
+ function skillsDisabled() {
13
+ return /^(?:1|true|on|yes)$/i.test(String(process.env.MIXDOG_DISABLE_SKILLS || ''));
14
+ }
15
+
12
16
  // --- mixdog asset roots (standalone CLI owns its own paths; never .claude) ---
13
17
  // Project-local: <cwd>/.mixdog/<kind>
14
18
  // Data-local: <mixdogData>/<kind> (standalone default: ~/.mixdog/data/<kind>)
@@ -80,6 +84,7 @@ function pluginSkillDirs() {
80
84
  * Full content loaded on demand via loadSkillContent().
81
85
  */
82
86
  export function collectSkills(cwd) {
87
+ if (skillsDisabled()) return [];
83
88
  // When cwd is null/missing (e.g. agent maintenance callers that pass
84
89
  // cwd:null on purpose so provider-cache shards don't fork per caller
85
90
  // workspace), skip project-scoped skills entirely — DO NOT fall back
@@ -144,6 +149,7 @@ export function isSkillDisabled(name, config = null) {
144
149
  }
145
150
 
146
151
  export function filterSkillsExcludingDisabled(skills, config = null) {
152
+ if (skillsDisabled()) return [];
147
153
  const disabled = getDisabledSkillNameSet(config);
148
154
  if (!disabled.size) return Array.isArray(skills) ? skills : [];
149
155
  return (Array.isArray(skills) ? skills : []).filter((s) => {
@@ -160,6 +166,7 @@ const _skillsCache = new Map();
160
166
  const _mtimeCache = new Map();
161
167
  const _MTIME_TTL_MS = 2000;
162
168
  export function collectSkillsCached(cwd) {
169
+ if (skillsDisabled()) return [];
163
170
  const key = cwd ?? '';
164
171
  const projectDir = (typeof cwd === 'string' && cwd.length > 0) ? cwd : null;
165
172
  // Same mixdog-owned dirs collectSkills() reads, used as the freshness gate.
@@ -278,6 +285,7 @@ function compactSkillManifestText(value, max = 180) {
278
285
  * Full SKILL.md content is still loaded only through Skill(name).
279
286
  */
280
287
  export function buildSkillManifest(skills, { limit = 80 } = {}) {
288
+ if (skillsDisabled()) return '';
281
289
  const list = (Array.isArray(skills) ? skills : [])
282
290
  .map((skill) => ({
283
291
  name: String(skill?.name || '').trim(),
@@ -507,6 +515,7 @@ let _skillToolDefsCache = null;
507
515
  * behaviour.
508
516
  */
509
517
  export function buildSkillToolDefs(skills, { ownerIsAgentSession = false } = {}) {
518
+ if (skillsDisabled()) return [];
510
519
  if (!ownerIsAgentSession && !skills.length) return [];
511
520
  if (_skillToolDefsCache) return _skillToolDefsCache;
512
521
  _skillToolDefsCache = [