mixdog 0.9.12 → 0.9.14

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 (58) 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/defaults/cycle3-review-prompt.md +14 -0
  5. package/src/defaults/memory-promote-prompt.md +9 -9
  6. package/src/lib/rules-builder.cjs +15 -11
  7. package/src/mixdog-session-runtime.mjs +10 -0
  8. package/src/rules/agent/00-common.md +5 -17
  9. package/src/rules/agent/00-core.md +21 -0
  10. package/src/rules/lead/lead-brief.md +7 -0
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
  14. package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
  15. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  16. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
  17. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
  18. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
  22. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
  24. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
  25. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
  26. package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
  28. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
  29. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
  30. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
  31. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
  32. package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
  33. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
  34. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +132 -11
  35. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
  36. package/src/runtime/agent/orchestrator/session/manager.mjs +30 -15
  37. package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
  38. package/src/runtime/channels/index.mjs +28 -1
  39. package/src/runtime/channels/lib/memory-client.mjs +200 -15
  40. package/src/runtime/memory/index.mjs +18 -13
  41. package/src/runtime/memory/lib/core-memory-store.mjs +108 -4
  42. package/src/runtime/memory/lib/cycle-scheduler.mjs +52 -18
  43. package/src/runtime/memory/lib/memory-cycle1.mjs +166 -23
  44. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +37 -25
  45. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +37 -0
  46. package/src/runtime/memory/lib/memory-cycle2.mjs +17 -7
  47. package/src/runtime/memory/lib/memory-cycle3.mjs +106 -6
  48. package/src/runtime/memory/lib/memory-embed.mjs +35 -1
  49. package/src/runtime/memory/lib/memory.mjs +7 -1
  50. package/src/runtime/memory/lib/query-handlers.mjs +1 -0
  51. package/src/standalone/agent-tool.mjs +89 -7
  52. package/src/tui/dist/index.mjs +203 -10
  53. package/src/tui/engine/tui-steering-persist.mjs +175 -0
  54. package/src/tui/engine.mjs +46 -2
  55. package/src/ui/statusline.mjs +2 -4
  56. package/src/workflows/default/WORKFLOW.md +2 -0
  57. package/src/workflows/sequential/WORKFLOW.md +2 -0
  58. 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
@@ -4,10 +4,13 @@ import { join } from 'path';
4
4
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
5
5
  import { updateJsonAtomicSync } from '../../../../shared/atomic-file.mjs';
6
6
  import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
7
+ import { loadSession } from '../store.mjs';
7
8
 
8
9
  const _sessionPendingMessages = new Map();
9
10
  const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
10
11
  const PENDING_MESSAGES_MODE = 0o600;
12
+ const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
13
+ const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
11
14
  const _pendingPersistBuffers = new Map();
12
15
  let _pendingPersistImmediate = null;
13
16
 
@@ -19,25 +22,58 @@ function isValidPendingSessionId(sessionId) {
19
22
  return typeof sessionId === 'string' && /^[A-Za-z0-9_-]+$/.test(sessionId);
20
23
  }
21
24
 
25
+ function isTuiSteeringPendingKey(sessionId) {
26
+ return typeof sessionId === 'string' && sessionId.startsWith('tui_');
27
+ }
28
+
29
+ function normalizeTuiSteeringQueueEntry(entry) {
30
+ if (typeof entry === 'string') {
31
+ const text = entry.trim();
32
+ return text || null;
33
+ }
34
+ if (!entry || typeof entry !== 'object') return null;
35
+ if (typeof entry.text === 'string' && entry.text.trim()) {
36
+ const text = entry.text.trim();
37
+ const id = typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : null;
38
+ return id ? { id, text } : text;
39
+ }
40
+ return null;
41
+ }
42
+
22
43
  function normalizePendingStore(raw) {
23
44
  const sessions = raw && typeof raw === 'object' && raw.sessions && typeof raw.sessions === 'object'
24
45
  ? raw.sessions
25
46
  : {};
26
- const out = { version: 1, updatedAt: Date.now(), sessions: {} };
47
+ const storeUpdatedAt = Number(raw?.updatedAt) || Date.now();
48
+ const touchedRaw = raw && typeof raw === 'object' && raw.sessionTouchedAt && typeof raw.sessionTouchedAt === 'object'
49
+ ? raw.sessionTouchedAt
50
+ : {};
51
+ const out = { version: 1, updatedAt: storeUpdatedAt, sessions: {}, sessionTouchedAt: {} };
27
52
  for (const [sid, value] of Object.entries(sessions)) {
28
53
  if (!isValidPendingSessionId(sid) || !Array.isArray(value)) continue;
29
- const q = value
30
- .map((entry) => {
31
- if (typeof entry === 'string') return entry;
32
- if (entry && typeof entry === 'object' && typeof entry.message === 'string') return entry.message;
33
- return '';
34
- })
35
- .filter(Boolean);
36
- if (q.length > 0) out.sessions[sid] = q;
54
+ const q = isTuiSteeringPendingKey(sid)
55
+ ? value.map(normalizeTuiSteeringQueueEntry).filter(Boolean)
56
+ : value
57
+ .map((entry) => {
58
+ if (typeof entry === 'string') return entry;
59
+ if (entry && typeof entry === 'object' && typeof entry.message === 'string') return entry.message;
60
+ return '';
61
+ })
62
+ .filter(Boolean);
63
+ if (q.length > 0) {
64
+ out.sessions[sid] = q;
65
+ const touched = Number(touchedRaw[sid]);
66
+ out.sessionTouchedAt[sid] = Number.isFinite(touched) && touched > 0 ? touched : storeUpdatedAt;
67
+ }
37
68
  }
38
69
  return out;
39
70
  }
40
71
 
72
+ function touchPendingSessionEntry(next, sessionId, now = Date.now()) {
73
+ if (!next.sessionTouchedAt || typeof next.sessionTouchedAt !== 'object') next.sessionTouchedAt = {};
74
+ next.sessionTouchedAt[sessionId] = now;
75
+ }
76
+
41
77
  function normalizePendingMessageEntry(entry) {
42
78
  if (typeof entry === 'string') {
43
79
  const text = entry.trim();
@@ -86,7 +122,9 @@ function persistPendingMessages(sessionId, messages) {
86
122
  const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
87
123
  q.push(...persistedMessages);
88
124
  next.sessions[sessionId] = q;
89
- next.updatedAt = Date.now();
125
+ const now = Date.now();
126
+ next.updatedAt = now;
127
+ touchPendingSessionEntry(next, sessionId, now);
90
128
  depth = q.length;
91
129
  return next;
92
130
  }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
@@ -143,6 +181,7 @@ function drainPersistedPendingMessages(sessionId) {
143
181
  drained = q.filter((m) => typeof m === 'string' && m.length > 0);
144
182
  if (drained.length === 0) return undefined;
145
183
  delete next.sessions[sessionId];
184
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
146
185
  next.updatedAt = Date.now();
147
186
  return next;
148
187
  }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
@@ -152,6 +191,75 @@ function drainPersistedPendingMessages(sessionId) {
152
191
  return drained;
153
192
  }
154
193
 
194
+ function clearPersistedPendingMessages(sessionId) {
195
+ if (!isValidPendingSessionId(sessionId)) return;
196
+ try {
197
+ updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
198
+ const next = normalizePendingStore(raw);
199
+ if (!Object.prototype.hasOwnProperty.call(next.sessions, sessionId)) return undefined;
200
+ delete next.sessions[sessionId];
201
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
202
+ next.updatedAt = Date.now();
203
+ return next;
204
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
205
+ } catch (err) {
206
+ try { process.stderr.write(`[session] pending-message clear failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
207
+ }
208
+ }
209
+
210
+ function shouldEvictPendingSession(sessionId, ttlMs, entryTouchedAt, now = Date.now()) {
211
+ if (isTuiSteeringPendingKey(sessionId)) {
212
+ const entryTouch = Number(entryTouchedAt) || 0;
213
+ if (entryTouch <= 0) return false;
214
+ return (now - entryTouch) > ttlMs;
215
+ }
216
+ const session = loadSession(sessionId);
217
+ if (session) {
218
+ const touched = Math.max(
219
+ Number(session.updatedAt) || 0,
220
+ Number(session.lastHeartbeatAt) || 0,
221
+ Number(session.createdAt) || 0,
222
+ );
223
+ return touched > 0 && (now - touched) > ttlMs;
224
+ }
225
+ const entryTouch = Number(entryTouchedAt) || 0;
226
+ return entryTouch > 0 && (now - entryTouch) > PENDING_ORPHAN_GRACE_MS;
227
+ }
228
+
229
+ export function sweepOrphanedPendingMessages({ ttlMs = PENDING_ORPHAN_TTL_MS } = {}) {
230
+ const now = Date.now();
231
+ const removed = [];
232
+ try {
233
+ updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
234
+ const next = normalizePendingStore(raw);
235
+ const ids = Object.keys(next.sessions);
236
+ if (ids.length === 0) return undefined;
237
+ for (const sid of ids) {
238
+ const entryTouchedAt = next.sessionTouchedAt?.[sid];
239
+ if (shouldEvictPendingSession(sid, ttlMs, entryTouchedAt, now)) {
240
+ delete next.sessions[sid];
241
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[sid];
242
+ removed.push(sid);
243
+ }
244
+ }
245
+ if (removed.length === 0) return undefined;
246
+ next.updatedAt = now;
247
+ return next;
248
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
249
+ } catch (err) {
250
+ try { process.stderr.write(`[session] pending-message sweep failed: ${err?.message || err}\n`); } catch {}
251
+ return 0;
252
+ }
253
+ if (removed.length > 0) {
254
+ try {
255
+ process.stderr.write(
256
+ `[session] pending-message sweep: removed ${removed.length} stale/orphan queue(s) (ttl=${Math.round(ttlMs / 86400000)}d) (${removed.slice(0, 5).join(', ')}${removed.length > 5 ? `, +${removed.length - 5} more` : ''})\n`,
257
+ );
258
+ } catch { /* ignore */ }
259
+ }
260
+ return removed.length;
261
+ }
262
+
155
263
  function modelVisiblePendingMessages(messages) {
156
264
  return (Array.isArray(messages) ? messages : [])
157
265
  .map(pendingMessageQueueEntry)
@@ -229,7 +337,20 @@ export function drainPendingMessages(sessionId) {
229
337
 
230
338
  // Cleanup hook for closeSession — drop the in-memory queue and buffered-persist
231
339
  // entry so both Maps do not accumulate one entry per closed session.
232
- export function _dropPendingMessageState(id) {
340
+ export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
341
+ if (!clearPersisted) {
342
+ const buffered = _pendingPersistBuffers.get(id);
343
+ if (buffered?.length) {
344
+ try { persistPendingMessages(id, buffered); } catch { /* ignore */ }
345
+ }
346
+ }
233
347
  try { _sessionPendingMessages.delete(id); } catch { /* ignore */ }
234
348
  try { _pendingPersistBuffers.delete(id); } catch { /* ignore */ }
349
+ if (clearPersisted) {
350
+ try { clearPersistedPendingMessages(id); } catch { /* ignore */ }
351
+ }
235
352
  }
353
+
354
+ setImmediate(() => {
355
+ try { sweepOrphanedPendingMessages(); } catch { /* ignore */ }
356
+ });
@@ -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);
@@ -72,6 +72,7 @@ import {
72
72
  enqueuePendingMessage,
73
73
  drainPendingMessages,
74
74
  _dropPendingMessageState,
75
+ sweepOrphanedPendingMessages,
75
76
  } from './manager/pending-messages.mjs';
76
77
  import {
77
78
  bumpUsageMetricsTurnId,
@@ -649,24 +650,37 @@ export function createSession(opts) {
649
650
  const roleRules = skipAgentRules ? '' : (ownerIsAgent ? _buildAgentRules(agentRulesProfile) : _buildLeadRules());
650
651
  const metaContext = skipAgentRules ? '' : (ownerIsAgent ? '' : _buildLeadMetaContext());
651
652
  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;
653
+ // Prompt permission is metadata for the write bundle, but a read-only role
654
+ // is stamped BEFORE the toolSpec decision so its schema ships the narrowed
655
+ // bundle. Resolve toolPermission (with profile/preset fallbacks) first, and
656
+ // let the stored/logged `permission` reflect that resolved value — not just
657
+ // opts.permission — so diagnostics show the effective read/write class.
666
658
  const toolPermission = opts.permission
667
659
  || profile?.permission
668
660
  || permissionFromToolSpec(toolPreset)
669
661
  || null;
662
+ const permission = toolPermission;
663
+
664
+ // Agent sessions do not inherit arbitrary role/profile/preset tool
665
+ // narrowing — that would shatter provider prefix reuse into one shard per
666
+ // role. Instead they collapse onto exactly TWO stable, bit-identical
667
+ // bundles, one cache group each:
668
+ // - read-only roles (reviewer / debugger / hidden retrieval, i.e. any
669
+ // session resolving to permission 'read') -> 'readonly' bundle:
670
+ // read builtins (code_graph/find/glob/list/grep/read) + retrieval
671
+ // (explore/search/web_fetch/Skill), no apply_patch/shell/task, no
672
+ // MCP-write. applyToolPermissionNarrowing('read') below trims the
673
+ // bundle to AGENT_STRING_PERMISSION_READ_ALLOW so the final surface is
674
+ // bit-identical across these roles regardless of MCP registry state.
675
+ // - write roles (worker / heavy-worker / maintainer / …) -> 'full'
676
+ // bundle: the historical full schema.
677
+ // Call-time permission enforcement below is UNCHANGED (defense in depth):
678
+ // applyToolPermissionNarrowing still runs so the bundle choice never
679
+ // widens effective access.
680
+ const isReadOnlyAgentBundle = ownerIsAgent && toolPermission === 'read';
681
+ const toolSpec = ownerIsAgent
682
+ ? (isReadOnlyAgentBundle ? 'readonly' : 'full')
683
+ : (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
670
684
  let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
671
685
  // Fail-closed permission intersection: when a session declares an explicit
672
686
  // object-form permission, intersect the
@@ -2069,7 +2083,7 @@ export function closeSession(id, reason = 'manual', opts = {}) {
2069
2083
  // Drop the in-memory pending-message queue and any buffered-persist entry
2070
2084
  // for this session — otherwise both Maps accumulate one entry per closed
2071
2085
  // session for the life of the mcp-server.
2072
- _dropPendingMessageState(id);
2086
+ _dropPendingMessageState(id, { clearPersisted: tombstone });
2073
2087
  // 4. Defer runtime map clear to next tick so any settling askSession can
2074
2088
  // observe `closed=true` / bumped generation before we yank the entry.
2075
2089
  // Disk tombstone remains — that's what blocks resurrection.
@@ -2209,6 +2223,7 @@ function hasActiveRuntimeWork() {
2209
2223
 
2210
2224
  function _runCleanupCycle() {
2211
2225
  if (hasActiveRuntimeWork()) return;
2226
+ sweepOrphanedPendingMessages();
2212
2227
  sweepIdleSessions({ includeTombstones: true });
2213
2228
  }
2214
2229