mixdog 0.9.85 → 0.9.87

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 (102) hide show
  1. package/README.md +7 -1
  2. package/package.json +4 -3
  3. package/src/defaults/agents.json +12 -0
  4. package/src/defaults/skills/setup/SKILL.md +44 -12
  5. package/src/help.mjs +40 -9
  6. package/src/lib/keychain-cjs.cjs +11 -1
  7. package/src/rules/agent/43-title-agent.md +22 -0
  8. package/src/rules/shared/01-tool.md +3 -3
  9. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -2
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
  11. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  12. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +125 -16
  13. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  15. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  16. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  18. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
  19. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  20. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
  21. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  22. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  23. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  24. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  25. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  26. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  27. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  28. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  32. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  35. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  36. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -3
  37. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  38. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  39. package/src/runtime/agent/orchestrator/tools/token-manifest.json +26 -0
  40. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  41. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  42. package/src/runtime/media/renditions.mjs +1 -1
  43. package/src/runtime/media/store.mjs +1 -1
  44. package/src/runtime/memory/lib/memory-action-handlers.mjs +4 -1
  45. package/src/runtime/memory/tool-defs.mjs +19 -3
  46. package/src/runtime/shared/atomic-file.mjs +25 -14
  47. package/src/runtime/shared/automation-attachments.mjs +3 -3
  48. package/src/runtime/shared/child-guardian.mjs +15 -5
  49. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  50. package/src/runtime/shared/resource-admission.mjs +7 -3
  51. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  52. package/src/session-runtime/lifecycle-api.mjs +49 -9
  53. package/src/session-runtime/prewarm.mjs +35 -0
  54. package/src/session-runtime/provider-auth-api.mjs +8 -0
  55. package/src/session-runtime/provider-usage.mjs +30 -5
  56. package/src/session-runtime/remote-control.mjs +8 -47
  57. package/src/session-runtime/remote-transcript.mjs +3 -7
  58. package/src/session-runtime/route-preparation.mjs +24 -0
  59. package/src/session-runtime/runtime-core.mjs +286 -548
  60. package/src/session-runtime/session-lifecycle.mjs +378 -0
  61. package/src/session-runtime/session-turn-api.mjs +9 -2
  62. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  63. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  64. package/src/standalone/agent-shard/shard-pool.mjs +443 -0
  65. package/src/standalone/agent-tool/job-views.mjs +329 -0
  66. package/src/standalone/agent-tool/spawn-flow.mjs +629 -0
  67. package/src/standalone/agent-tool/tag-registry.mjs +340 -0
  68. package/src/standalone/agent-tool.mjs +112 -941
  69. package/src/standalone/channel-daemon-transport.mjs +126 -185
  70. package/src/standalone/channel-daemon.mjs +9 -10
  71. package/src/standalone/usage-dashboard.mjs +23 -1
  72. package/src/tui/App.jsx +362 -2569
  73. package/src/tui/app/app-view.jsx +504 -0
  74. package/src/tui/app/channel-pickers.mjs +3 -8
  75. package/src/tui/app/create-app-pickers.mjs +313 -0
  76. package/src/tui/app/prompt-submit.mjs +501 -0
  77. package/src/tui/app/route-pickers.mjs +117 -152
  78. package/src/tui/app/settings-picker.mjs +90 -89
  79. package/src/tui/app/shell-layout.mjs +563 -0
  80. package/src/tui/app/slash-commands.mjs +7 -7
  81. package/src/tui/app/slash-dispatch.mjs +0 -45
  82. package/src/tui/app/usage-context-panels.mjs +288 -0
  83. package/src/tui/app/use-copy-selection.mjs +56 -0
  84. package/src/tui/app/use-global-key-input.mjs +151 -0
  85. package/src/tui/app/use-pasted-buffers.mjs +115 -0
  86. package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
  87. package/src/tui/app/use-prompt-hint.mjs +58 -0
  88. package/src/tui/app/use-prompt-queue-history.mjs +100 -0
  89. package/src/tui/app/use-terminal-chrome.mjs +72 -0
  90. package/src/tui/app/use-transcript-activity.mjs +131 -0
  91. package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
  92. package/src/tui/components/StatusLine.jsx +1 -1
  93. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  94. package/src/tui/dist/index.mjs +9857 -9159
  95. package/src/tui/engine/session-api-ext.mjs +47 -9
  96. package/src/tui/engine.mjs +7 -1
  97. package/src/tui/figures.mjs +0 -1
  98. package/src/ui/statusline-format.mjs +0 -1
  99. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  100. package/src/lib/config-cjs.cjs +0 -61
  101. package/src/runtime/shared/launcher-control.mjs +0 -258
  102. package/src/runtime/shared/workspace-router.mjs +0 -259
@@ -0,0 +1,201 @@
1
+ // token-binary-fetcher.mjs — fetches the prebuilt mixdog-token native binary
2
+ // from the GitHub release manifest. Token counting has an in-process WASM
3
+ // fallback, so callers treat fetch failures as a soft degrade (never a tool
4
+ // error). Caches under <dataDir>/token-bin/. Mirrors graph-binary-fetcher.mjs.
5
+ //
6
+ // Public API:
7
+ // ensureTokenBinary(dataDir) -> absolute path to the verified binary.
8
+ // Throws on no-asset / download / verify failure.
9
+ // findCachedTokenBinary(dataDir) -> path | null (sync, no network).
10
+ // Both accept an optional dependency object used by deterministic tests:
11
+ // { bundledManifest, download }.
12
+
13
+ import { createHash } from 'node:crypto';
14
+ import {
15
+ chmodSync, createWriteStream, existsSync, mkdirSync,
16
+ readFileSync, readdirSync, renameSync, rmSync,
17
+ } from 'node:fs';
18
+ import { readFile } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { pipeline } from 'node:stream/promises';
22
+
23
+ const BUNDLED_MANIFEST_PATH = fileURLToPath(new URL('./token-manifest.json', import.meta.url));
24
+ const MANIFEST_URL = 'https://raw.githubusercontent.com/tribgames/mixdog/main/src/runtime/agent/orchestrator/tools/token-manifest.json';
25
+
26
+ function binSuffix() {
27
+ return process.platform === 'win32' ? '.exe' : '';
28
+ }
29
+
30
+ function platformKey() {
31
+ const os = process.platform === 'win32' ? 'win32' : process.platform;
32
+ return `${os}-${process.arch}`;
33
+ }
34
+
35
+ function tokenBinDir(dataDir) {
36
+ return join(dataDir, 'token-bin');
37
+ }
38
+
39
+ function readJson(path) {
40
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
41
+ }
42
+
43
+ function manifestVersion(manifest) {
44
+ const value = String(manifest?.version || '');
45
+ if (!/^\d+\.\d+\.\d+$/.test(value)) return null;
46
+ return value.split('.').map(Number);
47
+ }
48
+
49
+ function compareManifestVersions(a, b) {
50
+ const av = manifestVersion(a);
51
+ const bv = manifestVersion(b);
52
+ if (!av || !bv) return null;
53
+ for (let i = 0; i < 3; i++) {
54
+ if (av[i] !== bv[i]) return av[i] > bv[i] ? 1 : -1;
55
+ }
56
+ return 0;
57
+ }
58
+
59
+ function validSha256(value) {
60
+ return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value);
61
+ }
62
+
63
+ function validCachedUpgrade(manifest, pkey) {
64
+ if (!manifestVersion(manifest)) return false;
65
+ const asset = manifest.assets?.[pkey];
66
+ if (!asset || !validSha256(asset.sha256) || typeof asset.url !== 'string') return false;
67
+ const expectedUrl = `https://github.com/tribgames/mixdog/releases/download/token-v${manifest.version}`
68
+ + `/mixdog-token-${pkey}${binSuffix()}`;
69
+ return asset.url === expectedUrl;
70
+ }
71
+
72
+ function bundledManifest(options) {
73
+ if (options.bundledManifest) return options.bundledManifest;
74
+ return existsSync(BUNDLED_MANIFEST_PATH) ? readJson(BUNDLED_MANIFEST_PATH) : null;
75
+ }
76
+
77
+ function selectLocalManifest(dataDir, options = {}) {
78
+ const bundled = bundledManifest(options);
79
+ const cached = join(tokenBinDir(dataDir), 'manifest.json');
80
+ const cachedManifest = existsSync(cached) ? readJson(cached) : null;
81
+ if (bundled) {
82
+ // The installed manifest is the minimum policy. A cache may advance it,
83
+ // but only with a strict newer semver and a trusted, fully hashed asset.
84
+ if (compareManifestVersions(cachedManifest, bundled) === 1
85
+ && validCachedUpgrade(cachedManifest, platformKey())) {
86
+ return cachedManifest;
87
+ }
88
+ return bundled;
89
+ }
90
+ return validCachedUpgrade(cachedManifest, platformKey()) ? cachedManifest : null;
91
+ }
92
+
93
+ async function loadManifest(dataDir, options = {}) {
94
+ const local = selectLocalManifest(dataDir, options);
95
+ if (local) return local;
96
+ const fetchFn = options.fetch || fetch;
97
+ const res = await fetchFn(MANIFEST_URL, { signal: AbortSignal.timeout(30_000) });
98
+ if (!res.ok) {
99
+ throw new Error(`[token-fetcher] manifest fetch failed: ${res.status} ${res.statusText}`);
100
+ }
101
+ return res.json();
102
+ }
103
+
104
+ async function sha256File(filePath) {
105
+ return createHash('sha256').update(await readFile(filePath)).digest('hex');
106
+ }
107
+
108
+ async function downloadWithRetry(url, destPath) {
109
+ const delays = [1000, 3000, 9000];
110
+ let lastErr;
111
+ for (let attempt = 0; attempt < 4; attempt++) {
112
+ try {
113
+ const res = await fetch(url, { signal: AbortSignal.timeout(180_000) });
114
+ if (res.status >= 400 && res.status < 500) {
115
+ throw new Error(`[token-fetcher] asset HTTP ${res.status} (terminal) — ${url}`);
116
+ }
117
+ if (!res.ok) throw new Error(`[token-fetcher] asset HTTP ${res.status} — ${url}`);
118
+ await pipeline(res.body, createWriteStream(destPath));
119
+ return;
120
+ } catch (err) {
121
+ lastErr = err;
122
+ if (String(err?.message || '').includes('(terminal)')) throw err;
123
+ if (attempt < 3) {
124
+ process.stderr.write(`[token-fetcher] download attempt ${attempt + 1} failed (${err?.message}), retrying in ${delays[attempt]}ms…\n`);
125
+ await new Promise((r) => setTimeout(r, delays[attempt]));
126
+ }
127
+ }
128
+ }
129
+ throw lastErr;
130
+ }
131
+
132
+ function gcTokenBin(dir, keepFile) {
133
+ try {
134
+ for (const name of readdirSync(dir)) {
135
+ if (name === 'manifest.json' || name === keepFile) continue;
136
+ if (name.startsWith('mixdog-token')) {
137
+ try { rmSync(join(dir, name), { force: true }); } catch { /* best-effort */ }
138
+ }
139
+ }
140
+ } catch { /* dir may not exist yet */ }
141
+ }
142
+
143
+ export function findCachedTokenBinary(dataDir, options = {}) {
144
+ try {
145
+ const dir = tokenBinDir(dataDir);
146
+ const manifest = selectLocalManifest(dataDir, options);
147
+ const asset = manifest?.assets?.[platformKey()];
148
+ const version = manifestVersion(manifest);
149
+ if (!version || !validSha256(asset?.sha256)) return null;
150
+ const fileName = `mixdog-token-${manifest.version}${binSuffix()}`;
151
+ const hit = join(dir, fileName);
152
+ if (!existsSync(hit)) return null;
153
+ const actual = createHash('sha256').update(readFileSync(hit)).digest('hex');
154
+ return actual === asset.sha256.toLowerCase() ? hit : null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ let _inflight = null;
161
+
162
+ export function ensureTokenBinary(dataDir, options = {}) {
163
+ if (_inflight) return _inflight;
164
+ _inflight = (async () => {
165
+ const manifest = await loadManifest(dataDir, options);
166
+ const pkey = platformKey();
167
+ const asset = manifest.assets?.[pkey];
168
+ if (!asset || !asset.url || !validSha256(asset.sha256) || !manifestVersion(manifest)) {
169
+ // Unsupported platform/arch, or no token-v release published yet. The
170
+ // caller degrades to the in-process WASM worker, so the message only
171
+ // needs to explain why the accelerator is absent.
172
+ const supported = Object.keys(manifest.assets || {}).join(', ') || '(none)';
173
+ throw new Error(
174
+ `[token-fetcher] no prebuilt mixdog-token binary for platform ${pkey} `
175
+ + `(token counting degrades to the in-process WASM worker). `
176
+ + `Manifest platforms: ${supported}. `
177
+ + `Build it locally: cargo build --release in native/mixdog-token.`,
178
+ );
179
+ }
180
+ const version = String(manifest.version || '0');
181
+ const dir = tokenBinDir(dataDir);
182
+ mkdirSync(dir, { recursive: true });
183
+ const fileName = `mixdog-token-${version}${binSuffix()}`;
184
+ const destPath = join(dir, fileName);
185
+ if (existsSync(destPath)) {
186
+ try { if (await sha256File(destPath) === asset.sha256.toLowerCase()) return destPath; } catch { /* re-download */ }
187
+ }
188
+ const tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}`;
189
+ await (options.download || downloadWithRetry)(asset.url, tmpPath);
190
+ const actual = await sha256File(tmpPath);
191
+ if (actual !== asset.sha256.toLowerCase()) {
192
+ try { rmSync(tmpPath, { force: true }); } catch { /* best-effort */ }
193
+ throw new Error(`[token-fetcher] sha256 mismatch for ${pkey}: expected ${asset.sha256}, got ${actual}`);
194
+ }
195
+ renameSync(tmpPath, destPath);
196
+ if (process.platform !== 'win32') { try { chmodSync(destPath, 0o755); } catch { /* best-effort */ } }
197
+ gcTokenBin(dir, fileName);
198
+ return destPath;
199
+ })().finally(() => { _inflight = null; });
200
+ return _inflight;
201
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "version": "0.1.0",
3
+ "_comment": "Synced from immutable token-v release assets.",
4
+ "assets": {
5
+ "darwin-arm64": {
6
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-darwin-arm64",
7
+ "sha256": "1d5259803207dd5ace466c506d8db4087460a20c1e3f4f6cc249832a8a996f6b"
8
+ },
9
+ "darwin-x64": {
10
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-darwin-x64",
11
+ "sha256": "2212a434a89419ce0a4122ce4647e2feb2b4642e26d3e74e11b4de2819a6c2e3"
12
+ },
13
+ "linux-arm64": {
14
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-linux-arm64",
15
+ "sha256": "332839da398050218fbed4b6700a3e34dbdcba800893b9a3ba16ebca3e88b9ee"
16
+ },
17
+ "linux-x64": {
18
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-linux-x64",
19
+ "sha256": "ac79201259985705cf378d225a25a779591d1f75fefd626e470b398e3d8fdd9c"
20
+ },
21
+ "win32-x64": {
22
+ "url": "https://github.com/tribgames/mixdog/releases/download/token-v0.1.0/mixdog-token-win32-x64.exe",
23
+ "sha256": "da6e39974811123b3e0c72f539c9e5b424261b66a3f2a30eb0e9cc837036d1ba"
24
+ }
25
+ }
26
+ }
@@ -28,14 +28,14 @@ export function resolveHookRelayUrl(env = process.env) {
28
28
  return raw || DEFAULT_RELAY_URL;
29
29
  }
30
30
 
31
- export function hookIdentityPath() {
31
+ function hookIdentityPath() {
32
32
  return join(DATA_DIR, "relay-hook-device.json");
33
33
  }
34
34
 
35
35
  // Stable per-install identity (trust-on-first-use at the relay, mirroring
36
36
  // the desktop leg). The secret never leaves this machine except toward the
37
37
  // relay; the deviceId doubles as the public URL path segment.
38
- export function loadOrCreateHookIdentity() {
38
+ function loadOrCreateHookIdentity() {
39
39
  const path = hookIdentityPath();
40
40
  try {
41
41
  const parsed = JSON.parse(readFileSync(path, "utf8"));
@@ -58,7 +58,7 @@ export function loadOrCreateHookIdentity() {
58
58
  return identity;
59
59
  }
60
60
 
61
- export function hookPublicBase(relayUrl, deviceId) {
61
+ function hookPublicBase(relayUrl, deviceId) {
62
62
  const url = new URL(relayUrl);
63
63
  url.protocol = url.protocol === "wss:" ? "https:" : "http:";
64
64
  url.pathname = `/hook/${deviceId}`;
@@ -16,7 +16,7 @@ const OMNI_TIMEOUT_MS = 600_000;
16
16
  const POLL_INTERVAL_MS = 8_000;
17
17
  const TOTAL_TIMEOUT_MS = 900_000;
18
18
 
19
- export function isOmniModel(model) {
19
+ function isOmniModel(model) {
20
20
  return /omni/i.test(String(model || ''));
21
21
  }
22
22
 
@@ -30,7 +30,7 @@ const EXTENSION_MIME = { '.webp': 'image/webp', '.png': 'image/png' };
30
30
  const POSTER_TIMEOUT_MS = 20_000;
31
31
  const MAX_POSTER_BYTES = 32 * 1024 * 1024;
32
32
 
33
- export const MEDIA_VARIANTS = Object.keys(SPECS);
33
+ const MEDIA_VARIANTS = Object.keys(SPECS);
34
34
 
35
35
  /** Spec for a variant name, or null when the caller asked for the original. */
36
36
  export function renditionSpec(variant) {
@@ -222,7 +222,7 @@ async function prewarmThumbs(assets) {
222
222
  await Promise.all(workers).catch(() => {});
223
223
  }
224
224
 
225
- export function getMediaAsset(id) {
225
+ function getMediaAsset(id) {
226
226
  ensureDirs();
227
227
  return readIndex().find((entry) => entry.id === id) || null;
228
228
  }
@@ -877,7 +877,10 @@ export function createMemoryActionHandlers({
877
877
  return { text: `retro_eval_active: total=${total} archived=${archived} kept=${kept} updated=${updated} merged=${merged} errors=${errors}` }
878
878
  }
879
879
 
880
- return { text: `unknown memory action: ${action}`, isError: true }
880
+ return {
881
+ text: `unknown memory action: ${action}; valid: core, status. Mutation verbs belong in op.`,
882
+ isError: true,
883
+ }
881
884
  }
882
885
 
883
886
  const handleToolCall = createToolCallHandler({ handleSearch, handleMemoryAction })
@@ -8,12 +8,24 @@ export const TOOL_DEFS = [
8
8
  name: 'memory',
9
9
  title: 'Memory Cycle',
10
10
  annotations: { title: 'Memory Cycle', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
11
- description: 'Core-memory mutation and status; use recall for retrieval. Store durable rules/preferences/facts, one compact ENGLISH clause each — never transient task state. add requires project_id+summary; edit works by id alone.',
11
+ description: [
12
+ 'Core-memory mutation/status; recall retrieves.',
13
+ 'Mutations use action=core with op; status uses action=status.',
14
+ 'Store only durable compact ENGLISH facts/rules/preferences.',
15
+ ].join(' '),
12
16
  inputSchema: {
13
17
  type: 'object',
14
18
  properties: {
15
- action: { type: 'string', enum: ['core','status'], description: 'Operation.' },
16
- op: { type: 'string', enum: ['add','edit','delete','list','candidates','promote','dismiss'], description: 'Mutation op. candidates/promote/dismiss drive core-memory proposal approval.' },
19
+ action: {
20
+ type: 'string',
21
+ enum: ['core','status'],
22
+ description: 'core for mutations; status otherwise. Mutation verbs belong in op.',
23
+ },
24
+ op: {
25
+ type: 'string',
26
+ enum: ['add','edit','delete','list','candidates','promote','dismiss'],
27
+ description: 'Required for action=core.',
28
+ },
17
29
  id: { type: 'number', description: 'Exact memory id.' },
18
30
  element: { type: 'string', maxLength: 40, description: 'Memory key/title. Defaults to the first 40 chars of summary. Max 40 chars.' },
19
31
  summary: { type: 'string', maxLength: 100, description: 'Memory content: one short English clause, max 100 chars.' },
@@ -22,6 +34,10 @@ export const TOOL_DEFS = [
22
34
  confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
23
35
  project_id: { type: 'string', description: 'Core pool: explicit common or slug. Required for core add only (edit uses the id\'s stored pool); there is no default pool.' },
24
36
  },
37
+ anyOf: [
38
+ { properties: { action: { const: 'status' } }, required: ['action'] },
39
+ { properties: { action: { const: 'core' } }, required: ['action', 'op'] },
40
+ ],
25
41
  additionalProperties: false,
26
42
  required: ['action'],
27
43
  },
@@ -118,10 +118,15 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
118
118
  } catch (err) {
119
119
  lastErr = err;
120
120
  if (!LOCK_WAIT_CODES.has(err?.code)) throw err;
121
- // try-once semantics: timeoutMs:0 makes a single openSync attempt and
122
- // never sleeps — on contention it throws ELOCKCONTENDED immediately so
123
- // the main loop is never blocked by Atomics.wait. No stale-reclaim,
124
- // since reclaim would require waiting on the guard.
121
+ // Reclaim is itself try-once: the guard is acquired atomically and never
122
+ // waited on. This lets timeoutMs:0 recover a dead/pidless corpse without
123
+ // weakening its no-sleep contract.
124
+ try {
125
+ if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
126
+ } catch {}
127
+ // try-once semantics: timeoutMs:0 makes one guarded reclaim attempt and
128
+ // never sleeps — live contention throws ELOCKCONTENDED immediately so
129
+ // the main loop is never blocked by Atomics.wait.
125
130
  if (timeoutMs <= 0) {
126
131
  const contErr = new Error(`atomic lock contended (try-once): ${lockPath} [${_describeLockHolder(lockPath)}]`);
127
132
  contErr.code = 'ELOCKCONTENDED';
@@ -130,9 +135,6 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
130
135
  }
131
136
  // Reclaim only when owner death is positively established. Age, an
132
137
  // unreadable owner, or a same-pid foreign token remain contended.
133
- try {
134
- if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
135
- } catch {}
136
138
  if (Date.now() >= deadline) break;
137
139
  const base = DEFAULT_BACKOFFS_MS[Math.min(attempt, DEFAULT_BACKOFFS_MS.length - 1)];
138
140
  const jitter = Math.floor(Math.random() * Math.min(75, Math.max(1, base)));
@@ -223,14 +225,19 @@ function _lockOwnedBySelf(lockPath) {
223
225
  //
224
226
  // Reclaim decision table (owner = identity recorded in the lock file):
225
227
  // owner pid DEAD (ESRCH) → reclaim
226
- // owner pid NULL/uncertain or same-pid foreign token → back off
228
+ // owner pid NULL/uncertain and lock age >= staleMs → reclaim
229
+ // owner pid NULL/uncertain and lock age < staleMs → back off
230
+ // same-pid foreign token → back off
227
231
  // owner token MISMATCH on guarded re-read (new holder took over) → back off
228
232
  // owner LIVE → back off regardless of age
229
233
  function _tryReclaimStaleLock(lockPath, staleMs) {
230
- try { statSync(lockPath); } catch { return false; }
234
+ let initial;
235
+ try { initial = statSync(lockPath); } catch { return false; }
231
236
  const owner = _readLockOwner(lockPath);
232
237
  const dead = owner.pid !== null && !_ownerIsLive(owner);
233
- if (!dead) return false;
238
+ const pidlessStale = owner.pid === null
239
+ && Date.now() - (Number(initial.mtimeMs) || 0) >= Math.max(0, staleMs);
240
+ if (!dead && !pidlessStale) return false;
234
241
  const reclaim = _tryAcquireReclaimGuard(lockPath, staleMs);
235
242
  if (reclaim === null) return false;
236
243
  try {
@@ -243,7 +250,9 @@ function _tryReclaimStaleLock(lockPath, staleMs) {
243
250
  // corpse — so we never yank a freshly-acquired live lock.
244
251
  if (curOwner.pid !== owner.pid || curOwner.token !== owner.token) return false;
245
252
  const curDead = curOwner.pid !== null && !_ownerIsLive(curOwner);
246
- if (curDead) {
253
+ const curPidlessStale = curOwner.pid === null
254
+ && Date.now() - (Number(cur.mtimeMs) || 0) >= Math.max(0, staleMs);
255
+ if (curDead || curPidlessStale) {
247
256
  try { unlinkSync(lockPath); return true; } catch { return false; }
248
257
  }
249
258
  return false;
@@ -307,6 +316,11 @@ export async function withFileLock(lockPath, fn, opts = {}) {
307
316
  } catch (err) {
308
317
  lastErr = err;
309
318
  if (!LOCK_WAIT_CODES.has(err?.code)) throw err;
319
+ // The guarded reclaim is non-blocking, so try-once callers can recover
320
+ // stale corpses while still returning immediately for a live holder.
321
+ try {
322
+ if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
323
+ } catch {}
310
324
  if (timeoutMs <= 0) {
311
325
  const contErr = new Error(`atomic lock contended (try-once): ${lockPath} [${_describeLockHolder(lockPath)}]`);
312
326
  contErr.code = 'ELOCKCONTENDED';
@@ -316,9 +330,6 @@ export async function withFileLock(lockPath, fn, opts = {}) {
316
330
  // Stale-lock reclaim (shared with the sync variant): dead owner pid,
317
331
  // a same-pid-but-foreign-token corpse from a reused pid, or a
318
332
  // pidless/empty corpse lock. A live foreign holder is never stolen.
319
- try {
320
- if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
321
- } catch {}
322
333
  if (Date.now() >= deadline) break;
323
334
  const base = DEFAULT_BACKOFFS_MS[Math.min(attempt, DEFAULT_BACKOFFS_MS.length - 1)];
324
335
  const jitter = Math.floor(Math.random() * Math.min(75, Math.max(1, base)));
@@ -7,11 +7,11 @@
7
7
  * {type:'file'}), so provider media normalization treats them identically.
8
8
  */
9
9
 
10
- export const AUTOMATION_ATTACHMENT_KINDS = ['image', 'text', 'pdf'];
10
+ const AUTOMATION_ATTACHMENT_KINDS = ['image', 'text', 'pdf'];
11
11
  export const MAX_AUTOMATION_ATTACHMENTS = 8;
12
12
  // Base64 total across image/pdf items; text totals separately.
13
- export const MAX_AUTOMATION_BINARY_TOTAL = 8_000_000;
14
- export const MAX_AUTOMATION_TEXT_TOTAL = 200_000;
13
+ const MAX_AUTOMATION_BINARY_TOTAL = 8_000_000;
14
+ const MAX_AUTOMATION_TEXT_TOTAL = 200_000;
15
15
 
16
16
  /** Validate + strip a stored/list attachments value to the persisted shape (or null). */
17
17
  export function normalizeAutomationAttachments(value) {
@@ -8,6 +8,20 @@ function positiveInt(value) {
8
8
  return Number.isInteger(n) && n > 0 ? n : null;
9
9
  }
10
10
 
11
+ export function childGuardianSpawnEnv(env = process.env) {
12
+ return {
13
+ PATH: env.PATH || '',
14
+ SystemRoot: env.SystemRoot || env.WINDIR || '',
15
+ WINDIR: env.WINDIR || env.SystemRoot || '',
16
+ // Desktop's engine runs inside an Electron utility process, where
17
+ // process.execPath points at electron.exe. Without Node mode Electron
18
+ // ignores --eval, starts a full app process, and the intended guardian
19
+ // loop never runs — every completed shell then leaks ~100 MB until the
20
+ // desktop exits.
21
+ ELECTRON_RUN_AS_NODE: '1',
22
+ };
23
+ }
24
+
11
25
  function guardianScript({ parentPid, childPid, childGroupPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
12
26
  return `
13
27
  const { spawnSync } = require('node:child_process');
@@ -88,11 +102,7 @@ export function startChildGuardian({
88
102
  }),
89
103
  ], {
90
104
  stdio: 'ignore',
91
- env: {
92
- PATH: process.env.PATH || '',
93
- SystemRoot: process.env.SystemRoot || process.env.WINDIR || '',
94
- WINDIR: process.env.WINDIR || process.env.SystemRoot || '',
95
- },
105
+ env: childGuardianSpawnEnv(),
96
106
  ...detachedSpawnOpts,
97
107
  });
98
108
  guardian.unref?.();
@@ -3,25 +3,19 @@
3
3
  // ── Module-global child-spawn semaphore ──────────────────────────────────
4
4
  //
5
5
  // Single-daemon premise: tool execution is in-process, so a module-level
6
- // singleton semaphore bounds the number of concurrent child processes (rg,
7
- // mixdog-graph, …) across ALL agents/workers in this daemon. The goal is to
8
- // absorb the over-saturation tail — when N agents fire grep/code_graph at
9
- // once each child no longer fans out across every core, and the rg 20s
10
- // deadline stops tripping en masse — WITHOUT serializing: up to the cap run
11
- // concurrently, only the overflow queues and drains as slots free.
6
+ // singleton semaphore CAN bound the number of concurrent child processes (rg,
7
+ // mixdog-graph, …) across ALL agents/workers in this daemon. By default the
8
+ // gate is UNBOUNDED — parallel waves must never queue behind it — and it only
9
+ // becomes a real cap when MIXDOG_CHILD_SPAWN_MAX_INFLIGHT is set explicitly.
12
10
  //
13
11
  // IMPORTANT: this is a resource-control knob and is deliberately NOT exposed
14
12
  // on any tool JSON schema / tool parameter surface. The only tuning surface
15
- // is the internal constant + the MIXDOG_CHILD_SPAWN_MAX_INFLIGHT env override.
16
-
17
- import os from 'node:os';
13
+ // is the MIXDOG_CHILD_SPAWN_MAX_INFLIGHT env override.
18
14
 
19
15
  function _defaultMaxInflight() {
20
16
  const override = Number(process.env.MIXDOG_CHILD_SPAWN_MAX_INFLIGHT);
21
17
  if (Number.isFinite(override) && override >= 1) return Math.floor(override);
22
- let cpus = 0;
23
- try { cpus = os.cpus()?.length || 0; } catch { cpus = 0; }
24
- return Math.max(4, cpus || 4);
18
+ return Infinity;
25
19
  }
26
20
 
27
21
  const MAX_INFLIGHT = _defaultMaxInflight();
@@ -4,10 +4,14 @@ import { requestMemoryPressureSnapshot } from './memory-snapshot.mjs';
4
4
 
5
5
  const MB = 1024 * 1024;
6
6
 
7
+ // Concurrency defaults are UNBOUNDED by design: parallel agent/shell waves
8
+ // must never serialize behind an admission queue. The only default guards are
9
+ // the memory-pressure checks below; hard caps exist solely as opt-in env
10
+ // overrides (MIXDOG_MAX_CONCURRENT_AGENTS / _SHELLS / _HIGH_LOAD).
7
11
  export const RESOURCE_ADMISSION_DEFAULTS = Object.freeze({
8
- maxAgents: 4,
9
- maxShells: 4,
10
- maxHighLoad: 6,
12
+ maxAgents: Infinity,
13
+ maxShells: Infinity,
14
+ maxHighLoad: Infinity,
11
15
  maxQueue: 32,
12
16
  minFreeMemoryMb: 1024,
13
17
  maxRssMb: 3072,
@@ -47,9 +47,6 @@ function publicAgentReviews(sessionId) {
47
47
  return [...turn.agents.values()].map((review) => ({ ...review }));
48
48
  }
49
49
 
50
- /** Compatibility no-op: apply_patch tracking has no project warmup cost. */
51
- export function prewarmTurnSnapshot() {}
52
-
53
50
  /** Start a new user turn and invalidate the prior turn's child review group. */
54
51
  export async function beginTurnSnapshot(_worktree, sessionId) {
55
52
  const ownerSessionId = clean(sessionId);
@@ -49,6 +49,7 @@ export function createLifecycleApi(deps) {
49
49
  createCurrentSession, refreshRouteEffort,
50
50
  invalidateContextStatusCache, invalidatePreSessionToolSurface,
51
51
  applyResolvedCwd, resolveRoute, applyDeferredToolSurface, getStandaloneTools,
52
+ beginRoutePreparation, clearRoutePreparation,
52
53
  pushTranscriptRebind,
53
54
  notificationListeners, remoteStateListeners, desktopSession,
54
55
  } = deps;
@@ -342,7 +343,8 @@ export function createLifecycleApi(deps) {
342
343
  pushTranscriptRebind?.();
343
344
  return true;
344
345
  },
345
- async switchContext({ cwd, desktopSession: nextDesktopSession } = {}) {
346
+ async switchContext({ cwd, desktopSession: nextDesktopSession, forResume = false } = {}) {
347
+ clearRoutePreparation?.();
346
348
  const session = getSession();
347
349
  if (session?.id) {
348
350
  const cleanupReason = 'desktop-context-switch';
@@ -376,7 +378,7 @@ export function createLifecycleApi(deps) {
376
378
  // that session's provider/model. A fresh desktop task or project must
377
379
  // return to the configured Lead route instead of inheriting the route
378
380
  // of whichever session happened to be open immediately beforehand.
379
- if (typeof setRoute === 'function' && typeof getConfig === 'function'
381
+ if (!forResume && typeof setRoute === 'function' && typeof getConfig === 'function'
380
382
  && typeof resolveRoute === 'function') {
381
383
  setRoute(resolveRoute(getConfig(), {}));
382
384
  await refreshRouteEffort?.();
@@ -405,7 +407,23 @@ export function createLifecycleApi(deps) {
405
407
  prefetchSession(id) {
406
408
  return mgr.prefetchSession?.(id, toolSpecForMode(getMode())) === true;
407
409
  },
410
+ // Read-only session peek (desktop split panes treat every visible pane as
411
+ // foreground): the parsed session object without resuming, attaching, or
412
+ // touching ownership/liveness. The current session returns live memory.
413
+ peekSession(id) {
414
+ const sessionId = clean(id);
415
+ if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
416
+ const current = getSession();
417
+ if (current?.id === sessionId) return current;
418
+ const session = mgr.loadSession?.(sessionId);
419
+ // `closed` means the previous live owner released this durable session;
420
+ // it is still a valid historical transcript. Desktop pane peeks are
421
+ // strictly read-only, so they may inspect it without resuming or
422
+ // changing ownership. Deleted/tombstoned sessions have no load result.
423
+ return session || null;
424
+ },
408
425
  async resume(id) {
426
+ clearRoutePreparation?.();
409
427
  const prev = getSession();
410
428
  const previousId = prev?.id || null;
411
429
  const previousMessages = prev?.messages || null;
@@ -430,22 +448,44 @@ export function createLifecycleApi(deps) {
430
448
  }
431
449
  setSession(resumed);
432
450
  applyResolvedCwd(resolveResumeCwd(resumed, getCurrentCwd()), { markRefresh: false });
451
+ // Cwd application is synchronous even though MCP reconnect may continue
452
+ // in the background. Commit it before returning the resume transcript.
453
+ resumed.cwd = getCurrentCwd();
433
454
  const route = getRoute();
434
455
  // The resumed session's OWN effort wins. resolveRoute always returns an
435
456
  // effort key, so this used to reinstate the effort of whichever session
436
457
  // happened to be open before — a schedule/webhook session opened with a
437
458
  // different effort than the one it actually ran with.
438
459
  const resumeEffort = resumed.effort || (hasOwn(route, 'effort') ? route.effort : undefined);
439
- setRoute(resolveRoute(getConfig(), { provider: resumed.provider, model: resumed.model, effort: resumeEffort }));
440
- await refreshRouteEffort();
441
- const session = getSession();
442
- session.effort = getRoute().effectiveEffort || null;
443
- session.cwd = getCurrentCwd();
444
- applyDeferredToolSurface(session, deferredSurfaceModeForLead(getMode()), getStandaloneTools(), { provider: getRoute().provider });
460
+ const resumedRoute = resolveRoute(
461
+ getConfig(),
462
+ { provider: resumed.provider, model: resumed.model, effort: resumeEffort },
463
+ );
464
+ setRoute(resumedRoute);
465
+ const finishRoutePreparation = async () => {
466
+ const preparedRoute = await refreshRouteEffort(null, resumedRoute);
467
+ // Session or route changed while provider metadata was loading.
468
+ if (!preparedRoute || getSession() !== resumed || getRoute() !== preparedRoute) return false;
469
+ const activeSession = getSession();
470
+ activeSession.effort = getRoute().effectiveEffort || null;
471
+ activeSession.cwd = getCurrentCwd();
472
+ applyDeferredToolSurface(
473
+ activeSession,
474
+ deferredSurfaceModeForLead(getMode()),
475
+ getStandaloneTools(),
476
+ { provider: getRoute().provider },
477
+ );
478
+ writeStatuslineRoute(statusRoutes, activeSession, getRoute());
479
+ return true;
480
+ };
481
+ if (typeof beginRoutePreparation === 'function') {
482
+ beginRoutePreparation(finishRoutePreparation);
483
+ } else {
484
+ await finishRoutePreparation();
485
+ }
445
486
  invalidatePreSessionToolSurface();
446
487
  invalidateContextStatusCache();
447
488
  setSessionNeedsCwdRefresh(false);
448
- writeStatuslineRoute(statusRoutes, session, getRoute());
449
489
  // Session swapped to the resumed one: repoint the worker to the current
450
490
  // transcript instead of waiting for the next inbound steal.
451
491
  pushTranscriptRebind?.();