mixdog 0.9.145 → 0.9.147

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 (46) hide show
  1. package/package.json +2 -2
  2. package/src/lib/rules-builder.cjs +12 -3
  3. package/src/output-styles/common.md +7 -9
  4. package/src/output-styles/simple.md +1 -1
  5. package/src/rules/shared/10-tool-workflow.md +18 -28
  6. package/src/rules/shared/40-editing.md +18 -6
  7. package/src/rules/shared/60-verification.md +0 -1
  8. package/src/rules/shared/70-delivery.md +4 -1
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +4 -1
  10. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +11 -4
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +132 -0
  12. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +82 -40
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +64 -29
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +24 -6
  15. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +41 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-ws-delta-parity.test.mjs +190 -0
  17. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +41 -28
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +9 -49
  19. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +78 -30
  20. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +12 -0
  21. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +8 -0
  22. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +9 -0
  23. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +79 -0
  25. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +10 -2
  26. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  27. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +22 -10
  28. package/src/runtime/agent/orchestrator/tools/builtin/shell-lossless-compact.mjs +30 -1
  29. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -1
  32. package/src/runtime/shared/edit-tool-dialect.mjs +8 -0
  33. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  34. package/src/runtime/shared/task-notification-envelope.mjs +14 -0
  35. package/src/session-runtime/lifecycle-api.mjs +44 -1
  36. package/src/session-runtime/runtime-core.mjs +1 -0
  37. package/src/session-runtime/session-lifecycle.test.mjs +23 -0
  38. package/src/session-runtime/tool-policy-refresh.mjs +4 -1
  39. package/src/session-runtime/tool-policy-surface.test.mjs +23 -8
  40. package/src/tui/session/context-state.mjs +17 -3
  41. package/src/tui/session/session-api-ext.mjs +6 -1
  42. package/src/tui/session/session-api.mjs +6 -1
  43. package/src/tui/session/tool-card-results.mjs +7 -0
  44. package/src/tui/session/tool-result-status.mjs +31 -1
  45. package/src/tui/session/tool-result-status.test.mjs +30 -0
  46. package/src/tui/session/turn.mjs +12 -3
@@ -25,7 +25,7 @@ import {
25
25
 
26
26
  // Human-readable transport label for handshake/acquire error messages. Shared
27
27
  // with openai-oauth-ws.mjs (stream-side errors use the same labels).
28
- import { _envOn, _codexUuidIdParity, _codexDashedId, _codexTurnStateGate, _codexDashedIdV7, _codexBetaFeatures, _dumpHandshakeHeaders, _dumpFrame, _formatRedactedHeaders, _cfCookieHeader, _cfCookieCapture } from './openai-ws-headers.mjs';
28
+ import { _envOn, _codexDashedId, _codexDashedIdV7, _codexBetaFeatures, _dumpHandshakeHeaders, _dumpFrame, _formatRedactedHeaders, _cfCookieHeader, _cfCookieCapture } from './openai-ws-headers.mjs';
29
29
  export function _wsErrLabel(p) {
30
30
  if (p === 'xai') return 'xAI WS';
31
31
  if (p === 'openai-direct' || p === 'openai') return 'OpenAI WS';
@@ -76,6 +76,46 @@ const MAX_POOLED_SOCKETS_PER_KEY = 8;
76
76
  export const _wsPool = new Map();
77
77
  let _releaseSequence = 0;
78
78
 
79
+ // The server issues an `x-codex-turn-state` sticky-routing token on the
80
+ // handshake 101 response; it pins the backend node that holds the warm prefix.
81
+ // codex keeps that token in a turn-scoped cell and replays it on the RECONNECT
82
+ // handshake (build_responses_headers -> connect_websocket), so a replacement
83
+ // connection re-pins the same node instead of cold-starting the prefix. Our
84
+ // pool entry dies with its socket, so the token is retained here at poolKey
85
+ // scope and replayed only for a replacement socket. A parallel peer must still
86
+ // open clean: inheriting a sibling's token makes openai-oauth treat the request
87
+ // as a continuation of another in-flight turn ("No tool output found for
88
+ // function call ...").
89
+ const _turnStateByPoolKey = new Map();
90
+ const MAX_RETAINED_TURN_STATE_KEYS = 4096;
91
+
92
+ function _retainTurnState(poolKey, entry) {
93
+ if (!poolKey || !entry) return;
94
+ const turnState = typeof entry.turnState === 'string' ? entry.turnState : '';
95
+ // A token already retired by the per-turn drop guard must not be revived.
96
+ if (!turnState) return;
97
+ // Bounded like the other pool maps so a long-lived process cannot
98
+ // accumulate one token per dead session.
99
+ if (!_turnStateByPoolKey.has(poolKey) && _turnStateByPoolKey.size >= MAX_RETAINED_TURN_STATE_KEYS) {
100
+ const oldest = _turnStateByPoolKey.keys().next().value;
101
+ if (oldest !== undefined) _turnStateByPoolKey.delete(oldest);
102
+ }
103
+ _turnStateByPoolKey.set(poolKey, {
104
+ turnState,
105
+ // Carry the turn owner so the per-turn drop guard still retires a
106
+ // replayed token when turn_id moves on.
107
+ turnStateTurnId: entry.turnStateTurnId ?? null,
108
+ });
109
+ }
110
+
111
+ function _replacementTurnState(poolKey) {
112
+ if (!poolKey) return null;
113
+ // A live entry in the bucket means this handshake is a parallel peer, not
114
+ // a reconnect.
115
+ if (hasPooledWebSocket(poolKey)) return null;
116
+ return _turnStateByPoolKey.get(poolKey) || null;
117
+ }
118
+
79
119
  function _poolCompatibility(auth, cacheKey) {
80
120
  const type = String(auth?.type || 'openai-oauth');
81
121
  // Prefer the stable OAuth account id so an access-token refresh can keep
@@ -139,6 +179,9 @@ function _removeFromPool(poolKey, entry) {
139
179
  // idle-close or liveness-ping interval.
140
180
  _clearIdle(entry);
141
181
  _clearLiveness(entry);
182
+ // The entry is going away; keep its shard pin so the session's next socket
183
+ // can reconnect onto the same backend node.
184
+ _retainTurnState(poolKey, entry);
142
185
  if (!poolKey) return;
143
186
  const arr = _wsPool.get(poolKey);
144
187
  if (!arr) return;
@@ -343,41 +386,24 @@ function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey: _cach
343
386
  'x-codex-beta-features': _codexBetaFeatures(),
344
387
  };
345
388
  const isOpenAiOauth = auth.type !== 'xai' && auth.type !== 'openai-direct';
346
- // The reference client sends only the dashed session-id/thread-id pair,
347
- // but OUR backend measurements disagree with pure
348
- // wire parity here: 2026-04-19 probes showed the OAuth backend dedupes
349
- // its in-memory prefix state by the underscore session_id handshake
350
- // header, and the only 0.0%-miss full-frame rounds (R7/R8, R15 regressed
351
- // to 13% after this header was dropped) all had it present. Send both.
352
- // The underscore session_id is the backend prefix-dedupe key (2026-04-19
353
- // probes; R15 regressed to 13% miss when it was dropped). Strict wire
354
- // parity sends ONLY the dashed pair, but dropping this
355
- // header is a KNOWN cache-unsafe change — so the general parity flag no
356
- // longer silently drops it. Keep it unless an operator EXPLICITLY opts
357
- // into the codex-exact dashed-only wire via
358
- // MIXDOG_OAI_CODEX_WIRE_PARITY_SESSION_ID=dashed.
359
- // The gate bundle implies the dashed-only wire (drop the underscore
360
- // session_id) on top of the standalone opt-in.
361
- const gate = _codexTurnStateGate();
362
- const dropUnderscoreSessionId = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY_SESSION_ID === 'dashed' || gate;
363
- if (sessionToken && !dropUnderscoreSessionId) {
389
+ // Default mode preserves the measured Mixdog underscore session_id cache
390
+ // key. Strict wire parity instead follows Codex exactly: dashed
391
+ // session-id/thread-id only, UUIDv7-shaped ids, and x-client-request-id.
392
+ const wireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
393
+ if (sessionToken && !wireParity) {
364
394
  headers['session_id'] = String(sessionToken);
365
395
  }
366
396
  if (isOpenAiOauth && (sessionToken || _cacheKey)) {
367
- // Gate bundle forces Codex-shaped UUIDv7 ids; the standalone parity flag
368
- // keeps its v5-derived shape.
369
- const uuidIds = _codexUuidIdParity() || gate;
370
- const shapeId = gate ? _codexDashedIdV7 : _codexDashedId;
397
+ const uuidIds = wireParity;
398
+ const shapeId = wireParity ? _codexDashedIdV7 : _codexDashedId;
371
399
  // Underscore `session_id` above is left as-is (backend prefix-dedupe
372
400
  // key); only the dashed codex pair is reshaped under the opt-in.
373
401
  const threadId = uuidIds ? shapeId(String(_cacheKey || sessionToken)) : String(_cacheKey || sessionToken);
374
402
  const sessionId = uuidIds ? shapeId(String(sessionToken || _cacheKey)) : String(sessionToken || _cacheKey);
375
403
  headers['session-id'] = sessionId;
376
404
  headers['thread-id'] = threadId;
377
- // Default/parity wire duplicates the dashed thread-id here. The gate
378
- // hypothesis is that a duplicated x-client-request-id suppresses
379
- // turn-state issuance, so omit it entirely under the bundle.
380
- if (!gate) headers['x-client-request-id'] = threadId;
405
+ // The reference client sets x-client-request-id to the dashed thread id.
406
+ headers['x-client-request-id'] = threadId;
381
407
  if (codexHeaders && typeof codexHeaders === 'object') {
382
408
  for (const [key, value] of Object.entries(codexHeaders)) {
383
409
  if (typeof key === 'string' && typeof value === 'string' && value) {
@@ -580,6 +606,13 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
580
606
  if (!_isOpen(arr[i]) || arr[i].closing || incompatibleIdle) {
581
607
  if (incompatibleIdle) {
582
608
  try { arr[i].socket.close(1000, 'pool_boundary_changed'); } catch {}
609
+ } else {
610
+ // A dead socket pruned here never reaches _removeFromPool,
611
+ // so retain its shard pin explicitly — this is the common
612
+ // reconnect path and the one the replacement handshake
613
+ // below reads. A boundary change deliberately drops the
614
+ // pin: it belongs to the old auth/cache identity.
615
+ _retainTurnState(poolKey, arr[i]);
583
616
  }
584
617
  _clearIdle(arr[i]);
585
618
  _clearLiveness(arr[i]);
@@ -668,13 +701,17 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
668
701
  // Parallel sockets must not inherit sibling turnState or the openai-oauth server
669
702
  // treats the new request as a continuation of another in-flight turn and
670
703
  // returns "No tool output found for function call …". turnState only
671
- // propagates within a single entry across its own iterations.
704
+ // propagates within a single entry across its own iterations — plus the
705
+ // codex reconnect case below, where the bucket holds no live entry and the
706
+ // retained token re-pins the shard the dead socket was using. forceFresh
707
+ // asks for a deliberately unpinned socket, so it always opens clean.
672
708
  const sessionToken = _mintSessionToken(cacheKey, auth);
673
709
  const compatibility = _poolCompatibility(auth, cacheKey);
710
+ const retainedTurnState = forceFresh ? null : _replacementTurnState(poolKey);
674
711
  if (process.env.MIXDOG_DEBUG_AGENT) {
675
712
  process.stderr.write(`[agent-trace] acquire-new tokenHash=${createHash('sha256').update(String(sessionToken)).digest('hex').slice(0, 8)} elapsed=${Date.now() - _acqStart}ms\n`);
676
713
  }
677
- const { socket, turnState } = await _openSocketImpl({ auth, sessionToken, turnState: null, externalSignal, cacheKey, codexHeaders });
714
+ const { socket, turnState } = await _openSocketImpl({ auth, sessionToken, turnState: retainedTurnState?.turnState || null, externalSignal, cacheKey, codexHeaders });
678
715
  // Drain may complete while the normal handshake is awaiting 'open'. Never
679
716
  // return or insert that late socket into the already-drained process pool.
680
717
  if (_drainComplete) {
@@ -693,7 +730,11 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
693
730
  lastInputPrefixHash: null,
694
731
  releaseSequence: 0,
695
732
  ...compatibility,
696
- turnState: turnState || null,
733
+ // A token issued on THIS handshake supersedes the replayed one and is
734
+ // attributed on first use; a surviving retained token keeps its
735
+ // original turn owner so the per-turn drop guard still retires it.
736
+ turnState: turnState || retainedTurnState?.turnState || null,
737
+ turnStateTurnId: turnState ? null : (retainedTurnState?.turnStateTurnId ?? null),
697
738
  closing: false,
698
739
  ephemeral: false,
699
740
  sessionToken,
@@ -733,10 +774,15 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
733
774
 
734
775
  export function closeOpenaiWsPoolForSession(poolKey, reason = 'session_closed') {
735
776
  if (!poolKey) return;
777
+ // The session is over, so its shard pin must not survive into a later
778
+ // session that reuses the key. Clearing each entry's token before the
779
+ // close() calls also keeps the async 'close' handlers from re-retaining it.
780
+ _turnStateByPoolKey.delete(poolKey);
736
781
  const entries = _wsPool.get(poolKey);
737
782
  if (!entries) return;
738
783
  _wsPool.delete(poolKey);
739
784
  for (const entry of entries) {
785
+ entry.turnState = null;
740
786
  _clearIdle(entry);
741
787
  _clearLiveness(entry);
742
788
  try { entry.socket.close(1000, reason); } catch {}
@@ -764,12 +810,14 @@ export function _seedWebSocketEntryForTest({ poolKey, auth, cacheKey, entry }) {
764
810
  export function _clearWebSocketPoolForTest() {
765
811
  for (const arr of _wsPool.values()) {
766
812
  for (const entry of arr) {
813
+ entry.turnState = null;
767
814
  _clearIdle(entry);
768
815
  _clearLiveness(entry);
769
816
  try { entry.socket.close(1000, 'test_cleanup'); } catch {}
770
817
  }
771
818
  }
772
819
  _wsPool.clear();
820
+ _turnStateByPoolKey.clear();
773
821
  _releaseSequence = 0;
774
822
  }
775
823
 
@@ -180,6 +180,11 @@ function _hasHeaderKey(headers, name) {
180
180
 
181
181
  export function _captureTurnStateFromEvent(entry, event) {
182
182
  if (!entry || entry.turnState || !event || typeof event !== 'object') return;
183
+ // The token normally arrives on the WebSocket 101 handshake and is held
184
+ // write-once per entry, which is what keeps sticky routing stable across
185
+ // previous_response_id calls. This event path is the fallback for
186
+ // deployments that surface the header on a response event instead; the
187
+ // write-once guard above means it can never swap a live chain's token.
183
188
  const turnState = _headerString(event.headers, X_CODEX_TURN_STATE_HEADER)
184
189
  || _headerString(event.response?.headers, X_CODEX_TURN_STATE_HEADER)
185
190
  || _headerString(event.response?.metadata?.headers, X_CODEX_TURN_STATE_HEADER)
@@ -354,6 +359,13 @@ export async function _streamResponse({
354
359
  id: item.id || '',
355
360
  encrypted_content: item.encrypted_content,
356
361
  summary: Array.isArray(item.summary) ? item.summary : [],
362
+ // Emission order is part of the chain proof. A turn can reason,
363
+ // speak, then reason again before calling a tool; rebuilding it
364
+ // with every reasoning item in front of the text yields an order
365
+ // the previous response never produced, and the incremental check
366
+ // then fails on the message item and drops the chain to a cold
367
+ // full frame. Record which side of the text this item arrived on.
368
+ ...(responseItemsAdded.some((added) => added?.type === 'message') ? { afterText: true } : {}),
357
369
  });
358
370
  };
359
371
  const pushResponseItem = (item) => {
@@ -37,6 +37,7 @@ import {
37
37
  shouldExcludeIngestMessage,
38
38
  } from '../../../../memory/lib/session-ingest.mjs';
39
39
  import { cleanMemoryText } from '../../../../memory/lib/memory-extraction.mjs';
40
+ import { codexWireSendOpts } from '../manager/session-id.mjs';
40
41
  import {
41
42
  COMPACTION_SYSTEM_PROMPT,
42
43
  fitCompactionPrompt,
@@ -446,6 +447,13 @@ export async function semanticCompactMessages(provider, messages, model, budgetT
446
447
  if (opts.providerCacheKey || opts.sendOpts?.providerCacheKey) {
447
448
  sendOpts.providerCacheKey = `${opts.providerCacheKey || opts.sendOpts.providerCacheKey}:compact`;
448
449
  }
450
+ // Compaction is a request of the SAME session, not a session of its own:
451
+ // thread identity and window carry over, only the request kind and the turn
452
+ // id change. The `:compact` suffixes above stay — they scope the socket
453
+ // pool bucket so a summary never steals the live turn's socket — while the
454
+ // wire identity below keeps both on one server-side prefix-cache slot.
455
+ const codexWire = codexWireSendOpts(sendOpts.session, { requestKind: 'compaction' });
456
+ if (codexWire) Object.assign(sendOpts, codexWire);
449
457
 
450
458
  const response = await provider.send([
451
459
  { role: 'system', content: COMPACTION_SYSTEM_PROMPT },
@@ -52,6 +52,7 @@ import {
52
52
  } from './runtime-liveness.mjs';
53
53
  import { SessionClosedError } from './session-errors.mjs';
54
54
  import { acquireSessionLock } from './session-lock.mjs';
55
+ import { codexWireSendOpts, ensureCodexWireSessionId, mintUuidV7 } from './session-id.mjs';
55
56
  import { _tryBridgeExplicitPrefetch } from './prefetch-bridge.mjs';
56
57
  import {
57
58
  filterModelVisibleSessionMessages,
@@ -283,6 +284,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
283
284
  if (preSession.closed === true) {
284
285
  throw new SessionClosedError(sessionId, 'session already closed');
285
286
  }
287
+ const _codexWireSessionId = ensureCodexWireSessionId(preSession);
286
288
  if (!_crashRecoveryChecked) {
287
289
  _crashRecoveryChecked = true;
288
290
  const recovery = recoverTurnCheckpoint(preSession);
@@ -341,6 +343,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
341
343
  const _turnInterruption = createTurnInterruptionTracker();
342
344
  const _turnCheckpointToken = randomUUID();
343
345
  const _turnCheckpointStartedAt = Date.now();
346
+ const _codexTurnId = _codexWireSessionId ? mintUuidV7(_turnCheckpointStartedAt) : null;
344
347
  const _TURN_CHECKPOINT_THROTTLE_MS = 150;
345
348
  let _turnCheckpointTimer = null;
346
349
  let _turnCheckpointStopped = false;
@@ -736,6 +739,12 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
736
739
  try { askOpts?.onSteerMessage?.(text, detail); } catch {}
737
740
  },
738
741
  notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
742
+ // Same projection compaction uses, so a session presents one
743
+ // identity on every request it makes.
744
+ ...(codexWireSendOpts(session, {
745
+ turnId: _codexTurnId,
746
+ startedAtMs: _turnCheckpointStartedAt,
747
+ }) || {}),
739
748
  promptCacheKey: session.promptCacheKey || sessionId,
740
749
  // Provider-scoped cache key (mixdog-codex, mixdog-claude…).
741
750
  // Distinct from sessionId — providers that pool sockets
@@ -349,6 +349,10 @@ export async function runSessionCompaction(session, opts = {}) {
349
349
  sessionId: resolvedSessionId,
350
350
  cwd: session.cwd,
351
351
  signal: opts.signal || null,
352
+ // Carries the session's wire identity so the
353
+ // summary request stays on the session's own
354
+ // prefix-cache slot instead of opening a second one.
355
+ sendOpts: { session },
352
356
  promptCacheKey: session.promptCacheKey || null,
353
357
  providerCacheKey: session.promptCacheKey || null,
354
358
  timeoutMs: semanticCompactTimeoutMs(session, beforeMessageTokens),
@@ -394,6 +398,9 @@ export async function runSessionCompaction(session, opts = {}) {
394
398
  sessionId: resolvedSessionId,
395
399
  cwd: session.cwd,
396
400
  signal: opts.signal || null,
401
+ // Same-session wire identity: one prefix-cache slot for
402
+ // turns and their summaries alike.
403
+ sendOpts: { session },
397
404
  promptCacheKey: session.promptCacheKey || null,
398
405
  providerCacheKey: session.promptCacheKey || null,
399
406
  timeoutMs: semanticCompactTimeoutMs(session, beforeMessageTokens),
@@ -4,6 +4,85 @@
4
4
  // clearSessionMessages (clear-fork), matching the original single `nextId`.
5
5
  import { randomBytes } from 'crypto';
6
6
  let nextId = Date.now();
7
+ const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
8
+ let lastUuidV7Ms = -1;
9
+ let uuidV7Sequence = 0;
10
+
7
11
  export function mintSessionId() {
8
12
  return `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
9
13
  }
14
+
15
+ export function isUuidV7(value) {
16
+ return UUID_V7_RE.test(String(value || '').trim());
17
+ }
18
+
19
+ export function mintUuidV7(nowMs = Date.now()) {
20
+ const parsedNow = Number(nowMs);
21
+ const timestampMs = Math.max(
22
+ 0,
23
+ Math.min(
24
+ 0xffffffffffff,
25
+ Math.floor(Number.isFinite(parsedNow) ? parsedNow : Date.now()),
26
+ ),
27
+ );
28
+ if (timestampMs === lastUuidV7Ms) {
29
+ uuidV7Sequence = (uuidV7Sequence + 1) & 0x0fff;
30
+ } else {
31
+ lastUuidV7Ms = timestampMs;
32
+ uuidV7Sequence = randomBytes(2).readUInt16BE(0) & 0x0fff;
33
+ }
34
+ const bytes = randomBytes(16);
35
+ const timestamp = BigInt(timestampMs);
36
+ bytes[0] = Number((timestamp >> 40n) & 0xffn);
37
+ bytes[1] = Number((timestamp >> 32n) & 0xffn);
38
+ bytes[2] = Number((timestamp >> 24n) & 0xffn);
39
+ bytes[3] = Number((timestamp >> 16n) & 0xffn);
40
+ bytes[4] = Number((timestamp >> 8n) & 0xffn);
41
+ bytes[5] = Number(timestamp & 0xffn);
42
+ bytes[6] = 0x70 | ((uuidV7Sequence >> 8) & 0x0f);
43
+ bytes[7] = uuidV7Sequence & 0xff;
44
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
45
+ const hex = bytes.toString('hex');
46
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
47
+ }
48
+
49
+ /**
50
+ * Send-opts projection of a session's Codex wire identity.
51
+ *
52
+ * Every request belonging to one session — live turns and compaction summaries
53
+ * alike — presents the SAME session/thread id, because the backend derives its
54
+ * prompt-cache slot from that id. Only `turn_id` and `request_kind` change per
55
+ * request. Splitting compaction onto its own identity would hand the same
56
+ * session two cache slots and cold-start one of them on every compaction.
57
+ *
58
+ * Returns null for providers without a Codex wire identity, so callers can
59
+ * spread the result unconditionally.
60
+ */
61
+ export function codexWireSendOpts(session, { requestKind = 'turn', turnId = null, startedAtMs = null } = {}) {
62
+ const wireSessionId = ensureCodexWireSessionId(session);
63
+ if (!wireSessionId) return null;
64
+ const parsedStartedAt = Number(startedAtMs);
65
+ const startedAt = Number.isFinite(parsedStartedAt) && parsedStartedAt > 0
66
+ ? Math.floor(parsedStartedAt)
67
+ : Date.now();
68
+ return {
69
+ codexSessionId: wireSessionId,
70
+ codexThreadId: wireSessionId,
71
+ turnId: turnId || mintUuidV7(startedAt),
72
+ windowId: `${wireSessionId}:0`,
73
+ turnStartedAtUnixMs: startedAt,
74
+ requestKind,
75
+ };
76
+ }
77
+
78
+ export function ensureCodexWireSessionId(session) {
79
+ if (!session || session.provider !== 'openai-oauth') return null;
80
+ const current = String(session.codexWireSessionId || '').trim().toLowerCase();
81
+ if (isUuidV7(current)) {
82
+ session.codexWireSessionId = current;
83
+ return current;
84
+ }
85
+ const minted = mintUuidV7();
86
+ session.codexWireSessionId = minted;
87
+ return minted;
88
+ }
@@ -29,6 +29,7 @@ import {
29
29
  import {
30
30
  filterModelEditToolNames,
31
31
  filterModelEditTools,
32
+ unusedModelEditToolName,
32
33
  } from '../../../../shared/edit-tool-dialect.mjs';
33
34
  import {
34
35
  positiveContextWindow,
@@ -36,7 +37,7 @@ import {
36
37
  resolveSessionContextMeta,
37
38
  } from './context-meta.mjs';
38
39
  import { getAgentRuntimeSync, warnAgentRuntimeResolveFailureOnce } from './agent-runtime-singleton.mjs';
39
- import { mintSessionId } from './session-id.mjs';
40
+ import { ensureCodexWireSessionId, mintSessionId, mintUuidV7 } from './session-id.mjs';
40
41
  import { providerCacheKey } from './provider-cache-key.mjs';
41
42
  import { clearTurnCheckpoint, recoverTurnCheckpoint } from './turn-checkpoint.mjs';
42
43
  import {
@@ -234,7 +235,11 @@ export function createSession(opts) {
234
235
  ...(hiddenAgent ? ['Skill'] : []),
235
236
  ...(!ownerIsAgent && workflowDisallowsAgentTool(opts.workflow) ? ['agent'] : []),
236
237
  ];
237
- const injectedRules = skipAgentRules ? '' : _buildSharedRules({ omitTools: sessionDeny });
238
+ // The edit dialect this model never receives is omitted from the rules as
239
+ // well, so a session never reads placement guidance for a tool it cannot
240
+ // call.
241
+ const ruleOmitTools = [...sessionDeny, unusedModelEditToolName(modelName)];
242
+ const injectedRules = skipAgentRules ? '' : _buildSharedRules({ omitTools: ruleOmitTools });
238
243
  const delegationFree = !ownerIsAgent && workflowDisallowsAgentTool(opts.workflow);
239
244
  const roleRules = skipAgentRules
240
245
  ? ''
@@ -385,6 +390,7 @@ export function createSession(opts) {
385
390
  });
386
391
  const session = {
387
392
  id,
393
+ codexWireSessionId: providerName === 'openai-oauth' ? mintUuidV7() : null,
388
394
  provider: providerName,
389
395
  model: modelName,
390
396
  messages,
@@ -495,6 +501,7 @@ export function updateSessionRoute(id, route = {}) {
495
501
  if (route.model) session.model = route.model;
496
502
  if (Object.prototype.hasOwnProperty.call(route, 'fast')) session.fast = route.fast === true;
497
503
  if (Object.prototype.hasOwnProperty.call(route, 'effort')) session.effort = route.effort || null;
504
+ ensureCodexWireSessionId(session);
498
505
  if (Object.prototype.hasOwnProperty.call(route, 'modelParameters')) {
499
506
  session.modelParameters = route.modelParameters && typeof route.modelParameters === 'object'
500
507
  ? { ...route.modelParameters }
@@ -809,6 +816,7 @@ export async function resumeSession(sessionId, preset, options = {}) {
809
816
  // save below would also block the write, but failing fast here is cleaner
810
817
  // than silently dropping the tool-refresh side effects.
811
818
  if (session.closed === true) return null;
819
+ ensureCodexWireSessionId(session);
812
820
  // Desktop callers pass their selected durable classification as a
813
821
  // capability check. Refuse a stale/tampered cross-class resume before any
814
822
  // tool refresh or save. CLI/TUI callers omit this option and retain the
@@ -14,8 +14,8 @@ const _shellSyntaxCheat =
14
14
  // Keep the routing map short and adjacent to the shell's primary description.
15
15
  // PowerShell aliases appear only on win32.
16
16
  const _shellToolRouting = process.platform === 'win32'
17
- ? 'Use read, NOT cat/Get-Content/head/tail; list, NOT ls/dir; find/glob, NOT find; grep, NOT grep/rg/Select-String; edit/apply_patch, NOT sed/awk/echo/Set-Content or a file-writing heredoc.'
18
- : 'Use read, NOT cat/head/tail; list, NOT ls; find/glob, NOT find; grep, NOT grep/rg; edit/apply_patch, NOT sed/awk/echo or a file-writing heredoc.';
17
+ ? 'Use read, NOT cat/Get-Content/head/tail; list, NOT ls/dir; find/glob, NOT find; grep, NOT grep/rg/Select-String; edit/apply_patch, NOT sed/awk/echo/Set-Content or a file-writing heredoc; git, NOT a plain git command; task, NOT Start-Job or a wait loop.'
18
+ : 'Use read, NOT cat/head/tail; list, NOT ls; find/glob, NOT find; grep, NOT grep/rg; edit/apply_patch, NOT sed/awk/echo or a file-writing heredoc; git, NOT a plain git command; task, NOT & or a wait loop.';
19
19
  // Process-stable switch used to describe foreground-only execution accurately.
20
20
  const _shellBackgroundDisabled = /^(1|true|yes|on)$/i.test(
21
21
  String(process.env.MIXDOG_SHELL_DISABLE_BACKGROUND_TASKS || '').trim(),
@@ -26,7 +26,7 @@ export const BUILTIN_TOOLS = [
26
26
  name: 'read',
27
27
  title: 'Read',
28
28
  annotations: { title: 'Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
29
- description: 'Read-only; safe to batch in parallel. Known-file contents or line ranges, bounded to the narrowest range that answers the question. Spans another tool already returned are source context; read only what they omit. Images render for viewing; not directories. Replaces cat/head/tail.',
29
+ description: 'Read-only; safe to batch in parallel. Known-file contents or line ranges, bounded to the narrowest range that answers the question. Images render for viewing; not directories. Replaces cat/head/tail.',
30
30
  inputSchema: {
31
31
  type: 'object',
32
32
  properties: {
@@ -52,7 +52,7 @@ export const BUILTIN_TOOLS = [
52
52
  name: 'edit',
53
53
  title: 'Edit',
54
54
  annotations: { title: 'Edit', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, compressible: false, compressibleLossless: true },
55
- description: 'Replace exact text in one file. Use exact text already in context; never re-open the file to build old_string or to verify a successful edit. old_string must match once unless replace_all is true. Empty old_string creates a missing file or fills an empty file; it never overwrites a non-empty file.',
55
+ description: 'Replace exact text in one file. Use exact text already in context; never re-open the file to build old_string or to verify a successful edit. old_string must match once unless replace_all is true. Empty old_string creates a missing file or fills an empty file; it never overwrites a non-empty file. Replaces sed/awk and echo redirection.',
56
56
  inputSchema: {
57
57
  type: 'object',
58
58
  properties: {
@@ -81,7 +81,7 @@ export const BUILTIN_TOOLS = [
81
81
  name: 'shell',
82
82
  title: 'Shell',
83
83
  annotations: { title: 'Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
84
- description: `Run programs, runtime/state operations, calculations, transformations, file generation, and unsupported-format inspection. ${_shellToolRouting} Avoid file operations covered by dedicated tools unless explicitly instructed or after verifying that a dedicated tool cannot do the job. An already-open shell is never a reason to route work to it. ${_shellBackgroundDisabled ? 'Commands run in the foreground until completion.' : 'Commands use a 10s foreground window by default—not a timeout. Still-running work continues as a tracked task_id. Completion is automatic; do not poll with task read/monitor unless the user explicitly requests polling or periodic progress. Otherwise, use task read only for an immediate decision and task monitor only for explicitly requested periodic progress.'}`,
84
+ description: `Run programs, runtime/state operations, calculations, transformations, file generation, and unsupported-format inspection. ${_shellToolRouting} ${_shellBackgroundDisabled ? 'Commands run in the foreground until completion.' : 'Commands use a 10s foreground window by default—not a timeout. Still-running work continues as a tracked task_id. Completion is automatic; do not call task read/monitor to wait or check progress. Continue independent work or end the turn. Use task read only when the user explicitly asks for current status, and task monitor only when they explicitly ask for periodic progress.'}`,
85
85
  inputSchema: {
86
86
  type: 'object',
87
87
  properties: {
@@ -102,7 +102,7 @@ export const BUILTIN_TOOLS = [
102
102
  name: 'task',
103
103
  title: 'Task',
104
104
  annotations: { title: 'Task', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
105
- description: 'List shell tasks, read one current snapshot, change periodic monitoring, or cancel by task_id. Completion is automatic; do not poll with read/monitor unless the user explicitly requests polling or periodic progress. Otherwise, use read only for an immediate decision and monitor only for explicitly requested periodic progress.',
105
+ description: 'List shell tasks, read one current snapshot, change periodic monitoring, or cancel by task_id. Replaces shell job control (jobs/wait/Start-Job). Completion is automatic; do not call read/monitor to wait or check progress. Continue independent work or end the turn. Use read only when the user explicitly asks for current status, and monitor only when they explicitly ask for periodic progress.',
106
106
  inputSchema: {
107
107
  type: 'object',
108
108
  properties: {
@@ -153,7 +153,7 @@ export const BUILTIN_TOOLS = [
153
153
  name: 'glob',
154
154
  title: 'Glob',
155
155
  annotations: { title: 'Glob', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
156
- description: 'Read-only; safe to batch in parallel. Return wildcard-matching file paths under a known base directory when those paths are needed. Omit path for the current Project; if the base location is unknown, use find first. Directories never match. Newest first by default. Replaces find -name.',
156
+ description: 'Read-only; safe to batch in parallel. Return wildcard-matching file paths under a known base directory when those paths are needed. Omit path for the current Project; an unknown base directory goes to find first. Directories never match. Newest first by default. Replaces find -name.',
157
157
  inputSchema: {
158
158
  type: 'object',
159
159
  properties: {
@@ -165,7 +165,7 @@ export const BUILTIN_TOOLS = [
165
165
  description: 'Glob or array of globs (max 10).',
166
166
  },
167
167
  path: {
168
- type: 'string', description: 'Known existing base directory; omit for the current Project; unknown location → find.',
168
+ type: 'string', description: 'Known existing base directory; omit for the current Project.',
169
169
  },
170
170
  sort: { type: 'string', enum: ['natural', 'mtime'], description: 'mtime default (newest first); natural = raw walk order, cheaper on huge trees.' },
171
171
  limit: { type: 'integer', minimum: 0, description: 'Max entries; default 100; 0 unlimited.' },
@@ -198,7 +198,7 @@ export const BUILTIN_TOOLS = [
198
198
  name: 'list',
199
199
  title: 'List Directory',
200
200
  annotations: { title: 'List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
201
- description: "Read-only; safe to batch in parallel. Return a known directory's immediate entries (path + type) when the entry list itself is needed; never as a prerequisite for another tool on that directory. No wildcard; meta:true adds size/mtime/mode.",
201
+ description: "Read-only; safe to batch in parallel. Return a known directory's immediate entries (path + type) when the entry list itself is needed; never as a prerequisite for another tool on that directory. No wildcard; meta:true adds size/mtime/mode. Replaces ls/dir.",
202
202
  inputSchema: {
203
203
  type: 'object',
204
204
  properties: {
@@ -13,27 +13,39 @@ function summarizeJobPreviewText(text, maxChars = 160) {
13
13
  return summary.length > maxChars ? `${summary.slice(0, maxChars - 1)}…` : summary;
14
14
  }
15
15
 
16
+ // The reported source must be the stream the summary actually came from: a
17
+ // present-but-blank preview previously mislabeled a stderr summary as stdout.
18
+ function pickJobSummary(candidates) {
19
+ for (const [source, text] of candidates) {
20
+ const summary = summarizeJobPreviewText(text);
21
+ if (summary) return { summary, summarySource: source };
22
+ }
23
+ return { summary: '', summarySource: '' };
24
+ }
25
+
16
26
  export function attachJobInsights(detail) {
17
27
  if (!detail || typeof detail !== 'object') return detail;
18
28
  const result = { ...detail };
19
29
  let summary = '';
20
30
  let summarySource = '';
31
+ const stdoutFirst = [['stdout', result.stdoutPreview], ['stderr', result.stderrPreview]];
32
+ const stderrFirst = [['stderr', result.stderrPreview], ['stdout', result.stdoutPreview]];
21
33
  if (result.status === 'completed') {
22
- summary = summarizeJobPreviewText(result.stdoutPreview)
23
- || summarizeJobPreviewText(result.stderrPreview);
24
- summarySource = summary ? (result.stdoutPreview ? 'stdout' : 'stderr') : '';
34
+ // A non-zero exit means the diagnosis lives in stderr, so lead with it
35
+ // instead of the last stdout line, which is usually unrelated progress.
36
+ const failedExit = typeof result.exitCode === 'number' && result.exitCode !== 0;
37
+ ({ summary, summarySource } = pickJobSummary(failedExit ? stderrFirst : stdoutFirst));
25
38
  } else if (result.status === 'failed') {
26
- summary = summarizeJobPreviewText(result.stderrPreview)
27
- || summarizeJobPreviewText(result.stdoutPreview)
28
- || String(result.error || '').trim();
29
- summarySource = summary ? (result.stderrPreview ? 'stderr' : (result.stdoutPreview ? 'stdout' : 'status')) : '';
39
+ ({ summary, summarySource } = pickJobSummary(stderrFirst));
40
+ if (!summary) {
41
+ summary = String(result.error || '').trim();
42
+ summarySource = summary ? 'status' : '';
43
+ }
30
44
  } else if (result.status === 'cancelled') {
31
45
  summary = 'cancelled before completion';
32
46
  summarySource = 'status';
33
47
  } else if (result.status === 'running') {
34
- summary = summarizeJobPreviewText(result.stdoutPreview)
35
- || summarizeJobPreviewText(result.stderrPreview);
36
- summarySource = summary ? (result.stdoutPreview ? 'stdout' : 'stderr') : '';
48
+ ({ summary, summarySource } = pickJobSummary(stdoutFirst));
37
49
  }
38
50
  if (summary) {
39
51
  result.summary = summary;
@@ -76,8 +76,32 @@ function compactStructuredJson(stdout) {
76
76
  return `${compacted.join('\n')}${hadTrailingNewline ? '\n' : ''}`;
77
77
  }
78
78
 
79
+ // A test summary states the RUN's verdict, so it may only replace output whose
80
+ // exit status belongs to the runner itself. A pipeline, chain, or substitution
81
+ // can hand back someone else's status (`node --test … | tail` exits 0 while the
82
+ // suite fails), which would turn a failed run into a success line. Plain
83
+ // redirects keep the runner's own status and stay eligible.
84
+ function statusOwningCommand(command) {
85
+ const text = String(command ?? '').replace(/\d?>&\d/g, ' ').trim();
86
+ if (!text) return false;
87
+ return !/[;&|<>`]|\$\(|\$\{/.test(text);
88
+ }
89
+
90
+ // Runner-agnostic failure markers. Whichever matcher would otherwise claim the
91
+ // run, an explicit failure line forbids a success summary.
92
+ function hasTestFailureMarker(stdout) {
93
+ const text = String(stdout ?? '');
94
+ return /(?:^|\n)\s*not ok\b/i.test(text)
95
+ || /^#\s*(?:fail|cancelled|todo)\s+[1-9]/m.test(text)
96
+ || /(?:^|\n)(?:---\s*FAIL|FAIL\b|FAILED\b)/.test(text)
97
+ || /(?:^|\n)\s*failures:/i.test(text)
98
+ || /\bpanicked at\b/.test(text)
99
+ || /[\u2716\u2718]/.test(text);
100
+ }
101
+
79
102
  function testLikeCommand(command) {
80
103
  const text = String(command ?? '').trim();
104
+ if (!statusOwningCommand(text)) return false;
81
105
  return simplePytestCommand(text)
82
106
  || /^(?:node|bun)(?:\.exe)?\s+--test(?:\s|$)/i.test(text)
83
107
  || /^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test(?:[:.\w-]*)(?:\s|$)/i.test(text)
@@ -137,10 +161,15 @@ function cargoTestSuccessSummary(stdout) {
137
161
  return `Cargo test: ${passed} passed in ${resultLines.length} suites${duration ? ` (${duration.toFixed(2)}s)` : ''}`;
138
162
  }
139
163
 
164
+ // Go prints `ok <pkg> 0.02s` or `ok <pkg> (cached)`. Anchoring on that
165
+ // trailing duration field keeps TAP's `ok 3 - name` result lines — which are
166
+ // individual assertions, not packages — from being counted as Go packages.
140
167
  function goTestSuccessSummary(stdout) {
141
168
  const text = String(stdout ?? '');
142
169
  if (/(?:^|\n)(?:FAIL\b|--- FAIL:)|\b(?:panic|warning):/im.test(text)) return null;
143
- const packages = text.split(/\r?\n/).filter((line) => /^ok\s+\S+/.test(line.trim()));
170
+ if (/^TAP version \d+/m.test(text) || /^1\.\.\d+\s*$/m.test(text)) return null;
171
+ const packages = text.split(/\r?\n/)
172
+ .filter((line) => /^ok\s+\S+\s+(?:\(cached\)|[\d.]+m?s)\b/.test(line.trim()));
144
173
  if (!packages.length) return null;
145
174
  return `Go test: ${packages.length} packages passed`;
146
175
  }
@@ -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: 'Read-only; safe to batch in parallel. Source-file structure, symbol relations, and flow. Exact identifiers route directly to find_symbol/references/callers/callees; symbol-name keywords use symbol_search/search. Source text, literals, regex, and conceptual keywords use grep. File modes use files[]; symbol modes use symbols[]. overview and mode:symbols return file summaries. find_symbol returns declaration/body; references returns declaration/usages plus optional body; callers/callees return locations.',
6
+ description: 'Read-only; safe to batch in parallel. Source-file structure, symbol relations, and flow. Exact identifiers route directly to find_symbol/references/callers/callees; symbol-name keywords use symbol_search/search. Text, literals, and regex belong to grep. File modes use files[]; symbol modes use symbols[]. overview and mode:symbols return file summaries. find_symbol returns declaration/body; references returns declaration/usages plus optional body; callers/callees return locations.',
7
7
  inputSchema: {
8
8
  type: 'object',
9
9
  properties: {