mixdog 0.9.12 → 0.9.13

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 (44) hide show
  1. package/package.json +1 -1
  2. package/scripts/internal-comms-bench.mjs +1 -0
  3. package/scripts/internal-comms-smoke.mjs +11 -7
  4. package/src/lib/rules-builder.cjs +15 -11
  5. package/src/mixdog-session-runtime.mjs +10 -0
  6. package/src/rules/agent/00-common.md +5 -17
  7. package/src/rules/agent/00-core.md +21 -0
  8. package/src/rules/lead/lead-brief.md +7 -0
  9. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
  10. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
  12. package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
  18. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
  19. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
  22. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
  23. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
  24. package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
  25. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
  26. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
  27. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
  28. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
  29. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
  30. package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
  32. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
  33. package/src/runtime/agent/orchestrator/session/manager.mjs +27 -14
  34. package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
  35. package/src/runtime/channels/index.mjs +28 -1
  36. package/src/runtime/channels/lib/memory-client.mjs +200 -15
  37. package/src/runtime/memory/index.mjs +11 -11
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +6 -4
  39. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +22 -17
  40. package/src/runtime/memory/lib/memory-embed.mjs +35 -1
  41. package/src/standalone/agent-tool.mjs +89 -7
  42. package/src/workflows/default/WORKFLOW.md +2 -0
  43. package/src/workflows/sequential/WORKFLOW.md +2 -0
  44. package/src/workflows/solo/WORKFLOW.md +2 -0
@@ -4,7 +4,7 @@ import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
4
4
  import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
5
5
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
6
6
  import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash, appendAgentTrace } from '../agent-trace.mjs';
7
- import { resolveSessionMaxLoopIterations, WORKER_SOFT_CAP_ITERATIONS, isWorkerSoftCapSession } from '../agent-runtime/agent-loop-policy.mjs';
7
+ import { resolveSessionMaxLoopIterations } from '../agent-runtime/agent-loop-policy.mjs';
8
8
  import { isAgentOwner } from '../agent-owner.mjs';
9
9
  import { markSessionToolCall, updateSessionStage, SessionClosedError, bumpUsageMetricsEpoch } from './manager.mjs';
10
10
  import {
@@ -48,8 +48,7 @@ import { mergeSteeringEntries, steeringContentText } from './loop/steering.mjs';
48
48
  import {
49
49
  crossTurnSignature,
50
50
  crossTurnDedupStub,
51
- SOFT_CAP_WRAPUP_MESSAGE,
52
- SOFT_CAP_REFUSAL_STUB,
51
+ ITERATION_CAP_REFUSAL_STUB,
53
52
  } from './loop/completion-guards.mjs';
54
53
  import { isEditProgressTool } from './loop/completion-guards.mjs';
55
54
  import { agentContextOverflowError } from './loop/context-overflow.mjs';
@@ -137,7 +136,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
137
136
  let lastUsage;
138
137
  let firstTurnUsage;
139
138
  let response;
140
- let contractNudges = 0;
141
139
  let contextOverflowRetryUsed = false;
142
140
  // Set when the hard iteration-cap break below fires. Consumed at the final
143
141
  // return to tag terminationReason='iteration_cap' so a worker that exhausts
@@ -240,14 +238,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
240
238
  const _crossTurnCalls = new Map();
241
239
  const _CROSS_TURN_CAP = 500;
242
240
  let _dedupStubTotal = 0;
243
- // Step 3: worker soft-cap wrap-up state.
244
- const _softCapEnabled = isWorkerSoftCapSession(sessionRef);
245
- let _softCapActive = false; // tools disabled + wrap-up injected
246
- let _softCapGraceTurns = 0; // text-only grace turns consumed (max 2)
247
- let _terminatedBySoftCap = false;
248
241
  // Hard-cap final-answer turn: one tool-less wrap-up turn granted when the
249
242
  // hard iteration cap fires, so the session ends with text, not empty.
250
243
  let _capFinalTurnUsed = false;
244
+ // True while the granted hard-cap final turn is active (no tool defs).
245
+ let _capFinalToolsDisabled = false;
251
246
  // Completion-first steering ladder controller. Owns the (cumulative) level-1
252
247
  // fire count, the all-read-only / serial-single / same-file-grep streaks,
253
248
  // and the level-2 latch. Threaded via live getters so it reads the loop's
@@ -257,7 +252,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
257
252
  sessionAgent,
258
253
  tools,
259
254
  getIterations: () => iterations,
260
- softCapEnabled: _softCapEnabled,
261
255
  getEditCount: () => _editCount,
262
256
  readOnlyRole: String(sessionRef?.permission || sessionRef?.toolPermission || '') === 'read',
263
257
  pushUserMessage: (msg) => messages.push(msg),
@@ -272,8 +266,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
272
266
  // forced termination is the hard cap at maxLoopIterations (default 200):
273
267
  // a genuine runaway guard. Before it, staged warnings fire at 50%/75%/90%
274
268
  // of the cap steering the model to converge — warnings only, nothing is
275
- // cut off early. All other runaway protection is behavior-based (steering
276
- // ladder early wrap-up, REPEAT_FAIL_LIMIT), never a lower count.
269
+ // cut off early. Other runaway protection is behavior-based (steering
270
+ // ladder hints, REPEAT_FAIL_LIMIT), never a lower iteration count.
277
271
  let _iterWarnStage = 0;
278
272
  const _iterWarnAt = [
279
273
  Math.floor(maxLoopIterations * 0.5),
@@ -286,7 +280,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
286
280
  // Final-answer turn: instead of breaking mid-transcript (which
287
281
  // yields an empty final for locator-style agents that never got to
288
282
  // answer), give the model ONE tool-less text turn to wrap up, then
289
- // stop. Same mechanism as the worker soft cap (empty sendTools).
283
+ // stop (empty sendTools + refusal stubs if tools are requested).
290
284
  if (_capFinalTurnUsed) {
291
285
  process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
292
286
  terminatedByCap = true;
@@ -302,8 +296,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
302
296
  break;
303
297
  }
304
298
  _capFinalTurnUsed = true;
305
- _softCapActive = true; // reuse soft-cap plumbing: no tool defs, refusal stubs
306
- messages.push({ role: 'user', content: '<system-reminder>\nIteration cap reached. Tools are disabled. Answer NOW with your best result from what you already found.\n</system-reminder>', meta: 'hook' });
299
+ _capFinalToolsDisabled = true;
300
+ messages.push({ role: 'user', content: '<system-reminder>\nIteration cap reached tools disabled; answer with your best result from context.\n</system-reminder>', meta: 'hook' });
307
301
  process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); forcing final text turn.\n`);
308
302
  }
309
303
  if (_iterWarnStage < _iterWarnAt.length && iterations >= _iterWarnAt[_iterWarnStage]) {
@@ -324,25 +318,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
324
318
  });
325
319
  } catch { /* best-effort */ }
326
320
  }
327
- // Worker soft cap (Step 3): non-lead sessions that reach the soft-cap
328
- // iteration count switch to a text-only wrap-up. On the FIRST crossing
329
- // we disable tool defs (below, via _softCapActive) and inject the
330
- // assistant-visible wrap-up directive as a user message so the next
331
- // send produces a final text summary. Lead/TUI sessions never enter.
332
- const _earlySoftCap = _steeringLadder.earlySoftCapArmed();
333
- if (_softCapEnabled && !_softCapActive && (iterations >= WORKER_SOFT_CAP_ITERATIONS || _earlySoftCap)) {
334
- _softCapActive = true;
335
- messages.push({ role: 'user', content: `<system-reminder>\n${SOFT_CAP_WRAPUP_MESSAGE}\n</system-reminder>`, meta: 'hook' });
336
- try {
337
- appendAgentTrace({
338
- sessionId,
339
- iteration: iterations,
340
- kind: 'steer',
341
- payload: { tag: 'soft_cap_wrapup', soft_cap: WORKER_SOFT_CAP_ITERATIONS, early: _earlySoftCap, level2_fires: _steeringLadder.level2FireCount },
342
- agent: sessionAgent || null,
343
- });
344
- } catch { /* best-effort */ }
345
- }
346
321
  // Drain queued steering/prompts BEFORE the
347
322
  // pre-send compact check. The compact decision must see the exact
348
323
  // message set that the next provider.send would receive, including
@@ -757,9 +732,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
757
732
  } else {
758
733
  delete opts.toolChoice;
759
734
  }
760
- // Soft-cap wrap-up (Step 3a): once active, send NO tool definitions so
761
- // the provider can only emit text. Overrides the forced-first-tool path.
762
- const sendTools = _softCapActive
735
+ // Hard-cap final turn: send NO tool definitions so the provider can
736
+ // only emit text. Overrides the forced-first-tool path.
737
+ const sendTools = _capFinalToolsDisabled
763
738
  ? []
764
739
  : (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
765
740
  // Eager-dispatch queue: when the provider streams a tool-call event,
@@ -934,7 +909,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
934
909
  if (
935
910
  sendErr?.streamStalled === true
936
911
  && sendErr.pendingToolUse !== true
937
- && sendErr.unsafeToRetry !== true
912
+ // NOT gated on unsafeToRetry: live-text stalls stamp
913
+ // unsafeToRetry=true (replay would double-render), but
914
+ // ACCEPTING the already-streamed partial is exactly the safe
915
+ // move (CC rule). Only an emitted tool call blocks acceptance.
916
+ && sendErr.emittedToolCall !== true
938
917
  && typeof sendErr.partialContent === 'string'
939
918
  && sendErr.partialContent.trim().length > 0
940
919
  && !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
@@ -1138,14 +1117,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1138
1117
  } catch { /* best-effort — never break the loop */ }
1139
1118
  }
1140
1119
  // No tool calls. For PUBLIC agents, the agent contract
1141
- // (rules/agent/00-common.md) requires either a tool call or a
1142
- // `<final-answer>` wrapped reply.
1120
+ // (rules/agent/00-core.md) requires either a tool call or a final
1121
+ // handoff text (fragments).
1143
1122
  // A text-only turn without those tags violates the contract (e.g.
1144
1123
  // Opus 4.6 emits 'Now I'll polish…' preamble before its first tool
1145
1124
  // call) and used to leave the session idle until the idle sweep
1146
- // collected it. Re-prompt the worker with a contract reminder; cap
1147
- // at 2 nudges so a model that never complies still terminates the
1148
- // loop. Hidden roles (cycle1-agent / cycle2-agent / explorer /
1125
+ // collected it. Re-prompt the worker with a contract reminder on each
1126
+ // empty turn (hard iteration cap bounds total turns). Hidden roles
1127
+ // (cycle1-agent / cycle2-agent / explorer /
1149
1128
  // scheduler-task / webhook-handler) are exempt:
1150
1129
  // their own role rules define a different output contract (pipe-
1151
1130
  // separated chunker output, structured pipe-format, etc.) and a
@@ -1162,9 +1141,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1162
1141
  // - has content + non-hidden role → valid final, break.
1163
1142
  // - empty content + hidden role → contract allows text-only
1164
1143
  // terminal turn, break.
1165
- // - empty content + non-hidden role → one soft nudge. Repeated
1166
- // reminders waste turns and fragment the working context, so
1167
- // the second empty turn is accepted as terminal.
1144
+ // - empty content + non-hidden role → contract nudge, continue.
1168
1145
  const hasContent = typeof response.content === 'string' && response.content.trim().length > 0;
1169
1146
  const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
1170
1147
  const stopReason = response.stopReason ?? response.stop_reason ?? null;
@@ -1181,13 +1158,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1181
1158
  continue;
1182
1159
  }
1183
1160
  if (!hasContent && !isHidden) {
1184
- if (contractNudges >= 1) break;
1185
- contractNudges += 1;
1186
1161
  let nudgeMsg;
1187
1162
  if (isIncompleteStop) {
1188
- nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit <final-answer>...</final-answer> with your synthesis so far, or call more tools to finish.`;
1163
+ nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit your final handoff (fragments, file:line) with your synthesis so far, or call more tools to finish.`;
1189
1164
  } else {
1190
- nudgeMsg = '[mixdog-runtime] Your previous response was empty (no <final-answer> tag and no tool call). Either emit your final answer wrapped in <final-answer>...</final-answer> tags, or continue with tool calls. Do not return an empty turn.';
1165
+ nudgeMsg = '[mixdog-runtime] Your previous response was empty (no handoff text and no tool call). Either emit your final handoff text now, or continue with tool calls. Do not return an empty turn.';
1191
1166
  }
1192
1167
  messages.push({ role: 'user', content: nudgeMsg });
1193
1168
  continue;
@@ -1232,34 +1207,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1232
1207
  : {}),
1233
1208
  };
1234
1209
  messages.push(_assistantTurnMsg);
1235
- // Soft-cap wrap-up (Step 3b): tools are disabled but the model still
1236
- // emitted tool calls. Do NOT execute them — push a refusal stub for
1237
- // each (after the assistant turn is appended so tool_use/tool_result
1238
- // pairing stays valid) and consume a grace turn. After 2 grace turns,
1239
- // terminate via the soft-cap path so a model that never complies stops.
1240
- if (_softCapActive) {
1210
+ // Hard-cap final turn: tools are disabled but the model still emitted
1211
+ // tool calls. Do NOT execute them — push a refusal stub for each.
1212
+ if (_capFinalToolsDisabled) {
1241
1213
  for (const _c of calls) {
1242
1214
  pushToolResultMessage({
1243
1215
  role: 'tool',
1244
- content: SOFT_CAP_REFUSAL_STUB,
1216
+ content: ITERATION_CAP_REFUSAL_STUB,
1245
1217
  toolCallId: _c.id,
1246
1218
  toolKind: 'error',
1247
1219
  });
1248
1220
  }
1249
- _softCapGraceTurns += 1;
1250
- try {
1251
- appendAgentTrace({
1252
- sessionId,
1253
- iteration: iterations,
1254
- kind: 'steer',
1255
- payload: { tag: 'soft_cap_wrapup', grace_turn: _softCapGraceTurns },
1256
- agent: sessionAgent || null,
1257
- });
1258
- } catch { /* best-effort */ }
1259
- if (_softCapGraceTurns >= 2) {
1260
- _terminatedBySoftCap = true;
1261
- break;
1262
- }
1263
1221
  if (sessionId) updateSessionStage(sessionId, 'connecting');
1264
1222
  continue;
1265
1223
  }
@@ -1851,10 +1809,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1851
1809
  }
1852
1810
  }
1853
1811
  // Completion-first steering hints (missed-parallelism / all-read-only /
1854
- // serial-rewording). At most ONE hint per turn (priority: soft-cap >
1855
- // level-2 > same-file grep > level-1); soft-cap active suppresses all.
1856
- // The ladder controller owns the cumulative counters and streaks.
1857
- _steeringLadder.emitPostBatchSteering(calls, _softCapActive);
1812
+ // serial-rewording). At most ONE hint per turn. The ladder controller
1813
+ // owns the cumulative counters and streaks.
1814
+ _steeringLadder.emitPostBatchSteering(calls, false);
1858
1815
  // Mid-turn steering is drained at the next loop's pre-send point,
1859
1816
  // AFTER any auto-compact pass. Draining here would put the steering
1860
1817
  // user turn after the fresh tool results before compaction runs; then
@@ -1868,8 +1825,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1868
1825
  // "completed" (see classifyTerminationReason in ./loop/termination.mjs).
1869
1826
  const terminationReason = classifyTerminationReason(response, {
1870
1827
  terminatedByCap,
1871
- terminatedBySoftCap: _terminatedBySoftCap,
1872
- softCapActive: _softCapActive,
1873
1828
  sessionAgent,
1874
1829
  });
1875
1830
  return {
@@ -12,10 +12,10 @@ import {
12
12
  effectiveBudget as compactEffectiveBudget,
13
13
  compactTypeIsRecallFastTrack,
14
14
  compactTypeIsSemantic,
15
- drainSessionCycle1,
16
15
  } from '../compact.mjs';
17
16
  import { estimateMessagesTokens, estimateRequestReserveTokens, estimateTranscriptContextUsage, resolveCompactBufferRatio } from '../context-utils.mjs';
18
17
  import { executeInternalTool } from '../../internal-tools.mjs';
18
+ import { truncateToKb, DIGEST_DEFAULT_MAX_KB } from '../loop/recall-fasttrack.mjs';
19
19
  import {
20
20
  positiveContextWindow,
21
21
  compactTriggerForSession,
@@ -81,52 +81,40 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
81
81
  } catch (err) {
82
82
  try { process.stderr.write(`[session] recall-fasttrack ingest skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
83
83
  }
84
- const dumpArgs = {
85
- action: 'dump_session_roots',
86
- sessionId,
87
- includeRaw: true,
88
- limit: positiveContextWindow(session?.compaction?.recallChunkLimit ?? session?.compaction?.recallLimit) || hydrateLimit,
89
- };
90
- const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
91
- let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
92
- let cycle1Text = '';
84
+ // Digest injection (mirrors loop/recall-fasttrack.mjs): no dump + cycle1
85
+ // drain — that ran memory-pipeline LLM chunking inside the compaction.
86
+ // ingest_session above stored the full transcript; background cycle1
87
+ // chunks it on its own schedule and recall serves anything beyond the
88
+ // digest.
89
+ let recallText = '';
93
90
  let cycle1Error = null;
94
- const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
95
- if (hasRawRows) {
96
- try {
97
- // Drain this session's cycle1 in window×concurrency units until no
98
- // raw rows remain, so the injected root is fully chunked rather than
99
- // carrying the unprocessed transcript tail (single-pass left raw in).
100
- const drained = await drainSessionCycle1(runTool, {
101
- sessionId,
102
- dumpArgs,
103
- deadlineMs: positiveContextWindow(session?.compaction?.recallCycle1DeadlineMs) || 120_000,
104
- maxPasses: positiveContextWindow(session?.compaction?.recallCycle1MaxPasses) || 0,
105
- cycleArgs: {
106
- min_batch: 1,
107
- session_cap: 1,
108
- batch_size: positiveContextWindow(session?.compaction?.recallCycle1BatchSize) || 100,
109
- rows_per_session: positiveContextWindow(session?.compaction?.recallRowsPerSession) || 100,
110
- window_size: positiveContextWindow(session?.compaction?.recallWindowSize) || 20,
111
- concurrency: positiveContextWindow(session?.compaction?.recallConcurrency) || 5,
112
- },
113
- });
114
- recallText = drained.recallText;
115
- cycle1Text = drained.cycle1Text;
116
- if (drained.error) {
117
- cycle1Error = drained.error;
118
- try { process.stderr.write(`[session] recall-fasttrack cycle1 error (sess=${sessionId}): ${drained.error}\n`); } catch {}
119
- }
120
- if (drained.rawRemaining > 0) {
121
- try { process.stderr.write(`[session] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId})\n`); } catch {}
122
- }
123
- } catch (err) {
124
- try { process.stderr.write(`[session] recall-fasttrack cycle1 skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
125
- }
126
- } else {
127
- cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
91
+ try {
92
+ const browsed = await executeInternalTool('memory', {
93
+ action: 'search',
94
+ sessionId,
95
+ limit: positiveContextWindow(session?.compaction?.recallDigestLimit) || 30,
96
+ includeMembers: true,
97
+ includeRaw: true,
98
+ }, callerCtx);
99
+ recallText = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
100
+ } catch (err) {
101
+ cycle1Error = err?.message || String(err);
102
+ try { process.stderr.write(`[session] recall-digest browse failed (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
128
103
  }
129
- return { query, querySha, cycle1Error, recallText: [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n') };
104
+ return {
105
+ query,
106
+ querySha,
107
+ cycle1Error,
108
+ recallText: [
109
+ `session_id=${sessionId}`,
110
+ `Full history is in memory — use the recall tool for details beyond this digest.`,
111
+ // Same byte cap as the loop digest path (recallDigestMaxKb,
112
+ // default = shared tool-output limit) — without it the memory
113
+ // renderer bounds the browse at ~200 rows × 1000 chars, letting a
114
+ // manual//clear compact process a far larger digest than loop's.
115
+ truncateToKb(recallText, positiveContextWindow(session?.compaction?.recallDigestMaxKb) || DIGEST_DEFAULT_MAX_KB),
116
+ ].map(v => String(v || '').trim()).filter(Boolean).join('\n\n'),
117
+ };
130
118
  }
131
119
  // Element-identity change detection (same approach as loop.mjs messagesArrayChanged): two
132
120
  // arrays are "unchanged" only when same length AND every slot is the same object
@@ -95,6 +95,7 @@ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
95
95
  'grep',
96
96
  'read',
97
97
  'apply_patch',
98
+ 'shell',
98
99
  'explore',
99
100
  'search',
100
101
  'web_fetch',
@@ -131,7 +132,7 @@ export function applyToolPermissionNarrowing(tools, toolPermission, warnRole = n
131
132
  return tools;
132
133
  }
133
134
 
134
- function recursiveWrapperToolNameForPublicAgent(agent) {
135
+ export function recursiveWrapperToolNameForPublicAgent(agent) {
135
136
  if (!agent) return null;
136
137
  const key = String(agent).trim();
137
138
  if (key === 'explore') return 'explore';
@@ -161,11 +162,11 @@ export function finalizeSessionToolList(tools, {
161
162
  const denySet = new Set(callerDeny.map(n => n.toLowerCase()));
162
163
  out = out.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
163
164
  }
164
- const recursiveDeny = ownerIsAgent ? recursiveWrapperToolNameForPublicAgent(resolvedAgent) : null;
165
- if (recursiveDeny) {
166
- const deny = recursiveDeny.toLowerCase();
167
- out = out.filter(t => String(t?.name || '').toLowerCase() !== deny);
168
- }
165
+ // NOTE: the self-wrapper anti-recursion deny is intentionally NOT applied
166
+ // here. Stripping a role's own wrapper tool (e.g. explore) from the schema
167
+ // would fork the read-only cache group into one shard per wrapper role.
168
+ // The bundle stays bit-identical; recursion is broken at call time in
169
+ // pre-dispatch-deny.mjs (recursiveWrapperToolNameForPublicAgent) instead.
169
170
  if (ownerIsAgent) {
170
171
  out = out.filter(t => !t?.annotations?.agentHidden);
171
172
  out = orderSessionTools(out);
@@ -649,24 +649,37 @@ export function createSession(opts) {
649
649
  const roleRules = skipAgentRules ? '' : (ownerIsAgent ? _buildAgentRules(agentRulesProfile) : _buildLeadRules());
650
650
  const metaContext = skipAgentRules ? '' : (ownerIsAgent ? '' : _buildLeadMetaContext());
651
651
  const roleSpecific = ownerIsAgent && !skipAgentRules ? _buildAgentSpecific(agentRulesAgent) : '';
652
- // Agent sessions must not inherit role/profile/preset tool narrowing: Pool
653
- // B and Pool C share one bit-identical tool schema to maximize provider
654
- // prefix reuse, and permission differences are enforced only at call time. Raw
655
- // non-agent callers keep the historical profile.tools / preset.tools
656
- // behaviour.
657
- const toolSpec = ownerIsAgent
658
- ? 'full'
659
- : (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
660
-
661
- // Prompt permission is metadata only. Preset tool restrictions must NOT
662
- // enter the prompt, or they split the shared agent cache tail; they map
663
- // to toolPermission below and are enforced only at call time.
664
- const permission = opts.permission
665
- || null;
652
+ // Prompt permission is metadata for the write bundle, but a read-only role
653
+ // is stamped BEFORE the toolSpec decision so its schema ships the narrowed
654
+ // bundle. Resolve toolPermission (with profile/preset fallbacks) first, and
655
+ // let the stored/logged `permission` reflect that resolved value — not just
656
+ // opts.permission — so diagnostics show the effective read/write class.
666
657
  const toolPermission = opts.permission
667
658
  || profile?.permission
668
659
  || permissionFromToolSpec(toolPreset)
669
660
  || null;
661
+ const permission = toolPermission;
662
+
663
+ // Agent sessions do not inherit arbitrary role/profile/preset tool
664
+ // narrowing — that would shatter provider prefix reuse into one shard per
665
+ // role. Instead they collapse onto exactly TWO stable, bit-identical
666
+ // bundles, one cache group each:
667
+ // - read-only roles (reviewer / debugger / hidden retrieval, i.e. any
668
+ // session resolving to permission 'read') -> 'readonly' bundle:
669
+ // read builtins (code_graph/find/glob/list/grep/read) + retrieval
670
+ // (explore/search/web_fetch/Skill), no apply_patch/shell/task, no
671
+ // MCP-write. applyToolPermissionNarrowing('read') below trims the
672
+ // bundle to AGENT_STRING_PERMISSION_READ_ALLOW so the final surface is
673
+ // bit-identical across these roles regardless of MCP registry state.
674
+ // - write roles (worker / heavy-worker / maintainer / …) -> 'full'
675
+ // bundle: the historical full schema.
676
+ // Call-time permission enforcement below is UNCHANGED (defense in depth):
677
+ // applyToolPermissionNarrowing still runs so the bundle choice never
678
+ // widens effective access.
679
+ const isReadOnlyAgentBundle = ownerIsAgent && toolPermission === 'read';
680
+ const toolSpec = ownerIsAgent
681
+ ? (isReadOnlyAgentBundle ? 'readonly' : 'full')
682
+ : (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
670
683
  let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
671
684
  // Fail-closed permission intersection: when a session declares an explicit
672
685
  // object-form permission, intersect the
@@ -193,6 +193,22 @@ export const PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS = resolveTimeoutMs(
193
193
  { minMs: MIN_PROVIDER_TIMEOUT_MS, maxMs: STALL_ABORT_MS },
194
194
  );
195
195
 
196
+ // First-MEANINGFUL-frame watchdog (WS). The inter-chunk timer resets on EVERY
197
+ // received frame (keepalive/metadata/rate_limits keep the socket "alive"), so a
198
+ // server that ACKs response.create with only keepalive/metadata frames — never
199
+ // a response.created or a content/tool delta — resets provider idle forever and
200
+ // the pre-response first-byte window never wins. This distinct timer is cleared
201
+ // ONLY by a MEANINGFUL response event (response.created or the first content /
202
+ // tool-arg delta); keepalive/metadata frames do NOT touch it. On expiry the
203
+ // stream is treated as stalled (streamStalledError → existing retry/fallback).
204
+ // Kept comfortably below the agent stall first-byte abort (300s default) so the
205
+ // provider layer catches the wedge before the agent watchdog does. Env-tunable.
206
+ export const PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS = resolveTimeoutMs(
207
+ 'MIXDOG_PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS',
208
+ 120_000,
209
+ { minMs: 10_000, maxMs: STALL_WARN_MS },
210
+ );
211
+
196
212
  // First retry has a small floor (250ms) instead of 0ms: an immediate reissue on
197
213
  // a transient 5xx burst lets parallel workers thundering-herd the backend in
198
214
  // lockstep (jitter alone can't decluster a 0ms base). Subsequent steps keep the
@@ -70,6 +70,7 @@ const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.ur
70
70
  const {
71
71
  appendEntry: memoryAppendEntry,
72
72
  ingestTranscript: memoryIngestTranscript,
73
+ drainBuffer: memoryDrainBuffer,
73
74
  } = await import(memoryClientModulePath);
74
75
  // Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
75
76
  // after an unhandled rejection whose async state was already corrupted
@@ -188,7 +189,19 @@ if (!_isWorkerMode) {
188
189
  // owner_lead_alive() sees a live owner and uses the full connect budget
189
190
  // instead of the 5s no-owner grace (fixes missing recap/core on restart).
190
191
  // backendReady intentionally omitted — readiness stays gated until connect.
191
- refreshActiveInstance(INSTANCE_ID);
192
+ try {
193
+ refreshActiveInstance(INSTANCE_ID);
194
+ } catch (e) {
195
+ const code = e?.code;
196
+ const transient =
197
+ code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOENT";
198
+ if (!transient) throw e;
199
+ try {
200
+ process.stderr.write(
201
+ `mixdog channels: refreshActiveInstance at startup failed (non-fatal, ${code}): ${e instanceof Error ? e.message : String(e)}\n`,
202
+ );
203
+ } catch {}
204
+ }
192
205
  startCliWorker();
193
206
  }
194
207
  const INSTRUCTIONS = "";
@@ -314,6 +327,10 @@ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
314
327
  const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
315
328
  forwarder.startWatch();
316
329
  void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
330
+ // Opportunistic drain: an ingest that had to buffer (memory port not yet
331
+ // published) leaves entry-/ingest- files behind; kick the drainer so they
332
+ // replay as soon as the port appears, without waiting for the periodic tick.
333
+ void memoryDrainBuffer().catch(() => {});
317
334
  // onlyIfOwned: binds happen on the owned path, but discovery/poll loops
318
335
  // above can outlast an ownership handoff — never overwrite a newer owner.
319
336
  refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
@@ -549,6 +566,7 @@ const ACTIVE_OWNER_STALE_MS = 1e4;
549
566
  let bindingReadyStatus = "pending";
550
567
  let _bindingReadyResolve;
551
568
  const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
569
+ let _memoryDrainTimer = null;
552
570
  dropTrace("bindingReady.create", { status: bindingReadyStatus });
553
571
  // ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
554
572
  // Extracted → lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
@@ -723,6 +741,13 @@ async function startOwnedRuntime(options = {}) {
723
741
  return;
724
742
  }
725
743
  startOwnerHeartbeat();
744
+ // Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
745
+ // memory service publishes its port. Idempotent + reentrancy-guarded inside
746
+ // drainBuffer(); unref'd so it never holds the worker open.
747
+ if (!_memoryDrainTimer) {
748
+ _memoryDrainTimer = setInterval(() => { void memoryDrainBuffer().catch(() => {}); }, 5e3);
749
+ _memoryDrainTimer.unref?.();
750
+ }
726
751
  // Re-check after each post-connect await so a stopOwnedRuntime() landing
727
752
  // mid-start cannot be overridden by the resuming start (scheduler/snapshot/
728
753
  // webhook/binding launches below would revive owner state after stop).
@@ -808,6 +833,7 @@ async function startOwnedRuntime(options = {}) {
808
833
  try { stopOwnerHeartbeat(); } catch {}
809
834
  try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
810
835
  try { clearActiveInstance(INSTANCE_ID); } catch {}
836
+ if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
811
837
  } finally {
812
838
  bridgeRuntimeStarting = false;
813
839
  }
@@ -837,6 +863,7 @@ async function stopOwnedRuntime(reason) {
837
863
  // file handle + timers for the rest of the process lifetime.
838
864
  try { forwarder.stopWatch(); } catch {}
839
865
  stopOwnerHeartbeat();
866
+ if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
840
867
  scheduler.stop();
841
868
  stopSnapshotWriter();
842
869
  await stopWebhookAndEventRuntime();