mixdog 0.9.40 → 0.9.43
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 +2 -1
- package/scripts/agent-tag-reuse-smoke.mjs +124 -10
- package/scripts/anthropic-oauth-refresh-race-test.mjs +397 -0
- package/scripts/arg-guard-test.mjs +16 -0
- package/scripts/compact-pressure-test.mjs +383 -0
- package/scripts/compact-trigger-migration-smoke.mjs +8 -8
- package/scripts/internal-comms-bench.mjs +0 -1
- package/scripts/internal-comms-smoke.mjs +54 -31
- package/scripts/internal-tools-normalization-test.mjs +52 -0
- package/scripts/max-output-recovery-persist-test.mjs +81 -0
- package/scripts/max-output-recovery-test.mjs +271 -0
- package/scripts/mcp-client-normalization-test.mjs +45 -0
- package/scripts/provider-toolcall-test.mjs +55 -0
- package/scripts/result-classification-test.mjs +75 -0
- package/scripts/smoke.mjs +1 -1
- package/scripts/submit-commandbusy-race-test.mjs +99 -7
- package/scripts/tui-transcript-perf-test.mjs +56 -1
- package/src/agents/reviewer/AGENT.md +8 -0
- package/src/rules/lead/lead-brief.md +11 -3
- package/src/rules/lead/lead-tool.md +0 -2
- package/src/rules/shared/01-tool.md +4 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
- package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
- package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +193 -13
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +72 -12
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +144 -84
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +124 -8
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -7
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +28 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
- package/src/runtime/memory/tool-defs.mjs +4 -3
- package/src/runtime/shared/tool-surface.mjs +1 -2
- package/src/session-runtime/context-status.mjs +8 -2
- package/src/standalone/agent-tool/render.mjs +2 -0
- package/src/standalone/agent-tool.mjs +202 -22
- package/src/tui/components/StatusLine.jsx +4 -26
- package/src/tui/dist/index.mjs +46 -31
- package/src/tui/engine/session-api.mjs +3 -0
- package/src/tui/engine/session-flow.mjs +19 -8
- package/src/tui/engine/turn.mjs +24 -2
- package/src/tui/engine.mjs +0 -1
- package/src/ui/statusline-agents.mjs +0 -8
- package/src/ui/statusline.mjs +2 -4
- package/src/workflows/default/WORKFLOW.md +38 -24
- package/src/workflows/solo-review/WORKFLOW.md +47 -0
- package/src/workflows/bench/WORKFLOW.md +0 -36
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* the public functions so external callers keep their existing import path.
|
|
8
8
|
*/
|
|
9
9
|
import { readFileSync, existsSync, statSync } from 'fs';
|
|
10
|
-
import { join } from 'path';
|
|
10
|
+
import { join, resolve } from 'path';
|
|
11
11
|
import { createServer } from 'http';
|
|
12
12
|
import { randomBytes, createHash } from 'crypto';
|
|
13
|
-
import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
|
|
13
|
+
import { writeJsonAtomicSync, withFileLock } from '../../../shared/atomic-file.mjs';
|
|
14
14
|
import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
|
|
15
15
|
import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
|
|
16
16
|
|
|
@@ -33,6 +33,7 @@ export const TOKEN_URL = assertSafeTokenURL(process.env.ANTHROPIC_OAUTH_TOKEN_UR
|
|
|
33
33
|
export const DEFAULT_CREDENTIALS_PATH = join(resolvePluginData(), 'anthropic-oauth-credentials.json');
|
|
34
34
|
export const CLAUDE_CODE_CLIENT_ID = process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
35
35
|
export const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
|
|
36
|
+
export const ANTHROPIC_OAUTH_REFRESH_DISABLED_ENV = 'MIXDOG_ANTHROPIC_OAUTH_REFRESH_DISABLED';
|
|
36
37
|
const CLAUDE_AI_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
|
|
37
38
|
const ALL_OAUTH_SCOPES = [
|
|
38
39
|
'org:create_api_key',
|
|
@@ -52,7 +53,10 @@ const OAUTH_SUCCESS_REDIRECT_URL = process.env.ANTHROPIC_OAUTH_SUCCESS_REDIRECT_
|
|
|
52
53
|
const OAUTH_LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
53
54
|
const OAUTH_TOKEN_TIMEOUT_MS = 30_000;
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
// Anthropic's token edge validates the Claude Code client identity. Keep this
|
|
57
|
+
// fallback aligned with the current official CLI while retaining the env
|
|
58
|
+
// override for seats where Claude Code is updated ahead of Mixdog.
|
|
59
|
+
export const DEFAULT_CLI_VERSION = '2.1.207';
|
|
56
60
|
|
|
57
61
|
export function resolveCliVersion() {
|
|
58
62
|
return process.env.MIXDOG_CLI_VERSION
|
|
@@ -67,10 +71,9 @@ function _pushUnique(list, value) {
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
export function credentialCandidates() {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return paths;
|
|
74
|
+
const explicit = process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH;
|
|
75
|
+
if (explicit) return [resolve(explicit)];
|
|
76
|
+
return [DEFAULT_CREDENTIALS_PATH];
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
// Fallback expiry from the access_token's JWT `exp` claim (epoch ms) when the
|
|
@@ -109,6 +112,11 @@ function _loadCredentialsFile(path) {
|
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
export function loadCredentialsFromPath(path) {
|
|
116
|
+
if (!path) return null;
|
|
117
|
+
return _loadCredentialsFile(resolve(path));
|
|
118
|
+
}
|
|
119
|
+
|
|
112
120
|
// Cross-process safe credential save. Lockfile (O_EXCL) prevents two Mixdog
|
|
113
121
|
// refreshers from clobbering each other; atomic rename guarantees readers see
|
|
114
122
|
// either the old or new file, never a half-written one. Used so refresh_token
|
|
@@ -205,17 +213,52 @@ export function _normalizeExpiresAt(value) {
|
|
|
205
213
|
return value < 1e12 ? value * 1000 : value;
|
|
206
214
|
}
|
|
207
215
|
|
|
208
|
-
export function _scrubTokens(text) {
|
|
209
|
-
|
|
210
|
-
.replace(/Bearer [A-Za-z0-9._\-]+/
|
|
216
|
+
export function _scrubTokens(text, secretValues = []) {
|
|
217
|
+
let scrubbed = String(text || '')
|
|
218
|
+
.replace(/Bearer [A-Za-z0-9._\-]+/gi, 'Bearer [REDACTED]')
|
|
211
219
|
.replace(/sk-ant-[A-Za-z0-9._\-]+/g, '[REDACTED]')
|
|
212
220
|
.replace(/"access[Tt]oken"\s*:\s*"[^"]+"/g, '"accessToken":"[REDACTED]"')
|
|
213
221
|
.replace(/"refresh[Tt]oken"\s*:\s*"[^"]+"/g, '"refreshToken":"[REDACTED]"')
|
|
214
222
|
.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token":"[REDACTED]"')
|
|
215
223
|
.replace(/"refresh_token"\s*:\s*"[^"]+"/g, '"refresh_token":"[REDACTED]"');
|
|
224
|
+
// Token services sometimes echo submitted values in non-JSON diagnostics.
|
|
225
|
+
// Scrub the exact request secrets as a final guard without logging them.
|
|
226
|
+
for (const secret of secretValues) {
|
|
227
|
+
if (typeof secret === 'string' && secret) {
|
|
228
|
+
scrubbed = scrubbed.split(secret).join('[REDACTED]');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return scrubbed;
|
|
216
232
|
}
|
|
217
233
|
|
|
218
|
-
|
|
234
|
+
function _tokenEndpointError(operation, status, text, secretValues = []) {
|
|
235
|
+
const safeDetail = _scrubTokens(text, secretValues)
|
|
236
|
+
.replace(/\s+/g, ' ')
|
|
237
|
+
.trim()
|
|
238
|
+
.slice(0, 500);
|
|
239
|
+
const detail = safeDetail ? `: ${safeDetail}` : '';
|
|
240
|
+
const compatibility = status === 403
|
|
241
|
+
? (
|
|
242
|
+
` Anthropic returned HTTP 403 for a request sent as claude-cli/${resolveCliVersion()}.`
|
|
243
|
+
+ ' Possible causes include OAuth client compatibility, account or scope policy,'
|
|
244
|
+
+ ' an intercepting proxy/VPN/WAF, or regional endpoint restrictions.'
|
|
245
|
+
+ ' Verify the account has Claude Code access and the required scopes;'
|
|
246
|
+
+ ' update Mixdog or set MIXDOG_CLI_VERSION to the installed official Claude Code version;'
|
|
247
|
+
+ ' then retry sign-in via /providers without an intercepting network layer if applicable.'
|
|
248
|
+
)
|
|
249
|
+
: '';
|
|
250
|
+
return new Error(`${operation} ${status}${detail}.${compatibility}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function isAnthropicOAuthRefreshDisabled() {
|
|
254
|
+
return process.env[ANTHROPIC_OAUTH_REFRESH_DISABLED_ENV] === '1';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _refreshLockPath(path) {
|
|
258
|
+
return `${resolve(path)}.anthropic-oauth-refresh.lock`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function _refreshOAuthCredentialsUnlocked(creds) {
|
|
219
262
|
if (!creds?.refreshToken) {
|
|
220
263
|
throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
|
|
221
264
|
}
|
|
@@ -248,7 +291,15 @@ export async function refreshOAuthCredentials(creds) {
|
|
|
248
291
|
try { json = text ? JSON.parse(text) : null; } catch { /* handled below */ }
|
|
249
292
|
if (!res.ok) {
|
|
250
293
|
const isInvalidGrant = text.includes('invalid_grant') || json?.error === 'invalid_grant';
|
|
251
|
-
throw Object.assign(
|
|
294
|
+
throw Object.assign(
|
|
295
|
+
_tokenEndpointError(
|
|
296
|
+
'token refresh',
|
|
297
|
+
res.status,
|
|
298
|
+
text,
|
|
299
|
+
[creds.refreshToken, creds.accessToken],
|
|
300
|
+
),
|
|
301
|
+
{ isInvalidGrant },
|
|
302
|
+
);
|
|
252
303
|
}
|
|
253
304
|
|
|
254
305
|
const accessToken = json?.access_token || json?.accessToken;
|
|
@@ -294,6 +345,130 @@ export async function refreshOAuthCredentials(creds) {
|
|
|
294
345
|
}
|
|
295
346
|
}
|
|
296
347
|
|
|
348
|
+
export async function refreshOAuthCredentials(creds) {
|
|
349
|
+
if (isAnthropicOAuthRefreshDisabled()) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
'Anthropic OAuth refresh is disabled in this process; '
|
|
352
|
+
+ 'host credential preflight must provide a fresh snapshot.',
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const credentialPath = resolve(
|
|
356
|
+
creds?.path
|
|
357
|
+
|| process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH
|
|
358
|
+
|| DEFAULT_CREDENTIALS_PATH,
|
|
359
|
+
);
|
|
360
|
+
return withFileLock(_refreshLockPath(credentialPath), async () => {
|
|
361
|
+
const disk = _loadCredentialsFile(credentialPath);
|
|
362
|
+
// A waiter that started with the prior generation must consume the
|
|
363
|
+
// winner's persisted rotation, not replay the single-use token.
|
|
364
|
+
if (disk?.accessToken && creds?.accessToken
|
|
365
|
+
&& disk.accessToken !== creds.accessToken
|
|
366
|
+
&& (!disk.expiresAt || disk.expiresAt > Date.now())) {
|
|
367
|
+
return disk;
|
|
368
|
+
}
|
|
369
|
+
return _refreshOAuthCredentialsUnlocked(
|
|
370
|
+
disk || { ...creds, path: credentialPath },
|
|
371
|
+
);
|
|
372
|
+
}, {
|
|
373
|
+
timeoutMs: 120_000,
|
|
374
|
+
staleMs: 120_000,
|
|
375
|
+
secret: true,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Serialize host-side refresh, establish a bounded access-token lease, and
|
|
381
|
+
* optionally write an owner-only snapshot while the lease lock is still held.
|
|
382
|
+
* The returned object deliberately contains metadata only, never token bytes.
|
|
383
|
+
*/
|
|
384
|
+
export async function preflightAnthropicOAuthCredentials({
|
|
385
|
+
credentialsPath = null,
|
|
386
|
+
minimumValidityMs = TOKEN_REFRESH_SKEW_MS,
|
|
387
|
+
snapshotPath = null,
|
|
388
|
+
refreshFn = refreshOAuthCredentials,
|
|
389
|
+
now = () => Date.now(),
|
|
390
|
+
lockTimeoutMs = 120_000,
|
|
391
|
+
} = {}) {
|
|
392
|
+
if (isAnthropicOAuthRefreshDisabled()) {
|
|
393
|
+
throw new Error('Anthropic OAuth host preflight cannot run while refresh is disabled.');
|
|
394
|
+
}
|
|
395
|
+
const requiredMs = Number(minimumValidityMs);
|
|
396
|
+
if (!Number.isFinite(requiredMs) || requiredMs < 0) {
|
|
397
|
+
throw new Error('Anthropic OAuth host preflight minimumValidityMs must be a non-negative number.');
|
|
398
|
+
}
|
|
399
|
+
const pinnedPath = resolve(
|
|
400
|
+
credentialsPath
|
|
401
|
+
|| process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH
|
|
402
|
+
|| DEFAULT_CREDENTIALS_PATH,
|
|
403
|
+
);
|
|
404
|
+
const initial = _loadCredentialsFile(pinnedPath);
|
|
405
|
+
if (!initial?.path || !initial.accessToken) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`Anthropic OAuth host preflight found no credentials at ${pinnedPath}. `
|
|
408
|
+
+ 'Open /providers in mixdog to sign in.',
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return withFileLock(_refreshLockPath(pinnedPath), async () => {
|
|
413
|
+
let leased = _loadCredentialsFile(pinnedPath);
|
|
414
|
+
if (!leased?.path || !leased.accessToken) {
|
|
415
|
+
throw new Error('Anthropic OAuth credentials disappeared during host preflight.');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const validAfter = now() + requiredMs;
|
|
419
|
+
let refreshed = false;
|
|
420
|
+
if (!leased.expiresAt || leased.expiresAt < validAfter) {
|
|
421
|
+
if (!leased.refreshToken) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
'Anthropic OAuth host preflight cannot establish the required lease: '
|
|
424
|
+
+ 'refresh token is unavailable.',
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
// The public refresh function owns this same lock. Call its
|
|
428
|
+
// unlocked exchange only because preflight already owns it;
|
|
429
|
+
// injected test exchanges run under the identical ownership.
|
|
430
|
+
const next = refreshFn === refreshOAuthCredentials
|
|
431
|
+
? await _refreshOAuthCredentialsUnlocked(leased)
|
|
432
|
+
: await refreshFn(leased);
|
|
433
|
+
const persisted = _loadCredentialsFile(pinnedPath);
|
|
434
|
+
if (!persisted?.accessToken || persisted.accessToken !== next?.accessToken) {
|
|
435
|
+
throw new Error('Anthropic OAuth host preflight refresh was not persisted.');
|
|
436
|
+
}
|
|
437
|
+
leased = persisted;
|
|
438
|
+
refreshed = true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const remainingMs = leased.expiresAt ? leased.expiresAt - now() : 0;
|
|
442
|
+
if (remainingMs < requiredMs) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`Anthropic OAuth host preflight cannot satisfy the ${Math.ceil(requiredMs / 1000)}s `
|
|
445
|
+
+ `credential lease (provider granted ${Math.max(0, Math.floor(remainingMs / 1000))}s).`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (snapshotPath) {
|
|
450
|
+
const raw = JSON.parse(readFileSync(leased.path, 'utf-8'));
|
|
451
|
+
// Containers need only the leased access token. Never distribute
|
|
452
|
+
// the rotating refresh credential, even into disposable storage.
|
|
453
|
+
if (raw?.claudeAiOauth) {
|
|
454
|
+
delete raw.claudeAiOauth.refreshToken;
|
|
455
|
+
delete raw.claudeAiOauth.refresh_token;
|
|
456
|
+
}
|
|
457
|
+
_saveCredentialsFile(snapshotPath, raw);
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
expiresAt: leased.expiresAt,
|
|
461
|
+
remainingMs,
|
|
462
|
+
refreshed,
|
|
463
|
+
snapshotWritten: Boolean(snapshotPath),
|
|
464
|
+
};
|
|
465
|
+
}, {
|
|
466
|
+
timeoutMs: lockTimeoutMs,
|
|
467
|
+
staleMs: 120_000,
|
|
468
|
+
secret: true,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
297
472
|
// --- Login flow (PKCE loopback, export for setup UI / CLI) ---
|
|
298
473
|
|
|
299
474
|
function _oauthGeneratePKCE() {
|
|
@@ -358,7 +533,12 @@ async function exchangeAuthorizationCode({ pkce, code, state, redirectUri }) {
|
|
|
358
533
|
});
|
|
359
534
|
if (!tokenRes.ok) {
|
|
360
535
|
const text = await tokenRes.text().catch(() => '');
|
|
361
|
-
throw
|
|
536
|
+
throw _tokenEndpointError(
|
|
537
|
+
'[anthropic-oauth] token exchange',
|
|
538
|
+
tokenRes.status,
|
|
539
|
+
text,
|
|
540
|
+
[cleanCode, pkce.verifier, state],
|
|
541
|
+
);
|
|
362
542
|
}
|
|
363
543
|
const json = await tokenRes.json();
|
|
364
544
|
const accessToken = json?.access_token || json?.accessToken;
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { sanitizeToolPairs, sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../session/context-utils.mjs';
|
|
28
28
|
import {
|
|
29
29
|
TOKEN_REFRESH_SKEW_MS,
|
|
30
|
+
isAnthropicOAuthRefreshDisabled,
|
|
30
31
|
resolveCliVersion,
|
|
31
32
|
loadCredentials,
|
|
32
33
|
hasAnthropicOAuthCredentials,
|
|
@@ -342,7 +343,7 @@ function toAnthropicMessages(messages) {
|
|
|
342
343
|
const m = messages[idx];
|
|
343
344
|
if (m.role === 'system') continue;
|
|
344
345
|
|
|
345
|
-
if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length)) {
|
|
346
|
+
if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length || m.thinkingBlocks?.length)) {
|
|
346
347
|
let content;
|
|
347
348
|
if (m.assistantBlocks?.length) {
|
|
348
349
|
content = m.assistantBlocks.slice();
|
|
@@ -358,7 +359,7 @@ function toAnthropicMessages(messages) {
|
|
|
358
359
|
}
|
|
359
360
|
}
|
|
360
361
|
if (m.content) content.push({ type: 'text', text: m.content });
|
|
361
|
-
for (const tc of m.toolCalls) {
|
|
362
|
+
for (const tc of m.toolCalls || []) {
|
|
362
363
|
content.push({
|
|
363
364
|
type: 'tool_use',
|
|
364
365
|
id: tc.id,
|
|
@@ -703,6 +704,12 @@ export class AnthropicOAuthProvider {
|
|
|
703
704
|
const expiring = this.credentials.expiresAt
|
|
704
705
|
&& this.credentials.expiresAt < Date.now() + TOKEN_REFRESH_SKEW_MS;
|
|
705
706
|
if (forceRefresh || expiring) {
|
|
707
|
+
if (isAnthropicOAuthRefreshDisabled()) {
|
|
708
|
+
throw new Error(
|
|
709
|
+
'Anthropic OAuth credentials require refresh, but refresh is disabled '
|
|
710
|
+
+ 'in this container. Host credential preflight must provide a fresh snapshot.',
|
|
711
|
+
);
|
|
712
|
+
}
|
|
706
713
|
this.credentials = await this._refreshCredentials({ force: forceRefresh, reason });
|
|
707
714
|
}
|
|
708
715
|
|
|
@@ -710,6 +717,12 @@ export class AnthropicOAuthProvider {
|
|
|
710
717
|
}
|
|
711
718
|
|
|
712
719
|
async _refreshCredentials({ force = false, reason = 'preemptive' } = {}) {
|
|
720
|
+
if (isAnthropicOAuthRefreshDisabled()) {
|
|
721
|
+
throw new Error(
|
|
722
|
+
'Anthropic OAuth refresh is disabled in this container; '
|
|
723
|
+
+ 'host credential preflight must provide a fresh snapshot.',
|
|
724
|
+
);
|
|
725
|
+
}
|
|
713
726
|
const currentToken = this.credentials?.accessToken || null;
|
|
714
727
|
const disk = loadCredentials();
|
|
715
728
|
const validAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
|
|
@@ -111,6 +111,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
111
111
|
let toolCalls = [];
|
|
112
112
|
let usage = { inputTokens: 0, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, raw: null };
|
|
113
113
|
let stopReason = null;
|
|
114
|
+
let stopDetails;
|
|
114
115
|
let buffer = '';
|
|
115
116
|
let idleTimedOut = false;
|
|
116
117
|
let firstMessageTimedOut = false;
|
|
@@ -546,6 +547,18 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
546
547
|
if (event.delta?.stop_reason) {
|
|
547
548
|
stopReason = event.delta.stop_reason;
|
|
548
549
|
}
|
|
550
|
+
if (event.delta && (event.delta.stop_details != null || event.delta.category != null)) {
|
|
551
|
+
const details = event.delta.stop_details;
|
|
552
|
+
stopDetails = details && typeof details === 'object' && !Array.isArray(details)
|
|
553
|
+
? {
|
|
554
|
+
...details,
|
|
555
|
+
...(event.delta.category != null ? { category: event.delta.category } : {}),
|
|
556
|
+
}
|
|
557
|
+
: {
|
|
558
|
+
...(details != null ? { value: details } : {}),
|
|
559
|
+
...(event.delta.category != null ? { category: event.delta.category } : {}),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
549
562
|
if (event.usage) {
|
|
550
563
|
usage.outputTokens = event.usage.output_tokens || 0;
|
|
551
564
|
usage.raw = { ...(usage.raw || {}), ...event.usage };
|
|
@@ -615,6 +628,7 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
|
|
|
615
628
|
toolCalls: toolCalls.length ? toolCalls : undefined,
|
|
616
629
|
usage,
|
|
617
630
|
stopReason,
|
|
631
|
+
stopDetails,
|
|
618
632
|
hasThinkingContent,
|
|
619
633
|
contentBlockTypes: Array.from(contentBlockTypes),
|
|
620
634
|
// Ordered extended-thinking blocks (verbatim thinking text +
|
|
@@ -347,7 +347,7 @@ function toAnthropicMessages(messages) {
|
|
|
347
347
|
for (let idx = 0; idx < messages.length; idx++) {
|
|
348
348
|
const m = messages[idx];
|
|
349
349
|
if (m.role === 'system') continue;
|
|
350
|
-
if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length)) {
|
|
350
|
+
if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length || m.thinkingBlocks?.length)) {
|
|
351
351
|
let content;
|
|
352
352
|
if (m.assistantBlocks?.length) {
|
|
353
353
|
content = m.assistantBlocks.slice();
|
|
@@ -362,7 +362,7 @@ function toAnthropicMessages(messages) {
|
|
|
362
362
|
}
|
|
363
363
|
}
|
|
364
364
|
if (m.content) content.push({ type: 'text', text: m.content });
|
|
365
|
-
for (const tc of m.toolCalls) {
|
|
365
|
+
for (const tc of m.toolCalls || []) {
|
|
366
366
|
content.push({
|
|
367
367
|
type: 'tool_use',
|
|
368
368
|
id: tc.id,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
const DEFAULT_IMAGE_MIME = 'image/png';
|
|
2
4
|
|
|
3
5
|
function cleanMimeType(value) {
|
|
@@ -96,6 +98,9 @@ function contentParts(content) {
|
|
|
96
98
|
if (content && typeof content === 'object' && Array.isArray(content.content)) {
|
|
97
99
|
return content.content;
|
|
98
100
|
}
|
|
101
|
+
if (content && typeof content === 'object'
|
|
102
|
+
&& (imageUrlFromPart(content) || imageFileIdFromPart(content)
|
|
103
|
+
|| imageFileUriFromPart(content) || geminiInlineInfo(content))) return [content];
|
|
99
104
|
return null;
|
|
100
105
|
}
|
|
101
106
|
|
|
@@ -108,9 +113,61 @@ function jsonFallbackFromPart(block) {
|
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
export function contentHasImage(content) {
|
|
116
|
+
return contentImageCount(content) > 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function contentImageCount(content) {
|
|
120
|
+
return contentImageDescriptors(content).length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function positiveDimension(...values) {
|
|
124
|
+
for (const value of values) {
|
|
125
|
+
const number = Number(value);
|
|
126
|
+
if (Number.isFinite(number) && number > 0) return number;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function imageIdentity(kind, value, mimeType = '') {
|
|
132
|
+
return createHash('sha256')
|
|
133
|
+
.update(`${kind}\0${mimeType}\0`)
|
|
134
|
+
.update(String(value || ''))
|
|
135
|
+
.digest('hex');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function imageDescriptor(part) {
|
|
139
|
+
if (!part || typeof part !== 'object') return null;
|
|
140
|
+
const info = imageInfo(part);
|
|
141
|
+
const inline = geminiInlineInfo(part);
|
|
142
|
+
const url = imageUrlFromPart(part);
|
|
143
|
+
const fileId = imageFileIdFromPart(part);
|
|
144
|
+
const fileUri = imageFileUriFromPart(part);
|
|
145
|
+
if (!info && !inline && !url && !fileId && !fileUri) return null;
|
|
146
|
+
const imageUrl = part.image_url && typeof part.image_url === 'object' ? part.image_url : null;
|
|
147
|
+
const source = part.source && typeof part.source === 'object' ? part.source : null;
|
|
148
|
+
const inlineData = part.inlineData || part.inline_data;
|
|
149
|
+
const dimensions = part.dimensions && typeof part.dimensions === 'object' ? part.dimensions : null;
|
|
150
|
+
const width = positiveDimension(part.width, dimensions?.width, imageUrl?.width, source?.width, inlineData?.width);
|
|
151
|
+
const height = positiveDimension(part.height, dimensions?.height, imageUrl?.height, source?.height, inlineData?.height);
|
|
152
|
+
const detail = String(part.detail ?? imageUrl?.detail ?? source?.detail ?? inlineData?.detail ?? 'auto').toLowerCase();
|
|
153
|
+
if (info || inline) {
|
|
154
|
+
const raw = info || inline;
|
|
155
|
+
return {
|
|
156
|
+
identity: imageIdentity('inline', raw.data, raw.mimeType),
|
|
157
|
+
width,
|
|
158
|
+
height,
|
|
159
|
+
detail,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (fileId) return { identity: imageIdentity('file-id', fileId), width, height, detail };
|
|
163
|
+
if (fileUri) return { identity: imageIdentity('file-uri', fileUri.fileUri, fileUri.mimeType), width, height, detail };
|
|
164
|
+
return { identity: imageIdentity('url', url), width, height, detail };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function contentImageDescriptors(content) {
|
|
111
168
|
const parts = contentParts(content);
|
|
112
|
-
if (!parts) return
|
|
113
|
-
return parts.
|
|
169
|
+
if (!parts) return [];
|
|
170
|
+
return parts.map(imageDescriptor).filter(Boolean);
|
|
114
171
|
}
|
|
115
172
|
|
|
116
173
|
export function contentToText(content, fallback = '') {
|
|
@@ -51,6 +51,11 @@ import {
|
|
|
51
51
|
createActiveToolItemTracker,
|
|
52
52
|
hasCompleteToolCall as sharedHasCompleteToolCall,
|
|
53
53
|
} from './tool-stream-state.mjs';
|
|
54
|
+
import {
|
|
55
|
+
enableSessionTransportTracking,
|
|
56
|
+
disableSessionTransportTracking,
|
|
57
|
+
markSessionTransportActivity,
|
|
58
|
+
} from '../session/manager/runtime-liveness.mjs';
|
|
54
59
|
|
|
55
60
|
// Facade re-exports: the delta/matching helpers and usage/event helpers below
|
|
56
61
|
// were extracted to openai-ws-delta.mjs / openai-ws-events.mjs (no behavior
|
|
@@ -92,6 +97,11 @@ export const WS_PRE_RESPONSE_CREATED_MS = (() => {
|
|
|
92
97
|
// including metadata/keepalive, proves the socket is live.
|
|
93
98
|
export const WS_INTER_CHUNK_MS = PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS;
|
|
94
99
|
const X_CODEX_TURN_STATE_HEADER = 'x-codex-turn-state';
|
|
100
|
+
const WS_TRACE_ENABLED = process.env.MIXDOG_WS_TRACE === '1';
|
|
101
|
+
|
|
102
|
+
function _writeWsLifecycleTrace(lifecycle) {
|
|
103
|
+
process.stderr.write(`[ws-trace] t=${new Date().toISOString()} lifecycle=${lifecycle}\n`);
|
|
104
|
+
}
|
|
95
105
|
|
|
96
106
|
function _responseItemKey(item, fallbackIndex = 0) {
|
|
97
107
|
if (!item || typeof item !== 'object') return `primitive:${fallbackIndex}`;
|
|
@@ -239,6 +249,7 @@ export async function _streamResponse({
|
|
|
239
249
|
}) {
|
|
240
250
|
const errLabel = _wsErrLabel(traceProvider);
|
|
241
251
|
const socket = entry.socket;
|
|
252
|
+
enableSessionTransportTracking(state?.sessionId);
|
|
242
253
|
const preResponseCreatedMs = _positiveInt(_timeouts?.preResponseCreatedMs, WS_PRE_RESPONSE_CREATED_MS);
|
|
243
254
|
const interChunkMs = _positiveInt(_timeouts?.interChunkMs, WS_INTER_CHUNK_MS);
|
|
244
255
|
// First-MEANINGFUL-frame deadline. Distinct from preResponseCreatedMs (a
|
|
@@ -471,6 +482,9 @@ export async function _streamResponse({
|
|
|
471
482
|
let messageHandler = null;
|
|
472
483
|
let closeHandler = null;
|
|
473
484
|
let errorHandler = null;
|
|
485
|
+
let traceOpenHandler = null;
|
|
486
|
+
let tracePingHandler = null;
|
|
487
|
+
let tracePongHandler = null;
|
|
474
488
|
// SEMANTIC idle timer: distinct from the inter-chunk
|
|
475
489
|
// timer, which resets on EVERY frame (rate_limits/metadata/keepalive keep
|
|
476
490
|
// the socket "alive"). This timer resets ONLY on meaningful output deltas
|
|
@@ -668,6 +682,7 @@ export async function _streamResponse({
|
|
|
668
682
|
// frames never reach here, so they cannot satisfy the watchdog.
|
|
669
683
|
const bumpSemanticIdle = () => { clearFirstMeaningfulWatchdog(); resetSemanticIdle(); };
|
|
670
684
|
const cleanup = () => {
|
|
685
|
+
disableSessionTransportTracking(midState.sessionId);
|
|
671
686
|
if (idleTimer) clearTimeout(idleTimer);
|
|
672
687
|
if (interChunkTimer) { clearTimeout(interChunkTimer); interChunkTimer = null; }
|
|
673
688
|
if (semanticIdleTimer) { clearTimeout(semanticIdleTimer); semanticIdleTimer = null; }
|
|
@@ -676,6 +691,9 @@ export async function _streamResponse({
|
|
|
676
691
|
if (messageHandler) socket.off('message', messageHandler);
|
|
677
692
|
if (closeHandler) socket.off('close', closeHandler);
|
|
678
693
|
if (errorHandler) socket.off('error', errorHandler);
|
|
694
|
+
if (traceOpenHandler) socket.off('open', traceOpenHandler);
|
|
695
|
+
if (tracePingHandler) socket.off('ping', tracePingHandler);
|
|
696
|
+
if (tracePongHandler) socket.off('pong', tracePongHandler);
|
|
679
697
|
if (abortHandler && externalSignal) externalSignal.removeEventListener('abort', abortHandler);
|
|
680
698
|
};
|
|
681
699
|
const finish = () => {
|
|
@@ -755,6 +773,15 @@ export async function _streamResponse({
|
|
|
755
773
|
const text = typeof data === 'string' ? data : data.toString('utf-8');
|
|
756
774
|
const event = _parseEvent(text);
|
|
757
775
|
if (!event) return;
|
|
776
|
+
if (WS_TRACE_ENABLED) {
|
|
777
|
+
const type = typeof event.type === 'string' ? event.type : '';
|
|
778
|
+
const empty = type.endsWith('.delta')
|
|
779
|
+
&& (event.delta == null || event.delta === '');
|
|
780
|
+
process.stderr.write(
|
|
781
|
+
`[ws-trace] t=${new Date().toISOString()} type=${type} bytes=${Buffer.byteLength(text)} empty=${empty}\n`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
markSessionTransportActivity(midState.sessionId);
|
|
758
785
|
_traceWsHeaderKeys(entry, event, midState, traceProvider, model);
|
|
759
786
|
_captureTurnStateFromEvent(entry, event);
|
|
760
787
|
if (event.error) {
|
|
@@ -780,12 +807,9 @@ export async function _streamResponse({
|
|
|
780
807
|
// first-meaningful watchdog (keepalive/metadata frames do
|
|
781
808
|
// NOT reach this case, so they never clear it).
|
|
782
809
|
clearFirstMeaningfulWatchdog();
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
// never emits a delta would stall unbounded until the outer
|
|
787
|
-
// watchdog. Arming here bounds that gap.
|
|
788
|
-
resetSemanticIdle();
|
|
810
|
+
// Do not arm semantic idle until actual model progress.
|
|
811
|
+
// Transport-only reasoning heartbeats are bounded by the
|
|
812
|
+
// outer first-visible ceiling instead.
|
|
789
813
|
break;
|
|
790
814
|
case 'response.output_text.delta':
|
|
791
815
|
try {
|
|
@@ -814,14 +838,12 @@ export async function _streamResponse({
|
|
|
814
838
|
case 'response.reasoning_summary_text.delta':
|
|
815
839
|
if (event.type === 'response.reasoning_text.delta') reasoningTextDeltaCount += 1;
|
|
816
840
|
else reasoningSummaryTextDeltaCount += 1;
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
try { onStreamDelta?.(); } catch {}
|
|
824
|
-
bumpSemanticIdle();
|
|
841
|
+
// Only non-empty reasoning text is model progress. Empty
|
|
842
|
+
// deltas remain transport activity via resetIdle() above.
|
|
843
|
+
if (event.delta) {
|
|
844
|
+
try { onStreamDelta?.(); } catch {}
|
|
845
|
+
bumpSemanticIdle();
|
|
846
|
+
}
|
|
825
847
|
break;
|
|
826
848
|
case 'response.output_item.added':
|
|
827
849
|
if (event.item?.type === 'function_call') {
|
|
@@ -1184,25 +1206,24 @@ export async function _streamResponse({
|
|
|
1184
1206
|
&& event.type.startsWith('response.reasoning')
|
|
1185
1207
|
&& event.type.endsWith('.delta')) {
|
|
1186
1208
|
reasoningOtherDeltaCount += 1;
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
bumpSemanticIdle();
|
|
1209
|
+
// Only non-empty reasoning deltas are model progress;
|
|
1210
|
+
// empty variants remain transport activity only.
|
|
1211
|
+
if (event.delta) {
|
|
1212
|
+
try { onStreamDelta?.(); } catch {}
|
|
1213
|
+
bumpSemanticIdle();
|
|
1214
|
+
}
|
|
1194
1215
|
}
|
|
1195
|
-
// response.in_progress is
|
|
1196
|
-
//
|
|
1197
|
-
// not counted as silence (resetIdle already ran at the top).
|
|
1216
|
+
// response.in_progress is transport activity only;
|
|
1217
|
+
// resetIdle() already kept the socket alive above.
|
|
1198
1218
|
else if (event.type === 'response.in_progress') {
|
|
1199
|
-
|
|
1219
|
+
// No semantic/model progress.
|
|
1200
1220
|
}
|
|
1201
1221
|
// Other trace-only events fall through.
|
|
1202
1222
|
break;
|
|
1203
1223
|
}
|
|
1204
1224
|
};
|
|
1205
1225
|
closeHandler = (code, reason) => {
|
|
1226
|
+
if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('close');
|
|
1206
1227
|
if (done) return;
|
|
1207
1228
|
midState.wsCloseCode = code;
|
|
1208
1229
|
if (!terminalError) {
|
|
@@ -1219,6 +1240,7 @@ export async function _streamResponse({
|
|
|
1219
1240
|
finish();
|
|
1220
1241
|
};
|
|
1221
1242
|
errorHandler = (err) => {
|
|
1243
|
+
if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('error');
|
|
1222
1244
|
if (done) return;
|
|
1223
1245
|
const wrapped = err instanceof Error ? err : new Error(String(err));
|
|
1224
1246
|
if (terminalError) {
|
|
@@ -1268,6 +1290,15 @@ export async function _streamResponse({
|
|
|
1268
1290
|
socket.on('message', messageHandler);
|
|
1269
1291
|
socket.on('close', closeHandler);
|
|
1270
1292
|
socket.on('error', errorHandler);
|
|
1293
|
+
if (WS_TRACE_ENABLED) {
|
|
1294
|
+
traceOpenHandler = () => _writeWsLifecycleTrace('open');
|
|
1295
|
+
tracePingHandler = () => _writeWsLifecycleTrace('ping');
|
|
1296
|
+
tracePongHandler = () => _writeWsLifecycleTrace('pong');
|
|
1297
|
+
socket.on('open', traceOpenHandler);
|
|
1298
|
+
socket.on('ping', tracePingHandler);
|
|
1299
|
+
socket.on('pong', tracePongHandler);
|
|
1300
|
+
if (socket.readyState === WebSocket.OPEN) _writeWsLifecycleTrace('open');
|
|
1301
|
+
}
|
|
1271
1302
|
armPreStreamWatchdog();
|
|
1272
1303
|
armFirstMeaningfulWatchdog();
|
|
1273
1304
|
// Periodic client-side WS ping while the stream is active. The server's
|
|
@@ -1280,6 +1311,7 @@ export async function _streamResponse({
|
|
|
1280
1311
|
keepaliveTimer = setInterval(() => {
|
|
1281
1312
|
try {
|
|
1282
1313
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
1314
|
+
if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('ping');
|
|
1283
1315
|
socket.ping();
|
|
1284
1316
|
} catch {}
|
|
1285
1317
|
}, 10_000);
|