mixdog 0.9.68 → 0.9.69

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 (48) hide show
  1. package/package.json +6 -4
  2. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
  4. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
  6. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
  7. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
  8. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
  10. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
  11. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  12. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  13. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  14. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  15. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  16. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  17. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  18. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  19. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  20. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  21. package/src/runtime/channels/lib/webhook.mjs +13 -1
  22. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  23. package/src/session-runtime/model-capabilities.mjs +6 -4
  24. package/src/session-runtime/notification-bus.mjs +82 -0
  25. package/src/session-runtime/provider-auth-api.mjs +16 -1
  26. package/src/session-runtime/provider-usage.mjs +3 -0
  27. package/src/session-runtime/remote-control.mjs +166 -0
  28. package/src/session-runtime/remote-transcript.mjs +126 -0
  29. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  30. package/src/session-runtime/runtime-core.mjs +143 -705
  31. package/src/session-runtime/runtime-tunables.mjs +57 -0
  32. package/src/session-runtime/self-update.mjs +129 -0
  33. package/src/session-runtime/skills-api.mjs +77 -0
  34. package/src/session-runtime/tool-surface.mjs +83 -0
  35. package/src/session-runtime/workflow-agents-api.mjs +206 -3
  36. package/src/session-runtime/workflow.mjs +84 -17
  37. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  38. package/src/standalone/agent-tool.mjs +22 -346
  39. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  40. package/src/standalone/channel-worker.mjs +7 -13
  41. package/src/tui/components/StatusLine.jsx +6 -5
  42. package/src/tui/dist/index.mjs +124 -94
  43. package/src/tui/engine/labels.mjs +0 -8
  44. package/src/tui/engine/session-api-ext.mjs +17 -8
  45. package/src/tui/engine/turn-watchdog.mjs +92 -0
  46. package/src/tui/engine/turn.mjs +18 -70
  47. package/src/workflows/default/WORKFLOW.md +1 -1
  48. package/src/workflows/solo/WORKFLOW.md +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.68",
3
+ "version": "0.9.69",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -79,11 +79,12 @@
79
79
  "test:tui-streaming-window": "node --test scripts/streaming-tail-window-test.mjs",
80
80
  "test:tui-ambiguous-width": "node --test scripts/tui-ambiguous-width-test.mjs",
81
81
  "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/verify-release-assets-test.mjs && node --test scripts/verify-release-assets-test.mjs",
82
- "test:release-focused": "npm run test:release-assets && npm run test:tool-contracts && npm run test:placeholder && npm run smoke:patch && npm run test:patch-binary-cache && npm run test:providers && npm run test:deferred-tools && npm run smoke:compact && node --test scripts/code-graph-root-federation-test.mjs scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs && npm run test:shellhardening && node --test scripts/windows-hide-spawn-options-test.mjs && npm run test:session && node --test scripts/tui-transcript-perf-test.mjs",
82
+ "test:release-focused": "npm run test:release-assets && npm run test:tool-contracts && npm run test:placeholder && npm run smoke:patch && npm run test:patch-binary-cache && npm run test:providers && npm run test:deferred-tools && npm run smoke:compact && node --test scripts/code-graph-root-federation-test.mjs scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs && npm run test:shellhardening && node --test scripts/windows-hide-spawn-options-test.mjs && npm run test:session && npm run test:workflow-editor && node --test scripts/tui-transcript-perf-test.mjs",
83
83
  "test:native-edit-wire": "node --test scripts/native-edit-wire-test.mjs",
84
84
  "test:patch-binary-cache": "node --test scripts/patch-binary-cache-test.mjs",
85
- "test:session": "node --test scripts/session-orphan-sweep-test.mjs scripts/interrupted-turn-history-test.mjs scripts/session-heartbeat-lifecycle-test.mjs",
85
+ "test:session": "node --test scripts/session-orphan-sweep-test.mjs scripts/interrupted-turn-history-test.mjs scripts/session-heartbeat-lifecycle-test.mjs scripts/remote-transition-order-test.mjs",
86
86
  "test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs",
87
+ "test:workflow-editor": "node --test scripts/workflow-id-test.mjs scripts/workflow-pack-editor-test.mjs",
87
88
  "failures": "node scripts/tool-failures.mjs",
88
89
  "trace:llm": "node scripts/llm-trace-summary.mjs",
89
90
  "diag:sessions": "node scripts/session-diag.mjs",
@@ -140,7 +141,8 @@
140
141
  },
141
142
  "onnxruntime-node": {
142
143
  "global-agent": "^4.1.3"
143
- }
144
+ },
145
+ "sharp": "^0.35.3"
144
146
  },
145
147
  "devDependencies": {
146
148
  "acorn": "^8.17.0",
@@ -72,15 +72,10 @@ function agentCompactEventLabel(event = {}) {
72
72
  return reactive ? 'Compact complete (overflow recovery)' : 'Compact complete';
73
73
  }
74
74
 
75
- function agentCompactEventDetail(event = {}) {
75
+ export function agentCompactEventDetail(event = {}) {
76
76
  const parts = [];
77
77
  const elapsed = formatCompactElapsedSeconds(Number(event.durationMs ?? event.elapsedMs ?? 0));
78
78
  if (elapsed) parts.push(elapsed);
79
- const type = String(event.compactType || event.type || '').trim();
80
- if (type && type !== 'semantic') parts.push(type);
81
- const trigger = String(event.trigger || '').toLowerCase();
82
- if (trigger === 'reactive') parts.push('reactive');
83
- else if (trigger === 'manual') parts.push('manual');
84
79
  const before = Number(event.beforeTokens ?? event.pressureTokens ?? 0);
85
80
  const after = Number(event.afterTokens ?? 0);
86
81
  const fmtTok = (n) => {
@@ -596,10 +596,22 @@ function loadHiddenAgentSnippets(pluginRoot) {
596
596
  }
597
597
  }
598
598
 
599
+ // Role-markdown roots, in precedence order (later wins on the same name):
600
+ // the shipped agents/ tree, then the user data dir. User-authored roles and
601
+ // built-in overrides written by the Workflows editor live in the data dir —
602
+ // reading only the install root left a custom agent with an EMPTY role
603
+ // catalog while Lead already saw the edited AGENT.md.
604
+ function agentSectionDirs(pluginRoot) {
605
+ const dirs = [];
606
+ if (pluginRoot) dirs.push(join(pluginRoot, 'agents'));
607
+ try {
608
+ const userDir = mixdogGlobalDir('agents');
609
+ if (userDir && !dirs.includes(userDir)) dirs.push(userDir);
610
+ } catch { /* unresolvable data dir — built-in roles still load */ }
611
+ return dirs;
612
+ }
613
+
599
614
  function loadAgentSections(pluginRoot) {
600
- const agentsDir = join(pluginRoot, 'agents');
601
- const agentSections = [];
602
- if (!existsSync(agentsDir)) return agentSections;
603
615
  // agents/ holds two layouts that must BOTH be loaded:
604
616
  // - flat: agents/<agent>.md (legacy: scheduler-task, webhook-handler)
605
617
  // - nested: agents/<agent>/AGENT.md (current: worker, heavy-worker, reviewer, …)
@@ -607,31 +619,34 @@ function loadAgentSections(pluginRoot) {
607
619
  // public agent like heavy-worker produced an EMPTY scoped instruction block
608
620
  // (BP2) — the model lost its agent contract and the tool smoke's
609
621
  // "heavy-worker AGENT.md must be included" assertion failed. Walk both.
610
- let entries;
611
- try {
612
- entries = readdirSync(agentsDir, { withFileTypes: true });
613
- } catch {
614
- return agentSections;
615
- }
616
- const sections = [];
617
- for (const entry of entries) {
618
- let name = '';
619
- let raw = null;
620
- if (entry.isDirectory()) {
621
- name = entry.name;
622
- raw = readSafe(join(agentsDir, entry.name, 'AGENT.md'));
623
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
624
- name = entry.name.replace(/\.md$/, '');
625
- raw = readSafe(join(agentsDir, entry.name));
622
+ const byName = new Map();
623
+ for (const agentsDir of agentSectionDirs(pluginRoot)) {
624
+ if (!existsSync(agentsDir)) continue;
625
+ let entries;
626
+ try {
627
+ entries = readdirSync(agentsDir, { withFileTypes: true });
628
+ } catch {
629
+ continue;
630
+ }
631
+ for (const entry of entries) {
632
+ let name = '';
633
+ let raw = null;
634
+ if (entry.isDirectory()) {
635
+ name = entry.name;
636
+ raw = readSafe(join(agentsDir, entry.name, 'AGENT.md'));
637
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
638
+ name = entry.name.replace(/\.md$/, '');
639
+ raw = readSafe(join(agentsDir, entry.name));
640
+ }
641
+ if (!name || !raw) continue;
642
+ const { body } = readMarkdownDocument(raw);
643
+ if (!body) continue;
644
+ byName.set(name, `## ${name}\n\n${body}`);
626
645
  }
627
- if (!name || !raw) continue;
628
- const { body } = readMarkdownDocument(raw);
629
- if (!body) continue;
630
- sections.push({ name, text: `## ${name}\n\n${body}` });
631
646
  }
632
- sections.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
633
- for (const s of sections) agentSections.push(s.text);
634
- return agentSections;
647
+ return [...byName.entries()]
648
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
649
+ .map(([, text]) => text);
635
650
  }
636
651
 
637
652
  // Empty by design: scoped agent markdown already rides BP2 for every provider.
@@ -664,7 +679,7 @@ export function loadScopedRoleInstructions(agent, provider = null) {
664
679
  mtime = mtimeCached.mtime;
665
680
  } else {
666
681
  mtime = maxMtimeRecursive([
667
- join(pluginRoot, 'agents'),
682
+ ...agentSectionDirs(pluginRoot),
668
683
  join(pluginRoot, 'rules', 'agent'),
669
684
  join(pluginRoot, 'defaults', 'agents.json'),
670
685
  ]);
@@ -22,7 +22,7 @@ import {
22
22
  emitGeminiToolCalls,
23
23
  collectGeminiGroundingSources,
24
24
  parseGeminiTextPartMetadata,
25
- } from './gemini.mjs';
25
+ } from './gemini-schema.mjs';
26
26
 
27
27
  export const GEMINI_FIRST_BYTE_TIMEOUT_MS = resolveTimeoutMs(
28
28
  'MIXDOG_GEMINI_FIRST_BYTE_TIMEOUT_MS',
@@ -62,12 +62,6 @@ import {
62
62
  fetchAndCacheGeminiModels,
63
63
  } from './lib/gemini-model-catalog.mjs';
64
64
 
65
- // Legacy import path: tests + gemini-stream import these from gemini.mjs.
66
- // Re-export the extracted symbols so existing importers resolve unchanged.
67
- export { createGeminiTextLeakGuard };
68
- export { parseToolCalls, emitGeminiToolCalls, collectGeminiGroundingSources, parseGeminiTextPartMetadata };
69
- export { fetchGeminiModelPages, resolveLatestGeminiModel, ensureLatestGeminiModel };
70
-
71
65
  // De-dupes concurrent force-refreshes so they share one HTTP round-trip,
72
66
  // mirroring anthropic-oauth's _modelRefreshInFlight.
73
67
  let _modelRefreshInFlight = null;
@@ -0,0 +1,161 @@
1
+ // Codex client-metadata for the openai-oauth WebSocket transport: the
2
+ // installation/session/thread/turn identity block that rides every frame, its
3
+ // handshake-header projection, and the per-turn x-codex-turn-state guard.
4
+ // Extracted from openai-oauth-ws.mjs, which now owns transport flow only.
5
+ import { createHash } from 'crypto';
6
+
7
+ export function _cleanMetaString(value) {
8
+ return typeof value === 'string' ? value.trim() : '';
9
+ }
10
+
11
+ export function _hashText(value, chars = 24) {
12
+ return createHash('sha256').update(String(value || '')).digest('hex').slice(0, chars);
13
+ }
14
+
15
+ // Session ids embed their creation stamp; fall back to now for foreign shapes.
16
+ function _sessionStartedAtUnixMs(sessionId) {
17
+ const parts = String(sessionId || '').split('_');
18
+ for (const part of parts) {
19
+ if (/^\d{12,}$/.test(part)) {
20
+ const n = Number(part);
21
+ if (Number.isFinite(n) && n > 0) return Math.floor(n);
22
+ }
23
+ }
24
+ return Date.now();
25
+ }
26
+
27
+ function _codexRequestKind(sendOpts, sessionId) {
28
+ const explicit = _cleanMetaString(sendOpts?.requestKind || sendOpts?.codexRequestKind);
29
+ if (explicit) return explicit;
30
+ return String(sessionId || '').includes(':compact') ? 'compaction' : 'turn';
31
+ }
32
+
33
+ function _codexInstallationId(sendOpts) {
34
+ return _cleanMetaString(sendOpts?.installationId || sendOpts?.codexInstallationId || process.env.MIXDOG_CODEX_INSTALLATION_ID)
35
+ || `mixdog-${_hashText(`${process.env.USERPROFILE || process.env.HOME || ''}:${process.cwd()}`, 32)}`;
36
+ }
37
+
38
+ // The identity block codex rebuilds per request (responses_metadata.rs
39
+ // client_metadata()): never cached on the pooled socket, or a later turn would
40
+ // replay the first turn's identity.
41
+ export function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = false } = {}) {
42
+ const sessionId = _cleanMetaString(sendOpts?.codexSessionId || sendOpts?.session?.codexSessionId || poolKey || cacheKey)
43
+ || 'mixdog-session';
44
+ const threadId = _cleanMetaString(sendOpts?.threadId || sendOpts?.codexThreadId || sendOpts?.session?.threadId || cacheKey || sessionId)
45
+ || sessionId;
46
+ const installationId = _codexInstallationId(sendOpts);
47
+ const startedAt = Number.isFinite(Number(sendOpts?.turnStartedAtUnixMs))
48
+ ? Math.floor(Number(sendOpts.turnStartedAtUnixMs))
49
+ : _sessionStartedAtUnixMs(sessionId);
50
+ const requestKind = _codexRequestKind(sendOpts, sessionId);
51
+ const wireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
52
+ // codex opens the WS with a prewarm (empty turn_id) BEFORE the real turn
53
+ // (client.rs). Under wire parity the handshake IS that prewarm, so its
54
+ // turn_id empties and its request_kind becomes 'prewarm' instead of
55
+ // presenting the handshake as a live turn. Parity off is unchanged.
56
+ const isPrewarm = requestKind === 'prewarm' || handshake === true;
57
+ const explicitTurnId = _cleanMetaString(sendOpts?.turnId || sendOpts?.codexTurnId || sendOpts?.session?.turnId);
58
+ const explicitWindowId = _cleanMetaString(sendOpts?.windowId || sendOpts?.codexWindowId || sendOpts?.session?.windowId);
59
+ const turnId = wireParity && isPrewarm ? '' : (explicitTurnId || sessionId);
60
+ const effectiveRequestKind = wireParity && isPrewarm ? 'prewarm' : requestKind;
61
+ const windowId = explicitWindowId || `${threadId}:${wireParity ? 0 : 1}`;
62
+ const turnMetadata = {
63
+ installation_id: installationId,
64
+ session_id: sessionId,
65
+ thread_id: threadId,
66
+ turn_id: turnId,
67
+ window_id: windowId,
68
+ request_kind: effectiveRequestKind,
69
+ // Richer codex turn-metadata (responses_metadata.rs:264-280). A/B
70
+ // 2026-07-04 showed no effect, so it stays behind a knob for future
71
+ // probes; wire parity implies it.
72
+ ...((process.env.MIXDOG_OAI_TURN_METADATA_RICH === '1' || wireParity) ? {
73
+ thread_source: 'user',
74
+ sandbox: 'read-only',
75
+ } : {}),
76
+ turn_started_at_unix_ms: startedAt,
77
+ };
78
+ return {
79
+ 'x-codex-installation-id': installationId,
80
+ session_id: sessionId,
81
+ thread_id: threadId,
82
+ turn_id: turnId,
83
+ 'x-codex-window-id': windowId,
84
+ 'x-codex-turn-metadata': JSON.stringify(turnMetadata),
85
+ };
86
+ }
87
+
88
+ export function _metadataTrace(metadata) {
89
+ if (!metadata || typeof metadata !== 'object') {
90
+ return { count: 0, hash: null, hasTurnMetadata: false, hasThreadId: false };
91
+ }
92
+ const keys = Object.keys(metadata).sort();
93
+ return {
94
+ count: keys.length,
95
+ hash: _hashText(keys.map((key) => `${key}=${metadata[key]}`).join('\n'), 12),
96
+ hasTurnMetadata: typeof metadata['x-codex-turn-metadata'] === 'string' && metadata['x-codex-turn-metadata'].length > 0,
97
+ hasThreadId: typeof metadata.thread_id === 'string' && metadata.thread_id.length > 0,
98
+ };
99
+ }
100
+
101
+ // Handshake projection of the same identity. codex attaches these on every
102
+ // request (client.rs:582-584, responses_metadata.rs:227-252); A/B 2026-07-04
103
+ // showed the turn-metadata blob alone lifts prefix-cache hits, so it is ON by
104
+ // default. MIXDOG_OAI_TURN_METADATA overrides:
105
+ // unset|1|turn-metadata : window-id + turn-metadata + installation-id
106
+ // parent : + x-codex-parent-thread-id
107
+ // window : window-id only (drop the blob)
108
+ // 0|off|false|no : pre-2026-07-04 baseline (drop the blob)
109
+ export function _codexWsCompatibilityHeaders(context = {}) {
110
+ const metadata = _codexMetadataBase(null, context);
111
+ const headers = {};
112
+ if (metadata['x-codex-window-id']) headers['x-codex-window-id'] = metadata['x-codex-window-id'];
113
+ if (metadata['x-codex-turn-metadata']) headers['x-codex-turn-metadata'] = metadata['x-codex-turn-metadata'];
114
+ if (metadata['x-codex-installation-id']) headers['x-codex-installation-id'] = metadata['x-codex-installation-id'];
115
+ const parentThreadId = () => _cleanMetaString(context?.sendOpts?.parentThreadId
116
+ || context?.sendOpts?.codexParentThreadId
117
+ || metadata.thread_id);
118
+ const probe = String(process.env.MIXDOG_OAI_TURN_METADATA || '').trim().toLowerCase();
119
+ if (probe === '0' || probe === 'off' || probe === 'false' || probe === 'no' || probe === 'window') {
120
+ delete headers['x-codex-turn-metadata'];
121
+ } else if (probe === 'parent') {
122
+ const parent = parentThreadId();
123
+ if (parent) headers['x-codex-parent-thread-id'] = parent;
124
+ }
125
+ // Turn-state gate probe: attach the parent header independent of the knob
126
+ // above (hypothesis: x-codex-turn-state issuance wants it). Default OFF.
127
+ const gate = String(process.env.MIXDOG_OAI_CODEX_TURN_STATE_GATE || '').trim().toLowerCase();
128
+ if (['1', 'true', 'yes', 'on'].includes(gate) && !headers['x-codex-parent-thread-id']) {
129
+ const parent = parentThreadId();
130
+ if (parent) headers['x-codex-parent-thread-id'] = parent;
131
+ }
132
+ return headers;
133
+ }
134
+
135
+ export function _withCodexWsClientMetadata(frame, entry, enabled, context = {}) {
136
+ if (!enabled || !frame || typeof frame !== 'object') return frame;
137
+ const base = _codexMetadataBase(entry, context);
138
+ const metadata = {
139
+ ...base,
140
+ ...(frame.client_metadata && typeof frame.client_metadata === 'object' ? frame.client_metadata : {}),
141
+ 'x-codex-ws-stream-request-start-ms': String(Date.now()),
142
+ };
143
+ if (entry && typeof entry === 'object') {
144
+ // codex scopes x-codex-turn-state to ONE turn (client.rs:263-279) while
145
+ // pooled sockets span turns: attribute a captured token to the FIRST
146
+ // turn that observes it, then drop it once turn_id moves on. An empty
147
+ // turn_id (parity prewarm) is a valid owner, so the check is against
148
+ // null rather than falsiness.
149
+ if (entry.turnState) {
150
+ if (entry.turnStateTurnId == null) {
151
+ entry.turnStateTurnId = base.turn_id;
152
+ } else if (entry.turnStateTurnId !== base.turn_id) {
153
+ entry.turnState = null;
154
+ entry.turnStateTurnId = null;
155
+ }
156
+ }
157
+ entry.currentTurnId = base.turn_id;
158
+ }
159
+ if (entry?.turnState) metadata['x-codex-turn-state'] = String(entry.turnState);
160
+ return { ...frame, client_metadata: metadata };
161
+ }
@@ -190,210 +190,14 @@ function _envRatio(name, fallback) {
190
190
  return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
191
191
  }
192
192
 
193
- function _cleanMetaString(value) {
194
- return typeof value === 'string' ? value.trim() : '';
195
- }
196
-
197
- function _hashText(value, chars = 24) {
198
- return createHash('sha256').update(String(value || '')).digest('hex').slice(0, chars);
199
- }
200
-
201
- function _sessionStartedAtUnixMs(sessionId) {
202
- const parts = String(sessionId || '').split('_');
203
- for (const part of parts) {
204
- if (/^\d{12,}$/.test(part)) {
205
- const n = Number(part);
206
- if (Number.isFinite(n) && n > 0) return Math.floor(n);
207
- }
208
- }
209
- return Date.now();
210
- }
211
-
212
- function _codexRequestKind(sendOpts, sessionId) {
213
- const explicit = _cleanMetaString(sendOpts?.requestKind || sendOpts?.codexRequestKind);
214
- if (explicit) return explicit;
215
- return String(sessionId || '').includes(':compact') ? 'compaction' : 'turn';
216
- }
217
-
218
- function _codexInstallationId(sendOpts) {
219
- return _cleanMetaString(sendOpts?.installationId || sendOpts?.codexInstallationId || process.env.MIXDOG_CODEX_INSTALLATION_ID)
220
- || `mixdog-${_hashText(`${process.env.USERPROFILE || process.env.HOME || ''}:${process.cwd()}`, 32)}`;
221
- }
222
-
223
- function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = false } = {}) {
224
- const sessionId = _cleanMetaString(sendOpts?.codexSessionId || sendOpts?.session?.codexSessionId || poolKey || cacheKey)
225
- || 'mixdog-session';
226
- const threadId = _cleanMetaString(sendOpts?.threadId || sendOpts?.codexThreadId || sendOpts?.session?.threadId || cacheKey || sessionId)
227
- || sessionId;
228
- const installationId = _codexInstallationId(sendOpts);
229
- const startedAt = Number.isFinite(Number(sendOpts?.turnStartedAtUnixMs))
230
- ? Math.floor(Number(sendOpts.turnStartedAtUnixMs))
231
- : _sessionStartedAtUnixMs(sessionId);
232
- const requestKind = _codexRequestKind(sendOpts, sessionId);
233
- const wireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
234
- // codex opens the WS with a prewarm (empty turn_id) BEFORE issuing the
235
- // real turn (client.rs). Our handshake headers are built once, up-front,
236
- // and were carrying a turn_id (= sessionId) — i.e. presenting the
237
- // handshake as a live turn. Under wire parity, treat the handshake as the
238
- // prewarm so its turn_id is empty, matching codex. Default (parity off) is
239
- // unchanged: the wireParity gate below leaves turnId as before.
240
- const isPrewarm = requestKind === 'prewarm' || handshake === true;
241
- const explicitTurnId = _cleanMetaString(sendOpts?.turnId || sendOpts?.codexTurnId || sendOpts?.session?.turnId);
242
- const explicitWindowId = _cleanMetaString(sendOpts?.windowId || sendOpts?.codexWindowId || sendOpts?.session?.windowId);
243
- const turnId = wireParity && isPrewarm
244
- ? ''
245
- : (explicitTurnId || sessionId);
246
- // Under wire parity the handshake IS the prewarm (empty turn_id above), so
247
- // its request_kind must be 'prewarm' too — codex tags the prewarm request
248
- // as prewarm, not turn (client.rs). Previously we emptied turn_id but left
249
- // request_kind='turn', presenting the prewarm as a live turn. Default
250
- // (parity off) is unchanged: requestKind passes through untouched.
251
- const effectiveRequestKind = wireParity && isPrewarm ? 'prewarm' : requestKind;
252
- const windowId = explicitWindowId
253
- || `${threadId}:${wireParity ? 0 : 1}`;
254
- const turnMetadata = {
255
- installation_id: installationId,
256
- session_id: sessionId,
257
- thread_id: threadId,
258
- turn_id: turnId,
259
- window_id: windowId,
260
- request_kind: effectiveRequestKind,
261
- // Richer codex turn-metadata fields (responses_metadata.rs:264-280:
262
- // thread_source "user" per protocol.rs:2751-2765, sandbox label).
263
- // A/B 2026-07-04 (rvA/rvB interleaved, 24 sessions/arm): no effect
264
- // (it2 full 3 vs 2, miss 2 vs 3 — noise). Default OFF; keep the knob
265
- // for future probes if the backend starts gating on payload richness.
266
- // Codex WS metadata parity (opt-in): the richer turn-metadata fields
267
- // codex emits (responses_metadata.rs:264-280) also go on the wire under
268
- // MIXDOG_OAI_CODEX_WIRE_PARITY. Default (both flags off) is unchanged.
269
- ...((process.env.MIXDOG_OAI_TURN_METADATA_RICH === '1' || wireParity) ? {
270
- thread_source: 'user',
271
- sandbox: 'read-only',
272
- } : {}),
273
- turn_started_at_unix_ms: startedAt,
274
- };
275
- const metadata = {
276
- 'x-codex-installation-id': installationId,
277
- session_id: sessionId,
278
- thread_id: threadId,
279
- turn_id: turnId,
280
- 'x-codex-window-id': windowId,
281
- 'x-codex-turn-metadata': JSON.stringify(turnMetadata),
282
- };
283
- // NOTE: no entry-level caching — pooled sockets outlive turns, and codex
284
- // rebuilds metadata per request (responses_metadata.rs client_metadata()).
285
- // Caching the first turn's turn_id on the socket would replay stale turn
286
- // identity for every later turn on that connection.
287
- return metadata;
288
- }
289
-
290
- function _metadataTrace(metadata) {
291
- if (!metadata || typeof metadata !== 'object') {
292
- return { count: 0, hash: null, hasTurnMetadata: false, hasThreadId: false };
293
- }
294
- const keys = Object.keys(metadata).sort();
295
- return {
296
- count: keys.length,
297
- hash: _hashText(keys.map((key) => `${key}=${metadata[key]}`).join('\n'), 12),
298
- hasTurnMetadata: typeof metadata['x-codex-turn-metadata'] === 'string' && metadata['x-codex-turn-metadata'].length > 0,
299
- hasThreadId: typeof metadata.thread_id === 'string' && metadata.thread_id.length > 0,
300
- };
301
- }
302
-
303
- function _codexWsCompatibilityHeaders(context = {}) {
304
- const metadata = _codexMetadataBase(null, context);
305
- const headers = {};
306
- if (metadata['x-codex-window-id']) headers['x-codex-window-id'] = metadata['x-codex-window-id'];
307
- if (metadata['x-codex-turn-metadata']) headers['x-codex-turn-metadata'] = metadata['x-codex-turn-metadata'];
308
- // codex sends x-codex-installation-id on EVERY request incl. the WS
309
- // handshake (client.rs:582-584). We previously carried it only in the
310
- // body client_metadata; the server may gate x-codex-turn-state issuance
311
- // on handshake-level client identity, so mirror codex here.
312
- if (metadata['x-codex-installation-id']) headers['x-codex-installation-id'] = metadata['x-codex-installation-id'];
313
- // Turn-metadata handshake header. codex's compatibility_headers()
314
- // (responses_metadata.rs:227-252) attaches x-codex-turn-metadata /
315
- // x-codex-parent-thread-id / x-codex-window-id on the request. A/B
316
- // (2026-07-04, tm0b vs tm1b, 10 parallel-5 sessions each, gpt-5.5):
317
- // WITH the turn-metadata blob it2 cache misses dropped 4/10 -> 0/10 and
318
- // full-prefix hits rose 2/10 -> 7/10; x-codex-turn-state was never issued
319
- // in either arm, so the win comes from better cache routing on the
320
- // handshake fingerprint, not sticky turn-state. Default ON.
321
- // MIXDOG_OAI_TURN_METADATA overrides:
322
- // unset / 1 / turn-metadata : window-id + turn-metadata + installation-id
323
- // parent : + x-codex-parent-thread-id (= thread_id)
324
- // window : window-id only (drop turn-metadata blob)
325
- // 0 / off / false / no : strip the blob (pre-2026-07-04 baseline)
326
- const probe = String(process.env.MIXDOG_OAI_TURN_METADATA || '').trim().toLowerCase();
327
- if (probe === '0' || probe === 'off' || probe === 'false' || probe === 'no') {
328
- // Not opted in: keep window-id + installation-id but drop the full
329
- // turn-metadata blob so we match the historical (turn-state-never-issued)
330
- // baseline exactly.
331
- delete headers['x-codex-turn-metadata'];
332
- } else if (probe === 'parent') {
333
- const parentThreadId = _cleanMetaString(context?.sendOpts?.parentThreadId
334
- || context?.sendOpts?.codexParentThreadId
335
- || metadata.thread_id);
336
- if (parentThreadId) headers['x-codex-parent-thread-id'] = parentThreadId;
337
- } else if (probe === 'window') {
338
- delete headers['x-codex-turn-metadata'];
339
- }
340
- // probe === '1' | 'true' | 'yes' | 'on' | 'turn-metadata' => full set as built above.
341
- // Turn-state gate experiment bundle (MIXDOG_OAI_CODEX_TURN_STATE_GATE): the
342
- // debugger's hypothesis is that x-codex-turn-state issuance also wants the
343
- // parent-thread header present, independent of MIXDOG_OAI_TURN_METADATA.
344
- // Attach it (= thread_id) when the gate is explicitly enabled and the probe
345
- // above didn't already set it. Default OFF; composes with the probe.
346
- const gate = String(process.env.MIXDOG_OAI_CODEX_TURN_STATE_GATE || '').trim().toLowerCase();
347
- if (['1', 'true', 'yes', 'on'].includes(gate) && !headers['x-codex-parent-thread-id']) {
348
- const parentThreadId = _cleanMetaString(context?.sendOpts?.parentThreadId
349
- || context?.sendOpts?.codexParentThreadId
350
- || metadata.thread_id);
351
- if (parentThreadId) headers['x-codex-parent-thread-id'] = parentThreadId;
352
- }
353
- return headers;
354
- }
355
-
356
- export function _withCodexWsClientMetadata(frame, entry, enabled, context = {}) {
357
- if (!enabled || !frame || typeof frame !== 'object') return frame;
358
- const base = _codexMetadataBase(entry, context);
359
- const metadata = {
360
- ...base,
361
- ...(frame.client_metadata && typeof frame.client_metadata === 'object' ? frame.client_metadata : {}),
362
- 'x-codex-ws-stream-request-start-ms': String(Date.now()),
363
- };
364
- if (entry && typeof entry === 'object') {
365
- // codex scopes x-codex-turn-state to ONE turn (client.rs:263-279 —
366
- // "must not send it between different turns"). Pooled sockets span
367
- // turns, so key the stored token by the turn that captured it and
368
- // drop it when the turn changes. turnState is captured at handshake
369
- // (pool) / mid-stream (ws-stream) without knowing the owning turn, so
370
- // attribute it to the FIRST turn that observes it here; once turn_id
371
- // moves off that owner, drop the stale token. (Previously
372
- // turnStateTurnId was checked but never assigned, so this guard was
373
- // dead and stale turn-state could leak across turns under parity.)
374
- if (entry.turnState) {
375
- // An empty turn_id (parity prewarm) is a VALID owner attribution, not
376
- // "unassigned". Test against null/undefined so a prewarm-owned token
377
- // (turn_id '') is retired once turn_id advances to the real turn,
378
- // instead of being reattributed onto it. Default (parity off) never
379
- // produces an empty turn_id, so this is unchanged there.
380
- if (entry.turnStateTurnId == null) {
381
- entry.turnStateTurnId = base.turn_id;
382
- } else if (entry.turnStateTurnId !== base.turn_id) {
383
- entry.turnState = null;
384
- entry.turnStateTurnId = null;
385
- }
386
- }
387
- entry.currentTurnId = base.turn_id;
388
- }
389
- if (entry?.turnState) {
390
- metadata['x-codex-turn-state'] = String(entry.turnState);
391
- }
392
- return {
393
- ...frame,
394
- client_metadata: metadata,
395
- };
396
- }
193
+ // Codex client metadata (identity block, handshake headers, per-turn
194
+ // turn-state guard) lives in openai-codex-metadata.mjs.
195
+ import {
196
+ _codexWsCompatibilityHeaders,
197
+ _hashText,
198
+ _metadataTrace,
199
+ _withCodexWsClientMetadata,
200
+ } from './openai-codex-metadata.mjs';
397
201
 
398
202
  function _cacheObservation({ entry, result, continuityResetReason = null }) {
399
203
  const inputTokens = _num(result?.usage?.inputTokens, 0);
@@ -7,6 +7,7 @@
7
7
  import { classifyResultKind } from '../result-classification.mjs';
8
8
  import { _normalizeAbs, _normalizeCacheKey, _statTuple, _statEqual } from './util.mjs';
9
9
  import { readFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
10
+ import { readdir, stat as statAsync, unlink } from 'node:fs/promises';
10
11
  import { join, dirname } from 'node:path';
11
12
  import { createHash } from 'node:crypto';
12
13
  import { writeJsonAtomicSync } from '../../../../shared/atomic-file.mjs';
@@ -24,6 +25,30 @@ const _pendingDiskWrites = new Map();
24
25
  let _diskWriteTimer = null;
25
26
 
26
27
  const DISK_CACHE_DIR = join(resolvePluginData(), 'cache', 'prefetch');
28
+ // Disk GC: an entry is only unlinked when something looks it up again and
29
+ // finds it stale, so files for paths that are never re-read (or whose process
30
+ // died) sit in cache/prefetch forever. Sweep past-TTL files on an interval.
31
+ const DISK_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
32
+ let _diskSweptAt = 0;
33
+ async function _sweepDiskCache() {
34
+ let names;
35
+ try { names = await readdir(DISK_CACHE_DIR); } catch { return; }
36
+ const cutoff = Date.now() - _prefetchTtlMs;
37
+ await Promise.all(names.map(async (name) => {
38
+ if (!name.endsWith('.json')) return;
39
+ const p = join(DISK_CACHE_DIR, name);
40
+ try {
41
+ const st = await statAsync(p);
42
+ if (st.mtimeMs < cutoff) await unlink(p).catch(() => {});
43
+ } catch { /* raced with another sweep */ }
44
+ }));
45
+ }
46
+ function _maybeSweepDiskCache() {
47
+ const now = Date.now();
48
+ if (now - _diskSweptAt < DISK_SWEEP_INTERVAL_MS) return;
49
+ _diskSweptAt = now;
50
+ void _sweepDiskCache();
51
+ }
27
52
  function _diskPath(absPath) {
28
53
  const hash = createHash('sha256').update(absPath).digest('hex').slice(0, 16);
29
54
  return join(DISK_CACHE_DIR, `${hash}.json`);
@@ -70,6 +95,7 @@ function _deleteDiskEntry(absPath) {
70
95
  */
71
96
  export function tryPrefetchCached(absPath) {
72
97
  if (typeof absPath !== 'string' || absPath.length === 0) return null;
98
+ _maybeSweepDiskCache();
73
99
  absPath = _normalizeCacheKey(absPath);
74
100
  let entry = _prefetchCache.get(absPath);
75
101
  if (!entry) {