mixdog 0.9.85 → 0.9.86

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 (74) hide show
  1. package/README.md +7 -1
  2. package/package.json +3 -2
  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/mcp/client.mjs +24 -0
  10. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  13. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  14. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  16. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
  17. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  18. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
  19. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  20. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  21. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  22. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  23. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  24. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  28. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  29. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  30. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  31. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  32. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  33. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  34. package/src/runtime/agent/orchestrator/tools/token-manifest.json +5 -0
  35. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  36. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  37. package/src/runtime/media/renditions.mjs +1 -1
  38. package/src/runtime/media/store.mjs +1 -1
  39. package/src/runtime/shared/atomic-file.mjs +25 -14
  40. package/src/runtime/shared/automation-attachments.mjs +3 -3
  41. package/src/runtime/shared/child-guardian.mjs +15 -5
  42. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  43. package/src/runtime/shared/resource-admission.mjs +7 -3
  44. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  45. package/src/session-runtime/lifecycle-api.mjs +34 -9
  46. package/src/session-runtime/prewarm.mjs +35 -0
  47. package/src/session-runtime/remote-control.mjs +8 -47
  48. package/src/session-runtime/remote-transcript.mjs +3 -7
  49. package/src/session-runtime/route-preparation.mjs +24 -0
  50. package/src/session-runtime/runtime-core.mjs +37 -85
  51. package/src/session-runtime/session-turn-api.mjs +9 -2
  52. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  53. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  54. package/src/standalone/agent-shard/shard-pool.mjs +442 -0
  55. package/src/standalone/agent-tool.mjs +222 -0
  56. package/src/standalone/channel-daemon-transport.mjs +126 -185
  57. package/src/standalone/channel-daemon.mjs +9 -10
  58. package/src/tui/App.jsx +16 -9
  59. package/src/tui/app/channel-pickers.mjs +3 -8
  60. package/src/tui/app/route-pickers.mjs +117 -152
  61. package/src/tui/app/settings-picker.mjs +90 -89
  62. package/src/tui/app/slash-commands.mjs +7 -7
  63. package/src/tui/app/slash-dispatch.mjs +0 -45
  64. package/src/tui/components/StatusLine.jsx +1 -1
  65. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  66. package/src/tui/dist/index.mjs +243 -323
  67. package/src/tui/engine/session-api-ext.mjs +24 -9
  68. package/src/tui/engine.mjs +7 -1
  69. package/src/tui/figures.mjs +0 -1
  70. package/src/ui/statusline-format.mjs +0 -1
  71. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  72. package/src/lib/config-cjs.cjs +0 -61
  73. package/src/runtime/shared/launcher-control.mjs +0 -258
  74. package/src/runtime/shared/workspace-router.mjs +0 -259
@@ -34,6 +34,7 @@ import {
34
34
  import { getAgentRuntimeSync, warnAgentRuntimeResolveFailureOnce } from './agent-runtime-singleton.mjs';
35
35
  import { mintSessionId } from './session-id.mjs';
36
36
  import { providerCacheKey } from './provider-cache-key.mjs';
37
+ import { clearTurnCheckpoint, recoverTurnCheckpoint } from './turn-checkpoint.mjs';
37
38
 
38
39
  function buildSessionProviderCacheOpts(providerName, sessionId, agent = null) {
39
40
  // Keep this in sync with createSession's provider-cache policy: only
@@ -657,6 +658,19 @@ export async function resumeSession(sessionId, preset, options = {}) {
657
658
  }
658
659
  return attached;
659
660
  }
661
+ const crashRecovery = recoverTurnCheckpoint(session);
662
+ if (crashRecovery.changed) {
663
+ // Resume must not expose a recovered frame that exists only in memory.
664
+ // Persist synchronously, then remove the sidecar; a second crash at
665
+ // either boundary therefore leaves at least one complete copy.
666
+ saveSession(session, {
667
+ sync: true,
668
+ expectedGeneration: session.generation,
669
+ });
670
+ if (crashRecovery.turnToken) {
671
+ clearTurnCheckpoint(sessionId, crashRecovery.turnToken);
672
+ }
673
+ }
660
674
  const oldTools = session.tools || [];
661
675
  const cached = _preparedResumes.get(sessionId);
662
676
  _preparedResumes.delete(sessionId);
@@ -0,0 +1,170 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ unlinkSync,
6
+ writeFileSync,
7
+ } from 'fs';
8
+ import { randomBytes } from 'crypto';
9
+ import { join } from 'path';
10
+ import { getPluginData } from '../../config.mjs';
11
+ import { renameWithRetrySync } from '../../../../shared/atomic-file.mjs';
12
+ import { sanitizeContentForStoredHistory } from '../../providers/media-normalization.mjs';
13
+ import { promptContentText } from './prompt-utils.mjs';
14
+ import { finalizeTurnInterruptionSnapshot } from './turn-interruption.mjs';
15
+
16
+ const TURN_CHECKPOINT_VERSION = 1;
17
+ const SESSION_ID = /^[A-Za-z0-9_-]+$/;
18
+
19
+ function checkpointDir() {
20
+ const dir = join(getPluginData(), 'turn-checkpoints');
21
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
22
+ return dir;
23
+ }
24
+
25
+ function turnCheckpointPath(sessionId) {
26
+ if (!SESSION_ID.test(String(sessionId || ''))) {
27
+ throw new Error(`[turn-checkpoint] invalid session id: ${JSON.stringify(sessionId)}`);
28
+ }
29
+ return join(checkpointDir(), `${sessionId}.json`);
30
+ }
31
+
32
+ function checkpointMessage(message) {
33
+ if (!message || typeof message !== 'object') return message;
34
+ const content = sanitizeContentForStoredHistory(message.content);
35
+ return content === message.content ? message : { ...message, content };
36
+ }
37
+
38
+ function matchingUserMessage(message, currentUserContent) {
39
+ return message?.role === 'user'
40
+ && (message.content === currentUserContent
41
+ || promptContentText(message.content) === promptContentText(currentUserContent));
42
+ }
43
+
44
+ export function turnMessagesForCheckpoint(messages, currentUserContent) {
45
+ const source = Array.isArray(messages) ? messages : [];
46
+ let start = -1;
47
+ for (let index = source.length - 1; index >= 0; index -= 1) {
48
+ if (matchingUserMessage(source[index], currentUserContent)) {
49
+ start = index;
50
+ break;
51
+ }
52
+ }
53
+ const turn = start >= 0 ? source.slice(start) : [];
54
+ return turn.map(checkpointMessage);
55
+ }
56
+
57
+ export function writeTurnCheckpoint(checkpoint) {
58
+ if (!checkpoint?.sessionId || !checkpoint?.turnToken) return false;
59
+ const target = turnCheckpointPath(checkpoint.sessionId);
60
+ const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
61
+ const payload = {
62
+ ...checkpoint,
63
+ version: TURN_CHECKPOINT_VERSION,
64
+ currentUserContent: sanitizeContentForStoredHistory(checkpoint.currentUserContent),
65
+ turnMessages: (Array.isArray(checkpoint.turnMessages) ? checkpoint.turnMessages : [])
66
+ .map(checkpointMessage),
67
+ updatedAt: Date.now(),
68
+ };
69
+ try {
70
+ writeFileSync(temp, JSON.stringify(payload), 'utf8');
71
+ renameWithRetrySync(temp, target);
72
+ return true;
73
+ } catch (error) {
74
+ try { unlinkSync(temp); } catch {}
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ export function readTurnCheckpoint(sessionId) {
80
+ const target = turnCheckpointPath(sessionId);
81
+ try {
82
+ const value = JSON.parse(readFileSync(target, 'utf8'));
83
+ if (value?.version !== TURN_CHECKPOINT_VERSION
84
+ || value?.sessionId !== sessionId
85
+ || !value?.turnToken
86
+ || !Array.isArray(value?.turnMessages)) {
87
+ return null;
88
+ }
89
+ return value;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export function clearTurnCheckpoint(sessionId, turnToken = null) {
96
+ const target = turnCheckpointPath(sessionId);
97
+ if (!existsSync(target)) return true;
98
+ if (turnToken) {
99
+ const current = readTurnCheckpoint(sessionId);
100
+ // A token guard prevents an older turn's late terminal save from
101
+ // deleting the checkpoint already created by its queued follow-up.
102
+ if (current?.turnToken && current.turnToken !== turnToken) return false;
103
+ }
104
+ try {
105
+ unlinkSync(target);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ function findPersistedTurnStart(messages, currentUserContent) {
113
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
114
+ if (matchingUserMessage(messages[index], currentUserContent)) return index;
115
+ }
116
+ return -1;
117
+ }
118
+
119
+ /**
120
+ * Merge a force-killed turn checkpoint into a loaded session. The caller must
121
+ * durably save the mutated session before clearing the returned token.
122
+ */
123
+ export function recoverTurnCheckpoint(session) {
124
+ if (!session?.id || session.closed === true) {
125
+ return { changed: false, recovered: false, turnToken: null };
126
+ }
127
+ const checkpoint = readTurnCheckpoint(session.id);
128
+ const marker = session.activeTurnCheckpoint;
129
+ if (!checkpoint) {
130
+ if (!marker) return { changed: false, recovered: false, turnToken: null };
131
+ delete session.activeTurnCheckpoint;
132
+ return { changed: true, recovered: false, turnToken: null };
133
+ }
134
+
135
+ const sessionGeneration = Number(session.generation) || 0;
136
+ const checkpointGeneration = Number(checkpoint.generation) || 0;
137
+ const markerToken = typeof marker?.turnToken === 'string' ? marker.turnToken : null;
138
+ const markerMatches = markerToken === checkpoint.turnToken;
139
+ const checkpointIsNewer = Number(checkpoint.updatedAt) > Number(session.updatedAt || 0);
140
+ if (checkpointGeneration !== sessionGeneration
141
+ || (markerToken && !markerMatches)
142
+ || (!markerToken && !checkpointIsNewer)) {
143
+ clearTurnCheckpoint(session.id, checkpoint.turnToken);
144
+ if (markerToken === checkpoint.turnToken) delete session.activeTurnCheckpoint;
145
+ return { changed: false, recovered: false, turnToken: null };
146
+ }
147
+
148
+ const finalized = finalizeTurnInterruptionSnapshot({
149
+ turnOutgoing: checkpoint.turnMessages,
150
+ currentUserContent: checkpoint.currentUserContent,
151
+ snapshot: checkpoint.interruption,
152
+ abortReason: 'process-crash',
153
+ });
154
+ const current = Array.isArray(session.messages) ? session.messages : [];
155
+ const start = findPersistedTurnStart(current, checkpoint.currentUserContent);
156
+ session.messages = [
157
+ ...(start >= 0 ? current.slice(0, start) : current),
158
+ ...finalized.messages,
159
+ ];
160
+ delete session.activeTurnCheckpoint;
161
+ delete session.providerState;
162
+ session.updatedAt = Date.now();
163
+ session.lastUsedAt = Date.now();
164
+ return {
165
+ changed: true,
166
+ recovered: true,
167
+ turnToken: checkpoint.turnToken,
168
+ responsePreserved: finalized.responsePreserved,
169
+ };
170
+ }
@@ -8,6 +8,8 @@ import { filterModelVisibleSessionMessages } from './message-sanitize.mjs';
8
8
  const INTERRUPT_MESSAGE = '[Request interrupted by user]';
9
9
  const INTERRUPT_MESSAGE_FOR_TOOL_USE = '[Request interrupted by user for tool use]';
10
10
  const STREAMING_INTERRUPTED_TOOL_RESULT = 'Interrupted by user';
11
+ const PROCESS_RESTART_INTERRUPT_MESSAGE = '[Request interrupted by process restart]';
12
+ const PROCESS_RESTART_TOOL_RESULT = 'Interrupted by process restart';
11
13
  const TOOL_USE_REJECT_RESULT = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
12
14
 
13
15
  // Abort reasons that represent an EXPLICIT user cancellation of this turn.
@@ -134,9 +136,11 @@ function finalizeInterruptedTurn({
134
136
  const executionStarted = phase === 'tools' || observed?.eagerStarted === true;
135
137
  messages.push({
136
138
  role: 'tool',
137
- content: executionStarted
138
- ? TOOL_USE_REJECT_RESULT
139
- : STREAMING_INTERRUPTED_TOOL_RESULT,
139
+ content: abortReason === 'process-crash'
140
+ ? PROCESS_RESTART_TOOL_RESULT
141
+ : (executionStarted
142
+ ? TOOL_USE_REJECT_RESULT
143
+ : STREAMING_INTERRUPTED_TOOL_RESULT),
140
144
  toolCallId: callId,
141
145
  toolKind: 'error',
142
146
  });
@@ -149,9 +153,11 @@ function finalizeInterruptedTurn({
149
153
  if (abortReason !== 'interrupt' && abortReason !== 'provider-error') {
150
154
  pairedMessages.push({
151
155
  role: 'user',
152
- content: phase === 'tools'
153
- ? INTERRUPT_MESSAGE_FOR_TOOL_USE
154
- : INTERRUPT_MESSAGE,
156
+ content: abortReason === 'process-crash'
157
+ ? PROCESS_RESTART_INTERRUPT_MESSAGE
158
+ : (phase === 'tools'
159
+ ? INTERRUPT_MESSAGE_FOR_TOOL_USE
160
+ : INTERRUPT_MESSAGE),
155
161
  });
156
162
  }
157
163
  return { messages: pairedMessages, responsePreserved: true, userTurnPreserved: true };
@@ -247,6 +253,19 @@ export function createTurnInterruptionTracker() {
247
253
  phase = 'tools';
248
254
  responseStarted = true;
249
255
  },
256
+ snapshot() {
257
+ return {
258
+ responseStarted,
259
+ // A reset acknowledged by the UI temporarily tombstones text.
260
+ // A process crash cannot complete that replacement, so retain
261
+ // the same visible bytes the normal error path restores.
262
+ partialAssistantContent: partialAssistantContent + tombstonedAssistantContent,
263
+ partialReasoningContent,
264
+ phase,
265
+ observedToolCalls: [...observedToolCalls.entries()],
266
+ observedToolResults: [...observedToolResults.entries()],
267
+ };
268
+ },
250
269
  finalize({ turnOutgoing, currentUserContent, abortReason = null }) {
251
270
  return finalizeInterruptedTurn({
252
271
  turnOutgoing,
@@ -262,3 +281,23 @@ export function createTurnInterruptionTracker() {
262
281
  },
263
282
  };
264
283
  }
284
+
285
+ export function finalizeTurnInterruptionSnapshot({
286
+ turnOutgoing,
287
+ currentUserContent,
288
+ snapshot,
289
+ abortReason = 'process-crash',
290
+ }) {
291
+ const source = snapshot && typeof snapshot === 'object' ? snapshot : {};
292
+ return finalizeInterruptedTurn({
293
+ turnOutgoing,
294
+ currentUserContent,
295
+ responseStarted: source.responseStarted === true,
296
+ partialAssistantContent: String(source.partialAssistantContent || ''),
297
+ partialReasoningContent: String(source.partialReasoningContent || ''),
298
+ observedToolCalls: new Map(Array.isArray(source.observedToolCalls) ? source.observedToolCalls : []),
299
+ observedToolResults: new Map(Array.isArray(source.observedToolResults) ? source.observedToolResults : []),
300
+ phase: source.phase === 'tools' ? 'tools' : 'streaming',
301
+ abortReason,
302
+ });
303
+ }
@@ -173,6 +173,25 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes()) {
173
173
  (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
174
174
  }
175
175
 
176
+ function sessionFilesAreNewerThan(indexPath) {
177
+ const directory = join(dataDir(), 'sessions');
178
+ if (!existsSync(directory)) return false;
179
+ let indexMtime = 0;
180
+ try { indexMtime = statSync(indexPath).mtimeMs || 0; }
181
+ catch { return true; }
182
+ try {
183
+ for (const filename of readdirSync(directory)) {
184
+ if (!filename.endsWith('.json')) continue;
185
+ try {
186
+ if ((statSync(join(directory, filename)).mtimeMs || 0) > indexMtime) return true;
187
+ } catch { /* session disappeared after readdir */ }
188
+ }
189
+ } catch {
190
+ return true;
191
+ }
192
+ return false;
193
+ }
194
+
176
195
  export function listStoredSessionSummaries(options = {}) {
177
196
  const heartbeatMtimes = sessionHeartbeatMtimes();
178
197
  if (options.refreshFromStorage === true) return scanSessionFiles(heartbeatMtimes);
@@ -185,6 +204,13 @@ export function listStoredSessionSummaries(options = {}) {
185
204
  .filter((row) => row && isLeadVisibleRow(row))
186
205
  .sort((left, right) =>
187
206
  (right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
207
+ // A crashed/non-blocking sidecar writer can leave a valid but old,
208
+ // non-empty index indefinitely. Detect only newer session JSONs
209
+ // (heartbeat churn does not count), then recover read-only from the
210
+ // authoritative files without trying to rewrite the stuck index.
211
+ if (options.rebuildIfMissing !== false && sessionFilesAreNewerThan(indexPath)) {
212
+ return scanSessionFiles(heartbeatMtimes);
213
+ }
188
214
  if (rows.length > 0 || options.rebuildIfMissing === false) return rows;
189
215
  }
190
216
  } catch {
@@ -0,0 +1,42 @@
1
+ // Chunked o200k BPE encode-count, shared by the main thread (context-utils)
2
+ // and the token-count worker. BPE merge cost is quadratic in WORD length, not
3
+ // text length: a degenerate single-word run ('p'.repeat(250k), base64/minified
4
+ // blobs) forms one giant pat_str word and encode() spins for minutes while the
5
+ // event loop is blocked. Encoding fixed-size slices caps the worst-case word at
6
+ // the chunk size, so cost stays linear. Chunk cuts snap back to the nearest
7
+ // whitespace so the next slice starts at a natural ` word` boundary — o200k
8
+ // tokenizes that identically to the unsliced text, keeping estimates EXACT for
9
+ // prose. Only a whitespace-free degenerate run falls back to a hard cut
10
+ // (±1 token per boundary). Hard cuts avoid splitting a surrogate pair so
11
+ // sliced emoji/CJK-ext code points still encode cleanly.
12
+ const BPE_ENCODE_CHUNK_CHARS = 4_096;
13
+ const BPE_CHUNK_BOUNDARY_SCAN = 512;
14
+
15
+ export function bpeEncodeCount(enc, s) {
16
+ if (s.length <= BPE_ENCODE_CHUNK_CHARS) return enc.encode(s, undefined, []).length;
17
+ let total = 0;
18
+ let i = 0;
19
+ while (i < s.length) {
20
+ let end = Math.min(s.length, i + BPE_ENCODE_CHUNK_CHARS);
21
+ if (end < s.length) {
22
+ // Prefer cutting BEFORE a whitespace run: the next chunk then
23
+ // starts with ` word`, which o200k merges exactly as it would
24
+ // mid-text. Scan a bounded window so degenerate inputs stay O(1).
25
+ let ws = -1;
26
+ const scanFloor = Math.max(i + 1, end - BPE_CHUNK_BOUNDARY_SCAN);
27
+ for (let j = end - 1; j >= scanFloor; j -= 1) {
28
+ const c = s.charCodeAt(j);
29
+ if (c === 0x20 || c === 0x0A || c === 0x0D || c === 0x09) { ws = j; break; }
30
+ }
31
+ if (ws > i) {
32
+ end = ws; // next chunk starts at the whitespace
33
+ } else {
34
+ const last = s.charCodeAt(end - 1);
35
+ if (last >= 0xD800 && last <= 0xDBFF) end += 1;
36
+ }
37
+ }
38
+ total += enc.encode(s.slice(i, end), undefined, []).length;
39
+ i = end;
40
+ }
41
+ return total;
42
+ }
@@ -0,0 +1,186 @@
1
+ // Native mixdog-token client: persistent stdio server (transport lifecycle
2
+ // modeled on tools/patch/native-server.mjs) that serves precise o200k BPE
3
+ // counts ~5-12x faster than the in-process tiktoken WASM worker.
4
+ //
5
+ // Wire format: length-prefixed BINARY frames (raw utf8 payload, no JSON
6
+ // escape/parse on either side — that overhead dominated once the encoder
7
+ // went linear-time):
8
+ // request : u8 op (1=ping, 2=count) | u32le id | u32le byteLen | payload
9
+ // response : u8 op | u32le id | u64le count
10
+ // A stale JSON-only server never answers a binary ping, so the ready probe
11
+ // times out and this module degrades to the WASM worker path.
12
+ //
13
+ // Strictly a best-effort accelerator behind context-utils' async count path:
14
+ // unavailable binary / dead server / timeout all resolve `null`, and the
15
+ // caller falls back to the JS worker thread. Kill switch:
16
+ // MIXDOG_TOKEN_NATIVE=0 (mode `auto` is the default; MIXDOG_TOKEN_NATIVE_BIN
17
+ // overrides binary resolution).
18
+ import { existsSync } from 'node:fs';
19
+ import { spawn } from 'node:child_process';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { resolve as pathResolve, dirname as pathDirname, join as pathJoin } from 'node:path';
22
+ import { startChildGuardian } from '../../../shared/child-guardian.mjs';
23
+ import { getPluginData } from '../config.mjs';
24
+ import { ensureTokenBinary, findCachedTokenBinary } from '../tools/token-binary-fetcher.mjs';
25
+
26
+ const PLUGIN_ROOT = process.env.MIXDOG_ROOT
27
+ || pathResolve(pathDirname(fileURLToPath(import.meta.url)), '../../../../..');
28
+ const BIN_NAME = process.platform === 'win32' ? 'mixdog-token.exe' : 'mixdog-token';
29
+ const LOCAL_BIN = pathJoin(PLUGIN_ROOT, 'native/mixdog-token/target/release', BIN_NAME);
30
+ const READY_TIMEOUT_MS = 10_000;
31
+ const REQUEST_TIMEOUT_MS = 30_000;
32
+ const OP_PING = 1;
33
+ const OP_COUNT = 2;
34
+ const RESPONSE_FRAME_BYTES = 13;
35
+ const UNKNOWN_OP_SENTINEL = 0xFFFF_FFFF_FFFF_FFFFn;
36
+ const EMPTY_PAYLOAD = Buffer.alloc(0);
37
+
38
+ function nativeTokenMode() {
39
+ return String(process.env.MIXDOG_TOKEN_NATIVE || 'auto').toLowerCase();
40
+ }
41
+
42
+ function nativeTokenModeEnabled() {
43
+ return !/^(0|false|no|off|js)$/i.test(nativeTokenMode());
44
+ }
45
+
46
+ let _binPath; // undefined = not resolved, null = none found
47
+ function _resolveBinary() {
48
+ if (_binPath !== undefined) return _binPath;
49
+ const candidates = [
50
+ String(process.env.MIXDOG_TOKEN_NATIVE_BIN || '').trim() || null,
51
+ LOCAL_BIN,
52
+ ].filter(Boolean);
53
+ _binPath = candidates.find((candidate) => {
54
+ try { return existsSync(candidate); } catch { return false; }
55
+ }) || null;
56
+ if (!_binPath) {
57
+ // Release-supply cache (sha256-verified against the bundled manifest).
58
+ try { _binPath = findCachedTokenBinary(getPluginData()) || null; } catch { _binPath = null; }
59
+ }
60
+ return _binPath;
61
+ }
62
+
63
+ let _server = null; // { child, pending: Map, seq, ready } null after hard failure
64
+ let _serverFailed = false;
65
+
66
+ function _dropServer(error) {
67
+ const server = _server;
68
+ _server = null;
69
+ if (!server) return;
70
+ const reason = error instanceof Error ? error : new Error(String(error || 'mixdog-token server closed'));
71
+ for (const [, entry] of server.pending) {
72
+ clearTimeout(entry.timer);
73
+ entry.resolve(null);
74
+ void reason;
75
+ }
76
+ server.pending.clear();
77
+ try { server.child.kill(); } catch { /* already down */ }
78
+ }
79
+
80
+ function _ensureServer() {
81
+ if (_server || _serverFailed || !nativeTokenModeEnabled()) return _server;
82
+ const bin = _resolveBinary();
83
+ if (!bin) { _serverFailed = true; return null; }
84
+ let child;
85
+ try {
86
+ child = spawn(bin, [], { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true });
87
+ } catch {
88
+ _serverFailed = true;
89
+ return null;
90
+ }
91
+ const server = { child, pending: new Map(), seq: 0, ready: null };
92
+ let inbox = EMPTY_PAYLOAD;
93
+ child.stdout.on('data', (chunk) => {
94
+ inbox = inbox.length ? Buffer.concat([inbox, chunk]) : chunk;
95
+ while (inbox.length >= RESPONSE_FRAME_BYTES) {
96
+ const id = inbox.readUInt32LE(1);
97
+ const count = inbox.readBigUInt64LE(5);
98
+ inbox = inbox.subarray(RESPONSE_FRAME_BYTES);
99
+ const entry = server.pending.get(id);
100
+ if (!entry) continue;
101
+ server.pending.delete(id);
102
+ clearTimeout(entry.timer);
103
+ entry.resolve(count === UNKNOWN_OP_SENTINEL ? null : Number(count));
104
+ }
105
+ });
106
+ child.on('error', () => { _serverFailed = true; _dropServer('spawn error'); });
107
+ child.on('exit', () => { _dropServer('exited'); });
108
+ // Best-effort accelerator: never keeps the process alive. Unref the
109
+ // stdio PIPES too — child.unref() alone leaves the socket handles ref'd,
110
+ // which kept short-lived hosts (node --test suites) from ever exiting.
111
+ child.unref();
112
+ child.stdin?.unref?.();
113
+ child.stdout?.unref?.();
114
+ child.stdin?.on?.('error', () => { /* EPIPE on teardown */ });
115
+ try {
116
+ startChildGuardian({ childPid: child.pid, label: 'mixdog-token', orphanGraceMs: 8000, forceGraceMs: 3000 });
117
+ } catch { /* guardian best-effort */ }
118
+ server.ready = _request(server, OP_PING, null, READY_TIMEOUT_MS).then((count) => count !== null);
119
+ _server = server;
120
+ return server;
121
+ }
122
+
123
+ function _request(server, op, text, timeoutMs = REQUEST_TIMEOUT_MS) {
124
+ return new Promise((resolve) => {
125
+ const id = ++server.seq;
126
+ const timer = setTimeout(() => {
127
+ server.pending.delete(id);
128
+ resolve(null);
129
+ }, timeoutMs);
130
+ timer.unref?.();
131
+ server.pending.set(id, { resolve, timer });
132
+ try {
133
+ const payload = text ? Buffer.from(text, 'utf8') : EMPTY_PAYLOAD;
134
+ const header = Buffer.allocUnsafe(9);
135
+ header[0] = op;
136
+ header.writeUInt32LE(id >>> 0, 1);
137
+ header.writeUInt32LE(payload.length >>> 0, 5);
138
+ const stdin = server.child.stdin;
139
+ stdin.cork();
140
+ stdin.write(header);
141
+ if (payload.length) stdin.write(payload);
142
+ stdin.uncork();
143
+ } catch {
144
+ server.pending.delete(id);
145
+ clearTimeout(timer);
146
+ resolve(null);
147
+ }
148
+ });
149
+ }
150
+
151
+ /** True when the native counter can be attempted (mode on + binary present). */
152
+ export function nativeTokenCounterEnabled() {
153
+ return nativeTokenModeEnabled() && !_serverFailed && Boolean(_resolveBinary());
154
+ }
155
+
156
+ /** Precise o200k count via the native server; null on any unavailability. */
157
+ export async function countTokensNative(text) {
158
+ const server = _ensureServer();
159
+ if (!server) return null;
160
+ const ready = await server.ready;
161
+ if (!ready) { _serverFailed = true; _dropServer('ready failed'); return null; }
162
+ const count = await _request(server, OP_COUNT, String(text ?? ''));
163
+ return Number.isFinite(count) && count >= 0 ? count : null;
164
+ }
165
+
166
+ let _fetchAttempted = false;
167
+
168
+ /** Boot prewarm: spawn the server + build the encoder off the first estimate.
169
+ * With no local/env/cached binary, kick ONE background manifest fetch; on
170
+ * success the resolution cache resets so the next estimate adopts it. */
171
+ export function prewarmNativeTokenCounter() {
172
+ const server = _ensureServer();
173
+ if (!server) {
174
+ if (!_fetchAttempted && nativeTokenModeEnabled() && !_resolveBinary()) {
175
+ _fetchAttempted = true;
176
+ void ensureTokenBinary(getPluginData()).then((path) => {
177
+ if (!path) return;
178
+ _binPath = undefined;
179
+ _serverFailed = false;
180
+ }).catch(() => { /* soft degrade — WASM worker path remains */ });
181
+ }
182
+ return false;
183
+ }
184
+ server.ready?.catch?.(() => {});
185
+ return true;
186
+ }
@@ -32,6 +32,7 @@ import { crossTurnSignature, crossTurnDedupStub, isEditProgressTool } from './lo
32
32
  import {
33
33
  getToolKind,
34
34
  isEagerDispatchable,
35
+ isParallelDispatchable,
35
36
  isToolCallDedupEligible,
36
37
  parseNativeToolSearchPayload,
37
38
  } from './loop/tool-helpers.mjs';
@@ -181,7 +182,11 @@ export async function processToolBatch(ctx) {
181
182
  // re-issue it cleanly in a new turn instead of copying back a
182
183
  // `[mixdog compacted …]` placeholder. Emits a matching is_error
183
184
  // tool_result so the assistant tool_use is not orphaned.
184
- if (_orderedMutationFailed && _isOrderedGateSkippable(call.name)) {
185
+ // Full-parallel caveat: a call that ALREADY started (pending eager
186
+ // promise) cannot be "skipped" — its side effects are running, so
187
+ // its real result must be consumed below. The gate only skips
188
+ // calls that have not started (apply_patch never starts eagerly).
189
+ if (_orderedMutationFailed && _isOrderedGateSkippable(call.name) && !pending.has(call.id)) {
185
190
  if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
186
191
  pushToolResultMessage({
187
192
  role: 'tool',
@@ -252,13 +257,18 @@ export async function processToolBatch(ctx) {
252
257
  _executeOk = true;
253
258
  } else {
254
259
  // Fallback for providers that don't stream tool calls early:
255
- // execute a contiguous read-only run in parallel, but never
256
- // cross a write/bash/MCP boundary that may change state.
257
- if (isEagerDispatchable(call.name, tools)) {
260
+ // start the whole remaining batch in parallel (full-parallel
261
+ // policy; only apply_patch stays in this serial body).
262
+ if (isParallelDispatchable(call.name)) {
258
263
  startEagerRun(calls, callIndex, _duplicateCallIds);
259
264
  }
260
265
  let eager = pending.get(call.id);
261
- if (eager !== undefined && eager.mutationEpoch < epoch.mutation) {
266
+ // Post-mutation invalidation applies ONLY to read-only results:
267
+ // a read that raced an apply_patch re-executes for fresh
268
+ // content. Non-read-only parallel calls (shell/MCP/...) already
269
+ // ran — their side effects are real, so their results are
270
+ // consumed as-is and NEVER re-executed.
271
+ if (eager !== undefined && eager.mutationEpoch < epoch.mutation && isEagerDispatchable(call.name, tools)) {
262
272
  pending.delete(call.id);
263
273
  eager = undefined;
264
274
  }
@@ -39,6 +39,7 @@ import {
39
39
  resolveExecutionMode,
40
40
  } from '../../../../shared/background-tasks.mjs';
41
41
  import { resolveShellFor } from './shell-runtime.mjs';
42
+ import { prewarmPwshStandbyPool } from '../lib/pwsh-standby-pool.mjs';
42
43
  import { smartMiddleTruncate } from './shell-output.mjs';
43
44
  import { normalizeOutputPath } from './path-utils.mjs';
44
45
  import { normalizeErrorMessage } from './path-diagnostics.mjs';
@@ -58,6 +59,25 @@ import { resourceAdmission } from '../../../../shared/resource-admission.mjs';
58
59
  export function _captureTrackedMtimes(_scope) {
59
60
  return new Map();
60
61
  }
62
+
63
+ /**
64
+ * Boot prewarm: build the EXACT spawn env/spec a real shell call uses and warm
65
+ * the pwsh standby pool with it, so the first shell call of a session skips
66
+ * the pwsh startup cost. No-op off Windows / non-pwsh. Best-effort.
67
+ */
68
+ export function prewarmShellStandbys() {
69
+ try {
70
+ const spec = resolveShellFor('default');
71
+ if (!spec || spec.shellType !== 'powershell') return false;
72
+ const env = { ...process.env, LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8' };
73
+ scrubProviderSecrets(env);
74
+ scrubLoaderVars(env);
75
+ scrubRuntimeRootVars(env);
76
+ return prewarmPwshStandbyPool({ shell: spec.shell, shellArgs: spec.shellArgs, env }) === true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
61
81
  export function _trackedDriftNoteAfter(_scope, _pre) {
62
82
  return '';
63
83
  }
@@ -74,7 +74,7 @@ export const BUILTIN_TOOLS = [
74
74
  name: 'shell',
75
75
  title: 'Mixdog Shell',
76
76
  annotations: { title: 'Mixdog Shell', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true, compressible: true },
77
- description: `Run programs or change system state; not for reading, listing or searching. Shell/write calls are serial.${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
77
+ description: `Run programs or change system state; not for reading, listing or searching. Independent calls in one turn run in parallel; order-dependent commands go in one command or later turns.${_shellSyntaxCheat} ${TOOL_ASYNC_EXECUTION_CONTRACT}`,
78
78
  inputSchema: {
79
79
  type: 'object',
80
80
  properties: {
@@ -117,6 +117,30 @@ function normalizeWindowsSnapshot(snapshot) {
117
117
  return rows;
118
118
  }
119
119
 
120
+ // Standby-pool support: does `pid` currently own live descendant processes?
121
+ // FRESH snapshot on purpose — the 750ms cache could miss a just-spawned
122
+ // grandchild and wrongly certify a polluted standby shell as clean. Unknown
123
+ // snapshots answer true (conservative: the pool discards instead of reusing).
124
+ export async function windowsPidHasDescendants(pid, { spawnFn = spawn } = {}) {
125
+ if (!Number.isFinite(pid) || pid <= 0) return false;
126
+ if (process.platform !== 'win32') return false;
127
+ let rows = null;
128
+ try { rows = await windowsProcessSnapshot({ fresh: true, spawnFn }); } catch { rows = null; }
129
+ if (!rows) return true;
130
+ const owned = new Set([pid]);
131
+ let discovered = true;
132
+ while (discovered) {
133
+ discovered = false;
134
+ for (const row of rows.values()) {
135
+ if (!owned.has(row.pid) && owned.has(row.parentPid)) {
136
+ owned.add(row.pid);
137
+ discovered = true;
138
+ }
139
+ }
140
+ }
141
+ return owned.size > 1;
142
+ }
143
+
120
144
  // Invoke onQuiescent exactly once, synchronously when already quiescent or
121
145
  // after positive liveness probes stop observing the owned tree/group.
122
146
  export function trackProcessTreeQuiescence(