mixdog 0.9.55 → 0.9.59

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
@@ -12,7 +12,7 @@ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, ass
12
12
 
13
13
  export function createRunTurn(bag) {
14
14
  const {
15
- runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, flushEmit, flushEmitImmediate, pushItem, appendItems, patchItem, replaceItems, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
15
+ runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS, flags, pending, itemIndexById, getState, set, flushEmit, flushEmitImmediate, pushItem, appendItems, patchItem, replaceItems, updateStreamingTail: updateStreamingTailFromStore, settleStreamingTail: settleStreamingTailFromStore, clearStreamingTail: clearStreamingTailFromStore, pushNotice, pushUserOrSyntheticItem, markToolCallActive, markToolCallDone, clearActiveToolSummary, agentStatusState, routeState, transcriptRouteMetadata, syncContextStats, denyAllToolApprovals, requestToolApproval, patchToolCardResult, flushToolResults, flushDeferredExecutionPendingResumeKick, drain, drainPendingSteering,
16
16
  } = bag;
17
17
  // Small fallbacks keep isolated createRunTurn harnesses source-compatible;
18
18
  // the real engine supplies atomic implementations that also maintain revision.
@@ -35,6 +35,13 @@ export function createRunTurn(bag) {
35
35
  async function runTurn(userText, options = {}) {
36
36
  const turnIndex = getState().stats.turns || 0;
37
37
  const startedAt = Date.now();
38
+ const completionVerb = pickDoneVerb(turnIndex);
39
+ const baseTranscriptMeta = flags.pendingTranscriptMeta
40
+ || transcriptRouteMetadata?.(startedAt)
41
+ || { at: startedAt };
42
+ const turnTranscriptMeta = { ...baseTranscriptMeta, completionVerb };
43
+ flags.pendingTranscriptMeta = null;
44
+ const { at: _userItemAt, ...turnRouteMeta } = turnTranscriptMeta;
38
45
  // Per-turn epoch. Force-release (watchdog grace) bumps the shared counter so
39
46
  // this turn's own eventual `finally` — which may run LONG after force-release
40
47
  // already started a new turn that reuses the per-session mutex — can detect
@@ -223,6 +230,34 @@ export function createRunTurn(bag) {
223
230
  const toolCards = [];
224
231
  const toolGroups = new Map();
225
232
  const resultsDone = new Set();
233
+ // ── Live shell-output tail → running tool card ─────────────────────────
234
+ // 1 s poll of orchestrator liveness while this turn runs. When exactly one
235
+ // standalone (non-aggregate) card is unresolved and the runtime reports a
236
+ // fresh toolOutputTail for the running tool, patch it onto the card as
237
+ // `liveOutput` so transcript consumers (desktop) can render live command
238
+ // output. The result patch clears the field; timer dies with the turn.
239
+ let liveTailTimer = null;
240
+ let lastLiveTailPatched = '';
241
+ const clearLiveTailTimer = () => {
242
+ if (liveTailTimer) { clearInterval(liveTailTimer); liveTailTimer = null; }
243
+ };
244
+ liveTailTimer = setInterval(() => {
245
+ if (!isCurrentTurn()) { clearLiveTailTimer(); return; }
246
+ let liveness = null;
247
+ try { liveness = runtime.getTurnLiveness?.(); } catch { return; }
248
+ if (!liveness || liveness.stage !== 'tool_running') return;
249
+ const tail = typeof liveness.toolOutputTail === 'string' ? liveness.toolOutputTail : '';
250
+ if (!tail || tail === lastLiveTailPatched) return;
251
+ const running = toolCards.filter((c) => !c.done && !c.aggregate);
252
+ if (running.length !== 1) return;
253
+ const card = running[0];
254
+ // Real output proves the tool is genuinely running — surface the card
255
+ // even if its deferred-display timer has not fired yet.
256
+ card.ensureVisible?.();
257
+ lastLiveTailPatched = tail;
258
+ patchItem(card.itemId, { liveOutput: tail });
259
+ }, 1000);
260
+ liveTailTimer.unref?.();
226
261
  // Streaming providers can deliver eager onToolResult before onToolCall registers
227
262
  // cards (send() still in flight). Hold those by callId until the batch lands.
228
263
  const earlyResultBuffer = new Map();
@@ -518,6 +553,11 @@ export function createRunTurn(bag) {
518
553
  const ensureAssistant = (initialText = '') => {
519
554
  if (!currentAssistantId) {
520
555
  currentAssistantId = nextId();
556
+ const assistantAt = Number.isFinite(Number(turnTranscriptMeta.assistantAt))
557
+ && Number(turnTranscriptMeta.assistantAt) > 0
558
+ ? Number(turnTranscriptMeta.assistantAt)
559
+ : Date.now();
560
+ turnTranscriptMeta.assistantAt = assistantAt;
521
561
  // Do NOT reset currentAssistantText here. The first onTextDelta has
522
562
  // already accumulated the opening chunk before this batched flush runs;
523
563
  // wiping it dropped the leading characters and forced a later set() to
@@ -525,7 +565,7 @@ export function createRunTurn(bag) {
525
565
  // Seed the new row with the already-visible text so the ● gutter and the
526
566
  // first body line appear in the SAME set()/emit() — no empty "●-only"
527
567
  // row that scrolls once on its own and again when the body lands.
528
- updateStreamingTail(currentAssistantId, { text: String(initialText || '') });
568
+ updateStreamingTail(currentAssistantId, { text: String(initialText || ''), at: assistantAt, ...turnRouteMeta });
529
569
  }
530
570
  return currentAssistantId;
531
571
  };
@@ -661,7 +701,15 @@ export function createRunTurn(bag) {
661
701
  const id = ensureAssistant(streamingVisibleText);
662
702
  const current = getState().streamingTail;
663
703
  if (!current || current.id !== id || !Object.is(current.text, streamingVisibleText)) {
664
- patch.streamingTail = { kind: 'assistant', id, text: streamingVisibleText, streaming: true };
704
+ patch.streamingTail = {
705
+ ...(current || {}),
706
+ kind: 'assistant',
707
+ id,
708
+ text: streamingVisibleText,
709
+ streaming: true,
710
+ at: current?.at || Date.now(),
711
+ ...turnRouteMeta,
712
+ };
665
713
  }
666
714
  }
667
715
  // Only touch the spinner when there is a real reason: a visible-line
@@ -799,6 +847,7 @@ export function createRunTurn(bag) {
799
847
 
800
848
  try {
801
849
  const { result, session } = await runtime.ask(userText, {
850
+ transcriptMeta: turnTranscriptMeta,
802
851
  drainSteering: (_sessionId, drainOptions) => (isCurrentTurn() ? drainPendingSteering(drainOptions) : []),
803
852
  onStreamDelta: () => {
804
853
  markTurnProgress('stream-delta');
@@ -1096,10 +1145,13 @@ export function createRunTurn(bag) {
1096
1145
  if (currentAssistantText) {
1097
1146
  set({
1098
1147
  streamingTail: {
1148
+ ...(getState().streamingTail || {}),
1099
1149
  kind: 'assistant',
1100
1150
  id: currentAssistantId,
1101
1151
  text: currentAssistantText,
1102
1152
  streaming: true,
1153
+ at: getState().streamingTail?.at || Date.now(),
1154
+ ...turnRouteMeta,
1103
1155
  },
1104
1156
  });
1105
1157
  } else {
@@ -1249,6 +1301,7 @@ export function createRunTurn(bag) {
1249
1301
  // Turn is unwinding normally (or via abort) — cancel the idle watchdog and
1250
1302
  // its force-release grace so they never fire on a live turn.
1251
1303
  clearWatchdog();
1304
+ clearLiveTailTimer();
1252
1305
  // If the watchdog force-release already fired, a NEWER turn now owns the
1253
1306
  // shared store (busy, flags.activePromptRestore, turndone, drain). This stale
1254
1307
  // unwind must NOT write shared getState() or it corrupts that turn.
@@ -1314,7 +1367,7 @@ export function createRunTurn(bag) {
1314
1367
  // in scrollback. (Previously TurnDone rendered only in the
1315
1368
  // bottom-fixed live-status slot and vanished on the next turn.)
1316
1369
  if (!reclaimed && !isNoOpTurn) {
1317
- closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
1370
+ closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: completionVerb, at: Date.now(), ...turnRouteMeta });
1318
1371
  }
1319
1372
  // Deferred cards + turndone + status all land in ONE set() (one commit).
1320
1373
  appendItemsBatch(closingItems, {
@@ -11,10 +11,10 @@
11
11
  * This file keeps the stateful createEngineSession store + notification plan.
12
12
  */
13
13
  import { performance } from 'node:perf_hooks';
14
- import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
14
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
15
15
  import { randomUUID } from 'node:crypto';
16
16
  import { tmpdir } from 'node:os';
17
- import { join } from 'node:path';
17
+ import { basename, dirname, join } from 'node:path';
18
18
  import { Worker } from 'node:worker_threads';
19
19
  import {
20
20
  aggregateToolCategoryEntry,
@@ -28,6 +28,7 @@ import {
28
28
  } from '../runtime/shared/tool-execution-contract.mjs';
29
29
  import { isLateToolAnnouncement } from '../session-runtime/session-text.mjs';
30
30
  import { presentErrorText } from '../runtime/shared/err-text.mjs';
31
+ import { sessionPath } from '../runtime/agent/orchestrator/session/store/paths-heartbeat.mjs';
31
32
  import { listThemes, getThemeSetting, setThemeSetting } from './theme.mjs';
32
33
  import { resetAllStreamingMarkdownStablePrefixes } from './markdown/streaming-markdown.mjs';
33
34
  import { bootProfile } from './engine/boot-profile.mjs';
@@ -104,6 +105,8 @@ import { createSessionFlow } from './engine/session-flow.mjs';
104
105
  import { createRunTurn } from './engine/turn.mjs';
105
106
  import { createEngineApi } from './engine/session-api.mjs';
106
107
  import { createFrameBatchedStorePublisher } from './engine/frame-batched-store.mjs';
108
+ import { createLiveShare, liveSharePipePath } from './engine/live-share.mjs';
109
+ import { displayModelName } from '../ui/model-display.mjs';
107
110
 
108
111
  // Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
109
112
  // src/tui/dist/index.mjs.
@@ -189,7 +192,9 @@ export function cleanupStaleTranscriptSpillDirs({
189
192
  export function createTranscriptSpillBuffer({
190
193
  cap = TRANSCRIPT_LIVE_ITEM_CAP,
191
194
  chunkSize = TRANSCRIPT_SPILL_CHUNK_ITEMS,
192
- workerFactory = (source) => new Worker(source, { eval: true }),
195
+ // stdout/stderr: worker threads otherwise copy straight into the REAL fds,
196
+ // bypassing the TUI stderr guard and printing over the terminal frame.
197
+ workerFactory = (source) => new Worker(source, { eval: true, stdout: true, stderr: true }),
193
198
  onWarning = (message) => tuiDebug(message),
194
199
  writeTimeoutMs = 5000,
195
200
  } = {}) {
@@ -269,6 +274,8 @@ export function createTranscriptSpillBuffer({
269
274
  const worker = workerFactory(workerSource);
270
275
  spillWorker = worker;
271
276
  workerSpawnCount += 1;
277
+ worker.stdout?.on?.('data', (chunk) => { try { process.stderr.write(chunk); } catch { /* best-effort */ } });
278
+ worker.stderr?.on?.('data', (chunk) => { try { process.stderr.write(chunk); } catch { /* best-effort */ } });
272
279
  worker.on('message', (result) => {
273
280
  if (spillWorker !== worker || result?.id !== activeWrite?.id) return;
274
281
  finishWrite(result?.ok === true, result?.error);
@@ -644,6 +651,7 @@ export async function createEngineSession({
644
651
  activePromptRestore: null,
645
652
  pushingFromDeferredEntry: false,
646
653
  flushDeferredBeforeImmediatePush: null,
654
+ pendingTranscriptMeta: null,
647
655
  };
648
656
  const lifecycle = { runtimePulseTimer: null, unsubscribeRuntimeNotifications: null, unsubscribeRemoteState: null };
649
657
  const pending = [];
@@ -912,6 +920,17 @@ export async function createEngineSession({
912
920
  activeToolCalls.clear();
913
921
  if (state.activeToolSummary) set({ activeToolSummary: null });
914
922
  };
923
+ const transcriptRouteMetadata = (at = Date.now()) => {
924
+ const route = routeState();
925
+ const modelName = displayModelName(route.model, route.provider);
926
+ const workflowLabel = String(route.workflow?.name || route.workflow?.id || '').trim();
927
+ return {
928
+ at,
929
+ ...(modelName ? { model: modelName } : {}),
930
+ ...(route.provider ? { provider: String(route.provider) } : {}),
931
+ ...(workflowLabel ? { agent: workflowLabel } : {}),
932
+ };
933
+ };
915
934
  const pushItem = (item) => {
916
935
  if (!flags.pushingFromDeferredEntry && flags.flushDeferredBeforeImmediatePush) {
917
936
  flags.flushDeferredBeforeImmediatePush();
@@ -1000,7 +1019,7 @@ export async function createEngineSession({
1000
1019
  });
1001
1020
  return true;
1002
1021
  };
1003
- const pushUserOrSyntheticItem = (text, id = nextId(), origin = 'user') => {
1022
+ const pushUserOrSyntheticItem = (text, id = nextId(), origin = 'user', extra = null) => {
1004
1023
  // The lenient shape-only wrapper check is display-suppression only and
1005
1024
  // must never hide a real, directly-typed/pasted user prompt just because
1006
1025
  // it happens to resemble "instruction + Result: + quoted body". Only
@@ -1021,7 +1040,15 @@ export async function createEngineSession({
1021
1040
  // up-arrow history survives across sessions. Runs before pushItem so the
1022
1041
  // merge in pushItem's user branch (loadPromptHistory) already sees it.
1023
1042
  if (origin === 'user') appendPromptHistory(state.cwd, text);
1024
- pushItem({ kind: 'user', id, text });
1043
+ const transcriptMeta = transcriptRouteMetadata();
1044
+ if (origin === 'user') flags.pendingTranscriptMeta = transcriptMeta;
1045
+ pushItem({
1046
+ kind: 'user', id, text, ...transcriptMeta,
1047
+ // Byte-free attachment metadata (name/mime/size) from the queue entry —
1048
+ // lets the desktop transcript render image chips without ever carrying
1049
+ // base64 payloads through snapshots.
1050
+ ...(extra && Array.isArray(extra.images) && extra.images.length ? { images: extra.images } : {}),
1051
+ });
1025
1052
  };
1026
1053
  const pushAsyncAgentResponse = (text, id = nextId(), origin = 'injected', metadata = {}) => {
1027
1054
  const synthetic = parseSyntheticAgentMessage(text);
@@ -1219,7 +1246,7 @@ export async function createEngineSession({
1219
1246
  pushToast, pushNotice, removeNotice, setProgressHint,
1220
1247
  pushUserOrSyntheticItem, pushAsyncAgentResponse, upsertSyntheticToolItem,
1221
1248
  markToolCallActive, markToolCallDone, clearActiveToolSummary, clearToastTimers,
1222
- autoClearState, agentStatusState, baseRouteState, routeState, syncContextStats,
1249
+ autoClearState, agentStatusState, baseRouteState, routeState, transcriptRouteMetadata, syncContextStats,
1223
1250
  disposeTranscriptSpill: () => transcriptSpill.dispose(),
1224
1251
  snapshotTranscriptSpill: () => transcriptSpill.snapshot(),
1225
1252
  restoreTranscriptSpill: (snapshot) => transcriptSpill.restoreSnapshot(snapshot),
@@ -1230,6 +1257,183 @@ export async function createEngineSession({
1230
1257
  });
1231
1258
  Object.assign(bag, createSessionFlow(bag));
1232
1259
  bag.runTurn = createRunTurn(bag);
1260
+ const api = createEngineApi(bag);
1261
+ // Cross-surface share: presence + the durable pending spool remain the
1262
+ // ownership/base layer; a local pipe layer (live-share) streams frame
1263
+ // deltas so co-open surfaces mirror each other in real time.
1264
+ // - presence: mark OUR current session as held open (idle included) so a
1265
+ // cross-open elsewhere attaches as a viewer instead of splitting
1266
+ // ownership; cleared on session switch here and on dispose
1267
+ // (session-api-ext), with sidecar staleness covering crashes.
1268
+ // - owner leg: host the live pipe, push transcript/tail/spinner deltas,
1269
+ // and run foreign submits through the normal queue — full user bubble +
1270
+ // streaming on every surface. The spool drain stays as the fallback
1271
+ // intake (instant via fs.watch, 3s tick as safety net).
1272
+ // - viewer leg: connect to the owner's pipe and mirror its live state.
1273
+ // While the pipe is up the disk-mtime re-resume is skipped (no turn-end
1274
+ // flicker); when the owner disappears the quiet re-resume promotes this
1275
+ // surface to real ownership.
1276
+ let heldPresenceId = '';
1277
+ let viewerStoreMtime = 0;
1278
+ const drainRemoteInjections = () => {
1279
+ const injected = runtime.takeRemoteInjections?.() || [];
1280
+ if (injected.length === 0) return;
1281
+ for (const text of injected) bag.enqueue(text);
1282
+ void bag.drain();
1283
+ };
1284
+ const liveShare = createLiveShare({
1285
+ ownerSessionId: () => (flags.disposed || flags.pendingSessionReset || state.sessionRemoteAttached
1286
+ ? '' : String(state.sessionId || '')),
1287
+ viewerSessionId: () => (flags.disposed || !state.sessionRemoteAttached
1288
+ ? '' : String(state.sessionId || '')),
1289
+ socketPathFor: (id) => liveSharePipePath(id, sessionPath(id)),
1290
+ getPublishedState: () => publishedState,
1291
+ listeners,
1292
+ onRemoteSubmit: (text) => {
1293
+ if (flags.disposed || state.sessionRemoteAttached) return;
1294
+ bag.enqueue(text);
1295
+ void bag.drain();
1296
+ },
1297
+ onOwnerClosed: (id) => {
1298
+ // Owner left (clean close or crash): promote via the normal quiet
1299
+ // re-resume once its final save/presence-clear has landed.
1300
+ const timer = setTimeout(() => {
1301
+ if (flags.disposed || !state.sessionRemoteAttached) return;
1302
+ if (String(state.sessionId || '') !== id) return;
1303
+ void Promise.resolve(api.resume(id, { quiet: true })).catch(() => { /* tick retries */ });
1304
+ }, 1500);
1305
+ timer.unref?.();
1306
+ },
1307
+ viewerApply: {
1308
+ getState: () => state,
1309
+ set,
1310
+ replaceItems: (...args) => bag.replaceItems(...args),
1311
+ patchItem: (...args) => bag.patchItem(...args),
1312
+ appendItems: (...args) => bag.appendItems(...args),
1313
+ updateStreamingTail: (...args) => bag.updateStreamingTail(...args),
1314
+ clearStreamingTail: (...args) => bag.clearStreamingTail(...args),
1315
+ },
1316
+ });
1317
+ // Immediate live-share reconcile for session entry/promotion. Waiting for
1318
+ // the 3s share tick left a just-resumed live-owned session showing the
1319
+ // stale disk snapshot, then full-swapped the transcript mid-view once the
1320
+ // pipe finally connected (visible up/down lurch until heights resettled).
1321
+ // resume() calls this right after installing the restored items so the
1322
+ // owner's full frame lands at the entry boundary instead of seconds later.
1323
+ bag.ensureLiveShare = () => { try { liveShare.ensure(); } catch { /* share tick retries */ } };
1324
+ // Live viewer submits ride the owner's pipe (instant user bubble + shared
1325
+ // streaming); the durable spool path below remains the fallback.
1326
+ if (typeof api.submit === 'function') {
1327
+ const baseSubmit = api.submit;
1328
+ api.submit = (prompt, options = {}) => {
1329
+ if (state.sessionRemoteAttached && liveShare.viewerConnected()) {
1330
+ const text = String(promptDisplayText(prompt, options) || '').trim();
1331
+ if (text && liveShare.sendSubmit(text)) return true;
1332
+ }
1333
+ return baseSubmit(prompt, options);
1334
+ };
1335
+ }
1336
+ // Attach-time pipe fast-path: session entry (resume) reconciles the live
1337
+ // pipe IMMEDIATELY instead of waiting for the 3s share tick. The attach
1338
+ // render comes from the last disk save WITHOUT the in-flight turn, so that
1339
+ // tick-wide window is exactly when a running tool call / mid-turn
1340
+ // conversation looks missing and then pops in late (user report). The
1341
+ // owner leg benefits equally: its pipe server starts the moment the
1342
+ // session opens, so cross-surface viewers can connect at once.
1343
+ const reconcileLiveShareNow = () => { try { liveShare.ensure(); } catch { /* tick retries */ } };
1344
+ for (const method of ['resume', 'newSession', 'switchContext']) {
1345
+ if (typeof api[method] !== 'function') continue;
1346
+ const base = api[method].bind(api);
1347
+ api[method] = async (...args) => {
1348
+ const result = await base(...args);
1349
+ reconcileLiveShareNow();
1350
+ if (method === 'resume' && result === true && state.sessionRemoteAttached) {
1351
+ const id = String(state.sessionId || '');
1352
+ // EngineHost holds renderer publications across resume. Keep that hold
1353
+ // until the owner's first FULL frame replaces the persisted transcript,
1354
+ // then synchronously publish the complete draft before getState().
1355
+ // Old owners without live-share fall back to the disk restore after the
1356
+ // short bounded wait rather than blocking session entry indefinitely.
1357
+ if (id && await liveShare.waitForViewerSync(id)) bag.flushEmit();
1358
+ }
1359
+ return result;
1360
+ };
1361
+ }
1362
+ // Instant input pickup: watch the shared pending spool so an attached
1363
+ // surface's fallback submit reaches this owner immediately instead of on
1364
+ // the 3s tick. Best-effort — the tick below remains the safety net.
1365
+ let spoolWatcher = null;
1366
+ let spoolDebounce = null;
1367
+ try {
1368
+ const spoolPath = String(runtime.pendingSpoolPath?.() || '');
1369
+ if (spoolPath) {
1370
+ const spoolFile = basename(spoolPath);
1371
+ spoolWatcher = watch(dirname(spoolPath), { persistent: false }, (_event, filename) => {
1372
+ if (filename && String(filename) !== spoolFile) return;
1373
+ if (flags.disposed || spoolDebounce) return;
1374
+ spoolDebounce = setTimeout(() => {
1375
+ spoolDebounce = null;
1376
+ if (flags.disposed || flags.pendingSessionReset) return;
1377
+ if (state.busy || state.commandBusy || state.sessionRemoteAttached) return;
1378
+ try { drainRemoteInjections(); } catch { /* tick fallback */ }
1379
+ }, 120);
1380
+ spoolDebounce.unref?.();
1381
+ });
1382
+ spoolWatcher.on?.('error', () => {
1383
+ try { spoolWatcher.close(); } catch { /* already closed */ }
1384
+ spoolWatcher = null;
1385
+ });
1386
+ }
1387
+ } catch { /* spool watch is an optimization; the 3s tick remains */ }
1388
+ const remoteAttachTimer = setInterval(() => {
1389
+ if (flags.disposed) {
1390
+ clearInterval(remoteAttachTimer);
1391
+ try { liveShare.dispose(); } catch { /* best-effort */ }
1392
+ try { spoolWatcher?.close(); } catch { /* already closed */ }
1393
+ if (spoolDebounce) { clearTimeout(spoolDebounce); spoolDebounce = null; }
1394
+ return;
1395
+ }
1396
+ if (flags.pendingSessionReset) return;
1397
+ try {
1398
+ const heldId = runtime.publishSessionPresence?.() || '';
1399
+ if (heldPresenceId && heldPresenceId !== heldId) runtime.clearSessionPresence?.(heldPresenceId);
1400
+ heldPresenceId = heldId;
1401
+ } catch { /* best-effort */ }
1402
+ try { liveShare.ensure(); } catch { /* next tick retries */ }
1403
+ try {
1404
+ if (state.busy || state.commandBusy) return;
1405
+ if (state.sessionRemoteAttached) {
1406
+ const id = String(state.sessionId || '');
1407
+ if (!id) return;
1408
+ // Pipe-connected viewers follow the owner live; the disk-mtime
1409
+ // re-resume would only reload mid-stream state and flicker.
1410
+ if (liveShare.viewerConnected()) { viewerStoreMtime = 0; return; }
1411
+ // Self-heal: a force-killed owner never announces onOwnerClosed and
1412
+ // its final save never bumps the store mtime, so without this probe
1413
+ // the surface stays a viewer forever, spooling messages to nobody.
1414
+ // When the resume guard says the owner is gone, promote via the same
1415
+ // quiet re-resume (it drains the pending spool on the next tick).
1416
+ if (runtime.sessionOwnerGone?.(id) === true) {
1417
+ viewerStoreMtime = 0;
1418
+ void Promise.resolve(api.resume(id, { quiet: true })).catch(() => { /* next tick retries */ });
1419
+ return;
1420
+ }
1421
+ let mtime = 0;
1422
+ try { mtime = statSync(sessionPath(id)).mtimeMs || 0; } catch { return; }
1423
+ // First attached tick only baselines: the resume that attached this
1424
+ // surface already loaded the current on-disk transcript.
1425
+ if (!viewerStoreMtime) { viewerStoreMtime = mtime; return; }
1426
+ if (mtime > viewerStoreMtime) {
1427
+ viewerStoreMtime = mtime;
1428
+ void Promise.resolve(api.resume(id, { quiet: true })).catch(() => { /* next tick retries */ });
1429
+ }
1430
+ return;
1431
+ }
1432
+ viewerStoreMtime = 0;
1433
+ drainRemoteInjections();
1434
+ } catch { /* best-effort */ }
1435
+ }, 3000);
1436
+ remoteAttachTimer.unref?.();
1233
1437
  void bag.restoreLeadSteeringFromDisk();
1234
- return createEngineApi(bag);
1438
+ return api;
1235
1439
  }
package/src/tui/index.jsx CHANGED
@@ -12,6 +12,7 @@ import { dirname, join } from 'node:path';
12
12
  import { performance } from 'node:perf_hooks';
13
13
  import { format } from 'node:util';
14
14
  import { App } from './App.jsx';
15
+ import { cancelPendingMouseTrackingRestores } from './app/use-mouse-input.mjs';
15
16
  import { createEngineSession } from './engine.mjs';
16
17
  import { scheduleRenderFrameAck } from './engine/render-timing.mjs';
17
18
  import { installProcessSignalCleanup } from '../runtime/shared/process-shutdown.mjs';
@@ -451,6 +452,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
451
452
  const restoreTerminal = () => {
452
453
  if (restored) return;
453
454
  restored = true;
455
+ cancelPendingMouseTrackingRestores();
454
456
  restorePrimedInput();
455
457
  try {
456
458
  process.stdout.write(
@@ -50,11 +50,14 @@ export function isVoiceEnabled() {
50
50
  /** Read the managed runtime state without installing or mutating anything. */
51
51
  export async function getVoiceStatus({ dataDir = resolvePluginData() } = {}) {
52
52
  const fetcher = await loadVoiceRuntimeFetcher();
53
- const runtime = fetcher.resolveVoiceRuntime(dataDir);
53
+ const runtime = fetcher.resolveVoiceRuntime(dataDir, {
54
+ modelId: fetcher.selectVoiceModelId(readSection('voice')),
55
+ });
54
56
  return {
55
57
  enabled: isVoiceEnabled(),
56
58
  busy: isVoiceInstallBusy(),
57
59
  installed: runtime.installed === true,
60
+ modelId: runtime.modelId,
58
61
  components: {
59
62
  whisper: Boolean(runtime.binary && runtime.serverCmd),
60
63
  model: Boolean(runtime.model),
@@ -112,7 +115,10 @@ function makeThrottledProgressNotice({ pushNotice, setProgressHint } = {}, inter
112
115
  */
113
116
  export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), pushNotice, setProgressHint } = {}) {
114
117
  const fetcher = await loadVoiceRuntimeFetcher();
115
- let runtime = fetcher.resolveVoiceRuntime(dataDir);
118
+ // System-language default: Korean devices install the Korean fine-tune,
119
+ // everything else the standard multilingual Q8. voice.model overrides.
120
+ const modelId = fetcher.selectVoiceModelId(readSection('voice'));
121
+ let runtime = fetcher.resolveVoiceRuntime(dataDir, { modelId });
116
122
  if (runtime.installed) return runtime;
117
123
 
118
124
  const onProgress = makeThrottledProgressNotice({ pushNotice, setProgressHint });
@@ -120,13 +126,13 @@ export async function ensureVoiceRuntimeReady({ dataDir = resolvePluginData(), p
120
126
  await fetcher.ensureWhisperRuntime(dataDir, onProgress);
121
127
  }
122
128
  if (!runtime.model) {
123
- await fetcher.ensureWhisperModel(dataDir, onProgress);
129
+ await fetcher.ensureWhisperModel(dataDir, onProgress, modelId);
124
130
  }
125
131
  if (!runtime.ffmpeg) {
126
132
  await fetcher.ensureFfmpegRuntime(dataDir, onProgress);
127
133
  }
128
134
 
129
- runtime = fetcher.resolveVoiceRuntime(dataDir);
135
+ runtime = fetcher.resolveVoiceRuntime(dataDir, { modelId });
130
136
  if (!runtime.installed) {
131
137
  throw new Error('voice runtime install did not complete (still missing a required component)');
132
138
  }
@@ -145,7 +145,10 @@ export function splitPastedImagePathCandidates(text) {
145
145
  return out;
146
146
  }
147
147
 
148
- async function imageAttachmentFromBuffer(buffer, mimeType, { filename = 'Pasted image', sourcePath = '' } = {}) {
148
+ // Exported for the desktop engine capability surface (resizeImage): the GUI
149
+ // composer routes its attachments through this SAME pipeline so desktop and
150
+ // TUI submit byte-identical image payloads.
151
+ export async function imageAttachmentFromBuffer(buffer, mimeType, { filename = 'Pasted image', sourcePath = '' } = {}) {
149
152
  if (!Buffer.isBuffer(buffer) || buffer.length === 0) throw new Error('image is empty');
150
153
  const ext = (mimeType || 'image/png').split('/')[1] || 'png';
151
154
  const resized = await resizeImageBuffer(buffer, ext);
@@ -6,6 +6,24 @@ import { CLAUDE_CURRENT_MODE } from './claude-current.mjs';
6
6
 
7
7
  const STORE_FILE = 'gateway-session-routes.json';
8
8
  const UUIDISH_SESSION_RE = /^[0-9a-z][0-9a-z._-]{7,}$/i;
9
+ // Route entries were written forever and never pruned — a two-week desktop
10
+ // installation reached a 1.1MB store that readStore() parsed synchronously on
11
+ // every route lookup. Bound the store by age and count; a route older than the
12
+ // TTL belongs to a session whose provider pin is long since irrelevant.
13
+ const ROUTE_TTL_MS = 14 * 24 * 60 * 60 * 1000;
14
+ const MAX_SESSION_ROUTES = 300;
15
+ const MAX_HOST_ROUTES = 100;
16
+
17
+ function pruneRouteMap(map, now, cap) {
18
+ const entries = Object.entries(map || {})
19
+ .filter(([, route]) => {
20
+ const at = Number(route?.updatedAt) || 0;
21
+ return at > 0 && (now - at) <= ROUTE_TTL_MS;
22
+ })
23
+ .sort((a, b) => (Number(b[1]?.updatedAt) || 0) - (Number(a[1]?.updatedAt) || 0))
24
+ .slice(0, cap);
25
+ return Object.fromEntries(entries);
26
+ }
9
27
 
10
28
  function cleanString(value) {
11
29
  const s = typeof value === 'string' ? value.trim() : '';
@@ -163,7 +181,12 @@ export function writeGatewaySessionRoutes(entries = []) {
163
181
  sessions[sid] = { ...route, sessionId: sid, updatedAt };
164
182
  }
165
183
  }
166
- return { version: 2, updatedAt, sessions, sessionHosts };
184
+ return {
185
+ version: 2,
186
+ updatedAt,
187
+ sessions: pruneRouteMap(sessions, updatedAt, MAX_SESSION_ROUTES),
188
+ sessionHosts: pruneRouteMap(sessionHosts, updatedAt, MAX_HOST_ROUTES),
189
+ };
167
190
  }, { compact: true, fsync: false, fsyncDir: false });
168
191
  return true;
169
192
  } catch {
@@ -15,6 +15,9 @@ approval.
15
15
 
16
16
  On approval, fan out at maximum width: one agent per independent scope, all
17
17
  spawned in one turn; only a scope that depends on another's output waits.
18
+ Split the plan into as many scopes as possible: disjoint file/module sets
19
+ are independent; merge only on a true output dependency. Prefer parallel
20
+ scopes over sequential slices in one agent.
18
21
 
19
22
  Route by complexity: simple, well-understood implementation goes to Worker;
20
23
  complex or investigative implementation goes to Heavy Worker; Lead itself
@@ -72,6 +72,20 @@ export function displayStringWidth(str) {
72
72
  return displayWidthWith(str, AMBIGUOUS_WIDE);
73
73
  }
74
74
 
75
+ /**
76
+ * Extra terminal cells that must be emitted as literal spaces after a glyph.
77
+ * Windows Terminal paints the policy glyphs across two cells but advances its
78
+ * text cursor by the plain string-width value (one), so Ink's zero-byte wide
79
+ * placeholder alone does not separate the following glyph. Native wide glyphs
80
+ * (CJK/emoji) return zero because the terminal already advances two cells.
81
+ */
82
+ export function syntheticWideCellPadding(str) {
83
+ const s = String(str ?? '');
84
+ if (!AMBIGUOUS_WIDE || !PROBLEM_RE.test(s))
85
+ return 0;
86
+ return Math.max(0, displayStringWidth(s) - stringWidth(s));
87
+ }
88
+
75
89
  /** widest-line equivalent under the policy. */
76
90
  export function displayWidestLine(text) {
77
91
  let lineWidth = 0;
@@ -1,10 +1,10 @@
1
- import sliceAnsi from 'slice-ansi';
2
1
  import { styledCharsFromTokens, styledCharsToString, tokenize, } from '@alcalzone/ansi-tokenize';
2
+ import { sliceTextByDisplayWidth } from './wrap-text.js';
3
3
  // [mixdog fork] use the shared display-width policy so ink's per-character
4
4
  // advance + width cache treat circled digits / arrows as 2 cells under Windows
5
5
  // Terminal, matching OUR wrap/row math. See display-width.js (kept in sync with
6
6
  // src/tui/display-width.mjs).
7
- import { displayStringWidth as stringWidth } from './display-width.js';
7
+ import { displayStringWidth as stringWidth, syntheticWideCellPadding } from './display-width.js';
8
8
  const RGB_COLOR_RE = /^rgb\(\s*(\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\s*\)$/;
9
9
  const rgbStyle = (value, type, fallback) => {
10
10
  const match = RGB_COLOR_RE.exec(String(value || ''));
@@ -191,7 +191,12 @@ export default class Output {
191
191
  const from = x < clip.x1 ? clip.x1 - x : 0;
192
192
  const width = this.caches.getStringWidth(line);
193
193
  const to = x + width > clip.x2 ? clip.x2 - x : width;
194
- return sliceAnsi(line, from, to);
194
+ // [mixdog fork] `from`/`to` are display-cell offsets.
195
+ // Plain slice-ansi treats enclosed alphanumerics as
196
+ // one cell and lets clipped transcript body text
197
+ // overwrite cells to its right. Slice with the same
198
+ // wide-glyph policy used by wrap/measure/output.
199
+ return sliceTextByDisplayWidth(line, from, to);
195
200
  });
196
201
  if (x < clip.x1) {
197
202
  x = clip.x1;
@@ -253,6 +258,14 @@ export default class Output {
253
258
  currentLine[offsetX] = character;
254
259
  // Determine printed width using string-width to align with measurement
255
260
  const characterWidth = Math.max(1, this.caches.getStringWidth(character.value));
261
+ // Policy-widened ambiguous glyphs need a REAL spacer in the
262
+ // terminal byte stream: unlike native CJK/emoji, WT paints
263
+ // their ink wide but advances only one cell. Keep the grid
264
+ // placeholder empty for layout/selection; serialization
265
+ // materializes only the synthetic tail cells as spaces.
266
+ const syntheticPadding = characterWidth > 1
267
+ ? syntheticWideCellPadding(character.value)
268
+ : 0;
256
269
  // For multi-column characters, clear following cells to avoid stray spaces/artifacts
257
270
  if (characterWidth > 1) {
258
271
  for (let index = 1; index < characterWidth; index++) {
@@ -261,6 +274,7 @@ export default class Output {
261
274
  value: '',
262
275
  fullWidth: false,
263
276
  styles: character.styles,
277
+ syntheticPad: syntheticPadding > 0 && index >= characterWidth - syntheticPadding,
264
278
  };
265
279
  }
266
280
  }
@@ -508,6 +522,13 @@ export default class Output {
508
522
  if (item === undefined) {
509
523
  continue;
510
524
  }
525
+ // A synthetic wide-cell placeholder is already included in
526
+ // the leading glyph's modeled width. Emit a literal blank so WT's
527
+ // cursor advances, but do not count that blank a second time.
528
+ if (item.syntheticPad) {
529
+ lineWithinWidth.push({ ...item, value: ' ' });
530
+ continue;
531
+ }
511
532
  const value = item.value ?? '';
512
533
  if (value === '') {
513
534
  lineWithinWidth.push(item);
@@ -1,3 +1,5 @@
1
1
  import { type Styles } from './styles.js';
2
+ export declare const sliceTextByDisplayWidthWithPolicy: (line: string, from: number, to: number, wide: boolean) => string;
3
+ export declare const sliceTextByDisplayWidth: (line: string, from: number, to: number) => string;
2
4
  declare const wrapText: (text: string, maxWidth: number, wrapType: Styles["textWrap"]) => string;
3
5
  export default wrapText;