mixdog 0.9.55 → 0.9.58

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 (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
@@ -7,9 +7,13 @@ import { fileURLToPath } from 'node:url';
7
7
  import { execFileSync } from 'node:child_process';
8
8
 
9
9
  import {
10
+ SESSION_SUMMARY_INDEX_VERSION,
10
11
  _normalizeSummaryIndex,
11
12
  _sessionSummary,
12
13
  } from '../src/runtime/agent/orchestrator/session/store-summary-index.mjs';
14
+ import {
15
+ listStoredSessionSummaries as listLightweightSessionSummaries,
16
+ } from '../src/runtime/agent/orchestrator/session/store-summary-reader.mjs';
13
17
  import { createLifecycleApi, resolveResumeCwd } from '../src/session-runtime/lifecycle-api.mjs';
14
18
  import { createCwdPlugins } from '../src/session-runtime/cwd-plugins.mjs';
15
19
  import {
@@ -99,6 +103,80 @@ test('desktop classification is optional and round-trips through the existing su
99
103
  assert.equal(malformed.desktopSession, null);
100
104
  });
101
105
 
106
+ test('cold summary reader loads the sidecar without importing the full store', () => {
107
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-cold-summary-reader-'));
108
+ const previousDataDir = process.env.MIXDOG_DATA_DIR;
109
+ process.env.MIXDOG_DATA_DIR = root;
110
+ try {
111
+ writeFileSync(join(root, 'session-summaries.json'), JSON.stringify({
112
+ version: SESSION_SUMMARY_INDEX_VERSION,
113
+ rows: [{
114
+ id: 'cold_reader',
115
+ updatedAt: 10,
116
+ cwd: '/workspace',
117
+ preview: 'Cold reader session',
118
+ desktopSession: { classification: 'task', projectPath: null },
119
+ }],
120
+ }));
121
+ assert.deepEqual(listLightweightSessionSummaries().map((row) => row.id), ['cold_reader']);
122
+ } finally {
123
+ if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
124
+ else process.env.MIXDOG_DATA_DIR = previousDataDir;
125
+ rmSync(root, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ test('loadSession reuses a parsed session until the atomic file identity changes', () => {
130
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-session-load-cache-'));
131
+ const previousDataDir = process.env.MIXDOG_DATA_DIR;
132
+ process.env.MIXDOG_DATA_DIR = root;
133
+ try {
134
+ const sessions = join(root, 'sessions');
135
+ mkdirSync(sessions, { recursive: true });
136
+ const target = join(sessions, 'cached_parse.json');
137
+ writeFileSync(target, JSON.stringify({
138
+ id: 'cached_parse',
139
+ messages: [{ role: 'user', content: 'first version' }],
140
+ }));
141
+ const first = loadSession('cached_parse');
142
+ const second = loadSession('cached_parse');
143
+ assert.equal(second, first);
144
+ writeFileSync(target, JSON.stringify({
145
+ id: 'cached_parse',
146
+ messages: [{ role: 'user', content: 'changed version with a different size' }],
147
+ }));
148
+ const changed = loadSession('cached_parse');
149
+ assert.notEqual(changed, first);
150
+ assert.equal(changed.messages[0].content, 'changed version with a different size');
151
+ } finally {
152
+ if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
153
+ else process.env.MIXDOG_DATA_DIR = previousDataDir;
154
+ rmSync(root, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test('session summaries use the first real user request and remove injected display blocks', () => {
159
+ const summary = _sessionSummary({
160
+ id: 'clean_title_source',
161
+ owner: 'user',
162
+ messages: [
163
+ { role: 'user', content: '<system-reminder>runtime bootstrap</system-reminder>' },
164
+ {
165
+ role: 'user',
166
+ content: 'A previous model worked on this task and produced the compacted handoff summary below. Build on it.',
167
+ },
168
+ {
169
+ role: 'user',
170
+ content: 'Reference files: [Image #1] <mcp-instructions>internal tools</mcp-instructions> Align the project dropdown',
171
+ },
172
+ { role: 'user', content: 'The async agent task task_agent completed with internal output.' },
173
+ { role: 'user', content: 'A later follow-up must not replace the stable title source' },
174
+ ],
175
+ });
176
+
177
+ assert.equal(summary.preview, 'Align the project dropdown');
178
+ });
179
+
102
180
  test('authoritative summary refresh repairs a stale index and skips malformed session files', () => {
103
181
  const root = mkdtempSync(join(tmpdir(), 'mixdog-desktop-summary-'));
104
182
  const previousDataDir = process.env.MIXDOG_DATA_DIR;
@@ -165,6 +243,10 @@ test('cached summaries reflect local lifecycle mutations and forced refresh reco
165
243
  }));
166
244
 
167
245
  assert.deepEqual(listStoredSessionSummaries().map((row) => row.id), ['indexed']);
246
+ assert.equal(
247
+ JSON.parse(readFileSync(join(root, 'session-summaries.json'), 'utf8')).version,
248
+ SESSION_SUMMARY_INDEX_VERSION,
249
+ );
168
250
 
169
251
  saveSession({
170
252
  id: 'cached_new', owner: 'user', updatedAt: 20,
@@ -351,7 +433,7 @@ test('authoritative refresh fails closed when the session directory is unreadabl
351
433
  process.env.MIXDOG_DATA_DIR = root;
352
434
  try {
353
435
  writeFileSync(join(root, 'session-summaries.json'), JSON.stringify({
354
- version: 1,
436
+ version: SESSION_SUMMARY_INDEX_VERSION,
355
437
  rows: [{
356
438
  id: 'stale_desktop',
357
439
  updatedAt: 1,
@@ -507,10 +589,87 @@ test('desktop resume pins projects and unclassified tasks to their host-managed
507
589
  assert.equal(resolveResumeCwd({ cwd: '/cli' }, '/current'), '/cli');
508
590
  });
509
591
 
592
+ test('new desktop sessions immediately repoint remote transcript forwarding', async () => {
593
+ let current = null;
594
+ const events = [];
595
+ const runtime = createLifecycleApi({
596
+ getSession: () => current,
597
+ setSession: (value) => { current = value; },
598
+ mgr: { closeSession: () => true },
599
+ invalidateContextStatusCache: () => events.push('invalidate'),
600
+ createCurrentSession: async () => {
601
+ events.push('create');
602
+ current = { id: 'desktop_new', messages: [] };
603
+ },
604
+ pushTranscriptRebind: () => events.push('rebind'),
605
+ });
606
+
607
+ assert.equal(await runtime.newSession(), 'desktop_new');
608
+ assert.deepEqual(events, ['invalidate', 'create', 'rebind']);
609
+ });
610
+
611
+ test('session deletion hard-deletes inactive rows and safely replaces the active row', async () => {
612
+ let current = {
613
+ id: 'desktop_active',
614
+ owner: 'user',
615
+ sourceType: 'cli',
616
+ messages: [{ role: 'user', content: 'Active session' }],
617
+ };
618
+ const rows = [
619
+ current,
620
+ {
621
+ id: 'desktop_inactive',
622
+ owner: 'user',
623
+ sourceType: 'cli',
624
+ messages: [{ role: 'user', content: 'Inactive session' }],
625
+ },
626
+ ];
627
+ const events = [];
628
+ const runtime = createLifecycleApi({
629
+ getSession: () => current,
630
+ setSession: (value) => { current = value; },
631
+ mgr: {
632
+ listSessions: () => rows,
633
+ deleteSession: (id) => { events.push(['delete', id]); return true; },
634
+ closeSession: (...args) => { events.push(['close', ...args]); return true; },
635
+ },
636
+ cancelBackgroundTasks: (options) => events.push(['background', options]),
637
+ agentTool: { closeAll: (reason) => events.push(['agents', reason]) },
638
+ statusRoutes: { clearGatewaySessionRoute: (id) => events.push(['route', id]) },
639
+ invalidateContextStatusCache: () => events.push(['context']),
640
+ invalidatePreSessionToolSurface: () => events.push(['surface']),
641
+ createCurrentSession: async () => {
642
+ current = { id: 'desktop_replacement', messages: [] };
643
+ events.push(['create', current.id]);
644
+ },
645
+ pushTranscriptRebind: () => events.push(['rebind']),
646
+ });
647
+
648
+ assert.equal(await runtime.deleteSession('desktop_inactive'), true);
649
+ assert.deepEqual(events.shift(), ['delete', 'desktop_inactive']);
650
+ assert.equal(await runtime.deleteSession('desktop_active'), true);
651
+ assert.equal(current.id, 'desktop_replacement');
652
+ assert.deepEqual(events, [
653
+ ['background', {
654
+ reason: 'desktop-session-delete',
655
+ notify: false,
656
+ callerSessionId: 'desktop_active',
657
+ }],
658
+ ['agents', 'desktop-session-delete'],
659
+ ['route', 'desktop_active'],
660
+ ['close', 'desktop_active', 'desktop-session-delete', { tombstone: true }],
661
+ ['context'],
662
+ ['surface'],
663
+ ['create', 'desktop_replacement'],
664
+ ['rebind'],
665
+ ]);
666
+ });
667
+
510
668
  test('runtime resume returns the persisted transcript and restores desktop task scope', async () => {
511
669
  let current = null;
512
670
  let cwd = '/app/workspace';
513
671
  let route = { provider: 'test', model: 'model' };
672
+ let transcriptRebinds = 0;
514
673
  const messages = [
515
674
  { role: 'user', content: 'Persisted question' },
516
675
  { role: 'assistant', content: 'Persisted answer' },
@@ -548,13 +707,34 @@ test('runtime resume returns the persisted transcript and restores desktop task
548
707
  applyResolvedCwd: (value) => { cwd = value; },
549
708
  resolveRoute: (_config, value) => value,
550
709
  applyDeferredToolSurface: () => {},
551
- standaloneTools: [],
710
+ getStandaloneTools: () => [],
711
+ pushTranscriptRebind: () => { transcriptRebinds += 1; },
552
712
  });
553
713
 
554
714
  const resumed = await runtime.resume('desktop_task');
555
715
  assert.deepEqual(resumed.messages, messages);
556
716
  assert.equal(resumed.cwd, '/app/workspace');
557
717
  assert.equal(current.cwd, '/app/workspace');
718
+ assert.equal(transcriptRebinds, 1);
719
+ });
720
+
721
+ test('runtime session prefetch warms the manager without changing the active session', () => {
722
+ const calls = [];
723
+ const current = { id: 'active', messages: [] };
724
+ const runtime = createLifecycleApi({
725
+ getSession: () => current,
726
+ getMode: () => 'full',
727
+ mgr: {
728
+ prefetchSession: (...args) => {
729
+ calls.push(args);
730
+ return true;
731
+ },
732
+ },
733
+ });
734
+
735
+ assert.equal(runtime.prefetchSession('next'), true);
736
+ assert.deepEqual(calls, [['next', 'full']]);
737
+ assert.equal(current.id, 'active');
558
738
  });
559
739
 
560
740
  test('desktop context switches retain runtime resources while durably closing the old session', async () => {
@@ -565,6 +745,7 @@ test('desktop context switches retain runtime resources while durably closing th
565
745
  };
566
746
  let cwd = '/old';
567
747
  let desktopSession = { classification: 'task', projectPath: null };
748
+ let route = { provider: 'resumed-provider', model: 'resumed-model' };
568
749
  const closed = [];
569
750
  const cleanup = [];
570
751
  let releaseMcp;
@@ -574,19 +755,29 @@ test('desktop context switches retain runtime resources while durably closing th
574
755
  setSession: (value) => { current = value; },
575
756
  getDesktopSession: () => desktopSession,
576
757
  setDesktopSession: (value) => { desktopSession = value; },
758
+ getRoute: () => route,
759
+ setRoute: (value) => { route = value; },
760
+ getConfig: () => ({ default: 'workflow-lead' }),
577
761
  getCurrentCwd: () => cwd,
578
762
  mgr: { closeSession: (...args) => { closed.push(args); return true; } },
579
763
  cancelBackgroundTasks: (options) => cleanup.push(['background', options]),
580
764
  agentTool: { closeAll: (reason) => cleanup.push(['agents', reason]) },
581
765
  statusRoutes: { clearGatewaySessionRoute: (id) => cleanup.push(['route', id]) },
582
- applyResolvedCwd: async (value, options) => {
766
+ applyResolvedCwd: (value, options) => {
767
+ // Mirror the real contract: the cwd applies in place immediately while
768
+ // the project MCP reset continues in the background; the switch must not
769
+ // block on it.
583
770
  cleanup.push(['cwd:start', value, options]);
584
- await mcpReset;
585
771
  cwd = value;
586
- cleanup.push(['cwd:ready', value]);
772
+ void mcpReset.then(() => cleanup.push(['mcp:ready', value]));
773
+ return value;
587
774
  },
588
775
  invalidateContextStatusCache: () => {},
589
776
  invalidatePreSessionToolSurface: () => {},
777
+ refreshRouteEffort: async () => {},
778
+ resolveRoute: (_config, value) => Object.keys(value).length > 0
779
+ ? value
780
+ : { provider: 'configured-provider', model: 'configured-model' },
590
781
  });
591
782
 
592
783
  let settled = false;
@@ -595,8 +786,10 @@ test('desktop context switches retain runtime resources while durably closing th
595
786
  desktopSession: { classification: 'project', projectPath: '/project' },
596
787
  });
597
788
  switching.then(() => { settled = true; });
598
- await Promise.resolve();
599
- assert.equal(settled, false);
789
+ // Non-blocking switch: it settles WITHOUT the MCP reset resolving. The
790
+ // reconnect gate moved to the ask path (bounded awaitInitialMcpConnect).
791
+ await switching;
792
+ assert.equal(settled, true);
600
793
  assert.deepEqual(cleanup.slice(0, 4), [
601
794
  ['background', {
602
795
  reason: 'desktop-context-switch',
@@ -605,17 +798,86 @@ test('desktop context switches retain runtime resources while durably closing th
605
798
  }],
606
799
  ['agents', 'desktop-context-switch'],
607
800
  ['route', 'old'],
608
- ['cwd:start', '/project', { markRefresh: false, waitForMcpReset: true }],
801
+ ['cwd:start', '/project', { markRefresh: false }],
609
802
  ]);
610
803
  releaseMcp();
611
- await switching;
804
+ await mcpReset;
612
805
 
613
806
  assert.deepEqual(closed, [['old', 'desktop-context-switch', { tombstone: false }]]);
614
807
  assert.equal(current, null);
615
808
  assert.equal(cwd, '/project');
809
+ assert.deepEqual(route, { provider: 'configured-provider', model: 'configured-model' });
616
810
  assert.deepEqual(desktopSession, { classification: 'project', projectPath: '/project' });
617
811
  });
618
812
 
813
+ test('cleared desktop context resumes legacy rows without reviving the creation marker', async () => {
814
+ const taskDesktopSession = { classification: 'task', projectPath: null };
815
+ let current = null;
816
+ let cwd = '/task';
817
+ let desktopSession = taskDesktopSession;
818
+ const resumeOptions = [];
819
+ const stored = {
820
+ legacy: {
821
+ id: 'legacy',
822
+ provider: 'test',
823
+ model: 'model',
824
+ cwd: '/legacy',
825
+ desktopSession: null,
826
+ messages: [],
827
+ },
828
+ project: {
829
+ id: 'project',
830
+ provider: 'test',
831
+ model: 'model',
832
+ cwd: '/project',
833
+ desktopSession: { classification: 'project', projectPath: '/project' },
834
+ messages: [],
835
+ },
836
+ };
837
+ const runtime = createLifecycleApi({
838
+ getSession: () => current,
839
+ setSession: (value) => { current = value; },
840
+ getDesktopSession: () => desktopSession,
841
+ setDesktopSession: (value) => { desktopSession = value; },
842
+ getRoute: () => ({ provider: 'test', model: 'model' }),
843
+ setRoute: () => {},
844
+ getConfig: () => ({}),
845
+ getMode: () => 'full',
846
+ getCurrentCwd: () => cwd,
847
+ setSessionNeedsCwdRefresh: () => {},
848
+ desktopSession: taskDesktopSession,
849
+ mgr: {
850
+ closeSession: () => true,
851
+ resumeSession: async (id, _preset, options) => {
852
+ resumeOptions.push(options);
853
+ const session = stored[id];
854
+ const expected = options?.desktopSession;
855
+ if (expected && (!session.desktopSession
856
+ || expected.classification !== session.desktopSession.classification)) return null;
857
+ return session;
858
+ },
859
+ },
860
+ statusRoutes: {},
861
+ createCurrentSession: async () => {},
862
+ refreshRouteEffort: async () => {},
863
+ invalidateContextStatusCache: () => {},
864
+ invalidatePreSessionToolSurface: () => {},
865
+ applyResolvedCwd: async (value) => { cwd = value; },
866
+ resolveRoute: (_config, value) => value,
867
+ applyDeferredToolSurface: () => {},
868
+ getStandaloneTools: () => [],
869
+ });
870
+
871
+ await runtime.switchContext({ cwd: '/legacy', desktopSession: null });
872
+ assert.equal(desktopSession, null);
873
+ assert.equal((await runtime.resume('legacy')).id, 'legacy');
874
+ assert.equal(resumeOptions[0], undefined);
875
+
876
+ await runtime.switchContext({ cwd: '/task', desktopSession: taskDesktopSession });
877
+ assert.equal(await runtime.resume('project'), null);
878
+ assert.deepEqual(resumeOptions[1], { desktopSession: taskDesktopSession });
879
+ });
880
+
619
881
  test('desktop cwd application awaits project MCP reset before becoming ready', async () => {
620
882
  const root = mkdtempSync(join(tmpdir(), 'mixdog-cwd-mcp-order-'));
621
883
  const oldCwd = join(root, 'old');
@@ -0,0 +1,11 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ const src = readFileSync("C:/Project/refs/opencode/packages/ui/src/components/icon.tsx", "utf8");
3
+ const re = /^\s*"?([a-z0-9-]+)"?:\s*`([^`]+)`,?\s*$/gm;
4
+ const entries = [];
5
+ let m;
6
+ while ((m = re.exec(src))) entries.push([m[1], m[2].replace(/\s+/g, " ").trim()]);
7
+ if (entries.length < 80) throw new Error(`only ${entries.length} icons parsed`);
8
+ const body = entries.map(([name, svg]) => ` ${JSON.stringify(name)}: ${JSON.stringify(svg)},`).join("\n");
9
+ const out = `// GENERATED from refs/opencode packages/ui/src/components/icon.tsx (MIT).\n// Regenerate with scripts/generate-oc-icons.mjs — do not hand-edit path data.\n// All glyphs draw on a 20x20 viewBox with currentColor strokes/fills.\nexport const OC_ICON_MARKUP: Record<string, string> = {\n${body}\n};\n`;
10
+ writeFileSync("apps/desktop/src/renderer/oc-icon-markup.ts", out);
11
+ console.log("icons:", entries.length);
@@ -0,0 +1,148 @@
1
+ // Integration test for src/tui/engine/live-share.mjs: a real owner pipe
2
+ // server and viewer client exchange full/delta/tail frames and submits over
3
+ // the platform transport (named pipe / unix socket).
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { createLiveShare } from '../src/tui/engine/live-share.mjs';
9
+
10
+ const PIPE_ID = `livetest_${process.pid}_${Date.now()}`;
11
+ const pipePath = process.platform === 'win32'
12
+ ? `\\\\.\\pipe\\mixdog-live-${PIPE_ID}`
13
+ : join(tmpdir(), `mixdog-live-${PIPE_ID}.sock`);
14
+
15
+ function waitFor(check, label, timeoutMs = 4000) {
16
+ return new Promise((resolvePromise, rejectPromise) => {
17
+ const startedAt = Date.now();
18
+ const tick = () => {
19
+ let result = false;
20
+ try { result = check(); } catch { result = false; }
21
+ if (result) return resolvePromise(result);
22
+ if (Date.now() - startedAt > timeoutMs) {
23
+ return rejectPromise(new Error(`timeout waiting for ${label}`));
24
+ }
25
+ setTimeout(tick, 20);
26
+ };
27
+ tick();
28
+ });
29
+ }
30
+
31
+ function createViewerStore() {
32
+ const store = {
33
+ items: [{ id: 'disk-only', kind: 'user', text: 'persisted last user message' }],
34
+ streamingTail: null,
35
+ spinner: null,
36
+ };
37
+ return {
38
+ store,
39
+ apply: {
40
+ getState: () => store,
41
+ set: (patch) => Object.assign(store, patch),
42
+ replaceItems: (items) => { store.items = [...items]; },
43
+ patchItem: (id, patch) => {
44
+ const index = store.items.findIndex((item) => item?.id === id);
45
+ if (index < 0) return false;
46
+ store.items[index] = { ...store.items[index], ...patch };
47
+ return true;
48
+ },
49
+ appendItems: (items) => { store.items = [...store.items, ...items]; },
50
+ updateStreamingTail: (id, patch) => {
51
+ const current = store.streamingTail?.id === id ? store.streamingTail : { id, text: '' };
52
+ store.streamingTail = { ...current, ...patch, id };
53
+ },
54
+ clearStreamingTail: () => { store.streamingTail = null; },
55
+ },
56
+ };
57
+ }
58
+
59
+ test('live-share mirrors owner deltas and routes viewer submits', async () => {
60
+ const listeners = new Set();
61
+ let ownerState = {
62
+ items: [{ id: 'a1', kind: 'assistant', text: 'hello' }],
63
+ streamingTail: null,
64
+ spinner: null,
65
+ };
66
+ const receivedSubmits = [];
67
+ const owner = createLiveShare({
68
+ ownerSessionId: () => PIPE_ID,
69
+ viewerSessionId: () => '',
70
+ socketPathFor: () => pipePath,
71
+ getPublishedState: () => ownerState,
72
+ listeners,
73
+ onRemoteSubmit: (text) => receivedSubmits.push(text),
74
+ onOwnerClosed: () => {},
75
+ viewerApply: null,
76
+ });
77
+ const publish = (next) => {
78
+ ownerState = next;
79
+ for (const listener of listeners) listener();
80
+ };
81
+
82
+ const viewer = createViewerStore();
83
+ let ownerClosedCount = 0;
84
+ const viewerShare = createLiveShare({
85
+ ownerSessionId: () => '',
86
+ viewerSessionId: () => PIPE_ID,
87
+ socketPathFor: () => pipePath,
88
+ getPublishedState: () => ({ items: [], streamingTail: null, spinner: null }),
89
+ listeners: new Set(),
90
+ onRemoteSubmit: () => {},
91
+ onOwnerClosed: () => { ownerClosedCount += 1; },
92
+ viewerApply: viewer.apply,
93
+ });
94
+
95
+ try {
96
+ owner.ensure();
97
+ const initialSync = viewerShare.waitForViewerSync(PIPE_ID, 1_000);
98
+ await waitFor(() => {
99
+ viewerShare.ensure();
100
+ return viewerShare.viewerConnected();
101
+ }, 'viewer connect');
102
+ // Initial full frame mirrors the owner transcript.
103
+ assert.equal(await initialSync, true);
104
+ await waitFor(() => viewer.store.items.length === 1
105
+ && viewer.store.items[0].id === 'a1', 'initial full frame');
106
+ assert.equal(viewer.store.items.some((item) => item.id === 'disk-only'), false,
107
+ 'the initial sync barrier must replace the incomplete persisted transcript');
108
+
109
+ // Appended item + streaming tail start.
110
+ publish({
111
+ ...ownerState,
112
+ items: [...ownerState.items, { id: 'u1', kind: 'user', text: 'hi from owner side' }],
113
+ streamingTail: { kind: 'assistant', id: 't1', text: 'stream', streaming: true },
114
+ spinner: { active: true, mode: 'responding' },
115
+ });
116
+ await waitFor(() => viewer.store.items.length === 2
117
+ && viewer.store.streamingTail?.text === 'stream'
118
+ && viewer.store.spinner?.active === true, 'append + tail frame');
119
+
120
+ // Append-only tail growth rides the suffix protocol.
121
+ publish({
122
+ ...ownerState,
123
+ streamingTail: { kind: 'assistant', id: 't1', text: 'streaming more', streaming: true },
124
+ });
125
+ await waitFor(() => viewer.store.streamingTail?.text === 'streaming more', 'tail suffix frame');
126
+
127
+ // Patched item (owner edited an existing row) arrives as a change.
128
+ publish({
129
+ ...ownerState,
130
+ items: [{ ...ownerState.items[0], text: 'hello edited' }, ownerState.items[1]],
131
+ streamingTail: null,
132
+ });
133
+ await waitFor(() => viewer.store.items[0].text === 'hello edited'
134
+ && viewer.store.streamingTail === null, 'patch + tail clear frame');
135
+
136
+ // Viewer submit reaches the owner queue.
137
+ assert.equal(viewerShare.sendSubmit('typed on viewer'), true);
138
+ await waitFor(() => receivedSubmits.length === 1, 'viewer submit');
139
+ assert.equal(receivedSubmits[0], 'typed on viewer');
140
+
141
+ // Owner shutdown notifies the viewer promotion path.
142
+ owner.dispose();
143
+ await waitFor(() => ownerClosedCount === 1, 'owner close notification');
144
+ } finally {
145
+ owner.dispose();
146
+ viewerShare.dispose();
147
+ }
148
+ });
@@ -107,7 +107,7 @@ async function main() {
107
107
  const skipsReview = reviewSkipViolation(workflow);
108
108
  assert(hasAll(lead, 'write-role agents self-verify', 'cross-scope verification', 'benches', 'all git', 'current project/workspace'), 'lead tool rules must preserve verification, git, and workspace ownership');
109
109
  assert(hasAll(workflow, 'before the user explicitly approves the latest plan', 'no edits, no state mutation, no delegation'), 'default workflow must require latest-plan approval before work');
110
- assert(hasAll(workflow, 'on approval, fan out at maximum width', 'one agent per independent scope', 'all spawned in one turn'), 'default workflow must fan out independent scopes at maximum width');
110
+ assert(hasAll(workflow, 'on approval, fan out at maximum width', 'one agent per independent scope', 'all spawned in one turn', 'split the plan into as many scopes as possible', 'disjoint file/module sets are independent', 'merge only on a true output dependency', 'prefer parallel scopes over sequential slices in one agent'), 'default workflow must fan out independent scopes at maximum width');
111
111
  assert(hasAll(workflow, 'simple, well-understood implementation goes to worker', 'complex or investigative implementation goes to heavy worker', 'lead itself edits only a local, one-turn configuration/git change', 'debugger only on a defect needing deep root-cause analysis or a bug surviving 2+ review/fix cycles'), 'default workflow must enforce role routing and Lead/Debugger limits');
112
112
  assert(hasAll(workflow, 'every implementation gets its own reviewer, attached per scope', 'only the local lead-direct edits above are exempt', 'lead itself edits only a local, one-turn configuration/git change', 'keep the same reviewer through the fix loop', 'fix -> re-verify until clean', 'lead cross-verifies'), 'default workflow must require a Reviewer for every implementation except local Lead-direct one-turn config/git edits');
113
113
  assert(hasAll(workflow, 'build, deploy, commit, and push happen only on an explicit user request', 'on direction change, pause and re-consult the user'), 'default workflow must require user authorization and re-consultation');
@@ -36,6 +36,7 @@ function queuedProvider(responses, streamed = []) {
36
36
  async function run(provider, messages = [{ role: 'user', content: 'answer fully' }], options = {}) {
37
37
  return agentLoop(provider, messages, 'fake-model', [], options.onToolCall, process.cwd(), {
38
38
  onTextDelta: options.onTextDelta,
39
+ takeAssistantTranscriptMetadata: options.takeAssistantTranscriptMetadata,
39
40
  session: options.session,
40
41
  providerState: options.providerState,
41
42
  });
@@ -56,8 +57,17 @@ test('one max-output continuation resumes from preserved partial and returns one
56
57
  { content: 'omega', stopReason: 'end_turn' },
57
58
  ], streamed);
58
59
  const messages = [{ role: 'user', content: 'answer fully' }];
60
+ const recoveryTranscriptMeta = {
61
+ at: 1_735_689_600_000,
62
+ model: 'Fake Model',
63
+ provider: 'fake-provider',
64
+ agent: 'Test Workflow',
65
+ };
59
66
 
60
- const result = await run(provider, messages, { onTextDelta: () => {} });
67
+ const result = await run(provider, messages, {
68
+ onTextDelta: () => {},
69
+ takeAssistantTranscriptMetadata: () => recoveryTranscriptMeta,
70
+ });
61
71
 
62
72
  assert.equal(provider.sent.length, 2);
63
73
  assert.equal(result.content, 'alpha omega');
@@ -67,6 +77,7 @@ test('one max-output continuation resumes from preserved partial and returns one
67
77
  assert.equal(result.terminationReason, undefined);
68
78
  assert.deepEqual(provider.sent[1].map((message) => message.role), ['user', 'assistant', 'user']);
69
79
  assert.equal(provider.sent[1][1].content, 'alpha ');
80
+ assert.deepEqual(provider.sent[1][1].meta?.transcript, recoveryTranscriptMeta);
70
81
  assert.match(provider.sent[1][2].content, /Resume directly/);
71
82
  assert.match(provider.sent[1][2].content, /no apology, no recap/i);
72
83
  assert.equal(persistedAssistantText(messages, result), result.content);