mixdog 0.9.67 → 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.
- package/package.json +6 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
- package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
- package/src/runtime/channels/lib/config.mjs +6 -5
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
- package/src/runtime/channels/lib/scheduler.mjs +7 -15
- package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
- package/src/runtime/channels/lib/webhook.mjs +36 -46
- package/src/runtime/channels/lib/worker-main.mjs +45 -92
- package/src/runtime/shared/automation-attachments.mjs +72 -0
- package/src/runtime/shared/automation-workflow.mjs +41 -0
- package/src/runtime/shared/schedule-session-run.mjs +10 -1
- package/src/runtime/shared/schedules-db.mjs +18 -3
- package/src/runtime/shared/webhook-session-run.mjs +57 -0
- package/src/runtime/shared/webhooks-db.mjs +19 -3
- package/src/session-runtime/channel-config-api.mjs +8 -1
- package/src/session-runtime/lifecycle-api.mjs +7 -0
- package/src/session-runtime/memory-daemon-probe.mjs +61 -0
- package/src/session-runtime/model-capabilities.mjs +6 -4
- package/src/session-runtime/notification-bus.mjs +82 -0
- package/src/session-runtime/provider-auth-api.mjs +16 -1
- package/src/session-runtime/provider-usage.mjs +3 -0
- package/src/session-runtime/remote-control.mjs +166 -0
- package/src/session-runtime/remote-transcript.mjs +126 -0
- package/src/session-runtime/remote-transition-queue.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +184 -660
- package/src/session-runtime/runtime-tunables.mjs +57 -0
- package/src/session-runtime/self-update.mjs +129 -0
- package/src/session-runtime/skills-api.mjs +77 -0
- package/src/session-runtime/tool-surface.mjs +83 -0
- package/src/session-runtime/workflow-agents-api.mjs +206 -3
- package/src/session-runtime/workflow.mjs +84 -17
- package/src/standalone/agent-tool/worker-index.mjs +287 -0
- package/src/standalone/agent-tool.mjs +22 -346
- package/src/standalone/agent-watchdog-registry.mjs +101 -0
- package/src/standalone/channel-admin.mjs +36 -1
- package/src/standalone/channel-daemon-client.mjs +5 -1
- package/src/standalone/channel-daemon-transport.mjs +112 -20
- package/src/standalone/channel-daemon.mjs +6 -4
- package/src/standalone/channel-worker.mjs +7 -13
- package/src/tui/App.jsx +2 -32
- package/src/tui/app/channel-pickers.mjs +2 -143
- package/src/tui/app/slash-commands.mjs +1 -3
- package/src/tui/app/slash-dispatch.mjs +3 -3
- package/src/tui/components/StatusLine.jsx +6 -5
- package/src/tui/dist/index.mjs +162 -259
- package/src/tui/engine/labels.mjs +0 -8
- package/src/tui/engine/session-api-ext.mjs +45 -10
- package/src/tui/engine/turn-watchdog.mjs +92 -0
- package/src/tui/engine/turn.mjs +18 -70
- package/src/tui/engine.mjs +14 -1
- package/src/workflows/default/WORKFLOW.md +1 -1
- 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.
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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) {
|