mixdog 0.9.37 → 0.9.38

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 (27) hide show
  1. package/package.json +1 -1
  2. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  3. package/scripts/explore-bench.mjs +65 -8
  4. package/scripts/explore-prompt-policy-test.mjs +6 -2
  5. package/scripts/notify-completion-mirror-test.mjs +73 -0
  6. package/scripts/session-sweep.mjs +266 -0
  7. package/src/rules/agent/30-explorer.md +35 -17
  8. package/src/rules/shared/01-tool.md +7 -3
  9. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  10. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  11. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
  12. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
  13. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  14. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
  15. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  16. package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +6 -6
  18. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  19. package/src/session-runtime/provider-models.mjs +3 -3
  20. package/src/session-runtime/runtime-core.mjs +43 -8
  21. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  22. package/src/standalone/explore-tool.mjs +1 -1
  23. package/src/tui/dist/index.mjs +16 -1
  24. package/src/tui/engine/agent-job-feed.mjs +10 -0
  25. package/src/tui/engine/queue-helpers.mjs +8 -0
  26. package/src/tui/engine/session-flow.mjs +15 -1
  27. package/src/tui/engine/turn.mjs +6 -1
@@ -256,7 +256,16 @@ function _flushScheduled(id) {
256
256
  // Single long-lived Worker serializes all saveSessionAsync calls.
257
257
  // The worker's message queue preserves generation-race ordering.
258
258
  let _saveWorker = null;
259
- let _saveWorkerPending = new Map(); // reqId { resolve, reject, session, opts }
259
+ // In-flight writes, keyed by reqId. Value: { id, session, opts, waiters:[{resolve,reject}] }.
260
+ // At most ONE entry per session id at a time (single-in-flight-per-id).
261
+ let _saveWorkerPending = new Map();
262
+ // Latest-wins queued payload per session, keyed by id. Value: { session, opts, waiters:[] }.
263
+ // At most ONE queued write per id: a newer saveSessionAsync while a write is in
264
+ // flight overwrites session/opts here and appends its resolver to waiters, so
265
+ // every superseded caller resolves when this single queued write finally lands.
266
+ let _saveAsyncQueued = new Map();
267
+ // id → reqId of the in-flight write for that id (enforces one-in-flight-per-id).
268
+ let _saveAsyncInflight = new Map();
260
269
  let _saveWorkerReqId = 0;
261
270
  let _saveWorkerRefCount = 0;
262
271
 
@@ -273,12 +282,37 @@ function _getOrSpawnWorker() {
273
282
  // becomes unref'd again once all in-flight writes settle. _saveWorker
274
283
  // null-check covers the error/exit race where the worker died first.
275
284
  if (--_saveWorkerRefCount === 0 && _saveWorker) _saveWorker.unref();
276
- if (ok) p.resolve();
277
- else p.reject(new Error(`[session-store] worker save failed: ${error}`));
285
+ const { id, waiters } = p;
286
+ _saveAsyncInflight.delete(id);
287
+ // Resolve/reject every caller whose payload this write represents
288
+ // (the originating call plus any that coalesced onto it before it was
289
+ // posted). A supersede never lands here as a rejection — only a real
290
+ // worker failure does.
291
+ if (ok) { for (const w of waiters) w.resolve(); }
292
+ else {
293
+ const e = new Error(`[session-store] worker save failed: ${error}`);
294
+ for (const w of waiters) w.reject(e);
295
+ }
296
+ // Promote the latest-wins queued payload (if any) into the now-free
297
+ // in-flight slot for this id. Runs regardless of ok: the queued write
298
+ // is a newer, independent payload and must still be attempted so its
299
+ // (possibly superseded) waiters resolve when it lands.
300
+ const q = _saveAsyncQueued.get(id);
301
+ if (q) {
302
+ _saveAsyncQueued.delete(id);
303
+ try {
304
+ _postAsyncWrite(id, q.session, q.opts, q.waiters);
305
+ } catch (err) {
306
+ for (const w of q.waiters) w.reject(err);
307
+ }
308
+ }
278
309
  });
279
310
  _saveWorker.on('error', (err) => {
280
- for (const [, p] of _saveWorkerPending) p.reject(err);
311
+ for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
281
312
  _saveWorkerPending.clear();
313
+ for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
314
+ _saveAsyncQueued.clear();
315
+ _saveAsyncInflight.clear();
282
316
  _saveWorkerRefCount = 0;
283
317
  _saveWorker = null;
284
318
  });
@@ -290,8 +324,11 @@ function _getOrSpawnWorker() {
290
324
  // saveSessionAsync registered a resolver but before the worker
291
325
  // received the message.
292
326
  const err = new Error(`[session-store] save worker exited with code ${code}`);
293
- for (const [, p] of _saveWorkerPending) p.reject(err);
327
+ for (const [, p] of _saveWorkerPending) for (const w of p.waiters) w.reject(err);
294
328
  _saveWorkerPending.clear();
329
+ for (const [, q] of _saveAsyncQueued) for (const w of q.waiters) w.reject(err);
330
+ _saveAsyncQueued.clear();
331
+ _saveAsyncInflight.clear();
295
332
  _saveWorkerRefCount = 0;
296
333
  _saveWorker = null;
297
334
  });
@@ -299,14 +336,45 @@ function _getOrSpawnWorker() {
299
336
  return _saveWorker;
300
337
  }
301
338
 
339
+ /**
340
+ * Post one in-flight write for `id` to the worker and register it as the
341
+ * single in-flight entry for that id. Callers guarantee no write is already
342
+ * in flight for `id`. Throws (after cleaning its own map entries) if the
343
+ * worker postMessage fails so the caller can reject the affected waiters.
344
+ */
345
+ function _postAsyncWrite(id, session, opts, waiters) {
346
+ const reqId = ++_saveWorkerReqId;
347
+ _saveWorkerPending.set(reqId, { id, session, opts, waiters });
348
+ _saveAsyncInflight.set(id, reqId);
349
+ try {
350
+ const w = _getOrSpawnWorker();
351
+ w.postMessage({ session, opts, reqId });
352
+ // Ref AFTER successful postMessage so a queue/throw failure path does
353
+ // not leave the worker held alive with no pending message. Paired with
354
+ // the unref in the message handler when count hits 0.
355
+ if (++_saveWorkerRefCount === 1) w.ref();
356
+ } catch (err) {
357
+ _saveWorkerPending.delete(reqId);
358
+ _saveAsyncInflight.delete(id);
359
+ throw err;
360
+ }
361
+ }
362
+
302
363
  /**
303
364
  * Async save via a dedicated Worker thread.
304
365
  * Errors surface as thrown Errors — callers must not silently swallow them.
366
+ *
367
+ * Per-session latest-wins coalescing: for a given id there is at most one
368
+ * write in flight plus one queued follow-up. N rapid saves for the same id in
369
+ * a turn collapse to (in-flight + one queued-latest), keeping the single
370
+ * worker's backlog bounded. Per-id write ORDERING is preserved (a queued write
371
+ * is only posted once the prior in-flight write for that id settles); different
372
+ * ids interleave freely as before.
305
373
  */
306
374
  export function saveSessionAsync(session, opts) {
307
375
  _ensureLifecycleFields(session);
308
376
  setLiveSession(session);
309
- const reqId = ++_saveWorkerReqId;
377
+ const id = session.id;
310
378
  const safeOpts = opts || null;
311
379
  // The Worker `postMessage` below structured-clones the whole session on the
312
380
  // main thread. `session.liveTurnMessages` (live working transcript) and
@@ -322,18 +390,28 @@ export function saveSessionAsync(session, opts) {
322
390
  ? (() => { const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session; return rest; })()
323
391
  : session;
324
392
  return new Promise((resolve, reject) => {
325
- // Persist {session, opts} so drainSessionStore can sync-flush
326
- // outstanding writes if process exit interrupts the worker queue.
327
- _saveWorkerPending.set(reqId, { resolve, reject, session: clonePayload, opts: safeOpts });
393
+ const waiter = { resolve, reject };
394
+ if (_saveAsyncInflight.has(id)) {
395
+ // A write is already on disk for this id — coalesce into the single
396
+ // latest-wins queued slot. Existing queued waiters carry over so a
397
+ // superseded caller resolves when THIS newer write lands (never
398
+ // hang, never reject on supersede).
399
+ const q = _saveAsyncQueued.get(id);
400
+ if (q) {
401
+ q.session = clonePayload;
402
+ q.opts = safeOpts;
403
+ q.waiters.push(waiter);
404
+ } else {
405
+ _saveAsyncQueued.set(id, { session: clonePayload, opts: safeOpts, waiters: [waiter] });
406
+ }
407
+ return;
408
+ }
409
+ // Idle for this id — post immediately as the in-flight write. The
410
+ // in-flight entry persists {session, opts} so drainSessionStore can
411
+ // sync-flush outstanding writes if process exit interrupts the queue.
328
412
  try {
329
- const w = _getOrSpawnWorker();
330
- w.postMessage({ session: clonePayload, opts: safeOpts, reqId });
331
- // Ref AFTER successful postMessage so a queue/throw failure path
332
- // does not leave the worker held alive with no pending message.
333
- // Paired with the unref in the message handler when count hits 0.
334
- if (++_saveWorkerRefCount === 1) w.ref();
413
+ _postAsyncWrite(id, clonePayload, safeOpts, [waiter]);
335
414
  } catch (err) {
336
- _saveWorkerPending.delete(reqId);
337
415
  reject(err);
338
416
  }
339
417
  });
@@ -431,18 +509,35 @@ export function drainSessionStore() {
431
509
  // is sync-flushed directly here. The Promise is then rejected so the
432
510
  // caller's await site does not leak unresolved (caller is at process
433
511
  // exit so the rejection is informational, not actionable).
512
+ const _drainErr = new Error('[session-store] drain: worker-queue interrupted by process exit');
513
+ // Flush in-flight writes FIRST, then the latest-wins queued payloads, so
514
+ // for any id with both an in-flight and a queued write the queued (newest)
515
+ // state is written LAST and wins on disk — no lost last write.
434
516
  for (const [, pending] of _saveWorkerPending) {
435
- if (!pending.session) continue;
517
+ if (pending.session) {
518
+ try {
519
+ _saveSessionSync(pending.session, pending.opts);
520
+ } catch (err) {
521
+ process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
522
+ }
523
+ }
524
+ for (const w of pending.waiters) {
525
+ try { w.reject(_drainErr); } catch { /* best-effort */ }
526
+ }
527
+ }
528
+ for (const [, q] of _saveAsyncQueued) {
436
529
  try {
437
- _saveSessionSync(pending.session, pending.opts);
530
+ _saveSessionSync(q.session, q.opts);
438
531
  } catch (err) {
439
532
  process.stderr.write(`[session-store] drain worker-queue save failed: ${err?.message}\n`);
440
533
  }
441
- try {
442
- pending.reject(new Error('[session-store] drain: worker-queue interrupted by process exit'));
443
- } catch { /* best-effort */ }
534
+ for (const w of q.waiters) {
535
+ try { w.reject(_drainErr); } catch { /* best-effort */ }
536
+ }
444
537
  }
445
538
  _saveWorkerPending.clear();
539
+ _saveAsyncQueued.clear();
540
+ _saveAsyncInflight.clear();
446
541
  _saveWorkerRefCount = 0;
447
542
  }
448
543
 
@@ -191,6 +191,43 @@ export const PROVIDER_WS_ACQUIRE_TIMEOUT_MS = resolveTimeoutMs(
191
191
  { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
192
192
  );
193
193
 
194
+ // WS pool liveness (ping/pong) — closes the gap between a socket going
195
+ // half-open (peer gone / TLS wedge) and the semantic-idle watchdog noticing
196
+ // (120s). Node `ws` send() is fire-and-forget, so a dead pooled socket
197
+ // silently blackholes response.create frames until a downstream timeout. An
198
+ // idle pooled socket is pinged on this cadence; a reused socket that has been
199
+ // quiet longer than the stale window is ping-probed before hand-out.
200
+ // Gated OFF by default for codex/opencode parity — neither pings model
201
+ // sockets. Enable with MIXDOG_PROVIDER_WS_PING_ENABLED=1. When disabled, no
202
+ // liveness interval is armed and the acquire-reuse ping probe is skipped (the
203
+ // non-OPEN eviction scan still runs); lastAliveAt stamping is harmless.
204
+ const _wsPingRaw = process.env.MIXDOG_PROVIDER_WS_PING_ENABLED;
205
+ export const PROVIDER_WS_PING_ENABLED = _wsPingRaw === '1' || _wsPingRaw === 'true' || _wsPingRaw === 'yes';
206
+
207
+ export const PROVIDER_WS_PING_INTERVAL_MS = resolveTimeoutMs(
208
+ 'MIXDOG_PROVIDER_WS_PING_INTERVAL_MS',
209
+ 30_000,
210
+ { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
211
+ );
212
+
213
+ // Missed-pong grace: after a ping, the pong must land within this bound or the
214
+ // socket is treated as dead (closed + evicted). Doubles as the short probe
215
+ // bound on the acquire-reuse path so a busy caller is never handed a wedged
216
+ // socket.
217
+ export const PROVIDER_WS_PONG_TIMEOUT_MS = resolveTimeoutMs(
218
+ 'MIXDOG_PROVIDER_WS_PONG_TIMEOUT_MS',
219
+ 5_000,
220
+ { minMs: 1_000, maxMs: 60_000 },
221
+ );
222
+
223
+ // Activity freshness window: a pooled socket with observed activity (pong /
224
+ // release) newer than this is assumed live and skips the acquire-reuse probe.
225
+ export const PROVIDER_WS_LIVENESS_STALE_MS = resolveTimeoutMs(
226
+ 'MIXDOG_PROVIDER_WS_LIVENESS_STALE_MS',
227
+ 30_000,
228
+ { minMs: 5_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
229
+ );
230
+
194
231
  // Single inter-chunk idle timer (300s), matching the upstream WS provider's
195
232
  // default stream idle timeout. Mixdog resets one idle timer on every received
196
233
  // WS frame (openai-oauth-ws messageHandler resets on every parsed event). There
@@ -39,7 +39,7 @@ export const BUILTIN_TOOLS = [
39
39
  name: 'read',
40
40
  title: 'Mixdog Read',
41
41
  annotations: { title: 'Mixdog Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
42
- description: 'Read verified file path(s). Unknown path → find first. Batch paths/regions as real arrays in one call. Not for directory listing.',
42
+ description: 'Read verified file path(s); guessed path → find first (same turn as other probes). Batch paths/regions as real arrays in one call. Not for directory listing.',
43
43
  inputSchema: {
44
44
  type: 'object',
45
45
  properties: {
@@ -109,7 +109,7 @@ export const BUILTIN_TOOLS = [
109
109
  name: 'grep',
110
110
  title: 'Mixdog Grep',
111
111
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
112
- description: 'Exact text/regex in verified scope. Unknown path/name → find first; no path "." + guessed src/**. Broad: files_with_matches/count; narrow: content_with_context.',
112
+ description: 'Exact text/regex in verified scope (project root counts as verified — unscoped search needs no find). Only a guessed path fragment → find first, same turn. No path "." + guessed src/**. Broad: files_with_matches/count; narrow: content_with_context.',
113
113
  inputSchema: {
114
114
  type: 'object',
115
115
  properties: {
@@ -125,7 +125,7 @@ export const BUILTIN_TOOLS = [
125
125
  { type: 'string' },
126
126
  { type: 'array', items: { type: 'string' }, minItems: 1 },
127
127
  ],
128
- description: 'Verified file/dir only; unknown → find first.',
128
+ description: 'Verified file/dir, or project root; guessed → find first.',
129
129
  },
130
130
  glob: {
131
131
  anyOf: [
@@ -148,7 +148,7 @@ export const BUILTIN_TOOLS = [
148
148
  name: 'glob',
149
149
  title: 'Mixdog Glob',
150
150
  annotations: { title: 'Mixdog Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
151
- description: 'Exact glob from verified roots. Unknown root/name → find first; no path "." + guessed src/**. Batch arrays.',
151
+ description: 'Exact glob from verified roots (project root is verified). Guessed root/name → find first, same turn. No path "." + guessed src/**. Batch arrays.',
152
152
  inputSchema: {
153
153
  type: 'object',
154
154
  properties: {
@@ -164,7 +164,7 @@ export const BUILTIN_TOOLS = [
164
164
  { type: 'string' },
165
165
  { type: 'array', items: { type: 'string' }, minItems: 1 },
166
166
  ],
167
- description: 'Verified base dir(s); unknown → find first. Batch path[].',
167
+ description: 'Verified base dir(s) or project root; guessed → find first. Batch path[].',
168
168
  },
169
169
  head_limit: { type: 'number', description: 'Max entries.' },
170
170
  offset: { type: 'number', description: 'Skip entries.' },
@@ -197,7 +197,7 @@ export const BUILTIN_TOOLS = [
197
197
  name: 'list',
198
198
  title: 'Mixdog List Directory',
199
199
  annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
200
- description: 'List verified directories. Unknown dir → find first. Batch dirs as path[].',
200
+ description: 'List verified directories (project root included). Guessed dir → find first. Batch dirs as path[].',
201
201
  inputSchema: {
202
202
  type: 'object',
203
203
  properties: {
@@ -3,7 +3,7 @@ export const CODE_GRAPH_TOOL_DEFS = [
3
3
  name: 'code_graph',
4
4
  title: 'Code Graph',
5
5
  annotations: { title: 'Code Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false, compressibleLossless: true },
6
- description: 'Repo-local code structure/flow lookup (not web): symbols/references/calls/deps. Known symbols or verified files only. Batch symbols[]/files[].',
6
+ description: 'Repo-local code structure/flow lookup (not web): symbols/references/calls/deps. Symbols by name need no pre-verification; files[] must be verified paths. Batch symbols[]/files[].',
7
7
  inputSchema: {
8
8
  type: 'object',
9
9
  properties: {
@@ -277,13 +277,13 @@ export function createProviderModels({
277
277
  return providerModelsFromCacheRows(await caches.providerModelsPromise);
278
278
  }
279
279
 
280
- function warmProviderModelCache() {
280
+ function warmProviderModelCache({ loadSecrets = false } = {}) {
281
281
  if (Array.isArray(caches.providerModelsCache.models) || caches.providerModelsPromise) return caches.providerModelsPromise;
282
282
  profile('warm:start');
283
283
  const seq = ++caches.providerModelsLoadSeq;
284
- caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets: false })
284
+ caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets })
285
285
  .then((models) => {
286
- if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets: false })) {
286
+ if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets })) {
287
287
  caches.providerModelsCache = { models, at: Date.now() };
288
288
  }
289
289
  bootProfile('provider-models:warm-ready', { count: models.length });
@@ -306,6 +306,26 @@ export function __saveModelSettingsForTest(cfgMod, route, options = {}) {
306
306
  return saveModelSettings(cfgMod, route, options);
307
307
  }
308
308
 
309
+ // Decide whether notifyFnForSession should mirror a terminal tool completion
310
+ // into the pending-message queue. Only the TUI execution-ui path actually
311
+ // injects the model-visible twin of the completion into the active loop, and it
312
+ // signals that with an EXPLICIT ack (modelVisibleDelivered) — never a generic
313
+ // truthy onNotification return. Skipping the mirror on a bare `handled===true`
314
+ // would let a display-only / API listener suppress the sole model-visible copy,
315
+ // so the model would never see the completion body. Mirror UNLESS the body was
316
+ // explicitly delivered; unknown/generic-handled → keep the mirror.
317
+ export function shouldMirrorCompletionToPendingQueue({
318
+ callerSessionId,
319
+ modelVisibleDelivered,
320
+ hasEnqueue,
321
+ text,
322
+ meta = {},
323
+ } = {}) {
324
+ if (!callerSessionId || !hasEnqueue) return false;
325
+ if (modelVisibleDelivered) return false;
326
+ return shouldPersistModelVisibleToolCompletion(text, meta);
327
+ }
328
+
309
329
  export async function createMixdogSessionRuntime({
310
330
  provider,
311
331
  model,
@@ -742,7 +762,7 @@ export async function createMixdogSessionRuntime({
742
762
 
743
763
  function emitRuntimeNotification(content, meta = {}) {
744
764
  const text = String(content || '').trim();
745
- if (!text) return false;
765
+ if (!text) return { handled: false, modelVisibleDelivered: false };
746
766
  const event = { content: text, meta: meta && typeof meta === 'object' ? meta : {} };
747
767
  let handled = false;
748
768
  for (const listener of [...notificationListeners]) {
@@ -750,19 +770,34 @@ export async function createMixdogSessionRuntime({
750
770
  if (listener(event) === true) handled = true;
751
771
  } catch {}
752
772
  }
753
- return handled;
773
+ // EXPLICIT model-visible-delivery ack: only the TUI execution-ui path sets
774
+ // event.modelVisibleDelivered when it enqueues the model-visible completion
775
+ // body into the active loop. A generic display-only / API listener that
776
+ // returns true does NOT set it, so `handled` alone must never be read as
777
+ // "the model saw the body".
778
+ const modelVisibleDelivered = event.modelVisibleDelivered === true;
779
+ return { handled, modelVisibleDelivered };
754
780
  }
755
781
 
756
782
  function notifyFnForSession(callerSessionId) {
757
783
  return (text, meta = {}) => {
758
- const handledByRuntimeListener = emitRuntimeNotification(text, meta);
784
+ const { handled: handledByRuntimeListener, modelVisibleDelivered } = emitRuntimeNotification(text, meta);
759
785
  let enqueued = false;
760
786
  // TUI sessions consume raw execution notifications for UI/task cards via
761
787
  // onNotification, but those raw envelopes are internal-only in pending
762
- // drain. Always mirror terminal completions with a model-visible wrapper
763
- // while keeping the raw text for UI display.
764
- if (callerSessionId && typeof mgr.enqueuePendingMessage === 'function'
765
- && shouldPersistModelVisibleToolCompletion(text, meta)) {
788
+ // drain. The TUI execution-ui path injects the model-visible twin of a
789
+ // terminal completion into the active loop and acks with
790
+ // modelVisibleDelivered, so mirroring it into the pending queue would
791
+ // double-inject the same completion — skip only on that EXPLICIT ack. A
792
+ // generic display-only / API listener returning true is NOT an ack: the
793
+ // mirror stays as the sole model-visible delivery.
794
+ if (shouldMirrorCompletionToPendingQueue({
795
+ callerSessionId,
796
+ modelVisibleDelivered,
797
+ hasEnqueue: typeof mgr.enqueuePendingMessage === 'function',
798
+ text,
799
+ meta,
800
+ })) {
766
801
  try {
767
802
  const visible = modelVisibleToolCompletionMessage(text, meta);
768
803
  // Terminal completion (gated by shouldPersistModelVisibleToolCompletion)
@@ -973,7 +1008,7 @@ export async function createMixdogSessionRuntime({
973
1008
  const meta = params.meta && typeof params.meta === 'object' ? params.meta : {};
974
1009
  const content = channelNotificationModelContent(params);
975
1010
  if (!content) return;
976
- const handled = emitRuntimeNotification(content, meta);
1011
+ const { handled } = emitRuntimeNotification(content, meta);
977
1012
  if (!handled && session?.id && shouldMirrorChannelNotificationToPending(meta)) {
978
1013
  try { mgr.enqueuePendingMessage(session.id, content); } catch {}
979
1014
  }
@@ -102,7 +102,13 @@ export function createWarmupSchedulers({
102
102
  scheduleProviderModelWarmup(backgroundBusyRetryMs);
103
103
  return;
104
104
  }
105
- warmProviderModelCache();
105
+ if (!isFirstTurnCompleted() && !envFlag('MIXDOG_PROVIDER_WARMUP_BEFORE_FIRST_TURN')) {
106
+ bootProfile('provider-models:warm-deferred', { reason: 'first-turn-pending' });
107
+ warmProviderModelCache();
108
+ scheduleProviderModelWarmup(backgroundBusyRetryMs);
109
+ return;
110
+ }
111
+ warmProviderModelCache({ loadSecrets: true });
106
112
  }, delayMs);
107
113
  timers.providerModelWarmupTimer.unref?.();
108
114
  }
@@ -92,7 +92,7 @@ function escapeXml(str) {
92
92
  // reminder. The full no-verdict contract lives at system level
93
93
  // (rules/agent/30-explorer.md).
94
94
  export function buildExplorerPrompt(query) {
95
- return `<query>${escapeXml(query)}</query>\nReminder: RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> STOP and answer NOW with those exact coordinates, this IS your final turn; zero -> one more batch if budget remains. Turns 2-3 exist SOLELY as zero-hit recovery (the previous turn matched ZERO specific tokens); spending a turn to confirm, refine, or upgrade an anchor you already hold is a defect. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — never trace the chain, one anchor per concept. You locate WHERE, never WHY. Scope is ALWAYS the session working directory: omit path or pass only a path seen in an earlier result; inventing a directory (/workspace/..., another repo's layout) is a defect — on zero hits change TOKENS, never guess paths. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. Before emitting EXPLORATION_FAILED re-scan ALL earlier results: any line matching a specific query token -> answer with that anchor (? if weak) — a weak anchor beats a false miss; EXPLORATION_FAILED only when all 3 turns found zero token-matching lines. Output only anchor lines formatted as path:line — symbol/name — short reason, max 3 lines, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
95
+ return `<query>${escapeXml(query)}</query>\nReminder: BUDGET = TWO MESSAGES NORMALLY: message 1 = your multi-tool batch, message 2 = your answer text. A 3rd message (ANY extra tool call) is a DEFECT unless message 1 returned ZERO specific-token lines — extra code_graph/grep calls to confirm or upgrade an anchor you already hold are the single biggest source of overspend. TURN 1 IS ONE BATCH, NON-NEGOTIABLE: your FIRST tool message fires, in that single message together, grep (pattern[] packing 3-6 token variants) AND code_graph symbol_search (identifiers) AND find/glob (path/name fragments) — never emit grep alone and wait for its result before adding code_graph/find. Serial one-tool-per-turn is the #1 budget defect and forfeits the turn-1 -> answer path; a first turn that is not this multi-tool batch is malformed. After that batch returns, if ANY line carries a specific query token you MUST emit the ANSWER as your very next message: a further tool call to refine, confirm, or upgrade is the primary overspend defect — a second tool turn is legal ONLY when the batch returned ZERO specific-token lines. Then RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> STOP and answer NOW with those exact coordinates, this IS your final turn; zero -> one more batch if budget remains. Turns 2-3 exist SOLELY as zero-hit recovery (the previous turn matched ZERO specific tokens); spending a turn to confirm, refine, or upgrade an anchor you already hold is a defect. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — trace no chains, one anchor per concept; the SOLE exception: a query that EXPLICITLY asks flow/default-resolution whose turn 1 yielded only an entry anchor (not the resolved value) may take ONE turn-2 hop to the resolving site, then stop. You locate WHERE, never WHY. Scope is ALWAYS the session working directory: omit path (project cwd default) or pass only a path seen in an earlier result; an unverified path/name fragment rides the SAME turn-1 find query[] batch (a find-only turn is a defect) and scopes grep/glob only through the exact path find returns; inventing a directory (/workspace/..., another repo's layout) is a defect — on zero hits change TOKENS, never guess paths. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. Before emitting EXPLORATION_FAILED re-scan ALL earlier results: any line matching a specific query token -> answer with that anchor (? if weak) — a weak anchor beats a false miss; EXPLORATION_FAILED only when all 3 turns found zero token-matching lines. Output only anchor lines formatted as path:line — symbol/name — short reason, max 3 lines, or EXPLORATION_FAILED. For a CODE-location answer every line MUST carry :line (an explicit line number) — a bare filename with no :line is a DEFECT; with no line-anchored code evidence, return EXPLORATION_FAILED rather than a vague file-only or prose answer. EXCEPTION — file/dir-LOCATION queries (where does X store its config/logs/data on disk, which directory or file holds Y): an exact VERIFIED path (the file or directory itself, no :line) IS the valid answer — do not force a line number and do not FAIL. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
96
96
  }
97
97
 
98
98
  export function normalizeExploreQueries(rawQuery) {
@@ -22625,6 +22625,7 @@ function agentArgsWithResultMetadata(args, parsed) {
22625
22625
 
22626
22626
  // src/tui/engine/queue-helpers.mjs
22627
22627
  var QUEUE_PRIORITY = { now: 0, next: 1, later: 2 };
22628
+ var STEERING_SUPPRESSED_DISPLAY = "\0mixdog:suppress-steer-display\0";
22628
22629
  function queuePriorityValue(value) {
22629
22630
  return QUEUE_PRIORITY[String(value || "next")] ?? QUEUE_PRIORITY.next;
22630
22631
  }
@@ -23347,8 +23348,13 @@ function createAgentJobFeed({
23347
23348
  // next tool batch; no special pending-resume bypass.
23348
23349
  priority: "next",
23349
23350
  key: notificationKey || void 0,
23350
- displayText: delivery.displayText || text
23351
+ displayText: delivery.displayText || text,
23352
+ // The immediate Response card was already pushed above
23353
+ // (pushUserOrSyntheticItem). Keep this queued twin model-visible
23354
+ // but suppress its drain-time transcript card to avoid a duplicate.
23355
+ suppressDisplay: true
23351
23356
  });
23357
+ if (event && typeof event === "object") event.modelVisibleDelivered = true;
23352
23358
  }
23353
23359
  return true;
23354
23360
  }
@@ -23771,6 +23777,7 @@ function createSessionFlow(bag) {
23771
23777
  key: options.key || null,
23772
23778
  skipSlashCommands: options.skipSlashCommands === true,
23773
23779
  displayText: mode === "task-notification" ? notificationDisplayText(displayText) : String(displayText || ""),
23780
+ suppressDisplay: options.suppressDisplay === true,
23774
23781
  steeringPersistId: options.steeringPersistId || null,
23775
23782
  steeringPersistRestored: options.steeringPersistRestored === true
23776
23783
  };
@@ -23882,6 +23889,7 @@ function createSessionFlow(bag) {
23882
23889
  const merged = mergePromptContents(batch);
23883
23890
  for (const entry of batch) {
23884
23891
  if (entry.mode === "pending-resume") continue;
23892
+ if (entry.suppressDisplay) continue;
23885
23893
  pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? "user" : "injected");
23886
23894
  }
23887
23895
  const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
@@ -23948,6 +23956,12 @@ function createSessionFlow(bag) {
23948
23956
  if (batch.length === 0) break;
23949
23957
  for (const entry of batch) {
23950
23958
  const content = entry.content;
23959
+ if (entry.suppressDisplay) {
23960
+ if (Array.isArray(content) ? content.length > 0 : String(content ?? "").trim().length > 0) {
23961
+ out.push({ text: STEERING_SUPPRESSED_DISPLAY, content });
23962
+ }
23963
+ continue;
23964
+ }
23951
23965
  const value = typeof content === "string" ? content.trim() : { text: String(entry.text || "").trim(), content };
23952
23966
  if (typeof value === "string") {
23953
23967
  if (value.length > 0) out.push(value);
@@ -24770,6 +24784,7 @@ function createRunTurn(bag) {
24770
24784
  drainSteering: (_sessionId, drainOptions) => isCurrentTurn() ? drainPendingSteering(drainOptions) : [],
24771
24785
  onSteerMessage: (text) => {
24772
24786
  if (!markTurnProgress("steer-message")) return;
24787
+ if (text === STEERING_SUPPRESSED_DISPLAY) return;
24773
24788
  flushStreamBatch();
24774
24789
  if (currentAssistantId) {
24775
24790
  patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
@@ -152,7 +152,17 @@ export function createAgentJobFeed({
152
152
  priority: 'next',
153
153
  key: notificationKey || undefined,
154
154
  displayText: delivery.displayText || text,
155
+ // The immediate Response card was already pushed above
156
+ // (pushUserOrSyntheticItem). Keep this queued twin model-visible
157
+ // but suppress its drain-time transcript card to avoid a duplicate.
158
+ suppressDisplay: true,
155
159
  });
160
+ // EXPLICIT ack to the emitting runtime: the model-visible completion
161
+ // body was injected into the active loop here, so notifyFnForSession
162
+ // must NOT also mirror it into the pending queue (double injection).
163
+ // A bare truthy return below is display/status handling only — this
164
+ // flag is the sole model-visible-delivery signal.
165
+ if (event && typeof event === 'object') event.modelVisibleDelivered = true;
156
166
  }
157
167
  return true;
158
168
  }
@@ -12,6 +12,14 @@ import {
12
12
 
13
13
  const QUEUE_PRIORITY = { now: 0, next: 1, later: 2 };
14
14
 
15
+ // Sentinel display text for a steering entry that is model-visible only: its
16
+ // immediate transcript ("Response …") card was already pushed at delivery time
17
+ // (live execution completion), so the mid-turn steering drain must NOT render a
18
+ // second card for it. drainPendingSteering emits this as the entry's display
19
+ // text; turn.mjs's onSteerMessage recognizes it and skips the push while the
20
+ // model still receives the entry's content.
21
+ export const STEERING_SUPPRESSED_DISPLAY = '\u0000mixdog:suppress-steer-display\u0000';
22
+
15
23
  export function queuePriorityValue(value) {
16
24
  return QUEUE_PRIORITY[String(value || 'next')] ?? QUEUE_PRIORITY.next;
17
25
  }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { presentErrorText } from '../../runtime/shared/err-text.mjs';
5
5
  import { createSessionStats } from './session-stats.mjs';
6
- import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks } from './queue-helpers.mjs';
6
+ import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
7
7
  import { appendTuiSteeringPersist, dropTuiSteeringPersist, drainTuiSteeringPersist } from './tui-steering-persist.mjs';
8
8
 
9
9
  export function createSessionFlow(bag) {
@@ -53,6 +53,7 @@ export function createSessionFlow(bag) {
53
53
  key: options.key || null,
54
54
  skipSlashCommands: options.skipSlashCommands === true,
55
55
  displayText: mode === 'task-notification' ? notificationDisplayText(displayText) : String(displayText || ''),
56
+ suppressDisplay: options.suppressDisplay === true,
56
57
  steeringPersistId: options.steeringPersistId || null,
57
58
  steeringPersistRestored: options.steeringPersistRestored === true,
58
59
  };
@@ -186,6 +187,10 @@ export function createSessionFlow(bag) {
186
187
  const merged = mergePromptContents(batch);
187
188
  for (const entry of batch) {
188
189
  if (entry.mode === 'pending-resume') continue;
190
+ // Live execution completions push their own immediate Response card
191
+ // at delivery time; the queued twin is model-visible only and must
192
+ // NOT render a second transcript card here (no fall-back to content).
193
+ if (entry.suppressDisplay) continue;
189
194
  pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? 'user' : 'injected');
190
195
  }
191
196
  const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
@@ -274,6 +279,15 @@ export function createSessionFlow(bag) {
274
279
  if (batch.length === 0) break;
275
280
  for (const entry of batch) {
276
281
  const content = entry.content;
282
+ if (entry.suppressDisplay) {
283
+ // Model-visible twin of an already-rendered live completion: deliver
284
+ // content to the model but flag onSteerMessage to skip the duplicate
285
+ // transcript card (no fall-back to content-derived display text).
286
+ if (Array.isArray(content) ? content.length > 0 : String(content ?? '').trim().length > 0) {
287
+ out.push({ text: STEERING_SUPPRESSED_DISPLAY, content });
288
+ }
289
+ continue;
290
+ }
277
291
  const value = typeof content === 'string'
278
292
  ? content.trim()
279
293
  : { text: String(entry.text || '').trim(), content };
@@ -7,7 +7,7 @@ import { pickVerb, pickDoneVerb, compactEventLabel, compactEventDetail } from '.
7
7
  import { toolResultText, toolErrorDisplay } from './tool-result-text.mjs';
8
8
  import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
9
9
  import { toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
10
- import { promptDisplayText } from './queue-helpers.mjs';
10
+ import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
11
11
  import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
12
12
  import { yieldToRenderer } from './render-timing.mjs';
13
13
  import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder } from './tool-result-status.mjs';
@@ -727,6 +727,11 @@ export function createRunTurn(bag) {
727
727
  drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
728
728
  onSteerMessage: (text) => {
729
729
  if (!markTurnProgress('steer-message')) return;
730
+ // A suppressed live-completion twin is model-visible only; its
731
+ // Response card was already pushed at delivery time. Skip the
732
+ // duplicate transcript item (progress is still marked above since
733
+ // the content WAS injected into the model turn).
734
+ if (text === STEERING_SUPPRESSED_DISPLAY) return;
730
735
  // Steering can be injected after a terminal no-tool response has
731
736
  // already streamed but before runTurn finalizes. Seal the current
732
737
  // assistant segment first so the steered user turn and the next