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
@@ -0,0 +1,81 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ createSkillDocument,
6
+ parseSkillDocument,
7
+ updateSkillDocument,
8
+ validateSkillName,
9
+ } from './skill-document.mjs';
10
+
11
+ test('parses standard multiline YAML and the Markdown instructions body', () => {
12
+ const parsed = parseSkillDocument([
13
+ '---',
14
+ 'name: imported-skill',
15
+ 'description: >',
16
+ ' Use when an imported skill',
17
+ ' needs multiline matching.',
18
+ 'metadata:',
19
+ ' author: example',
20
+ 'allowed-tools:',
21
+ ' - shell',
22
+ '---',
23
+ '',
24
+ '# Instructions',
25
+ '',
26
+ 'Read `references/guide.md`.',
27
+ '',
28
+ ].join('\n'));
29
+
30
+ assert.equal(parsed.name, 'imported-skill');
31
+ assert.equal(parsed.description,
32
+ 'Use when an imported skill needs multiline matching.');
33
+ assert.deepEqual(parsed.frontmatter.metadata, { author: 'example' });
34
+ assert.deepEqual(parsed.frontmatter['allowed-tools'], ['shell']);
35
+ assert.match(parsed.body, /^# Instructions/);
36
+ });
37
+
38
+ test('updates the three editable fields without dropping optional frontmatter', () => {
39
+ const source = [
40
+ '---',
41
+ '# retained comment',
42
+ 'name: old-name',
43
+ 'description: Old trigger.',
44
+ 'license: MIT',
45
+ 'compatibility: Requires Python.',
46
+ 'metadata:',
47
+ ' author: example',
48
+ 'allowed-tools: shell',
49
+ '---',
50
+ '',
51
+ 'Old instructions.',
52
+ '',
53
+ ].join('\n');
54
+
55
+ const updated = updateSkillDocument(source, {
56
+ name: 'new-name',
57
+ description: 'Use when the new trigger matches.',
58
+ body: '# New instructions',
59
+ });
60
+ const parsed = parseSkillDocument(updated);
61
+
62
+ assert.equal(parsed.name, 'new-name');
63
+ assert.equal(parsed.description, 'Use when the new trigger matches.');
64
+ assert.equal(parsed.body, '# New instructions\n');
65
+ assert.equal(parsed.frontmatter.license, 'MIT');
66
+ assert.equal(parsed.frontmatter.compatibility, 'Requires Python.');
67
+ assert.deepEqual(parsed.frontmatter.metadata, { author: 'example' });
68
+ assert.equal(parsed.frontmatter['allowed-tools'], 'shell');
69
+ assert.match(updated, /# retained comment/);
70
+ });
71
+
72
+ test('creates a standard document and rejects non-standard names', () => {
73
+ const source = createSkillDocument({
74
+ name: 'project-skill',
75
+ description: 'Use when this project needs the skill.',
76
+ body: '# Instructions',
77
+ });
78
+ assert.equal(parseSkillDocument(source).name, 'project-skill');
79
+ assert.throws(() => validateSkillName('Project_Skill'), /lowercase letters/);
80
+ assert.throws(() => validateSkillName('project--skill'), /consecutive hyphens/);
81
+ });
@@ -153,6 +153,10 @@ export function displayToolName(name, args = {}) {
153
153
  case 'web_fetch':
154
154
  case 'fetch':
155
155
  return 'Fetch';
156
+ case 'browser':
157
+ return 'Browser';
158
+ case 'computer':
159
+ return 'Computer';
156
160
  case 'list_mcp_resources':
157
161
  case 'list_mcp_resource_templates':
158
162
  case 'cwd':
@@ -275,6 +279,16 @@ export function summarizeToolArgs(name, args, { max = DEFAULT_SUMMARY_MAX } = {}
275
279
  return formatCountedUnit(collectionCount(a.url, a.uri), 'URL', 'URLs');
276
280
  }
277
281
  return truncateToolText(a.url || a.uri || '', max);
282
+ case 'browser':
283
+ return compactParts([
284
+ String(a.action || ''),
285
+ a.url ? truncateToolText(a.url, max) : a.ref ? String(a.ref) : '',
286
+ ]);
287
+ case 'computer':
288
+ return compactParts([
289
+ String(a.action || ''),
290
+ a.window ? truncateToolText(a.window, max) : a.ref ? String(a.ref) : a.app ? truncateToolText(a.app, max) : '',
291
+ ]);
278
292
  case 'read_mcp_resource':
279
293
  return truncateToolText(a.uri || '', max);
280
294
  case 'list_mcp_resources':
@@ -559,6 +573,10 @@ export function toolWorkUnit(name, args = {}, category = '') {
559
573
  return unitDescriptor('Web Research', { count: queryCount(a, 'query', 'queries', 'keywords') || 1, noun: 'query', pluralNoun: 'queries' });
560
574
  case 'web_fetch':
561
575
  return unitDescriptor('Web Research', { count: queryCount(a, 'url', 'urls', 'uri', 'uris') || 1, active: 'Fetching', done: 'Fetched', noun: 'URL', pluralNoun: 'URLs' });
576
+ case 'browser':
577
+ return unitDescriptor('Browser', { count: 1, active: 'Browsing', done: 'Browsed', noun: 'action' });
578
+ case 'computer':
579
+ return unitDescriptor('Computer', { count: 1, active: 'Operating', done: 'Operated', noun: 'action' });
562
580
  case 'fetch': {
563
581
  const fetchLimit = Number(a.limit ?? a.messages);
564
582
  const fetchCount = Number.isFinite(fetchLimit) && fetchLimit > 0
@@ -109,6 +109,7 @@ export async function saveTurnSnapshotRecord(sessionId, turn) {
109
109
  const turns = (existing?.turns || []).filter((entry) => entry?.generation !== generation);
110
110
  turns.push({
111
111
  generation,
112
+ checkpointId: clean(turn?.checkpointId),
112
113
  root,
113
114
  baselineTree,
114
115
  // Relative, forward-slashed paths this session's own tools mutated.
@@ -89,6 +89,7 @@ function createDiffTracker(meta = {}) {
89
89
  return {
90
90
  ownerSessionId: clean(meta.ownerSessionId) || null,
91
91
  ownerGeneration: Number(meta.ownerGeneration) || 0,
92
+ checkpointId: clean(meta.checkpointId),
92
93
  worktreeRequest: clean(meta.worktreeRequest),
93
94
  worktreeContended: false,
94
95
  valid: true,
@@ -445,20 +446,26 @@ function publicAgentReviews(sessionId) {
445
446
  }
446
447
 
447
448
  /** Start a new user turn and invalidate the prior turn's child review group. */
448
- export async function beginTurnSnapshot(_worktree, sessionId) {
449
+ export async function beginTurnSnapshot(_worktree, sessionId, options = {}) {
449
450
  const ownerSessionId = clean(sessionId);
450
451
  if (DISABLED || !ownerSessionId) return;
451
452
  const generation = (_turnsBySession.get(ownerSessionId)?.generation || 0) + 1;
453
+ const checkpointId = clean(options?.checkpointId);
452
454
  for (const [trackedSessionId, tracker] of _diffTrackersBySession) {
453
455
  if (tracker.ownerSessionId === ownerSessionId) _diffTrackersBySession.delete(trackedSessionId);
454
456
  }
455
457
  _turnsBySession.delete(ownerSessionId);
456
458
  _turnsBySession.set(ownerSessionId, {
457
459
  generation,
460
+ checkpointId,
458
461
  agents: new Map(),
459
462
  });
460
463
  const worktreeRequest = pathKey(_worktree);
461
- const tracker = resetDiffTracker(ownerSessionId, { worktreeRequest });
464
+ const tracker = resetDiffTracker(ownerSessionId, {
465
+ ownerGeneration: generation,
466
+ checkpointId,
467
+ worktreeRequest,
468
+ });
462
469
  trimTurnCache();
463
470
  if (!tracker) return;
464
471
  for (const [otherSessionId, other] of _diffTrackersBySession) {
@@ -575,6 +582,7 @@ async function persistTurnSnapshotRecord(ownerSessionId, tracker) {
575
582
  try {
576
583
  await saveTurnSnapshotRecord(ownerSessionId, {
577
584
  generation: _turnsBySession.get(ownerSessionId)?.generation || 0,
585
+ checkpointId: tracker.checkpointId,
578
586
  root: snapshot.root,
579
587
  baselineTree: snapshot.baselineTree,
580
588
  toolFiles: owned,
@@ -669,6 +677,7 @@ async function scopedReviewFromRecord(ownerSessionId) {
669
677
  patch: resumed.snapshot.patch || '',
670
678
  snapshotKind: 'scoped',
671
679
  revertMode: 'scoped',
680
+ checkpointId: clean(resumed.record.checkpointId),
672
681
  patchTruncated: resumed.snapshot.patchTruncated === true,
673
682
  authoritative: true,
674
683
  agents: publicAgentReviews(ownerSessionId),
@@ -677,9 +686,20 @@ async function scopedReviewFromRecord(ownerSessionId) {
677
686
  }
678
687
 
679
688
  /** Restore recorded paths — every one of them, or just the file the user picked. */
680
- async function revertFromRecord(worktree, ownerSessionId, file) {
689
+ function assertCheckpointMatches(expectedCheckpointId, actualCheckpointId) {
690
+ const expected = clean(expectedCheckpointId);
691
+ if (!expected) return;
692
+ const actual = clean(actualCheckpointId);
693
+ if (!actual) throw new Error('turn review checkpoint is unavailable');
694
+ if (actual !== expected) {
695
+ throw new Error('turn review checkpoint changed; refresh the review');
696
+ }
697
+ }
698
+
699
+ async function revertFromRecord(worktree, ownerSessionId, file, expectedCheckpointId = '') {
681
700
  const resumed = await resumeRecordedSnapshot(ownerSessionId);
682
701
  if (!resumed) return null;
702
+ assertCheckpointMatches(expectedCheckpointId, resumed.record.checkpointId);
683
703
  const owned = resumed.record.toolFiles;
684
704
  const targets = file
685
705
  ? owned.filter((path) => recordedPathMatchesFile(path, resumed.record.root, file))
@@ -723,6 +743,7 @@ export async function getTurnReviewDiff(_worktree, sessionId, options = {}) {
723
743
  patch: snapshot?.patch ?? tracker?.unifiedDiff ?? '',
724
744
  snapshotKind: snapshot ? 'worktree' : 'tool',
725
745
  revertMode: snapshot ? 'worktree' : trackedRevertAvailable ? 'tracked' : '',
746
+ checkpointId: clean(turn?.checkpointId || tracker?.checkpointId),
726
747
  patchTruncated: snapshot?.patchTruncated === true,
727
748
  authoritative: Boolean(turn && tracker),
728
749
  agents: publicAgentReviews(ownerSessionId),
@@ -738,9 +759,10 @@ function unavailableTrackedRevertReason(tracker) {
738
759
  }
739
760
 
740
761
  /** Restore one reviewed file to this turn's worktree baseline, never to HEAD. */
741
- export async function revertTurnReviewFile(_worktree, sessionId, file) {
762
+ export async function revertTurnReviewFile(_worktree, sessionId, file, expectedCheckpointId = '') {
742
763
  const ownerSessionId = clean(sessionId);
743
764
  const tracker = _diffTrackersBySession.get(ownerSessionId);
765
+ if (tracker) assertCheckpointMatches(expectedCheckpointId, tracker.checkpointId);
744
766
  if (tracker && !worktreeSnapshotUsable(tracker)) {
745
767
  const pairs = trackedPairs(tracker);
746
768
  if (pairs.length > 0) {
@@ -749,7 +771,7 @@ export async function revertTurnReviewFile(_worktree, sessionId, file) {
749
771
  await revertTrackedPairs(tracker, _worktree, [pair]);
750
772
  return await getTurnReviewDiff(_worktree, ownerSessionId);
751
773
  }
752
- const recorded = await revertFromRecord(_worktree, ownerSessionId, file);
774
+ const recorded = await revertFromRecord(_worktree, ownerSessionId, file, expectedCheckpointId);
753
775
  if (recorded) return recorded;
754
776
  throw new Error(unavailableTrackedRevertReason(tracker));
755
777
  }
@@ -757,22 +779,23 @@ export async function revertTurnReviewFile(_worktree, sessionId, file) {
757
779
  await revertTurnWorktreeFile(tracker.worktreeSnapshot, file);
758
780
  return await getTurnReviewDiff(_worktree, ownerSessionId);
759
781
  }
760
- const recorded = await revertFromRecord(_worktree, ownerSessionId, file);
782
+ const recorded = await revertFromRecord(_worktree, ownerSessionId, file, expectedCheckpointId);
761
783
  if (recorded) return recorded;
762
784
  throw new Error('turn worktree snapshot is unavailable');
763
785
  }
764
786
 
765
787
  /** Restore every file in the reviewed turn to its turn-start state. */
766
- export async function revertTurnReview(_worktree, sessionId) {
788
+ export async function revertTurnReview(_worktree, sessionId, expectedCheckpointId = '') {
767
789
  const ownerSessionId = clean(sessionId);
768
790
  const tracker = _diffTrackersBySession.get(ownerSessionId);
791
+ if (tracker) assertCheckpointMatches(expectedCheckpointId, tracker.checkpointId);
769
792
  if (tracker && !worktreeSnapshotUsable(tracker)) {
770
793
  const pairs = trackedPairs(tracker);
771
794
  if (pairs.length > 0) {
772
795
  await revertTrackedPairs(tracker, _worktree, pairs);
773
796
  return await getTurnReviewDiff(_worktree, ownerSessionId);
774
797
  }
775
- const recorded = await revertFromRecord(_worktree, ownerSessionId, null);
798
+ const recorded = await revertFromRecord(_worktree, ownerSessionId, null, expectedCheckpointId);
776
799
  if (recorded) return recorded;
777
800
  throw new Error(unavailableTrackedRevertReason(tracker));
778
801
  }
@@ -780,7 +803,7 @@ export async function revertTurnReview(_worktree, sessionId) {
780
803
  await revertTurnWorktreeSnapshot(tracker.worktreeSnapshot);
781
804
  return await getTurnReviewDiff(_worktree, ownerSessionId);
782
805
  }
783
- const recorded = await revertFromRecord(_worktree, ownerSessionId, null);
806
+ const recorded = await revertFromRecord(_worktree, ownerSessionId, null, expectedCheckpointId);
784
807
  if (recorded) return recorded;
785
808
  throw new Error('turn worktree snapshot is unavailable');
786
809
  }
@@ -6,15 +6,13 @@
6
6
  */
7
7
 
8
8
  /**
9
- * user-cwd.mjs — shared helper to resolve the user's working directory
10
- * from the persisted user-cwd.txt sentinel file, with an optional
11
- * session-cwd override (process.env.MIXDOG_SESSION_CWD) that takes
12
- * precedence when set.
9
+ * user-cwd.mjs — shared helper to resolve the user's working directory.
10
+ * Inline runtimes use AsyncLocalStorage; an inherited
11
+ * MIXDOG_SESSION_CWD remains only a boot/isolated-process fallback.
13
12
  *
14
13
  * Single-source-of-truth model:
15
- * - captureOriginalUserCwd() reads MIXDOG_SESSION_CWD first (if set
16
- * to a valid directory), otherwise user-cwd.txt fresh on every
17
- * call. No in-memory freeze.
14
+ * - captureOriginalUserCwd() reads the async session scope first, then
15
+ * MIXDOG_SESSION_CWD/user-cwd.txt fresh on every call.
18
16
  * - rawUserCwd() reads ONLY user-cwd.txt (no env consult) — exposed
19
17
  * for the cwd-tool auto-init path so the env-var fallback cannot
20
18
  * become self-referential.
@@ -32,6 +30,12 @@ import { resolvePluginData, mixdogRoot } from './plugin-paths.mjs'
32
30
 
33
31
  const _cwdOverride = new AsyncLocalStorage()
34
32
 
33
+ function _activeCwdOverride() {
34
+ const store = _cwdOverride.getStore()
35
+ if (typeof store === 'string') return store
36
+ return store && typeof store.cwd === 'string' ? store.cwd : null
37
+ }
38
+
35
39
  function _dataFile(name) {
36
40
  return join(resolvePluginData(), name)
37
41
  }
@@ -75,10 +79,9 @@ function _normalizePlatformCwd(p) {
75
79
 
76
80
  /**
77
81
  * Resolve the session cwd from EXPLICIT signals only:
78
- * 1. process.env.MIXDOG_SESSION_CWD — session-level override set via
79
- * the `cwd` MCP tool. Honoured only when non-empty AND the resolved
80
- * directory actually exists.
81
- * 2. user-cwd.txt — single source of truth maintained by Mixdog
82
+ * 1. AsyncLocalStorage — current inline-session scope.
83
+ * 2. process.env.MIXDOG_SESSION_CWD — boot/isolated-process override.
84
+ * 3. user-cwd.txt — persisted fallback maintained by Mixdog
82
85
  * (rewritten at every session start).
83
86
  * Returns null when neither is available.
84
87
  *
@@ -89,6 +92,8 @@ function _normalizePlatformCwd(p) {
89
92
  * project_id resolution; use pwd() for relative-path resolution.
90
93
  */
91
94
  export function explicitSessionCwd() {
95
+ const scoped = _activeCwdOverride()
96
+ if (scoped) return scoped
92
97
  const sessionRaw = process.env.MIXDOG_SESSION_CWD
93
98
  if (typeof sessionRaw === 'string' && sessionRaw.length > 0) {
94
99
  const normalized = _normalizePlatformCwd(sessionRaw)
@@ -208,8 +213,22 @@ export function readLastSessionCwd(keyPid) {
208
213
  * Run fn inside an async context where pwd() returns cwd.
209
214
  * All descendant async calls within fn see cwd as their working directory.
210
215
  */
211
- function runWithCwdOverride(cwd, fn) {
212
- return _cwdOverride.run(cwd, fn)
216
+ export function runWithCwdOverride(cwd, fn) {
217
+ const normalized = _normalizePlatformCwd(cwd)
218
+ return _cwdOverride.run({ cwd: normalized || String(cwd || '') }, fn)
219
+ }
220
+
221
+ /**
222
+ * Retarget the current async session after an in-turn `cwd set`.
223
+ * Other concurrent sessions hold different AsyncLocalStorage records.
224
+ */
225
+ export function updateCurrentCwdOverride(cwd) {
226
+ const store = _cwdOverride.getStore()
227
+ if (!store || typeof store !== 'object') return false
228
+ const normalized = _normalizePlatformCwd(cwd)
229
+ if (!normalized) return false
230
+ store.cwd = normalized
231
+ return true
213
232
  }
214
233
 
215
234
  /**
@@ -217,5 +236,5 @@ function runWithCwdOverride(cwd, fn) {
217
236
  * override set by runWithCwdOverride (innermost wins) ?? original user cwd.
218
237
  */
219
238
  export function pwd() {
220
- return _cwdOverride.getStore() ?? captureOriginalUserCwd()
239
+ return _activeCwdOverride() ?? captureOriginalUserCwd()
221
240
  }
@@ -0,0 +1,43 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ explicitSessionCwd,
9
+ pwd,
10
+ runWithCwdOverride,
11
+ updateCurrentCwdOverride,
12
+ } from './user-cwd.mjs';
13
+
14
+ test('cwd overrides stay isolated across concurrent inline sessions', async () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-user-cwd-'));
16
+ const a = join(root, 'a');
17
+ const b = join(root, 'b');
18
+ const next = join(root, 'next');
19
+ mkdirSync(a);
20
+ mkdirSync(b);
21
+ mkdirSync(next);
22
+ try {
23
+ await Promise.all([
24
+ runWithCwdOverride(a, async () => {
25
+ await new Promise((resolve) => setTimeout(resolve, 10));
26
+ assert.equal(pwd(), a);
27
+ assert.equal(explicitSessionCwd(), a);
28
+ assert.equal(updateCurrentCwdOverride(next), true);
29
+ await new Promise((resolve) => setImmediate(resolve));
30
+ assert.equal(pwd(), next);
31
+ assert.equal(explicitSessionCwd(), next);
32
+ }),
33
+ runWithCwdOverride(b, async () => {
34
+ assert.equal(pwd(), b);
35
+ await new Promise((resolve) => setTimeout(resolve, 20));
36
+ assert.equal(pwd(), b);
37
+ assert.equal(explicitSessionCwd(), b);
38
+ }),
39
+ ]);
40
+ } finally {
41
+ rmSync(root, { recursive: true, force: true });
42
+ }
43
+ });
@@ -1,6 +1,7 @@
1
1
  import { discoverPluginMcp } from './plugin-mcp.mjs';
2
2
  import { featureEnvOverride, memoryToolsEnabled } from './config-helpers.mjs';
3
3
  import { readSessionCoreMemoryPayload } from '../runtime/memory/lib/core-memory-file.mjs';
4
+ import { saveSession } from '../runtime/agent/orchestrator/session/store.mjs';
4
5
 
5
6
  // cwd-plugins.mjs — cwd resolution/apply + plugins-status + core-memory context,
6
7
  // extracted from mixdog-session-runtime.mjs. Dependency-injected factory that
@@ -15,16 +16,14 @@ export function createCwdPlugins({
15
16
  setCurrentCwd,
16
17
  getConfig,
17
18
  getSession,
19
+ getDesktopSession,
20
+ setDesktopSession,
18
21
  getRoute,
19
- getLastProjectMcpKey,
20
- setLastProjectMcpKey,
21
22
  isCodeGraphPrewarmLazy,
22
23
  isCodeGraphFirstTurnPrewarmDone,
23
24
  getCodeGraphPrewarmDelayMs,
24
25
  setSessionNeedsCwdRefresh,
25
26
  // callbacks / deps
26
- connectConfiguredMcp,
27
- invalidatePreSessionToolSurface,
28
27
  scheduleCodeGraphPrewarm,
29
28
  hooks,
30
29
  hookCommonPayload,
@@ -37,8 +36,9 @@ export function createCwdPlugins({
37
36
  pluginMcpServerName,
38
37
  mcpScriptForPlugin,
39
38
  countSkillFiles,
40
- readProjectMcpServers,
41
39
  writeLastSessionCwd,
40
+ updateCurrentCwdOverride,
41
+ persistSession = (session) => saveSession(session, { immediate: true }),
42
42
  // shared utils
43
43
  clean,
44
44
  resolve,
@@ -98,17 +98,31 @@ export function createCwdPlugins({
98
98
  return next;
99
99
  }
100
100
 
101
- function applyResolvedCwd(nextCwd, { markRefresh = true, waitForMcpReset = false } = {}) {
101
+ function applyResolvedCwd(nextCwd, {
102
+ markRefresh = true,
103
+ waitForMcpReset = false,
104
+ persistProjectSelection = false,
105
+ } = {}) {
102
106
  const resolved = resolve(nextCwd);
103
107
  const stat = statSync(resolved);
104
108
  if (!stat.isDirectory()) throw new Error(`cwd: not a directory: ${resolved}`);
105
109
  const changed = resolve(getCurrentCwd()) !== resolved;
106
110
  setCurrentCwd(resolved);
107
111
  const currentCwd = resolved;
108
- process.env.MIXDOG_SESSION_CWD = currentCwd;
109
- writeLastSessionCwd(currentCwd);
110
112
  const session = getSession();
111
113
  if (session) session.cwd = currentCwd;
114
+ updateCurrentCwdOverride?.(currentCwd);
115
+ writeLastSessionCwd(currentCwd, session?.clientHostPid);
116
+ if (persistProjectSelection && session) {
117
+ const desktop = getDesktopSession?.() || session.desktopSession;
118
+ if (desktop && typeof desktop === 'object') {
119
+ const nextDesktop = { classification: 'project', projectPath: currentCwd };
120
+ setDesktopSession?.(nextDesktop);
121
+ session.desktopSession = nextDesktop;
122
+ }
123
+ session.updatedAt = Date.now();
124
+ persistSession(session);
125
+ }
112
126
  // cwd changes NEVER recreate the session: a mid-conversation cwd switch must
113
127
  // preserve the full message history. We retarget the live execution cwd in
114
128
  // place; the BP3 session snapshot remains the session-start environment.
@@ -125,29 +139,14 @@ export function createCwdPlugins({
125
139
  const delay = getCodeGraphPrewarmDelayMs();
126
140
  scheduleCodeGraphPrewarm(changed ? 0 : delay, changed ? 'cwd-change' : 'cwd');
127
141
  }
128
- // Project-local `.mcp.json` follows the cwd. Ordinary in-session cwd changes
129
- // reconnect in the background, while desktop context replacement requests
130
- // an awaitable reset so the next session/turn cannot observe the old registry.
131
- let mcpReset = null;
132
- if (changed) {
133
- try {
134
- const nextKey = resolved + '\u0000' + JSON.stringify(readProjectMcpServers(resolved));
135
- if (nextKey !== getLastProjectMcpKey()) {
136
- setLastProjectMcpKey(nextKey);
137
- mcpReset = Promise.resolve(connectConfiguredMcp({ reset: true }))
138
- .then(() => invalidatePreSessionToolSurface())
139
- .catch(() => {});
140
- }
141
- } catch {}
142
- }
142
+ // Extension settings are global; cwd changes never reload MCP or Skills.
143
+ void waitForMcpReset;
143
144
  // CwdChanged: bridge an effective cwd switch to the standard hook bus.
144
145
  // No matcher event — payload is minimal { cwd }. Fire-and-forget.
145
146
  if (changed) {
146
147
  try { void hooks.dispatch('CwdChanged', hookCommonPayload({ cwd: currentCwd })); } catch {}
147
148
  }
148
- return waitForMcpReset
149
- ? Promise.resolve(mcpReset).then(() => currentCwd)
150
- : currentCwd;
149
+ return currentCwd;
151
150
  }
152
151
 
153
152
  async function refreshSessionForCwdIfNeeded(reason = 'cwd-change') {
@@ -183,6 +182,7 @@ export function createCwdPlugins({
183
182
  sourceUrl: clean(entry.source),
184
183
  sourceType: clean(entry.sourceType) || 'git',
185
184
  managed: entry.managed !== false,
185
+ enabled: entry.enabled !== false,
186
186
  root,
187
187
  installedAt: entry.installedAt || null,
188
188
  updatedAt: entry.updatedAt || null,
@@ -0,0 +1,75 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, mkdirSync, rmSync, statSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { resolve, join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { createCwdPlugins } from './cwd-plugins.mjs';
8
+
9
+ test('explicit cwd selection persists execution cwd and desktop project metadata together', () => {
10
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-cwd-persist-'));
11
+ const before = join(root, 'before');
12
+ const after = join(root, 'after');
13
+ mkdirSync(before);
14
+ mkdirSync(after);
15
+ let currentCwd = before;
16
+ let desktopSession = { classification: 'project', projectPath: before };
17
+ const session = {
18
+ id: 'sess-cwd-persist',
19
+ cwd: before,
20
+ desktopSession,
21
+ clientHostPid: 4321,
22
+ };
23
+ const persisted = [];
24
+ const sentinels = [];
25
+ const overrides = [];
26
+ try {
27
+ const { applyResolvedCwd } = createCwdPlugins({
28
+ getCurrentCwd: () => currentCwd,
29
+ setCurrentCwd: (next) => { currentCwd = next; },
30
+ getConfig: () => ({}),
31
+ getSession: () => session,
32
+ getDesktopSession: () => desktopSession,
33
+ setDesktopSession: (next) => { desktopSession = next; },
34
+ getRoute: () => ({}),
35
+ isCodeGraphPrewarmLazy: () => true,
36
+ isCodeGraphFirstTurnPrewarmDone: () => false,
37
+ getCodeGraphPrewarmDelayMs: () => 0,
38
+ setSessionNeedsCwdRefresh: () => {},
39
+ scheduleCodeGraphPrewarm: () => {},
40
+ hooks: { dispatch: async () => {} },
41
+ hookCommonPayload: (value) => value,
42
+ bootProfile: () => {},
43
+ getMemoryModule: async () => null,
44
+ listRegisteredPlugins: () => [],
45
+ pluginAdminStatus: () => ({}),
46
+ pluginManifest: () => ({}),
47
+ pluginMcpServerName: () => '',
48
+ mcpScriptForPlugin: () => '',
49
+ countSkillFiles: () => 0,
50
+ writeLastSessionCwd: (cwd, pid) => sentinels.push({ cwd, pid }),
51
+ updateCurrentCwdOverride: (cwd) => overrides.push(cwd),
52
+ persistSession: (value) => persisted.push(structuredClone(value)),
53
+ clean: (value) => String(value || '').trim(),
54
+ resolve,
55
+ statSync,
56
+ existsSync: () => false,
57
+ cfgMod: {},
58
+ STANDALONE_DATA_DIR: root,
59
+ });
60
+
61
+ applyResolvedCwd(after, { persistProjectSelection: true });
62
+
63
+ assert.equal(currentCwd, after);
64
+ assert.equal(session.cwd, after);
65
+ assert.deepEqual(desktopSession, { classification: 'project', projectPath: after });
66
+ assert.deepEqual(session.desktopSession, desktopSession);
67
+ assert.equal(persisted.length, 1);
68
+ assert.equal(persisted[0].cwd, after);
69
+ assert.deepEqual(persisted[0].desktopSession, desktopSession);
70
+ assert.deepEqual(sentinels, [{ cwd: after, pid: 4321 }]);
71
+ assert.deepEqual(overrides, [after]);
72
+ } finally {
73
+ rmSync(root, { recursive: true, force: true });
74
+ }
75
+ });
@@ -14,6 +14,8 @@ test('cwd internal tool stays bound to its caller when another runtime owns the
14
14
  mkdirSync(cwdA);
15
15
  mkdirSync(cwdB);
16
16
  mkdirSync(cwdNext);
17
+ const previousProcessCwd = process.env.MIXDOG_SESSION_CWD;
18
+ process.env.MIXDOG_SESSION_CWD = 'process-global-must-not-change';
17
19
 
18
20
  const runtimeA = await createMixdogSessionRuntime({ cwd: cwdA, toolMode: 'full' });
19
21
  const runtimeB = await createMixdogSessionRuntime({ cwd: cwdB, toolMode: 'full' });
@@ -22,6 +24,8 @@ test('cwd internal tool stays bound to its caller when another runtime owns the
22
24
  runtimeA.close('cwd-routing-test', { waitForExit: false }),
23
25
  runtimeB.close('cwd-routing-test', { waitForExit: false }),
24
26
  ]);
27
+ if (previousProcessCwd === undefined) delete process.env.MIXDOG_SESSION_CWD;
28
+ else process.env.MIXDOG_SESSION_CWD = previousProcessCwd;
25
29
  rmSync(root, { recursive: true, force: true });
26
30
  });
27
31
 
@@ -53,4 +57,5 @@ test('cwd internal tool stays bound to its caller when another runtime owns the
53
57
  assert.equal(setResult.sessionId, callerSession.id);
54
58
  assert.equal(runtimeA.cwd, cwdNext);
55
59
  assert.equal(runtimeB.cwd, cwdB);
60
+ assert.equal(process.env.MIXDOG_SESSION_CWD, 'process-global-must-not-change');
56
61
  });
@@ -0,0 +1,26 @@
1
+ // In-process fan-out for machine-global extension settings. The daemon hosts
2
+ // multiple session runtimes in one process; a write from one runtime must make
3
+ // every other live runtime reload the same Skills, Plugins, and MCP state.
4
+ const subscribers = new Map();
5
+ let nextSubscriberId = 0;
6
+
7
+ export function subscribeGlobalExtensionChanges(listener) {
8
+ if (typeof listener !== 'function') throw new TypeError('global extension listener is required');
9
+ const id = ++nextSubscriberId;
10
+ subscribers.set(id, listener);
11
+ return {
12
+ id,
13
+ unsubscribe() {
14
+ subscribers.delete(id);
15
+ },
16
+ };
17
+ }
18
+
19
+ export async function publishGlobalExtensionChange(kind, originId = null) {
20
+ const jobs = [];
21
+ for (const [id, listener] of subscribers) {
22
+ if (id === originId) continue;
23
+ jobs.push(Promise.resolve().then(() => listener(kind)));
24
+ }
25
+ await Promise.allSettled(jobs);
26
+ }
@@ -0,0 +1,28 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ publishGlobalExtensionChange,
6
+ subscribeGlobalExtensionChanges,
7
+ } from './global-extensions.mjs';
8
+
9
+ test('global extension changes fan out to every runtime except the writer', async () => {
10
+ const events = [];
11
+ const writer = subscribeGlobalExtensionChanges((kind) => events.push(['writer', kind]));
12
+ const peer = subscribeGlobalExtensionChanges((kind) => events.push(['peer', kind]));
13
+ try {
14
+ await publishGlobalExtensionChange('mcp', writer.id);
15
+ assert.deepEqual(events, [['peer', 'mcp']]);
16
+ } finally {
17
+ writer.unsubscribe();
18
+ peer.unsubscribe();
19
+ }
20
+ });
21
+
22
+ test('unsubscribed runtimes stop receiving global extension changes', async () => {
23
+ const events = [];
24
+ const runtime = subscribeGlobalExtensionChanges((kind) => events.push(kind));
25
+ runtime.unsubscribe();
26
+ await publishGlobalExtensionChange('skills');
27
+ assert.deepEqual(events, []);
28
+ });