mixdog 0.9.141 → 0.9.143

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 (62) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-lead-e2e-probe.mjs +1 -6
  3. package/scripts/agent-turn-trace-probe.mjs +3 -5
  4. package/scripts/native-binary-arch.mjs +93 -0
  5. package/scripts/native-binary-arch.test.mjs +75 -0
  6. package/scripts/native-tool-download.mjs +118 -0
  7. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +0 -39
  8. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +28 -22
  9. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  10. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +1 -1
  11. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +1 -1
  13. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +9 -9
  15. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +1 -1
  18. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +1 -16
  19. package/src/runtime/agent/orchestrator/session/token-native.mjs +40 -40
  20. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +10 -10
  22. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -7
  23. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +6 -6
  24. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +3 -3
  25. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +3 -3
  27. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +2 -2
  28. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  29. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +1 -1
  30. package/src/runtime/channels/lib/runtime-paths.mjs +1 -1
  31. package/src/runtime/shared/child-spawn-remote.mjs +7 -8
  32. package/src/runtime/shared/resource-admission.mjs +20 -0
  33. package/src/runtime/shared/resource-admission.test.mjs +43 -0
  34. package/src/runtime/shared/{session-shard-health.mjs → session-runtime-health.mjs} +14 -14
  35. package/src/runtime/shared/session-runtime-health.test.mjs +30 -0
  36. package/src/session-runtime/runtime-core.mjs +21 -41
  37. package/src/session-runtime/session-lifecycle.mjs +0 -32
  38. package/src/session-runtime/session-turn-api.mjs +1 -1
  39. package/src/standalone/agent-tool/helpers.mjs +1 -1
  40. package/src/standalone/agent-tool/spawn-flow.mjs +1 -30
  41. package/src/standalone/agent-tool.mjs +1 -7
  42. package/src/standalone/channel-worker.mjs +3 -3
  43. package/src/standalone/daemon.mjs +14 -18
  44. package/src/standalone/session-client.mjs +1 -1
  45. package/src/standalone/{session-runtime-pool-health.test.mjs → session-runtime-host-health.test.mjs} +22 -23
  46. package/src/standalone/{session-runtime-pool.mjs → session-runtime-host.mjs} +69 -291
  47. package/src/standalone/session-runtime-record.mjs +2 -2
  48. package/src/standalone/session-runtime-worker.mjs +20 -21
  49. package/src/standalone/session-service.mjs +3 -28
  50. package/src/standalone/session-state-patch.mjs +1 -1
  51. package/src/standalone/session-transport.mjs +1 -1
  52. package/src/tui/app/use-mouse-input.mjs +1 -1
  53. package/src/tui/components/tool-output-format.mjs +2 -2
  54. package/src/tui/dist/index.mjs +1 -1
  55. package/src/tui/session-local.mjs +1 -3
  56. package/src/tui/themes/teal.mjs +1 -1
  57. package/scripts/agent-long-prompt-repro.mjs +0 -82
  58. package/scripts/agent-shard-spread-perf.mjs +0 -245
  59. package/scripts/fixtures/session-shard-fixture-worker.mjs +0 -13
  60. package/src/runtime/shared/session-shard-health.test.mjs +0 -30
  61. package/src/standalone/agent-tool/shard-spread.mjs +0 -709
  62. package/src/standalone/agent-tool/shard-spread.test.mjs +0 -68
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.141",
3
+ "version": "0.9.143",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -15,12 +15,7 @@ import { homedir, tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
 
17
17
  const ROOT = mkdtempSync(join(tmpdir(), 'mixdog-lead-e2e-'));
18
- // MIXDOG_LEAD_E2E_LIVE_DAEMON=1: keep the inherited runtime root (live daemon
19
- // discovery) and opt into agent shard spread, so the spawned worker runs on
20
- // the INSTALLED daemon's shard pool — the deployed remote-completion path.
21
- const LIVE_DAEMON = process.env.MIXDOG_LEAD_E2E_LIVE_DAEMON === '1';
22
- if (LIVE_DAEMON) process.env.MIXDOG_AGENT_SHARD_SPREAD = '1';
23
- else process.env.MIXDOG_RUNTIME_ROOT = ROOT;
18
+ process.env.MIXDOG_RUNTIME_ROOT = ROOT;
24
19
  process.env.MIXDOG_BOOT_CORE_MEMORY = '0';
25
20
  process.env.MIXDOG_DAEMON_SKIP_MEMORY = '1';
26
21
  process.env.MIXDOG_FEATURE_MEMORY = '0';
@@ -17,9 +17,7 @@ const ROOT = mkdtempSync(join(tmpdir(), 'mixdog-turn-trace-'));
17
17
  process.env.MIXDOG_RUNTIME_ROOT = ROOT;
18
18
  process.env.MIXDOG_DAEMON_SKIP_MEMORY = '1';
19
19
  process.env.MIXDOG_BOOT_CORE_MEMORY = '0';
20
- process.env.MIXDOG_AGENT_SHARD_SPREAD = '1';
21
- // The probe's whole point is the trace: explicit path (shared by daemon and
22
- // shard children via env inheritance) + timing rows.
20
+ // The probe's whole point is the trace: explicit path plus timing rows.
23
21
  const TRACE_PATH = join(ROOT, 'agent-trace.jsonl');
24
22
  delete process.env.MIXDOG_AGENT_TRACE_DISABLE;
25
23
  process.env.MIXDOG_AGENT_TRACE_PATH = TRACE_PATH;
@@ -75,7 +73,7 @@ try {
75
73
  '2) read README.md (first 40 lines)',
76
74
  '3) run the shell command: node -v',
77
75
  '4) read apps/desktop/package.json',
78
- '5) grep the string "createSessionRuntimePool" under src/standalone (files list only)',
76
+ '5) grep the string "createSessionRuntimeHost" under src/standalone (files list only)',
79
77
  'Then reply with one line: DONE <package name> <node version>. Do not edit anything.',
80
78
  ].join('\n'),
81
79
  }, { invocationSource: 'model-tool', cwd: REPO });
@@ -90,7 +88,7 @@ try {
90
88
  process.stdout.write(`wall=${((Date.now() - t0) / 1000).toFixed(1)}s\n--- result ---\n${last.slice(0, 400)}\n`);
91
89
  try { agent.closeAll('turn-trace probe end'); } catch { /* teardown */ }
92
90
 
93
- // Shard children flush their local trace buffers on a short timer.
91
+ // The runtime worker flushes its local trace buffer on a short timer.
94
92
  await sleep(9_000);
95
93
  const rows = existsSync(TRACE_PATH)
96
94
  ? readFileSync(TRACE_PATH, 'utf8').split('\n').filter(Boolean).flatMap((line) => {
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Architecture of a compiled artifact, read from its own header.
5
+ *
6
+ * The desktop runtime is prepared for a TARGET that need not match the build
7
+ * host, and the expensive failure is silent: an arm64 addon inside an x64 app
8
+ * packages, uploads, and publishes without complaint, then refuses to load on
9
+ * the user's machine. Reading the header turns that into a build error at the
10
+ * moment the archive is assembled.
11
+ *
12
+ * Only the fields that name the architecture are parsed; nothing here loads or
13
+ * executes the file.
14
+ */
15
+
16
+ // Mach-O cpu_type_t. The 0x01000000 bit marks the 64-bit variants.
17
+ const MACHO_CPU = new Map([[0x01000007, 'x64'], [0x0100000c, 'arm64'], [7, 'ia32']])
18
+ // ELF e_machine.
19
+ const ELF_MACHINE = new Map([[0x3e, 'x64'], [0xb7, 'arm64'], [0x03, 'ia32']])
20
+ // PE IMAGE_FILE_HEADER.Machine.
21
+ const PE_MACHINE = new Map([[0x8664, 'x64'], [0xaa64, 'arm64'], [0x14c, 'ia32']])
22
+
23
+ function machOArch(buffer) {
24
+ if (buffer.length < 8) return null
25
+ const magic = buffer.readUInt32BE(0)
26
+ // Thin Mach-O: 0xfeedfacf (64-bit) / 0xfeedface (32-bit), either endianness.
27
+ if (magic === 0xfeedfacf || magic === 0xfeedface) return MACHO_CPU.get(buffer.readUInt32BE(4)) || null
28
+ if (magic === 0xcffaedfe || magic === 0xcefaedfe) return MACHO_CPU.get(buffer.readUInt32LE(4)) || null
29
+ return null
30
+ }
31
+
32
+ /** Universal binaries name every slice they carry, so they are read as a set. */
33
+ function machOFatArches(buffer) {
34
+ if (buffer.length < 8) return null
35
+ const magic = buffer.readUInt32BE(0)
36
+ // 0xcafebabe is also a Java class file; those never reach an addon path, and
37
+ // the slice count sanity check below rejects the overlap in practice.
38
+ if (magic !== 0xcafebabe && magic !== 0xcafebabf) return null
39
+ const wide = magic === 0xcafebabf
40
+ const count = buffer.readUInt32BE(4)
41
+ if (!count || count > 16) return null
42
+ const entrySize = wide ? 32 : 20
43
+ const arches = []
44
+ for (let index = 0; index < count; index += 1) {
45
+ const offset = 8 + index * entrySize
46
+ if (offset + 4 > buffer.length) return null
47
+ const arch = MACHO_CPU.get(buffer.readUInt32BE(offset))
48
+ if (arch) arches.push(arch)
49
+ }
50
+ return arches.length ? arches : null
51
+ }
52
+
53
+ function elfArch(buffer) {
54
+ if (buffer.length < 20) return null
55
+ if (buffer.readUInt32BE(0) !== 0x7f454c46) return null
56
+ const littleEndian = buffer[5] === 1
57
+ const machine = littleEndian ? buffer.readUInt16LE(18) : buffer.readUInt16BE(18)
58
+ return ELF_MACHINE.get(machine) || null
59
+ }
60
+
61
+ function peArch(buffer) {
62
+ if (buffer.length < 64 || buffer.readUInt16LE(0) !== 0x5a4d) return null
63
+ const headerOffset = buffer.readUInt32LE(60)
64
+ if (headerOffset + 6 > buffer.length) return null
65
+ if (buffer.readUInt32LE(headerOffset) !== 0x00004550) return null
66
+ return PE_MACHINE.get(buffer.readUInt16LE(headerOffset + 4)) || null
67
+ }
68
+
69
+ /**
70
+ * Every architecture the artifact can run on, or null when the bytes carry no
71
+ * recognizable object header (a text stub, a placeholder, a data file).
72
+ */
73
+ export function nativeBinaryArches(buffer) {
74
+ const fat = machOFatArches(buffer)
75
+ if (fat) return fat
76
+ const single = machOArch(buffer) || elfArch(buffer) || peArch(buffer)
77
+ return single ? [single] : null
78
+ }
79
+
80
+ /** Convenience for the single-slice case; a fat binary reports its first slice. */
81
+ export function nativeBinaryArch(buffer) {
82
+ return nativeBinaryArches(buffer)?.[0] ?? null
83
+ }
84
+
85
+ /**
86
+ * True when the artifact can run on `arch`. An unrecognized header answers
87
+ * true: the caller is verifying compiled objects, and refusing every file it
88
+ * cannot parse would fail builds over unrelated payloads.
89
+ */
90
+ export function nativeBinaryRunsOn(buffer, arch) {
91
+ const arches = nativeBinaryArches(buffer)
92
+ return arches === null || arches.includes(arch)
93
+ }
@@ -0,0 +1,75 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import {
5
+ nativeBinaryArch,
6
+ nativeBinaryArches,
7
+ nativeBinaryRunsOn,
8
+ } from './native-binary-arch.mjs'
9
+
10
+ function machO({ cpu, littleEndian = true, wide = true }) {
11
+ const buffer = Buffer.alloc(32)
12
+ const magic = wide ? 0xfeedfacf : 0xfeedface
13
+ if (littleEndian) {
14
+ buffer.writeUInt32BE(wide ? 0xcffaedfe : 0xcefaedfe, 0)
15
+ buffer.writeUInt32LE(cpu, 4)
16
+ } else {
17
+ buffer.writeUInt32BE(magic, 0)
18
+ buffer.writeUInt32BE(cpu, 4)
19
+ }
20
+ return buffer
21
+ }
22
+
23
+ function machOFat(cpus) {
24
+ const buffer = Buffer.alloc(8 + cpus.length * 20)
25
+ buffer.writeUInt32BE(0xcafebabe, 0)
26
+ buffer.writeUInt32BE(cpus.length, 4)
27
+ cpus.forEach((cpu, index) => buffer.writeUInt32BE(cpu, 8 + index * 20))
28
+ return buffer
29
+ }
30
+
31
+ function elf(machine) {
32
+ const buffer = Buffer.alloc(32)
33
+ buffer.writeUInt32BE(0x7f454c46, 0)
34
+ buffer[5] = 1
35
+ buffer.writeUInt16LE(machine, 18)
36
+ return buffer
37
+ }
38
+
39
+ function pe(machine) {
40
+ const buffer = Buffer.alloc(128)
41
+ buffer.writeUInt16LE(0x5a4d, 0)
42
+ buffer.writeUInt32LE(64, 60)
43
+ buffer.writeUInt32LE(0x00004550, 64)
44
+ buffer.writeUInt16LE(machine, 68)
45
+ return buffer
46
+ }
47
+
48
+ test('Mach-O headers name their architecture in both endiannesses', () => {
49
+ assert.equal(nativeBinaryArch(machO({ cpu: 0x01000007 })), 'x64')
50
+ assert.equal(nativeBinaryArch(machO({ cpu: 0x0100000c })), 'arm64')
51
+ assert.equal(nativeBinaryArch(machO({ cpu: 0x01000007, littleEndian: false })), 'x64')
52
+ assert.equal(nativeBinaryArch(machO({ cpu: 7, wide: false })), 'ia32')
53
+ })
54
+
55
+ test('universal binaries report every slice they carry', () => {
56
+ assert.deepEqual(nativeBinaryArches(machOFat([0x01000007, 0x0100000c])), ['x64', 'arm64'])
57
+ assert.equal(nativeBinaryRunsOn(machOFat([0x01000007, 0x0100000c]), 'arm64'), true)
58
+ })
59
+
60
+ test('ELF and PE headers are read too', () => {
61
+ assert.equal(nativeBinaryArch(elf(0x3e)), 'x64')
62
+ assert.equal(nativeBinaryArch(elf(0xb7)), 'arm64')
63
+ assert.equal(nativeBinaryArch(pe(0x8664)), 'x64')
64
+ assert.equal(nativeBinaryArch(pe(0xaa64)), 'arm64')
65
+ })
66
+
67
+ test('a foreign architecture is rejected for the target', () => {
68
+ assert.equal(nativeBinaryRunsOn(machO({ cpu: 0x0100000c }), 'x64'), false)
69
+ assert.equal(nativeBinaryRunsOn(elf(0x3e), 'arm64'), false)
70
+ })
71
+
72
+ test('bytes without a recognizable object header do not fail a build', () => {
73
+ assert.equal(nativeBinaryArches(Buffer.from('#!/bin/sh\nexec node "$0"\n')), null)
74
+ assert.equal(nativeBinaryRunsOn(Buffer.alloc(4), 'x64'), true)
75
+ })
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Target-aware download of the four product-native tools for a desktop build.
5
+ *
6
+ * The runtime fetchers (graph/patch/spawn/token) resolve the HOST's asset.
7
+ * That is exactly right for an installed app fetching its own binary, and
8
+ * exactly wrong for preparing a runtime for another platform — so rather than
9
+ * teach four production code paths a build-only concern, this reads the same
10
+ * manifests and names the target explicitly.
11
+ *
12
+ * The URL is rebuilt from the manifest version and the target instead of being
13
+ * trusted verbatim, which keeps a rewritten manifest from redirecting the
14
+ * download; the recorded sha256 still has to match the bytes that arrive.
15
+ */
16
+ import { createHash } from 'node:crypto'
17
+ import { mkdir, readFile, rename, rm } from 'node:fs/promises'
18
+ import { join } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ import {
22
+ MAX_NATIVE_BINARY_DOWNLOAD_BYTES,
23
+ streamResponseToFile,
24
+ } from '../src/runtime/shared/bounded-download.mjs'
25
+
26
+ const RELEASE_ROOT = 'https://github.com/tribgames/mixdog/releases/download'
27
+ const TOOLS_DIR = new URL('../src/runtime/agent/orchestrator/tools/', import.meta.url)
28
+
29
+ export const NATIVE_TOOL_KINDS = Object.freeze(['graph', 'patch', 'spawn', 'token'])
30
+
31
+ /** Released asset name. Only token ships as a Node addon; the rest are executables. */
32
+ export function nativeToolAssetName(kind, target) {
33
+ const suffix = kind === 'token' ? '.node' : (target.platform === 'win32' ? '.exe' : '')
34
+ return `mixdog-${kind}-${target.platform}-${target.arch}${suffix}`
35
+ }
36
+
37
+ export function nativeToolAssetUrl(kind, version, target) {
38
+ return `${RELEASE_ROOT}/${kind}-v${version}/${nativeToolAssetName(kind, target)}`
39
+ }
40
+
41
+ /** Installed name inside the app, named for the TARGET rather than the host. */
42
+ export function nativeToolInstalledName(kind, target) {
43
+ if (kind === 'token') return 'mixdog-token.node'
44
+ return target.platform === 'win32' ? `mixdog-${kind}.exe` : `mixdog-${kind}`
45
+ }
46
+
47
+ async function sha256File(path) {
48
+ return createHash('sha256').update(await readFile(path)).digest('hex')
49
+ }
50
+
51
+ async function download(url, destination, label) {
52
+ // 4 attempts, 1s/3s/9s backoff. A 4xx is the manifest being wrong, not the
53
+ // network being unlucky, so it ends the attempt sequence immediately.
54
+ const delays = [1000, 3000, 9000]
55
+ let lastError
56
+ for (let attempt = 0; attempt <= delays.length; attempt += 1) {
57
+ try {
58
+ const response = await fetch(url, { signal: AbortSignal.timeout(180_000) })
59
+ if (response.status >= 400 && response.status < 500) {
60
+ throw new Error(`${label}: HTTP ${response.status} is terminal — ${url}`)
61
+ }
62
+ if (!response.ok) throw new Error(`${label}: HTTP ${response.status} — ${url}`)
63
+ await streamResponseToFile(response, destination, {
64
+ maxBytes: MAX_NATIVE_BINARY_DOWNLOAD_BYTES,
65
+ label,
66
+ })
67
+ return
68
+ } catch (error) {
69
+ lastError = error
70
+ if (String(error?.message || '').includes('is terminal')) throw error
71
+ if (attempt < delays.length) {
72
+ process.stderr.write(
73
+ `${label}: attempt ${attempt + 1} failed (${error?.message}); retrying…\n`,
74
+ )
75
+ await new Promise((resolve) => setTimeout(resolve, delays[attempt]))
76
+ }
77
+ }
78
+ }
79
+ throw lastError
80
+ }
81
+
82
+ export async function downloadNativeTool(kind, target, cacheDir) {
83
+ const manifestPath = fileURLToPath(new URL(`${kind}-manifest.json`, TOOLS_DIR))
84
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
85
+ const version = String(manifest?.version || '')
86
+ if (!/^\d+\.\d+\.\d+$/.test(version)) {
87
+ throw new Error(`${kind}-manifest.json carries no usable version: ${version || '(empty)'}`)
88
+ }
89
+ const key = `${target.platform}-${target.arch}`
90
+ const asset = manifest?.assets?.[key]
91
+ const expectedSha256 = String(asset?.sha256 || '').toLowerCase()
92
+ if (!/^[a-f0-9]{64}$/.test(expectedSha256)) {
93
+ const supported = Object.keys(manifest?.assets || {}).join(', ') || '(none)'
94
+ throw new Error(`${kind}-manifest.json has no ${key} asset. Available: ${supported}.`)
95
+ }
96
+ const url = nativeToolAssetUrl(kind, version, target)
97
+ if (asset.url !== url) {
98
+ throw new Error(
99
+ `${kind}-manifest.json ${key} url does not match its own release identity.\n`
100
+ + ` manifest: ${asset.url}\n expected: ${url}`,
101
+ )
102
+ }
103
+ await mkdir(cacheDir, { recursive: true })
104
+ const destination = join(cacheDir, `${nativeToolAssetName(kind, target)}-${version}`)
105
+ const label = `${kind} ${key} download`
106
+ try {
107
+ if (await sha256File(destination) === expectedSha256) return destination
108
+ } catch { /* absent or unreadable: download it */ }
109
+ const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`
110
+ await download(url, temporary, label)
111
+ const actual = await sha256File(temporary)
112
+ if (actual !== expectedSha256) {
113
+ await rm(temporary, { force: true })
114
+ throw new Error(`${label}: sha256 mismatch — expected ${expectedSha256}, got ${actual}`)
115
+ }
116
+ await rename(temporary, destination)
117
+ return destination
118
+ }
@@ -334,45 +334,6 @@ export function makeAgentDispatch(opts = {}) {
334
334
  // pooled or resumed. Cache prefix matching happens at the provider
335
335
  // layer (account-level), not the session level.
336
336
  const finalPrompt = prompt;
337
- // Agent shard spread: inside a shard child the ephemeral hidden-role
338
- // session runs on a peer shard through the daemon session protocol —
339
- // the same single spawn path the agent tool uses. ONE path per mode:
340
- // a remote failure fails the dispatch (no in-process fallback).
341
- const spreadMod = await import('../../../../standalone/agent-tool/shard-spread.mjs')
342
- .catch(() => null);
343
- if (spreadMod?.agentShardSpreadEnabled?.()) {
344
- const raw = await spreadMod.dispatchHiddenAgentRemote({
345
- spec: {
346
- agent,
347
- presetName,
348
- preset,
349
- runtimeSpec,
350
- permission,
351
- cwd,
352
- sourceType: opts.sourceType,
353
- sourceName: sourceNameArg || opts.sourceName,
354
- parentSessionId: opts.parentSessionId || null,
355
- ownerSessionId: opts.ownerSessionId === undefined ? (opts.parentSessionId || null) : opts.ownerSessionId,
356
- clientHostPid: opts.clientHostPid,
357
- skipRoleReminder: isPoolC,
358
- schemaAllowedTools: resolveHiddenRoleSchemaAllowedTools(hidden),
359
- taskType: opts.taskType,
360
- maxLoopIterations: opts.maxLoopIterations,
361
- },
362
- provider: preset.provider,
363
- model: preset.model,
364
- cwd,
365
- prompt: finalPrompt,
366
- parentSignals: [opts.parentSignal, callParentSignal],
367
- watchdogPolicy: resolveAgentWatchdogPolicy(agent, {
368
- idleTimeoutMs: Number.isFinite(callIdleTimeoutMs)
369
- ? callIdleTimeoutMs
370
- : opts.idleTimeoutMs,
371
- firstResponseTimeoutMs: opts.firstResponseTimeoutMs,
372
- }),
373
- });
374
- return opts.brief === false ? raw : applyBriefCap(raw);
375
- }
376
337
  const { session } = prepareAgentSession({
377
338
  agent,
378
339
  presetName,
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { resourceAdmission } from '../../../shared/resource-admission.mjs';
3
4
 
4
5
  // Normal provider traffic is not concurrency-gated. Independent sessions and
5
6
  // accounts start immediately; a finite limit exists only when an operator
@@ -577,28 +578,33 @@ export function wrapProviderAdmission(provider, providerName, scheduler = provid
577
578
  const opts = sendOpts || {};
578
579
  const signal = opts.signal || null;
579
580
  const key = providerAdmissionKey(providerName, this);
580
- return scheduler.run(key, (admissionSignal) => {
581
- // Admission is the common request-clock boundary for WS/SSE/HTTP.
582
- // Queue wait therefore cannot consume first-byte or agent-watchdog
583
- // time. Provider-local retry remains the sole retry owner.
584
- try { opts.onStageChange?.('requesting'); } catch {}
585
- return originalSend.call(this, messages, model, tools, {
586
- ...opts,
587
- signal: admissionSignal,
588
- });
589
- }, {
590
- signal,
591
- ownerKey: opts.admissionOwner || opts.sessionId || null,
592
- priority: opts.admissionPriority || 'user-visible',
593
- onCooldownWait: (waitMs) => {
594
- // Display-only: the TUI/desktop 'reconnecting' stage already
595
- // renders a custom verb, so no new stage vocabulary is needed.
596
- const secs = Math.max(1, Math.ceil(waitMs / 1000));
597
- try {
598
- opts.onStageChange?.('reconnecting', { message: `Rate-limited waiting ~${secs}s for the provider window` });
599
- } catch { /* display-only */ }
600
- },
601
- });
581
+ // Provider queueing and network wait consume no local CPU slot. The
582
+ // resource controller reacquires the agent lease before model output
583
+ // continues through local context/tool processing.
584
+ return resourceAdmission.runYielded(() =>
585
+ scheduler.run(key, (admissionSignal) => {
586
+ // Admission is the common request-clock boundary for WS/SSE/HTTP.
587
+ // Queue wait therefore cannot consume first-byte or agent-watchdog
588
+ // time. Provider-local retry remains the sole retry owner.
589
+ try { opts.onStageChange?.('requesting'); } catch {}
590
+ return originalSend.call(this, messages, model, tools, {
591
+ ...opts,
592
+ signal: admissionSignal,
593
+ });
594
+ }, {
595
+ signal,
596
+ ownerKey: opts.admissionOwner || opts.sessionId || null,
597
+ priority: opts.admissionPriority || 'user-visible',
598
+ onCooldownWait: (waitMs) => {
599
+ // Display-only: the TUI/desktop 'reconnecting' stage already
600
+ // renders a custom verb, so no new stage vocabulary is needed.
601
+ const secs = Math.max(1, Math.ceil(waitMs / 1000));
602
+ try {
603
+ opts.onStageChange?.('reconnecting', { message: `Rate-limited — waiting ~${secs}s for the provider window` });
604
+ } catch { /* display-only */ }
605
+ },
606
+ })
607
+ );
602
608
  };
603
609
  return provider;
604
610
  }
@@ -164,7 +164,7 @@ export class GeminiProvider {
164
164
  /**
165
165
  * Stream-death recovery: re-issue a dead stream ONCE as a
166
166
  * non-streaming generateContent call instead of failing the turn.
167
- * Narrower than cc by design — canFallbackNonStreaming() clears only a
167
+ * Deliberately narrow — canFallbackNonStreaming() clears only a
168
168
  * stream that exposed nothing, so rendered text is never duplicated and a
169
169
  * dispatched tool can never run twice. Returns the aggregated response, or
170
170
  * null when the failure is ineligible or the fallback itself fails.
@@ -130,7 +130,7 @@ export const LOGIN_TIMEOUT_MS = 5 * 60_000;
130
130
  // SSRF guard for any endpoint pulled from the discovery document or saved
131
131
  // tokens. xAI OAuth endpoints must be https on x.ai / *.x.ai — reject
132
132
  // anything else outright so a hostile discovery response can't redirect the
133
- // token / refresh request. Mirrors openclaw's isTrustedXaiOAuthEndpoint.
133
+ // token / refresh request.
134
134
  function assertTrustedXaiEndpoint(endpoint, label) {
135
135
  let url;
136
136
  try {
@@ -456,7 +456,7 @@ export async function consumeCompatChatCompletionStream(stream, {
456
456
  if (leakGuard.enabled) flushLeak();
457
457
  } catch (err) {
458
458
  // Any mid-stream failure after live text was relayed is non-retryable —
459
- // but the streamed partial must still ride on the error (CC rule: once
459
+ // but the streamed partial must still ride on the error (rule: once
460
460
  // output is visible, keep it and finalize with a notice instead of
461
461
  // discarding the turn). The loop's partial-final path consumes these.
462
462
  if (emittedText) {
@@ -1029,7 +1029,7 @@ export async function consumeCompatResponsesStream(stream, {
1029
1029
  const err = truncatedCompatStreamError(label, 'no response.completed');
1030
1030
  if (state.emittedText) {
1031
1031
  // Truncation after visible output: keep the streamed partial
1032
- // (CC rule) so the loop can finalize it as partial-final instead
1032
+ // (same rule) so the loop can finalize it as partial-final instead
1033
1033
  // of dropping the turn. liveText marking still blocks replay.
1034
1034
  markErrorLiveTextEmitted(err);
1035
1035
  try {
@@ -312,7 +312,7 @@ function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey: _cach
312
312
  // xAI WS: do NOT pin x-grok-conv-id. Measured parallel runs show that
313
313
  // forcing a routing shard via that header alternates cold caches across
314
314
  // parallel workers; the automatic prompt-prefix cache holds up better
315
- // when each handshake is unpinned. Reference: vercel/ai xai provider.
315
+ // when each handshake is unpinned.
316
316
  const headers = auth.type === 'xai'
317
317
  ? {
318
318
  'Authorization': `Bearer ${auth.apiKey}`,
@@ -689,7 +689,7 @@ export async function _streamResponse({
689
689
  finish();
690
690
  }, interChunkMs);
691
691
  };
692
- // pi per-event idle: (re)armed only on meaningful output deltas via
692
+ // Per-event idle: (re)armed only on meaningful output deltas via
693
693
  // bumpSemanticIdle(). Keepalive/metadata frames DON'T touch it, so a
694
694
  // deltas-then-silent wedge trips this short semantic window.
695
695
  const resetSemanticIdle = () => {
@@ -116,7 +116,7 @@ export function classifyError(err) {
116
116
  const status = Number(err.httpStatus || err.status || err.response?.status || 0) || 0
117
117
  if (AUTH_STATUSES.has(status)) return 'auth'
118
118
  // Stale previous_response_id is recoverable by dropping the chain and
119
- // re-issuing a full frame (codex maps it to ApiError::Retryable). A typed
119
+ // re-issuing a full frame, which is the retryable path. A typed
120
120
  // 400 here is not a deterministic payload refusal.
121
121
  if (shouldDropPreviousResponseId(err)) return 'transient'
122
122
  if (typedErrorCode(err) === WEBSOCKET_CONNECTION_LIMIT) return 'transient'
@@ -137,7 +137,7 @@ export function classifyError(err) {
137
137
  // ever attempted (observed live: pooled WS retired by the server between
138
138
  // turns → close 1000 before response.created → the whole turn failed).
139
139
  // Reference behavior retries the same disconnect (codex `CodexErr::Stream`
140
- // is_retryable, cc falls back/retries the stream); the exposure deny above
140
+ // is_retryable, and a stream fallback/retry applies); the exposure deny above
141
141
  // still fails closed for anything already relayed or dispatched.
142
142
  if (isNonTerminalStreamClose(err)) return 'transient'
143
143
 
@@ -162,10 +162,10 @@ export function classifyError(err) {
162
162
  ))) return 'transient'
163
163
 
164
164
  // Provider wire error event (`response.failed` / terminal `error` frame):
165
- // default-retry, codex parity. codex maps every response.failed whose typed
166
- // code is not a deterministic refusal to ApiError::Retryable (fatal codes
167
- // are an explicit allow-list); cc retries all 5xx/overloaded 10×; opencode
168
- // marks server_error/server_is_overloaded isRetryable. Evidence stays
165
+ // default-retry. Every response.failed whose typed code is not a
166
+ // deterministic refusal is retryable (fatal codes are an explicit
167
+ // allow-list), and server_error / server_is_overloaded count as retryable
168
+ // too. Evidence stays
169
169
  // structural — the event's own typed code/type field — message text is
170
170
  // never parsed. Exposure precedence is preserved: the replayUnsafe gate at
171
171
  // the top of this function already returned 'permanent' for any stream
@@ -379,8 +379,8 @@ function isPermanentQuotaError(err) {
379
379
 
380
380
  // ── Wire error events: default-retry with a fatal-code deny-list ────────────
381
381
  // Deterministic refusal codes a `response.failed` / `error` wire event may
382
- // carry: retrying the identical request can never succeed. Mirrors codex's
383
- // fatal set (context/quota/policy) plus the auth/billing refusals the
382
+ // carry: retrying the identical request can never succeed. The fatal set is
383
+ // context/quota/policy plus the auth/billing refusals the
384
384
  // Responses and Anthropic wire formats use. Everything OUTSIDE this set —
385
385
  // server_error, server_is_overloaded, slow_down, or an event with no code at
386
386
  // all — is a server-side fault and is retried under the bounded budgets
@@ -753,7 +753,7 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
753
753
  // A typed non-transient 4xx on the failure payload is a deterministic
754
754
  // refusal even without a recognized code string.
755
755
  if (failedStatus >= 400 && failedStatus < 500 && !TRANSIENT_STATUSES.has(failedStatus)) return null
756
- // Default-retry (codex parity): a wire failure that is neither a fatal
756
+ // Default-retry: a wire failure that is neither a fatal
757
757
  // refusal nor a typed 4xx is a server-side fault — re-issue it under the
758
758
  // bounded mid-stream budget instead of failing the turn.
759
759
  return _allowMidstream('response_failed_retryable', attemptIndex, policy)
@@ -1,5 +1,5 @@
1
- // Post-compact file re-attachment (claude-code createPostCompactFileAttachments
2
- // parity). Files the summarized-away head had `read` are re-read FRESH from
1
+ // Post-compact file re-attachment. Files the summarized-away head had `read`
2
+ // are re-read FRESH from
3
3
  // disk and re-injected right after the summary message, so the model does not
4
4
  // burn a turn (and tokens) re-reading files it was actively working with.
5
5
  // - newest-first, capped at MAX_REATTACH_FILES and per-file/total token caps
@@ -59,7 +59,7 @@ import {
59
59
  stripWorkingFileSections,
60
60
  } from './handoff.mjs';
61
61
 
62
- // Post-compact file re-attachment (claude-code parity): re-inject fresh reads
62
+ // Post-compact file re-attachment: re-inject fresh reads
63
63
  // of files the summarized-away head was working with, when they still fit the
64
64
  // budget. Semantic compact still uses this; recall-fasttrack lists paths
65
65
  // instead of file bodies.
@@ -152,7 +152,7 @@ export function markSessionAskStart(id) {
152
152
  // Publish heartbeat immediately so the status aggregator picks the
153
153
  // session up in the connecting / requesting window. Without this the
154
154
  // .hb file only landed on the first stream chunk — producing a 3–10s
155
- // (xhigh: 30s+) invisible gap where agent sessions ran but the CC
155
+ // (xhigh: 30s+) invisible gap where agent sessions ran but the
156
156
  // statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
157
157
  // still drops a session whose provider truly never returns a chunk;
158
158
  // markSessionStreamDelta keeps refreshing once chunks arrive.
@@ -64,27 +64,12 @@ export function projectTurnCheckpointMessages(session, checkpoint) {
64
64
  || !Array.isArray(checkpoint?.turnMessages)) return source;
65
65
  const sessionGeneration = Number(session.generation) || 0;
66
66
  const checkpointGeneration = Number(checkpoint.generation) || 0;
67
- const owner = String(session.owner || '').trim().toLowerCase();
68
- const agent = String(session.agent || '').trim().toLowerCase();
69
- const detachedLiveAgent = session.closed === true
70
- && (owner === 'agent' || (agent && agent !== 'lead'))
71
- && Boolean(String(session.ownerSessionId || session.parentSessionId || '').trim());
72
67
  const markerToken = typeof session.activeTurnCheckpoint?.turnToken === 'string'
73
68
  ? session.activeTurnCheckpoint.turnToken
74
69
  : null;
75
70
  const markerMatches = markerToken === checkpoint.turnToken;
76
71
  const checkpointIsNewer = Number(checkpoint.updatedAt) > Number(session.updatedAt || 0);
77
- // External workers deliberately detach the durable session before their
78
- // shard-owned turn finishes. closeSession writes the lifecycle fence by
79
- // advancing the session generation once, while that already-running turn
80
- // keeps publishing under its original generation. Accept exactly that
81
- // one-generation handoff only when the durable marker token still proves
82
- // identity and the checkpoint was written after the detach.
83
- const detachedGenerationHandoff = detachedLiveAgent
84
- && sessionGeneration === checkpointGeneration + 1
85
- && markerMatches
86
- && checkpointIsNewer;
87
- if ((checkpointGeneration !== sessionGeneration && !detachedGenerationHandoff)
72
+ if (checkpointGeneration !== sessionGeneration
88
73
  || (markerToken && !markerMatches)
89
74
  || (!markerToken && !checkpointIsNewer)) return source;
90
75
  let start = -1;