mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -1,59 +1,27 @@
1
- import {
2
- existsSync,
3
- mkdirSync,
4
- readFileSync,
5
- unlinkSync,
6
- writeFileSync,
7
- } from 'fs';
8
- import { writeFile } from 'fs/promises';
9
- import { randomBytes } from 'crypto';
10
- import { join } from 'path';
11
- import { getPluginData } from '../../config.mjs';
12
- import { renameWithRetrySync } from '../../../../shared/atomic-file.mjs';
13
- import { sanitizeContentForStoredHistory } from '../../providers/media-normalization.mjs';
14
- import { promptContentText } from './prompt-utils.mjs';
1
+ import { existsSync, unlinkSync } from 'fs';
15
2
  import { finalizeTurnInterruptionSnapshot } from './turn-interruption.mjs';
16
-
17
- const TURN_CHECKPOINT_VERSION = 1;
18
- const SESSION_ID = /^[A-Za-z0-9_-]+$/;
19
-
20
- function checkpointDir() {
21
- const dir = join(getPluginData(), 'turn-checkpoints');
22
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
23
- return dir;
24
- }
25
-
26
- function turnCheckpointPath(sessionId) {
27
- if (!SESSION_ID.test(String(sessionId || ''))) {
28
- throw new Error(`[turn-checkpoint] invalid session id: ${JSON.stringify(sessionId)}`);
29
- }
30
- return join(checkpointDir(), `${sessionId}.json`);
31
- }
32
-
33
- function checkpointMessage(message) {
34
- if (!message || typeof message !== 'object') return message;
35
- const content = sanitizeContentForStoredHistory(message.content);
36
- return content === message.content ? message : { ...message, content };
37
- }
38
-
39
- function matchingUserMessage(message, currentUserContent) {
40
- return message?.role === 'user'
41
- && (message.content === currentUserContent
42
- || promptContentText(message.content) === promptContentText(currentUserContent));
43
- }
44
-
45
- export function turnMessagesForCheckpoint(messages, currentUserContent) {
46
- const source = Array.isArray(messages) ? messages : [];
47
- let start = -1;
48
- for (let index = source.length - 1; index >= 0; index -= 1) {
49
- if (matchingUserMessage(source[index], currentUserContent)) {
50
- start = index;
51
- break;
52
- }
53
- }
54
- const turn = start >= 0 ? source.slice(start) : [];
55
- return turn.map(checkpointMessage);
56
- }
3
+ import {
4
+ appendJournalLines,
5
+ cancelJournalWrites,
6
+ createTurnJournalEncoder,
7
+ emptyInterruptionSnapshot,
8
+ findTurnStart,
9
+ journalHeadLines,
10
+ matchingUserMessage,
11
+ openJournalForTurn,
12
+ readTurnCheckpointHeader,
13
+ removeTurnJournal,
14
+ replayTurnJournal,
15
+ settleTurnJournalWrites,
16
+ turnCheckpointPath,
17
+ turnMessagesForCheckpoint,
18
+ writeCheckpointHeader,
19
+ } from './turn-checkpoint-journal.mjs';
20
+
21
+ export { turnCheckpointPath, turnMessagesForCheckpoint };
22
+
23
+ /** Resolve once every queued journal append for a session has been written. */
24
+ export const settleTurnCheckpointWrites = settleTurnJournalWrites;
57
25
 
58
26
  /** Read-only projection for a turn still owned by another process. Unlike
59
27
  * recoverTurnCheckpoint this never mutates the session, clears the checkpoint,
@@ -85,160 +53,110 @@ export function projectTurnCheckpointMessages(session, checkpoint) {
85
53
  ];
86
54
  }
87
55
 
88
- // ── Async checkpoint writes ──────────────────────────────────────────────────
89
- // The throttled streaming-delta flushes used to serialize+write synchronously
90
- // on the engine thread (up to ~6 stalls/second during a tool-heavy turn). The
91
- // async lane keeps per-session latest-wins coalescing (one in-flight write,
92
- // one queued payload) with an epoch so clear/stop can retire stale writes —
93
- // no locks, sessions stay fully parallel. The FIRST write of a turn stays
94
- // sync (opts.sync) because it is the crash-durability anchor for the prompt.
95
- const _pendingCheckpointWrites = new Map(); // sessionId → { queued, writing, epoch }
96
- const _asyncWriteWarned = new Set(); // sessionId — warn once per session
97
-
98
- function _warnAsyncWriteOnce(sessionId, error) {
99
- if (_asyncWriteWarned.has(sessionId)) return;
100
- _asyncWriteWarned.add(sessionId);
101
- try {
102
- process.stderr.write(`[turn-checkpoint] async write failed session=${sessionId}: ${error?.message || error}\n`);
103
- } catch { /* stderr best-effort */ }
56
+ // ── Journal-backed checkpoint writes ─────────────────────────────────────────
57
+ // A turn is checkpointed as a write-once header plus an append-only delta
58
+ // journal (turn-checkpoint-journal.mjs). The recorder owns exactly one turn:
59
+ // the FIRST record() writes the header synchronously — it is the crash
60
+ // durability anchor for the prompt and must land before the provider runs —
61
+ // and every later record() appends ONLY what changed since the previous flush.
62
+ // Per-flush cost is therefore O(new bytes), not O(whole growing turn), so a
63
+ // long tool-heavy turn no longer amplifies into the shared engine thread that
64
+ // every fanned-out agent session shares.
65
+ export function createTurnCheckpointRecorder({ sessionId, generation, turnToken, startedAt }) {
66
+ const encoder = createTurnJournalEncoder({ turnToken });
67
+ let opened = false;
68
+ let stopped = false;
69
+ return {
70
+ get opened() { return opened; },
71
+ /** True when this call wrote or queued durable state. */
72
+ record({ currentUserContent, turnOutgoing, interruption }) {
73
+ if (stopped || !sessionId || !turnToken) return false;
74
+ if (!opened) {
75
+ const seed = encoder.seed(turnOutgoing, currentUserContent, interruption);
76
+ writeCheckpointHeader({
77
+ sessionId,
78
+ generation,
79
+ turnToken,
80
+ startedAt,
81
+ currentUserContent,
82
+ turnMessages: seed.turnMessages,
83
+ interruption: typeof interruption?.snapshot === 'function'
84
+ ? interruption.snapshot()
85
+ : emptyInterruptionSnapshot(),
86
+ });
87
+ // Opening the journal is part of the anchor: a leftover journal
88
+ // from an earlier turn of this session must never replay onto
89
+ // the new header. It lands synchronously when the lane is idle
90
+ // and is otherwise FENCED behind the older in-flight write, so
91
+ // an old turn can never appear beneath this turn's token (the
92
+ // head record's token guards the interim window).
93
+ openJournalForTurn(sessionId, seed.openLines);
94
+ opened = true;
95
+ return true;
96
+ }
97
+ const lines = encoder.encode({ turnOutgoing, currentUserContent, interruption });
98
+ if (lines.length === 0) return false;
99
+ appendJournalLines(sessionId, lines);
100
+ return true;
101
+ },
102
+ stop() {
103
+ if (stopped) return;
104
+ stopped = true;
105
+ cancelPendingTurnCheckpoint(sessionId);
106
+ },
107
+ };
104
108
  }
105
109
 
106
- /** Retire queued + in-flight async writes for a session (epoch bump). */
110
+ /** Retire queued (not yet written) journal appends for a session. The durable
111
+ * prefix stays on disk on purpose: between turn commit and clearTurnCheckpoint
112
+ * a crash must still recover the turn's work, not just its opening header. */
107
113
  export function cancelPendingTurnCheckpoint(sessionId) {
108
- const entry = _pendingCheckpointWrites.get(sessionId);
109
- if (!entry) return;
110
- entry.queued = null;
111
- entry.epoch += 1;
112
- if (!entry.writing) _pendingCheckpointWrites.delete(sessionId);
114
+ cancelJournalWrites(sessionId);
113
115
  }
114
116
 
115
- function _pumpCheckpointWrites(sessionId) {
116
- const entry = _pendingCheckpointWrites.get(sessionId);
117
- if (!entry || entry.writing) return;
118
- if (!entry.queued) {
119
- _pendingCheckpointWrites.delete(sessionId);
120
- return;
121
- }
122
- const { target, json } = entry.queued;
123
- entry.queued = null;
124
- entry.writing = true;
125
- const epoch = entry.epoch;
126
- const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
127
- writeFile(temp, json, 'utf8')
128
- .then(() => {
129
- // A clear/stop after this write was dequeued must win: renaming a
130
- // retired payload would resurrect a checkpoint the turn already
131
- // finalized. The epoch check and rename run in one synchronous
132
- // block, so cancel (also synchronous) can never interleave.
133
- if (entry.epoch !== epoch) {
134
- try { unlinkSync(temp); } catch { /* best-effort */ }
135
- return;
136
- }
137
- renameWithRetrySync(temp, target);
138
- })
139
- .catch((error) => {
140
- try { unlinkSync(temp); } catch { /* best-effort */ }
141
- _warnAsyncWriteOnce(sessionId, error);
142
- })
143
- .finally(() => {
144
- entry.writing = false;
145
- _pumpCheckpointWrites(sessionId);
146
- });
147
- }
148
-
149
- // Hard-exit parity with the old sync behavior: a queued-but-unwritten payload
150
- // is flushed synchronously so a clean process exit never loses the newest
151
- // checkpoint state (crash loss remains bounded by the caller's throttle).
152
- process.on('exit', () => {
153
- for (const [sessionId, entry] of _pendingCheckpointWrites) {
154
- if (!entry.queued) continue;
155
- const { target, json } = entry.queued;
156
- entry.queued = null;
157
- const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
158
- try {
159
- writeFileSync(temp, json, 'utf8');
160
- renameWithRetrySync(temp, target);
161
- } catch (error) {
162
- try { unlinkSync(temp); } catch { /* best-effort */ }
163
- _warnAsyncWriteOnce(sessionId, error);
164
- }
165
- }
166
- });
167
-
168
- export function writeTurnCheckpoint(checkpoint, { sync = true } = {}) {
117
+ /** Full-snapshot write (header + journal reset). The streaming path uses
118
+ * createTurnCheckpointRecorder; this stays for callers holding a complete
119
+ * checkpoint object that must become durable in one shot. */
120
+ export function writeTurnCheckpoint(checkpoint) {
169
121
  if (!checkpoint?.sessionId || !checkpoint?.turnToken) return false;
170
- const target = turnCheckpointPath(checkpoint.sessionId);
171
- const payload = {
172
- ...checkpoint,
173
- version: TURN_CHECKPOINT_VERSION,
174
- currentUserContent: sanitizeContentForStoredHistory(checkpoint.currentUserContent),
175
- turnMessages: (Array.isArray(checkpoint.turnMessages) ? checkpoint.turnMessages : [])
176
- .map(checkpointMessage),
177
- updatedAt: Date.now(),
178
- };
179
- if (!sync) {
180
- const json = JSON.stringify(payload);
181
- const entry = _pendingCheckpointWrites.get(checkpoint.sessionId)
182
- ?? { queued: null, writing: false, epoch: 0 };
183
- _pendingCheckpointWrites.set(checkpoint.sessionId, entry);
184
- entry.queued = { target, json };
185
- _pumpCheckpointWrites(checkpoint.sessionId);
186
- return true;
187
- }
188
- const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
189
- try {
190
- writeFileSync(temp, JSON.stringify(payload), 'utf8');
191
- renameWithRetrySync(temp, target);
192
- return true;
193
- } catch (error) {
194
- try { unlinkSync(temp); } catch {}
195
- throw error;
196
- }
122
+ writeCheckpointHeader(checkpoint);
123
+ openJournalForTurn(checkpoint.sessionId, journalHeadLines(checkpoint.turnToken));
124
+ return true;
197
125
  }
198
126
 
199
127
  export function readTurnCheckpoint(sessionId) {
200
- const target = turnCheckpointPath(sessionId);
201
- try {
202
- const value = JSON.parse(readFileSync(target, 'utf8'));
203
- if (value?.version !== TURN_CHECKPOINT_VERSION
204
- || value?.sessionId !== sessionId
205
- || !value?.turnToken
206
- || !Array.isArray(value?.turnMessages)) {
207
- return null;
208
- }
209
- return value;
210
- } catch {
211
- return null;
212
- }
128
+ const header = readTurnCheckpointHeader(sessionId);
129
+ if (!header) return null;
130
+ return replayTurnJournal(header);
213
131
  }
214
132
 
215
133
  export function clearTurnCheckpoint(sessionId, turnToken = null) {
216
134
  const target = turnCheckpointPath(sessionId);
217
- // Queued async writes are stale the moment the caller decides to clear
218
- // this turn's checkpoint; retire them BEFORE the token guard reads disk
219
- // so a lagging write can never resurrect the file after the unlink.
135
+ // Queued appends are stale the moment the caller decides to clear this
136
+ // turn's checkpoint; retire them BEFORE the token guard reads disk so a
137
+ // lagging write can never resurrect state after the unlink.
220
138
  if (!turnToken) cancelPendingTurnCheckpoint(sessionId);
221
- if (!existsSync(target)) return true;
139
+ if (!existsSync(target)) {
140
+ // Header already gone: drop any orphan journal (a late in-flight append
141
+ // can recreate the file after a previous clear).
142
+ if (!turnToken) removeTurnJournal(sessionId);
143
+ return true;
144
+ }
222
145
  if (turnToken) {
223
- const current = readTurnCheckpoint(sessionId);
146
+ const current = readTurnCheckpointHeader(sessionId);
224
147
  // A token guard prevents an older turn's late terminal save from
225
148
  // deleting the checkpoint already created by its queued follow-up.
226
149
  if (current?.turnToken && current.turnToken !== turnToken) return false;
227
150
  cancelPendingTurnCheckpoint(sessionId);
228
151
  }
152
+ let removed = true;
229
153
  try {
230
154
  unlinkSync(target);
231
- return true;
232
155
  } catch {
233
- return false;
234
- }
235
- }
236
-
237
- function findPersistedTurnStart(messages, currentUserContent) {
238
- for (let index = messages.length - 1; index >= 0; index -= 1) {
239
- if (matchingUserMessage(messages[index], currentUserContent)) return index;
156
+ removed = false;
240
157
  }
241
- return -1;
158
+ removeTurnJournal(sessionId);
159
+ return removed;
242
160
  }
243
161
 
244
162
  /**
@@ -277,13 +195,14 @@ export function recoverTurnCheckpoint(session) {
277
195
  abortReason: 'process-crash',
278
196
  });
279
197
  const current = Array.isArray(session.messages) ? session.messages : [];
280
- const start = findPersistedTurnStart(current, checkpoint.currentUserContent);
198
+ const start = findTurnStart(current, checkpoint.currentUserContent);
281
199
  session.messages = [
282
200
  ...(start >= 0 ? current.slice(0, start) : current),
283
201
  ...finalized.messages,
284
202
  ];
285
203
  delete session.activeTurnCheckpoint;
286
204
  delete session.providerState;
205
+ delete session._providerPrefixGuardState;
287
206
  session.updatedAt = Date.now();
288
207
  session.lastUsedAt = Date.now();
289
208
  return {
@@ -188,6 +188,14 @@ export function createTurnInterruptionTracker() {
188
188
  let phase = 'streaming';
189
189
  const observedToolCalls = new Map();
190
190
  const observedToolResults = new Map();
191
+ // Epochs make the checkpoint journal's delta encoding sound WITHOUT
192
+ // comparing whole buffers on every flush: an epoch bump means "this buffer
193
+ // changed in a way that is not a plain append", so the encoder falls back
194
+ // to a full (still small) set record instead of an append record.
195
+ let textEpoch = 0;
196
+ let reasoningEpoch = 0;
197
+ let tombEpoch = 0;
198
+ let callsEpoch = 0;
191
199
 
192
200
  return {
193
201
  recordTextDelta(chunk) {
@@ -206,11 +214,16 @@ export function createTurnInterruptionTracker() {
206
214
  0,
207
215
  cutAt,
208
216
  );
217
+ textEpoch += 1;
218
+ tombEpoch += 1;
209
219
  },
210
220
  restoreTombstonedText() {
211
221
  if (!tombstonedAssistantContent) return false;
222
+ // Append-only for the partial buffer (no textEpoch bump); only the
223
+ // tombstone side is structurally reset.
212
224
  partialAssistantContent += tombstonedAssistantContent;
213
225
  tombstonedAssistantContent = '';
226
+ tombEpoch += 1;
214
227
  return true;
215
228
  },
216
229
  hasResponseStarted() {
@@ -235,6 +248,10 @@ export function createTurnInterruptionTracker() {
235
248
  tombstonedAssistantContent = '';
236
249
  partialReasoningContent = '';
237
250
  observedToolCalls.clear();
251
+ textEpoch += 1;
252
+ reasoningEpoch += 1;
253
+ tombEpoch += 1;
254
+ callsEpoch += 1;
238
255
  },
239
256
  recordToolCalls(calls, { eagerStarted = false } = {}) {
240
257
  for (const call of Array.isArray(calls) ? calls : []) {
@@ -283,6 +300,104 @@ export function createTurnInterruptionTracker() {
283
300
  observedToolResults: [...observedToolResults.entries()],
284
301
  };
285
302
  },
303
+ /**
304
+ * Delta against an opaque cursor from a previous call (null = seed).
305
+ * The checkpoint journal uses this instead of snapshot() so a flush
306
+ * serializes only what changed: appended text/reasoning, newly observed
307
+ * tool calls/results, and phase/responseStarted transitions. Cost is
308
+ * O(new bytes + observed tool entries), never O(turn).
309
+ * Returns { changed, delta, cursor }.
310
+ */
311
+ journalDelta(cursor) {
312
+ const prev = cursor && typeof cursor === 'object' ? cursor : null;
313
+ const prevCalls = prev?.calls instanceof Map ? prev.calls : new Map();
314
+ const prevResults = prev?.results instanceof Map ? prev.results : new Map();
315
+ const delta = {};
316
+ let changed = false;
317
+ if (prev ? prev.responseStarted !== responseStarted : responseStarted) {
318
+ delta.rs = responseStarted;
319
+ changed = true;
320
+ }
321
+ if (prev ? prev.phase !== phase : phase !== 'streaming') {
322
+ delta.ph = phase;
323
+ changed = true;
324
+ }
325
+ if (!prev || prev.textEpoch !== textEpoch
326
+ || partialAssistantContent.length < prev.textLen) {
327
+ if (prev || partialAssistantContent) {
328
+ delta.ts = partialAssistantContent;
329
+ changed = true;
330
+ }
331
+ } else if (partialAssistantContent.length > prev.textLen) {
332
+ delta.ta = partialAssistantContent.slice(prev.textLen);
333
+ changed = true;
334
+ }
335
+ if (!prev || prev.reasoningEpoch !== reasoningEpoch
336
+ || partialReasoningContent.length < prev.reasoningLen) {
337
+ if (prev || partialReasoningContent) {
338
+ delta.qs = partialReasoningContent;
339
+ changed = true;
340
+ }
341
+ } else if (partialReasoningContent.length > prev.reasoningLen) {
342
+ delta.qa = partialReasoningContent.slice(prev.reasoningLen);
343
+ changed = true;
344
+ }
345
+ if (!prev || prev.tombEpoch !== tombEpoch) {
346
+ if (prev || tombstonedAssistantContent) {
347
+ delta.tb = tombstonedAssistantContent;
348
+ changed = true;
349
+ }
350
+ }
351
+ // A cleared call map is a structural reset: journal the clear and
352
+ // re-emit whatever was recorded after it.
353
+ const callsReset = Boolean(prev) && prev.callsEpoch !== callsEpoch;
354
+ if (callsReset) {
355
+ delta.cc = true;
356
+ changed = true;
357
+ }
358
+ const callSet = [];
359
+ for (const [id, entry] of observedToolCalls) {
360
+ if (!callsReset && prevCalls.get(id) === entry) continue;
361
+ callSet.push([id, entry]);
362
+ }
363
+ if (callSet.length > 0) {
364
+ delta.cs = callSet;
365
+ changed = true;
366
+ }
367
+ const resultSet = [];
368
+ for (const [id, entry] of observedToolResults) {
369
+ if (prevResults.get(id) === entry) continue;
370
+ resultSet.push([id, entry]);
371
+ }
372
+ if (resultSet.length > 0) {
373
+ delta.os = resultSet;
374
+ changed = true;
375
+ }
376
+ const resultDeleted = [];
377
+ for (const id of prevResults.keys()) {
378
+ if (!observedToolResults.has(id)) resultDeleted.push(id);
379
+ }
380
+ if (resultDeleted.length > 0) {
381
+ delta.od = resultDeleted;
382
+ changed = true;
383
+ }
384
+ return {
385
+ changed,
386
+ delta,
387
+ cursor: {
388
+ responseStarted,
389
+ phase,
390
+ textEpoch,
391
+ textLen: partialAssistantContent.length,
392
+ reasoningEpoch,
393
+ reasoningLen: partialReasoningContent.length,
394
+ tombEpoch,
395
+ callsEpoch,
396
+ calls: new Map(observedToolCalls),
397
+ results: new Map(observedToolResults),
398
+ },
399
+ };
400
+ },
286
401
  finalize({ turnOutgoing, currentUserContent, abortReason = null }) {
287
402
  return finalizeInterruptedTurn({
288
403
  turnOutgoing,
@@ -33,6 +33,7 @@ import { isContextOverflowError } from '../providers/retry-classifier.mjs';
33
33
  import { traceAgentCompact, messagePrefixHash } from '../agent-trace.mjs';
34
34
  import { invalidateProviderRequestToolsScope } from '../../../../session-runtime/provider-request-tools.mjs';
35
35
  import { bumpUsageMetricsEpoch } from './manager.mjs';
36
+ import { resetReadStateAfterCompaction } from './read-dedup.mjs';
36
37
 
37
38
  const RECOVERED_ERROR_MESSAGE_MAX_CHARS = 300;
38
39
  const ANSI_ESCAPE_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;
@@ -444,6 +445,7 @@ export async function runPreSendCompactPass(state) {
444
445
  if (compactChanged) {
445
446
  messages.length = 0;
446
447
  messages.push(...compacted);
448
+ resetReadStateAfterCompaction(sessionId);
447
449
  // This attempt's provider-tool scope was keyed to the old
448
450
  // transcript shape. Invalidate it synchronously before any
449
451
  // post-compact callback or subsequent async continuation.
@@ -24,6 +24,7 @@ import {
24
24
  } from './image-strip-recovery.mjs';
25
25
  import { setTimeout as sleepMs } from 'timers/promises';
26
26
  import { readStreamOutcome } from '../providers/lib/stream-outcome.mjs';
27
+ import { cloneProviderReplay } from '../providers/lib/provider-replay.mjs';
27
28
  import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
28
29
  import { agentContextOverflowError } from './loop/context-overflow.mjs';
29
30
  import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
@@ -262,6 +263,7 @@ export async function sendWithRecovery(ctx) {
262
263
  && outcome.toolCallsStarted !== true
263
264
  && outcome.sideEffectDispatched !== true
264
265
  ) {
266
+ const partialProviderReplay = cloneProviderReplay(sendErr.partialProviderReplay);
265
267
  response = {
266
268
  content: sendErr.partialContent,
267
269
  model: sendErr.model || model,
@@ -269,6 +271,7 @@ export async function sendWithRecovery(ctx) {
269
271
  usage: normalizedIncompleteUsage(sendErr.rawUsage),
270
272
  stopReason: sendErr.finishReason,
271
273
  truncated: true,
274
+ ...(partialProviderReplay ? { providerReplay: partialProviderReplay } : {}),
272
275
  providerMetadata: sendErr.providerMetadata,
273
276
  providerState: opts.providerState,
274
277
  providerIncompleteRecovery: true,
@@ -393,6 +396,7 @@ export async function sendWithRecovery(ctx) {
393
396
  partialContentLen: typeof sendErr.partialContent === 'string' ? sendErr.partialContent.length : 0,
394
397
  });
395
398
  } catch { /* best-effort */ }
399
+ const partialProviderReplay = cloneProviderReplay(sendErr.partialProviderReplay);
396
400
  response = {
397
401
  content: typeof sendErr.partialContent === 'string' ? sendErr.partialContent : '',
398
402
  model: sendErr.partialModel || model,
@@ -416,6 +420,9 @@ export async function sendWithRecovery(ctx) {
416
420
  ...(Array.isArray(sendErr.partialAssistantBlocks) && sendErr.partialAssistantBlocks.length
417
421
  ? { assistantBlocks: sendErr.partialAssistantBlocks }
418
422
  : {}),
423
+ ...(partialProviderReplay
424
+ ? { providerReplay: partialProviderReplay }
425
+ : {}),
419
426
  providerMetadata: sendErr.providerMetadata,
420
427
  partialToolRecovery: true,
421
428
  };
@@ -95,6 +95,7 @@ export { parkSessionSnapshotForDrain, unparkSessionSnapshotForDrain } from './st
95
95
  // no timer retry and no exit-drain entry survives the unlink. Layering stays
96
96
  // intact: the store never imports manager code.
97
97
  const _sessionPurgeHooks = new Set();
98
+ const _liveSessionSubscribers = new Set();
98
99
 
99
100
  export function registerSessionPurgeHook(hook) {
100
101
  if (typeof hook !== 'function') return () => {};
@@ -102,6 +103,24 @@ export function registerSessionPurgeHook(hook) {
102
103
  return () => _sessionPurgeHooks.delete(hook);
103
104
  }
104
105
 
106
+ /** In-process session publication seam.
107
+ *
108
+ * The runtime worker uses this to project agent sessions through the daemon's
109
+ * ordinary session-state lane. Persistence remains independent: subscribers
110
+ * observe the same immutable session object that was admitted to the live
111
+ * cache, before disk debounce or I/O can delay a visible pane. */
112
+ export function subscribeLiveSessions(listener) {
113
+ if (typeof listener !== 'function') return () => {};
114
+ _liveSessionSubscribers.add(listener);
115
+ return () => _liveSessionSubscribers.delete(listener);
116
+ }
117
+
118
+ function _publishLiveSession(session) {
119
+ for (const listener of [..._liveSessionSubscribers]) {
120
+ try { listener(session); } catch { /* observers never affect persistence */ }
121
+ }
122
+ }
123
+
105
124
  function _runSessionPurgeHooks(id) {
106
125
  for (const hook of _sessionPurgeHooks) {
107
126
  try { hook(id); } catch { /* a cleanup hook never breaks delete */ }
@@ -318,7 +337,10 @@ export function saveSession(session, opts) {
318
337
  // summary row. The write itself still travels the normal path, where the
319
338
  // strict under-lock admission refuses it and produces the established
320
339
  // outcome/markers — only the local "owned" caches are never touched.
321
- if (!refusal) setLiveSession(session);
340
+ if (!refusal) {
341
+ setLiveSession(session);
342
+ _publishLiveSession(session);
343
+ }
322
344
  const summaryVersion = refusal ? null : _cacheSessionSummary(session);
323
345
  // Identity of THIS attempt: only a landed save at least this new may clear
324
346
  // the id's failure/drop markers (see live-state _clearSaveStateIfCurrent),
@@ -0,0 +1,14 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { buildShellSpawnEnv } from './bash-tool.mjs';
5
+
6
+ test('shell child receives the caller session cwd without mutating the daemon env', () => {
7
+ const base = {
8
+ PATH: process.env.PATH || '',
9
+ MIXDOG_SESSION_CWD: 'C:\\wrong-session',
10
+ };
11
+ const env = buildShellSpawnEnv('C:\\Project\\mixdog', base);
12
+ assert.equal(env.MIXDOG_SESSION_CWD, 'C:\\Project\\mixdog');
13
+ assert.equal(base.MIXDOG_SESSION_CWD, 'C:\\wrong-session');
14
+ });
@@ -401,6 +401,24 @@ const ARTIFACT_RETRY_ATTEMPTS = 10;
401
401
  const PENDING_ARTIFACT_PATHS = new Set();
402
402
  let artifactExitHookInstalled = false;
403
403
 
404
+ export function buildShellSpawnEnv(cwd, baseEnv = process.env) {
405
+ const spawnEnv = {
406
+ ...baseEnv,
407
+ LANG: 'C.UTF-8',
408
+ LC_ALL: 'C.UTF-8',
409
+ MIXDOG_SESSION_CWD: String(cwd || ''),
410
+ };
411
+ if (process.platform === 'win32') {
412
+ if (spawnEnv.PYTHONUTF8 === undefined) spawnEnv.PYTHONUTF8 = '1';
413
+ if (spawnEnv.PYTHONIOENCODING === undefined) spawnEnv.PYTHONIOENCODING = 'utf-8';
414
+ }
415
+ scrubProviderSecrets(spawnEnv);
416
+ scrubLoaderVars(spawnEnv);
417
+ scrubRuntimeRootVars(spawnEnv);
418
+ applyShellEgressPolicy(spawnEnv);
419
+ return spawnEnv;
420
+ }
421
+
404
422
  function removeTransportFile(file) {
405
423
  try {
406
424
  unlinkSync(file);
@@ -661,16 +679,7 @@ export async function executeBashTool(args, workDir, options = {}) {
661
679
 
662
680
  try {
663
681
  const { shell, shellArg, shellArgs, shellType } = resolvedSpec;
664
- const spawnEnv = { ...process.env, LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8' };
665
- if (process.platform === 'win32') {
666
- if (spawnEnv.PYTHONUTF8 === undefined) spawnEnv.PYTHONUTF8 = '1';
667
- if (spawnEnv.PYTHONIOENCODING === undefined) spawnEnv.PYTHONIOENCODING = 'utf-8';
668
- }
669
- // R5/R11: same scrub as background/persistent spawn sites (env-scrub.mjs).
670
- scrubProviderSecrets(spawnEnv);
671
- scrubLoaderVars(spawnEnv);
672
- scrubRuntimeRootVars(spawnEnv);
673
- applyShellEgressPolicy(spawnEnv);
682
+ const spawnEnv = buildShellSpawnEnv(bashWorkDir);
674
683
  let wrappedCommand;
675
684
  let execScript = null;
676
685
  let _teePlan = null;