mixdog 0.9.19 → 0.9.21

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/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -86,6 +86,11 @@ import {
86
86
  drainTuiSteeringPersist,
87
87
  flushTuiSteeringPersist,
88
88
  } from './engine/tui-steering-persist.mjs';
89
+ import { createContextState } from './engine/context-state.mjs';
90
+ import { recomputePromptHistory } from './engine/prompt-history.mjs';
91
+ import { createSessionFlow } from './engine/session-flow.mjs';
92
+ import { createRunTurn } from './engine/turn.mjs';
93
+ import { createEngineApi } from './engine/session-api.mjs';
89
94
 
90
95
  // Source tests resolve from src/tui/engine.mjs; the built bundle resolves from
91
96
  // src/tui/dist/index.mjs.
@@ -152,68 +157,39 @@ export async function createEngineSession({
152
157
  bootProfile('engine:create:runtime-ready', { ms: (performance.now() - startedAt).toFixed(1) });
153
158
  const cwd = runtime.cwd || process.cwd();
154
159
  const stateStartedAt = performance.now();
155
- const autoClearState = () => runtime.getAutoClear?.() || runtime.autoClear || { enabled: true, idleMs: 60 * 60 * 1000, custom: false, providerDefault: 60 * 60 * 1000, provider: null, minContextPercent: 10 };
156
- const AGENT_STATUS_CACHE_MS = 250;
157
- let agentStatusCache = null;
158
- let agentStatusCacheAt = 0;
159
- const agentStatusState = ({ force = false } = {}) => {
160
- const now = Date.now();
161
- if (!force && agentStatusCache && now - agentStatusCacheAt < AGENT_STATUS_CACHE_MS) return agentStatusCache;
162
- const status = runtime.agentStatus?.() || {};
163
- agentStatusCache = {
164
- agentWorkers: Array.isArray(status.agentWorkers) ? status.agentWorkers : [],
165
- agentJobs: Array.isArray(status.agentJobs) ? status.agentJobs : [],
166
- agentScope: status.agentScope || null,
167
- };
168
- agentStatusCacheAt = now;
169
- return agentStatusCache;
160
+ const flags = {
161
+ disposed: false,
162
+ draining: false,
163
+ autoClearRunning: false,
164
+ pendingSessionReset: false,
165
+ lastUserActivityAt: Date.now(),
166
+ leadTurnEpoch: 0,
167
+ activePromptRestore: null,
168
+ pushingFromDeferredEntry: false,
169
+ flushDeferredBeforeImmediatePush: null,
170
170
  };
171
- const baseRouteState = () => ({
172
- sessionId: runtime.id,
173
- clientHostPid: runtime.clientHostPid || null,
174
- model: runtime.model,
175
- provider: runtime.provider,
176
- effort: runtime.effort,
177
- effortOptions: runtime.effortOptions,
178
- fast: runtime.fast,
179
- fastCapable: runtime.fastCapable,
180
- contextWindow: runtime.contextWindow,
181
- rawContextWindow: runtime.rawContextWindow,
182
- effectiveContextWindowPercent: runtime.effectiveContextWindowPercent,
183
- cwd: runtime.cwd || process.cwd(),
184
- systemShell: runtime.systemShell || { source: 'auto', command: '', effective: '' },
185
- searchRoute: runtime.getSearchRoute?.() || runtime.searchRoute || null,
186
- autoClear: autoClearState(),
187
- workflow: runtime.workflow || null,
188
- remoteEnabled: runtime.isRemoteEnabled?.() === true,
189
- });
190
-
191
- const routeState = () => ({
192
- ...baseRouteState(),
193
- displayContextWindow: state.displayContextWindow || 0,
194
- compactBoundaryTokens: state.compactBoundaryTokens || 0,
195
- autoCompactTokenLimit: state.autoCompactTokenLimit || 0,
171
+ const lifecycle = { runtimePulseTimer: null, unsubscribeRuntimeNotifications: null, unsubscribeRemoteState: null };
172
+ const pending = [];
173
+ const pendingNotificationKeys = new Set();
174
+ const displayedExecutionNotificationKeys = new Set();
175
+ const bag = {};
176
+ // Route/context/agent-status derivations live in ./engine/context-state.mjs.
177
+ // getState()/getPendingSessionReset() are late-bound to the `state` and
178
+ // `pendingSessionReset` closures declared below; the sync helpers mutate
179
+ // state.stats / display fields IN PLACE exactly as the old inline versions
180
+ // did (callers follow with a set({ stats: { ...state.stats }, ... })).
181
+ const {
182
+ autoClearState,
183
+ agentStatusState,
184
+ baseRouteState,
185
+ routeState,
186
+ syncContextStats,
187
+ } = createContextState({
188
+ runtime,
189
+ getState: () => state,
190
+ getPendingSessionReset: () => flags.pendingSessionReset,
196
191
  });
197
192
 
198
- function syncContextDisplayFields(ctx = null) {
199
- const status = ctx || runtime.contextStatus?.() || null;
200
- if (!status) return;
201
- const displayWindow = Number(status.contextWindow || 0);
202
- const compactBoundary = Number(status.compaction?.boundaryTokens || 0);
203
- // Prefer the resolved trigger (boundary - buffer): the statusline uses it
204
- // as the display denominator so context % reads 100% exactly when
205
- // auto-compact fires, instead of stalling at ~90% of the boundary.
206
- const autoCompact = Number(
207
- status.compaction?.triggerTokens
208
- || status.compaction?.autoCompactTokenLimit
209
- || runtime.session?.autoCompactTokenLimit
210
- || 0,
211
- );
212
- if (displayWindow > 0) state.displayContextWindow = displayWindow;
213
- if (compactBoundary > 0) state.compactBoundaryTokens = compactBoundary;
214
- if (autoCompact > 0) state.autoCompactTokenLimit = autoCompact;
215
- }
216
-
217
193
  const initialAgentState = {
218
194
  agentWorkers: [],
219
195
  agentJobs: [],
@@ -251,63 +227,6 @@ export async function createEngineSession({
251
227
  };
252
228
  bootProfile('engine:route-state-ready', { ms: (performance.now() - stateStartedAt).toFixed(1) });
253
229
  bootProfile('engine:state-ready', { ms: (performance.now() - stateStartedAt).toFixed(1) });
254
- let pendingSessionReset = false;
255
- const syncContextStats = ({ allowEstimated = false } = {}) => {
256
- if (pendingSessionReset) return null;
257
- const ctx = runtime.contextStatus?.() || null;
258
- if (!ctx) return null;
259
- syncContextDisplayFields(ctx);
260
- const hasProviderUsage = Number(state.stats.latestPromptTokens || state.stats.latestInputTokens || state.stats.inputTokens || 0) > 0;
261
- const hasApiContextUsage = Number(ctx?.lastApiRequestTokens ?? ctx?.usage?.lastContextTokens ?? 0) > 0;
262
- const hasTurnActivity = state.busy === true
263
- || state.spinner != null
264
- || state.thinking != null;
265
- const isFreshSession = !hasProviderUsage && !hasApiContextUsage && !hasTurnActivity;
266
- if (isFreshSession) {
267
- state.stats.currentEstimatedContextTokens = 0;
268
- state.stats.currentContextTokens = 0;
269
- state.stats.currentContextSource = null;
270
- state.stats.currentContextUpdatedAt = Date.now();
271
- return ctx;
272
- }
273
- const estimatedTokens = Math.max(0, Number(ctx.currentEstimatedTokens ?? ctx.usedTokens ?? 0));
274
- const usedTokens = Math.max(0, Number(ctx.usedTokens ?? estimatedTokens ?? 0));
275
- const usedSource = String(ctx.usedSource || '').toLowerCase();
276
- const shouldPublishEstimate = allowEstimated && (
277
- usedSource === 'estimated'
278
- || Number(ctx.currentEstimatedTokens) > 0
279
- || usedTokens > 0
280
- );
281
- if (!allowEstimated && !hasProviderUsage && usedSource !== 'last_api_request') return ctx;
282
- if (shouldPublishEstimate) {
283
- state.stats.currentEstimatedContextTokens = estimatedTokens;
284
- state.stats.currentContextSource = 'estimated';
285
- state.stats.currentContextTokens = 0;
286
- } else if (allowEstimated && (hasProviderUsage || hasApiContextUsage || hasTurnActivity)) {
287
- state.stats.currentEstimatedContextTokens = estimatedTokens;
288
- state.stats.currentContextSource = usedSource || (estimatedTokens > 0 ? 'estimated' : null);
289
- const publishedSource = String(state.stats.currentContextSource || '').toLowerCase();
290
- if (publishedSource === 'last_api_request') {
291
- const apiUsed = Math.max(0, Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0));
292
- state.stats.currentContextTokens = apiUsed;
293
- } else if (publishedSource === 'estimated') {
294
- state.stats.currentContextTokens = 0;
295
- } else {
296
- state.stats.currentContextTokens = usedTokens > 0 ? usedTokens : 0;
297
- }
298
- } else {
299
- state.stats.currentEstimatedContextTokens = 0;
300
- if (usedSource === 'last_api_request' && Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0) > 0) {
301
- state.stats.currentContextTokens = Math.max(0, Number(ctx.lastApiRequestTokens ?? usedTokens ?? 0));
302
- state.stats.currentContextSource = 'last_api_request';
303
- } else {
304
- state.stats.currentContextTokens = 0;
305
- state.stats.currentContextSource = null;
306
- }
307
- }
308
- state.stats.currentContextUpdatedAt = Date.now();
309
- return ctx;
310
- };
311
230
  const contextStartedAt = performance.now();
312
231
  syncContextStats({ allowEstimated: true });
313
232
  bootProfile('engine:context-ready', { ms: (performance.now() - contextStartedAt).toFixed(1) });
@@ -363,33 +282,13 @@ export async function createEngineSession({
363
282
  state = { ...state, items: nextItems, promptHistoryList: recomputePromptHistory(nextItems), activeToolSummary: null };
364
283
  return nextItems;
365
284
  };
366
- let flushDeferredBeforeImmediatePush = null;
367
- let pushingFromDeferredEntry = false;
368
285
  // --- Prompt-history list (newest-first, deduped) maintained incrementally ---
369
286
  // App previously rebuilt this from state.items on EVERY transcript change
370
287
  // (App.jsx recentPromptHistory useMemo). It only changes when a user item is
371
288
  // appended, so rebuild it there and on bulk item swaps, publishing to
372
- // state.promptHistoryList.
373
- const PROMPT_HISTORY_LIMIT = 50;
374
- const promptHistoryKey = (value) => String(value || '').trim().replace(/\s+/g, ' ');
375
- const recomputePromptHistory = (sourceItems = null) => {
376
- // Pure: derive the newest-first deduped user-prompt history from the given
377
- // items (defaults to state.items) and RETURN it. Callers decide whether to
378
- // publish via set() so the immutable-emit contract is preserved.
379
- const items = Array.isArray(sourceItems) ? sourceItems : (Array.isArray(state.items) ? state.items : []);
380
- const seen = new Set();
381
- const history = [];
382
- for (let i = items.length - 1; i >= 0 && history.length < PROMPT_HISTORY_LIMIT; i -= 1) {
383
- const item = items[i];
384
- if (item?.kind !== 'user') continue;
385
- const text = String(item.text || '').trim();
386
- const key = promptHistoryKey(text);
387
- if (!key || seen.has(key)) continue;
388
- seen.add(key);
389
- history.push(text);
390
- }
391
- return history;
392
- };
289
+ // state.promptHistoryList. Pure derivation now lives in
290
+ // ./engine/prompt-history.mjs (recomputePromptHistory); callers still pass the
291
+ // NEW items array explicitly and publish via set().
393
292
  // --- Active-tool summary (Explore/Search) maintained incrementally ---
394
293
  // App previously scanned every transcript item on every change to derive the
395
294
  // prompt-line "Exploring N / Searching N" status. Instead the tool lifecycle
@@ -433,8 +332,8 @@ export async function createEngineSession({
433
332
  if (state.activeToolSummary) set({ activeToolSummary: null });
434
333
  };
435
334
  const pushItem = (item) => {
436
- if (!pushingFromDeferredEntry && flushDeferredBeforeImmediatePush) {
437
- flushDeferredBeforeImmediatePush();
335
+ if (!flags.pushingFromDeferredEntry && flags.flushDeferredBeforeImmediatePush) {
336
+ flags.flushDeferredBeforeImmediatePush();
438
337
  }
439
338
  const index = state.items.length;
440
339
  const items = [...state.items, item];
@@ -497,7 +396,7 @@ export async function createEngineSession({
497
396
  set({ toasts: [...state.toasts.filter((toast) => toast.id !== id), { id, text: value, tone }] });
498
397
  const timer = setTimeout(() => {
499
398
  toastTimers.delete(timer);
500
- if (disposed) return;
399
+ if (flags.disposed) return;
501
400
  set({ toasts: state.toasts.filter((toast) => toast.id !== id) });
502
401
  }, ttlMs);
503
402
  toastTimers.add(timer);
@@ -542,7 +441,7 @@ export async function createEngineSession({
542
441
  getState: () => state,
543
442
  set,
544
443
  nextId,
545
- getDisposed: () => disposed,
444
+ getDisposed: () => flags.disposed,
546
445
  timeoutMs: TOOL_APPROVAL_TIMEOUT_MS,
547
446
  });
548
447
  const patchItem = (id, patch) => {
@@ -567,10 +466,9 @@ export async function createEngineSession({
567
466
  return true;
568
467
  };
569
468
  const toastTimers = new Set();
570
- let disposed = false;
571
- const runtimePulseTimer = setInterval(() => {
572
- if (disposed) return;
573
- if (pendingSessionReset) return;
469
+ lifecycle.runtimePulseTimer = setInterval(() => {
470
+ if (flags.disposed) return;
471
+ if (flags.pendingSessionReset) return;
574
472
  syncContextStats({ allowEstimated: true });
575
473
  set({
576
474
  ...routeState(),
@@ -578,7 +476,7 @@ export async function createEngineSession({
578
476
  ...agentStatusState(),
579
477
  });
580
478
  }, 2000);
581
- runtimePulseTimer.unref?.();
479
+ lifecycle.runtimePulseTimer.unref?.();
582
480
 
583
481
  function clearToastTimers() {
584
482
  for (const timer of toastTimers) {
@@ -587,11 +485,6 @@ export async function createEngineSession({
587
485
  toastTimers.clear();
588
486
  }
589
487
 
590
- let unsubscribeRuntimeNotifications = null;
591
- let lastUserActivityAt = Date.now();
592
- let autoClearRunning = false;
593
- const pendingNotificationKeys = new Set();
594
- const displayedExecutionNotificationKeys = new Set();
595
488
  const {
596
489
  kickExecutionPendingResume,
597
490
  flushDeferredExecutionPendingResumeKick,
@@ -603,25 +496,24 @@ export async function createEngineSession({
603
496
  getState: () => state,
604
497
  set,
605
498
  nextId,
606
- getDisposed: () => disposed,
499
+ getDisposed: () => flags.disposed,
607
500
  patchItem,
608
- enqueue: (...args) => enqueue(...args),
609
- drain: (...args) => drain(...args),
501
+ enqueue: (...args) => bag.enqueue(...args),
502
+ drain: (...args) => bag.drain(...args),
610
503
  pushUserOrSyntheticItem,
611
- makeQueueEntry: (...args) => makeQueueEntry(...args),
504
+ makeQueueEntry: (...args) => bag.makeQueueEntry(...args),
612
505
  getPending: () => pending,
613
506
  agentStatusState,
614
507
  displayedExecutionNotificationKeys,
615
508
  });
616
- unsubscribeRuntimeNotifications = subscribeRuntimeNotifications();
509
+ lifecycle.unsubscribeRuntimeNotifications = subscribeRuntimeNotifications();
617
510
 
618
511
  // Remote seat superseded by another session: runtime already stopped its
619
512
  // worker; sync the indicator and tell the user. Non-user-initiated, so a
620
513
  // toast (not transcript) is right.
621
- let unsubscribeRemoteState = null;
622
514
  if (typeof runtime.onRemoteStateChange === 'function') {
623
- unsubscribeRemoteState = runtime.onRemoteStateChange(({ enabled, reason }) => {
624
- if (disposed) return;
515
+ lifecycle.unsubscribeRemoteState = runtime.onRemoteStateChange(({ enabled, reason }) => {
516
+ if (flags.disposed) return;
625
517
  set({ remoteEnabled: enabled === true });
626
518
  if (reason === 'superseded') {
627
519
  pushNotice('Remote mode OFF — another session took over remote.', 'warn');
@@ -638,2465 +530,21 @@ export async function createEngineSession({
638
530
  agentStatusState,
639
531
  });
640
532
 
641
- async function runTurn(userText, options = {}) {
642
- const turnIndex = state.stats.turns || 0;
643
- const startedAt = Date.now();
644
- // Per-turn epoch. Force-release (watchdog grace) bumps the shared counter so
645
- // this turn's own eventual `finally` — which may run LONG after force-release
646
- // already started a new turn that reuses the per-session mutex — can detect
647
- // it is stale and skip all shared-state writes (busy, activePromptRestore,
648
- // turndone, drain kick). Neutralizes the stale unwind without touching the mutex.
649
- const turnEpoch = ++leadTurnEpoch;
650
- const inputBaseline = state.stats.inputTokens;
651
- const outputBaseline = state.stats.outputTokens;
652
- const submittedIds = Array.isArray(options.submittedIds) ? options.submittedIds : [];
653
- const displayText = promptDisplayText(userText, options);
654
- let promptCommittedCallbackCalled = false;
655
- activePromptRestore = {
656
- text: String(displayText || '').trim(),
657
- pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
658
- pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
659
- onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
660
- restorable: options.restorable !== false,
661
- submittedIds,
662
- reclaimed: false,
663
- committed: false,
664
- requeueEntries: Array.isArray(options.requeueOnAbort) ? options.requeueOnAbort.slice() : [],
665
- };
666
- set({ busy: true, lastTurn: null, spinner: { active: true, verb: pickVerb(turnIndex), startedAt, responseLength: 0, inputTokens: 0, outputTokens: 0, mode: 'requesting' } });
667
-
668
- let assistantText = '';
669
- let currentAssistantId = null;
670
-
671
- tuiDebug(`runTurn start turn=${turnIndex} pending=${pending.length} timeoutMs=${LEAD_TURN_TIMEOUT_MS}`);
672
- // ── Wall-clock watchdog ───────────────────────────────────────────────
673
- // If this turn never unwinds (provider call stuck), trip after the cap:
674
- // abort the in-flight run via the existing interrupt path (runtime.abort,
675
- // the same one Esc uses), which rejects the pending runtime.ask() with a
676
- // SessionClosedError so the normal cancelled-turn teardown runs. If even
677
- // that unwind is starved, forceReleaseTurn() in the timer hard-clears busy
678
- // and kicks the drain so input is never permanently dead.
679
- let watchdogTripped = false;
680
- let watchdogTimer = null;
681
- let watchdogGraceTimer = null;
682
- const clearWatchdog = () => {
683
- if (watchdogTimer) { clearTimeout(watchdogTimer); watchdogTimer = null; }
684
- if (watchdogGraceTimer) { clearTimeout(watchdogGraceTimer); watchdogGraceTimer = null; }
685
- };
686
- watchdogTimer = setTimeout(() => {
687
- if (disposed) return;
688
- watchdogTripped = true;
689
- const elapsed = Date.now() - startedAt;
690
- tuiDebug(`runTurn WATCHDOG TRIP turn=${turnIndex} elapsedMs=${elapsed} — aborting stuck turn`);
691
- pushNotice(`Turn timed out after ${Math.round(elapsed / 1000)}s — aborting stuck request. Input is available again.`, 'warn', { transcript: true });
692
- try { runtime.abort('cli-react-abort-watchdog'); } catch {}
693
- // Belt-and-suspenders: if runtime.abort() did not reject runtime.ask()
694
- // (unwind starved), hard-release the turn after a short grace so the
695
- // React store is never left with busy=true and no drain in flight.
696
- watchdogGraceTimer = setTimeout(() => {
697
- if (disposed) return;
698
- if (!state.busy) return;
699
- if (leadTurnEpoch !== turnEpoch) return; // a newer turn already owns the store
700
- tuiDebug(`runTurn WATCHDOG FORCE-RELEASE turn=${turnIndex} — abort unwind starved`);
701
- // Bump the epoch FIRST so this (still-stuck) turn's later finally becomes
702
- // a no-op for shared state and cannot corrupt the turn we hand off to.
703
- leadTurnEpoch++;
704
- set({ busy: false, spinner: null, thinking: null, lastTurn: null });
705
- activePromptRestore = null;
706
- if (draining) draining = false;
707
- if (pending.length > 0) void drain();
708
- }, 5_000);
709
- watchdogGraceTimer.unref?.();
710
- }, LEAD_TURN_TIMEOUT_MS);
711
- watchdogTimer.unref?.();
712
- let currentAssistantText = '';
713
- let thinkingText = '';
714
- let thinkingStartedAt = 0;
715
- let thinkingSegmentStartedAt = 0;
716
- let accumulatedThinkingMs = 0;
717
- let cancelled = false;
718
- let askResult = null;
719
- let turnFinishedNormally = false;
720
- const itemsAtTurnStart = state.items.length;
721
- const cardByCallId = new Map();
722
- const toolCards = [];
723
- const toolGroups = new Map();
724
- const resultsDone = new Set();
725
- // Streaming providers can deliver eager onToolResult before onToolCall registers
726
- // cards (send() still in flight). Hold those by callId until the batch lands.
727
- const earlyResultBuffer = new Map();
728
- const aggregateCards = []; // active aggregate cards in the current consecutive tool block
729
- let tailAggregate = null; // most recently touched aggregate card; only the tail may absorb the next same-bucket call
730
533
 
731
- // ── Deferred tool-card push (scroll/text sync) ────────────────────────────
732
- // A tool card used to enter the transcript the instant onToolCall fired,
733
- // reserving its estimated height (margin+header+detail) while ToolExecution
734
- // only painted blank placeholder rows for TOOL_PENDING_SHOW_DELAY_MS. With a
735
- // bottom-fixed viewport that shoved the body up BEFORE any glyph appeared, so
736
- // the scroll ran ahead of the text. We now hold each card off-screen for the
737
- // same delay and push it only when its real header/detail will paint (delay
738
- // elapsed) OR a result lands first (fast tool → completed card, no pending
739
- // flicker). Either way the pushed spec is stamped `deferredDisplayReady` so
740
- // ToolExecution renders the real header + 'Running' detail immediately
741
- // instead of the blank pre-delay placeholder — this matters for the
742
- // result-forced chain push (flushDeferredUpTo), where earlier-seq sibling
743
- // cards are pushed alongside the result-bearing one before their own delay
744
- // elapses and would otherwise paint an empty reserved band.
745
- // Mirrors components/ToolExecution.jsx TOOL_PENDING_SHOW_DELAY_MS.
746
- // [jitter fix] Reserve the tool-card row immediately instead of after a
747
- // 1000ms delay. Delayed insertion made rows appear late and shove the body
748
- // vertically; pushing now (with the real 'Running' header height via
749
- // deferredDisplayReady) keeps the layout stable. The timer path is kept as a
750
- // 0ms microtask fallback for entries that are registered but not explicitly
751
- // surfaced, preserving call-order flush semantics.
752
- const TOOL_CARD_PUSH_DELAY_MS = 0;
753
- let deferredSeqCounter = 0;
754
- const deferredEntries = []; // creation-order list; each is pushed at most once
755
- // Push this entry AND every earlier-created still-deferred entry, in order,
756
- // so transcript order always matches call order even when a later card's
757
- // result/timer fires before an earlier one's.
758
- const flushDeferredUpTo = (entry) => {
759
- if (!entry) return;
760
- for (const e of deferredEntries) {
761
- if (e.seq > entry.seq) break;
762
- if (e.pushed) continue;
763
- e.pushed = true;
764
- if (e.timer) { clearTimeout(e.timer); e.timer = null; }
765
- try { e.push(); } catch {}
766
- }
767
- };
768
- flushDeferredBeforeImmediatePush = () => {
769
- if (!deferredEntries.length) return;
770
- const last = deferredEntries[deferredEntries.length - 1];
771
- if (last) flushDeferredUpTo(last);
772
- };
773
- const registerDeferredCard = (card) => {
774
- const entry = {
775
- seq: deferredSeqCounter++,
776
- pushed: false,
777
- timer: null,
778
- // Mark the card visible and return its spec WITHOUT emitting, so a
779
- // batched turn-close flush can commit many specs in one set().
780
- materialize: () => {
781
- card.pushed = true;
782
- if (!card.spec) return null;
783
- card.spec.deferredDisplayReady = true;
784
- return card.spec;
785
- },
786
- push: () => {
787
- const spec = entry.materialize();
788
- if (!spec) return;
789
- pushingFromDeferredEntry = true;
790
- try { pushItem(spec); } finally { pushingFromDeferredEntry = false; }
791
- },
792
- };
793
- card.deferred = entry;
794
- card.ensureVisible = () => flushDeferredUpTo(entry);
795
- deferredEntries.push(entry);
796
- entry.timer = setTimeout(() => {
797
- entry.timer = null;
798
- if (disposed) return;
799
- flushDeferredUpTo(entry);
800
- }, TOOL_CARD_PUSH_DELAY_MS);
801
- entry.timer.unref?.();
802
- };
803
- const registerDeferredAggregate = (aggregate) => {
804
- const entry = {
805
- seq: deferredSeqCounter++,
806
- pushed: false,
807
- timer: null,
808
- materialize: () => {
809
- aggregate.pushed = true;
810
- if (!aggregate.pendingSpec) return null;
811
- aggregate.pendingSpec.deferredDisplayReady = true;
812
- return aggregate.pendingSpec;
813
- },
814
- push: () => {
815
- const spec = entry.materialize();
816
- if (!spec) return;
817
- pushingFromDeferredEntry = true;
818
- try { pushItem(spec); } finally { pushingFromDeferredEntry = false; }
819
- },
820
- };
821
- aggregate.deferred = entry;
822
- aggregate.ensureVisible = () => flushDeferredUpTo(entry);
823
- deferredEntries.push(entry);
824
- entry.timer = setTimeout(() => {
825
- entry.timer = null;
826
- if (disposed) return;
827
- flushDeferredUpTo(entry);
828
- }, TOOL_CARD_PUSH_DELAY_MS);
829
- entry.timer.unref?.();
830
- };
831
- const clearDeferredTimers = () => {
832
- for (const e of deferredEntries) {
833
- if (e.timer) { clearTimeout(e.timer); e.timer = null; }
834
- }
835
- };
836
- // Collect (mark pushed + cancel timers) every still-deferred entry up to
837
- // `entry` in creation order, returning their specs WITHOUT emitting — the
838
- // caller commits them (optionally alongside a trailing turndone item) in a
839
- // single set() so turn-close writes land as ONE visible commit.
840
- const collectDeferredUpTo = (entry) => {
841
- const specs = [];
842
- if (!entry) return specs;
843
- for (const e of deferredEntries) {
844
- if (e.seq > entry.seq) break;
845
- if (e.pushed) continue;
846
- e.pushed = true;
847
- if (e.timer) { clearTimeout(e.timer); e.timer = null; }
848
- const spec = e.materialize?.();
849
- if (spec) specs.push(spec);
850
- }
851
- return specs;
852
- };
853
- // Append pre-built items (deferred cards + turndone) in ONE set(). None are
854
- // 'user' kind, so no promptHistory rebuild is needed.
855
- const appendItemsBatch = (newItems, extra = {}) => {
856
- if (!newItems || !newItems.length) { set(extra); return; }
857
- const base = state.items.length;
858
- const items = [...state.items, ...newItems];
859
- for (let i = 0; i < newItems.length; i++) {
860
- const it = newItems[i];
861
- if (it?.id != null) itemIndexById.set(it.id, base + i);
862
- }
863
- set({ items, ...extra });
864
- };
865
-
866
- const markPromptCommitted = () => {
867
- if (activePromptRestore) {
868
- if (!promptCommittedCallbackCalled && typeof activePromptRestore.onCommitted === 'function') {
869
- promptCommittedCallbackCalled = true;
870
- try { activePromptRestore.onCommitted(); } catch {}
871
- }
872
- activePromptRestore.restorable = false;
873
- activePromptRestore.committed = true;
874
- activePromptRestore.requeueEntries = [];
875
- activePromptRestore.pastedImages = null;
876
- activePromptRestore.pastedTexts = null;
877
- }
878
- };
879
-
880
- const finalizeToolHeaders = () => {
881
- const ids = new Set();
882
- for (const card of toolCards || []) {
883
- if (card?.itemId) ids.add(card.itemId);
884
- // Seal not-yet-pushed specs too, so a card that pushes later (timer)
885
- // enters already-finalized instead of flashing the active header form.
886
- if (card && card.pushed === false && card.spec) card.spec.headerFinalized = true;
887
- }
888
- for (const aggregate of aggregateCards || []) {
889
- if (aggregate?.itemId) ids.add(aggregate.itemId);
890
- if (aggregate && aggregate.pushed === false && aggregate.pendingSpec) aggregate.pendingSpec.headerFinalized = true;
891
- }
892
- if (ids.size === 0) return false;
893
- let changed = false;
894
- const items = state.items.map((item) => {
895
- if (!ids.has(item?.id) || item.kind !== 'tool' || item.headerFinalized !== false) return item;
896
- changed = true;
897
- return { ...item, headerFinalized: true };
898
- });
899
- if (changed) set({ items });
900
- return changed;
901
- };
902
-
903
- const completeAggregateVisual = () => {
904
- for (const aggregate of aggregateCards) {
905
- const allCalls = [...aggregate.calls.values()];
906
- if (allCalls.length === 0) continue;
907
- aggregate.ensureVisible?.();
908
- const errors = allCalls.filter((r) => r.isError).length;
909
- const completed = allCalls.filter((r) => r.resolved).length;
910
- const succeeded = completed - errors;
911
- const rawResult = aggregateRawResult(allCalls);
912
- // Merged count summary (see patchToolCardResult); failures keep
913
- // 'N Ok · N Failed'. Raw preserved for ctrl+o expansion.
914
- const displayDetail = errors > 0
915
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
916
- : formatAggregateDetail(aggregateSummaries(aggregate));
917
- patchItem(aggregate.itemId, {
918
- result: displayDetail,
919
- text: displayDetail,
920
- rawResult: rawResult || null,
921
- isError: errors > 0,
922
- count: allCalls.length,
923
- completedCount: allCalls.length,
924
- completedAt: Date.now(),
925
- });
926
- }
927
- };
928
-
929
- const clearAggregateContinuation = () => {
930
- completeAggregateVisual();
931
- finalizeToolHeaders();
932
- aggregateCards.length = 0;
933
- // Seal the block: same-bucket calls after this point must open a fresh
934
- // card, never continue one from before the seal (assistant text/turn
935
- // end boundary).
936
- tailAggregate = null;
937
- };
938
-
939
- const rememberActiveAggregate = (aggregate) => {
940
- if (!aggregate) return;
941
- if (!aggregateCards.includes(aggregate)) aggregateCards.push(aggregate);
942
- tailAggregate = aggregate;
943
- };
944
-
945
- const ensureAggregateCard = (bucket) => {
946
- // Only the TAIL aggregate (most recent card) may absorb the next call,
947
- // and only when the bucket matches. Any different-bucket aggregate or
948
- // standalone card in between breaks the run, so Search, Memory, Search
949
- // renders as three cards in call order — a new call never merges into
950
- // an earlier card above the current tail (which read as out-of-order
951
- // count changes in the transcript). clearAggregateContinuation seals
952
- // the block at assistant-text/turn boundaries.
953
- const cached = tailAggregate && tailAggregate.bucket === bucket ? tailAggregate : null;
954
- if (cached) {
955
- rememberActiveAggregate(cached);
956
- return cached;
957
- }
958
- const itemId = nextId();
959
- const aggregate = {
960
- itemId,
961
- bucket,
962
- categories: new Map(),
963
- categoryOrder: [],
964
- calls: new Map(),
965
- nextSummarySeq: 0,
966
- pushed: false,
967
- startedAt: Date.now(),
968
- };
969
- // Arm the deferred push once at creation; syncAggregateHeader only keeps
970
- // pendingSpec current until the timer/result flushes it in call order.
971
- registerDeferredAggregate(aggregate);
972
- rememberActiveAggregate(aggregate);
973
- return aggregate;
974
- };
975
-
976
- const syncAggregateHeader = (aggregate) => {
977
- if (!aggregate?.itemId) return;
978
- const patch = {
979
- args: { categoryOrder: aggregate.categoryOrder.slice() },
980
- count: aggregate.calls.size,
981
- completedCount: [...aggregate.calls.values()].filter((r) => r.resolved || r.completedEarly).length,
982
- categories: Object.fromEntries(aggregate.categories),
983
- };
984
- if (aggregate.pushed) {
985
- patchItem(aggregate.itemId, patch);
986
- return;
987
- }
988
- // Not yet visible: keep the latest header spec current. The deferred entry
989
- // (armed at creation) pushes pendingSpec when its timer fires or a result
990
- // forces it visible, preserving call order via flushDeferredUpTo.
991
- aggregate.pendingSpec = {
992
- kind: 'tool',
993
- id: aggregate.itemId,
994
- name: '__aggregate__',
995
- ...patch,
996
- aggregate: true,
997
- result: null,
998
- rawResult: null,
999
- isError: false,
1000
- expanded: false,
1001
- headerFinalized: false,
1002
- startedAt: aggregate.startedAt || Date.now(),
1003
- };
1004
- };
1005
-
1006
- const ensureAssistant = (initialText = '') => {
1007
- if (!currentAssistantId) {
1008
- currentAssistantId = nextId();
1009
- // Do NOT reset currentAssistantText here. The first onTextDelta has
1010
- // already accumulated the opening chunk before this batched flush runs;
1011
- // wiping it dropped the leading characters and forced a later set() to
1012
- // re-add them. Segment resets are owned by closeAssistantSegment().
1013
- // Seed the new row with the already-visible text so the ● gutter and the
1014
- // first body line appear in the SAME set()/emit() — no empty "●-only"
1015
- // row that scrolls once on its own and again when the body lands.
1016
- pushItem({ kind: 'assistant', id: currentAssistantId, text: String(initialText || ''), streaming: true });
1017
- }
1018
- return currentAssistantId;
1019
- };
1020
-
1021
- const closeAssistantSegment = () => {
1022
- currentAssistantId = null;
1023
- currentAssistantText = '';
1024
- // Reset incremental-flush state so the next segment rescans from scratch.
1025
- _streamScanLen = 0;
1026
- _lastNewlineIdx = -1;
1027
- _emittedNewlineIdx = -2;
1028
- _emittedVisibleText = '';
1029
- _cachedAssistantIndex = -1;
1030
- };
1031
-
1032
- const commitAssistantSegment = ({ sealToolBlock = false } = {}) => {
1033
- const text = currentAssistantText || '';
1034
- if (!text.trim()) {
1035
- closeAssistantSegment();
1036
- return false;
1037
- }
1038
- if (sealToolBlock) clearAggregateContinuation();
1039
- const id = currentAssistantId || ensureAssistant(text);
1040
- patchItem(id, { text, streaming: false });
1041
- closeAssistantSegment();
1042
- return true;
1043
- };
1044
-
1045
- const startThinkingSegment = () => {
1046
- const now = Date.now();
1047
- if (!thinkingStartedAt) thinkingStartedAt = now;
1048
- if (!thinkingSegmentStartedAt) thinkingSegmentStartedAt = now;
1049
- return now;
1050
- };
1051
-
1052
- const closeThinkingSegment = () => {
1053
- if (!thinkingSegmentStartedAt) return;
1054
- const now = Date.now();
1055
- accumulatedThinkingMs += Math.max(0, now - thinkingSegmentStartedAt);
1056
- thinkingSegmentStartedAt = 0;
1057
- return now;
1058
- };
1059
-
1060
- // --- Streaming-delta batcher ---
1061
- // onTextDelta and onReasoningDelta fire on every tiny chunk (often <10 chars).
1062
- // Each call previously called set() → emit() → full React reconcile. We
1063
- // batch accumulated text and flush at most once per STREAM_BATCH_INTERVAL_MS
1064
- // (≈16ms / 60fps cap). A forced flush happens before any tool call,
1065
- // finalization, or error so those code paths see the correct text state.
1066
- // Flush cadence for streamed text/thinking. 8ms (~120fps) matches the Ink
1067
- // render maxFps (index.jsx render({ maxFps: 120 })), so a queued batch is
1068
- // never held back waiting for the next Ink frame. 16ms (~60fps) left every
1069
- // other Ink frame idle, which made fast provider streams visibly land in
1070
- // coarse chunks ("10 chars at a time").
1071
- const STREAM_BATCH_INTERVAL_MS = 16;
1072
- let _batchTimer = null;
1073
- let _pendingTextFlush = false; // true when a text/spinner update is queued
1074
- let _pendingThinkFlush = false; // true when a thinking update is queued
1075
- let _pendingThinkingLastEndedAt = 0;
1076
- let compactingActive = false;
1077
- // Incremental streaming-flush state: avoids rescanning the full accumulated
1078
- // assistant text (lastIndexOf) and re-finding the row index on every flush.
1079
- let _streamScanLen = 0; // chars of currentAssistantText already scanned for '\n'
1080
- let _lastNewlineIdx = -1; // offset of the last completed-line '\n' found so far
1081
- let _emittedNewlineIdx = -2; // newline offset backing _emittedVisibleText (-2 forces first compute)
1082
- let _emittedVisibleText = ''; // cached visible slice for the current newline offset
1083
- let _cachedAssistantIndex = -1;// cached state.items index of the streaming assistant row
1084
- // Engine-local streaming scalars. Neither responseLength nor thinkingText is
1085
- // rendered per-token by any consumer: App reads state.thinking only as a
1086
- // boolean (App.jsx `!!(state.thinking || liveSpinner?.thinking)`) and the
1087
- // Spinner takes outputTokens, not responseLength. So we keep these growing
1088
- // values in engine-local vars and publish to the store only on a visible
1089
- // transition (thinking on↔off), a completed visible text line, tool/usage
1090
- // updates, or finalization — not on every 8ms streaming flush.
1091
- let _publishedThinkingActive = false; // last thinking boolean pushed to store
1092
- // responseLength is only consumed at finalize as an outputTokens fallback
1093
- // (Math.round(responseLength/4)); we refresh state.spinner.responseLength on
1094
- // visible-line flush and finalize so that fallback stays valid.
1095
-
1096
- const flushStreamBatch = () => {
1097
- if (_batchTimer !== null) {
1098
- clearTimeout(_batchTimer);
1099
- _batchTimer = null;
1100
- }
1101
- if (_pendingTextFlush) {
1102
- _pendingTextFlush = false;
1103
- // Show only COMPLETED lines while streaming. The in-progress trailing
1104
- // line stays hidden until its '\n' arrives, so the visible text never
1105
- // grows a glyph at a time (no "Wh"→pause→"What happened…" partial reveal, no
1106
- // CJK-width reflow jitter). The final non-streaming patch
1107
- // (streaming:false) always carries the full text, so the tail line that
1108
- // never got a newline still lands once at finalize.
1109
- // Incrementally track the last completed-line '\n' offset instead of
1110
- // rescanning the whole accumulated text every flush. Each char is
1111
- // examined once across the stream (amortized O(n) total, not O(n) per
1112
- // flush); when the newline offset hasn't advanced the visible text is
1113
- // byte-identical to the last flush, so the slice below is skipped and
1114
- // reused. Reveal semantics are unchanged: still only completed lines.
1115
- const textLen = currentAssistantText.length;
1116
- if (textLen < _streamScanLen) { _streamScanLen = 0; _lastNewlineIdx = -1; }
1117
- for (let i = _streamScanLen; i < textLen; i++) {
1118
- if (currentAssistantText.charCodeAt(i) === 10) _lastNewlineIdx = i;
1119
- }
1120
- _streamScanLen = textLen;
1121
- let streamingVisibleText;
1122
- if (_lastNewlineIdx === _emittedNewlineIdx) {
1123
- streamingVisibleText = _emittedVisibleText;
1124
- } else {
1125
- streamingVisibleText = _lastNewlineIdx >= 0
1126
- ? currentAssistantText.slice(0, _lastNewlineIdx + 1)
1127
- : '';
1128
- _emittedNewlineIdx = _lastNewlineIdx;
1129
- _emittedVisibleText = streamingVisibleText;
1130
- }
1131
- const patch = {};
1132
- // Do NOT create the assistant row (and scroll the transcript) before
1133
- // there is a completed line with VISIBLE content to show. Until the
1134
- // first '\n' the only pending state is the spinner; the row appears
1135
- // together with its first visible line, so no empty "●-only" row
1136
- // flashes/scrolls ahead of text. `.trim()` also guards the
1137
- // whitespace-only case: a response that opens with leading newlines
1138
- // ("\n\n# …") completes a blank line first, whose estimated height
1139
- // still reserves rows and scrolls the transcript, but Markdown trims
1140
- // the body to nothing — so the scroll advances onto an empty band for
1141
- // a few seconds until a non-blank line lands. Don't create the row
1142
- // until there is real content to paint.
1143
- if (currentAssistantId || streamingVisibleText.trim()) {
1144
- const id = ensureAssistant(streamingVisibleText);
1145
- // Emit the accumulated assistant text and spinner update together so a
1146
- // streaming batch costs one set() → one emit() → one React reconcile.
1147
- // Cache the streaming row's index; re-find only on a cache miss (row
1148
- // added/removed) instead of a findIndex scan on every flush.
1149
- let index = _cachedAssistantIndex;
1150
- if (index < 0 || state.items[index]?.id !== id) {
1151
- index = state.items.findIndex((it) => it.id === id);
1152
- _cachedAssistantIndex = index;
1153
- }
1154
- if (index >= 0) {
1155
- const current = state.items[index];
1156
- if (!Object.is(current.text, streamingVisibleText) || current.streaming !== true) {
1157
- const items = state.items.slice();
1158
- items[index] = { ...current, text: streamingVisibleText, streaming: true };
1159
- patch.items = items;
1160
- }
1161
- }
1162
- }
1163
- // Only touch the spinner when there is a real reason: a visible-line
1164
- // change (patch.items set above), a thinking→responding transition, or a
1165
- // pending thinking end timestamp. Refresh responseLength here so the
1166
- // finalize outputTokens fallback stays valid without a per-token push.
1167
- const responseLengthVal = assistantText.length + thinkingText.length;
1168
- const visibleLineChanged = patch.items !== undefined;
1169
- const thinkingTransition = _publishedThinkingActive === true; // was thinking, now responding
1170
- if (state.spinner && (visibleLineChanged || thinkingTransition || _pendingThinkingLastEndedAt)) {
1171
- patch.spinner = { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingLastEndedAt: _pendingThinkingLastEndedAt || state.spinner.thinkingLastEndedAt, mode: compactingActive ? 'compacting' : 'responding' };
1172
- _publishedThinkingActive = false;
1173
- }
1174
- if (Object.keys(patch).length > 0) set(patch);
1175
- _pendingThinkingLastEndedAt = 0;
1176
- }
1177
- if (_pendingThinkFlush) {
1178
- _pendingThinkFlush = false;
1179
- // App only consumes state.thinking as a boolean and the Spinner only
1180
- // reads the thinking flag + timing anchors — none of them render the
1181
- // growing thinkingText. So publish the thinking boolean only on the
1182
- // OFF→ON transition (or when compacting toggles the flag), not on every
1183
- // 8ms reasoning chunk. The full thinkingText stays engine-local and is
1184
- // emitted at finalize via the normal spinner/thinking teardown.
1185
- const nextThinkingActive = !compactingActive;
1186
- // Skip the push when the published thinking boolean is unchanged: neither
1187
- // the growing thinkingText nor responseLength is rendered per-token, and
1188
- // the Spinner derives its live elapsed from the (already-published)
1189
- // thinkingSegmentStartedAt anchor. Applies to both thinking and
1190
- // compacting steady state.
1191
- if (nextThinkingActive === _publishedThinkingActive) {
1192
- // no-op: boolean unchanged
1193
- } else {
1194
- const responseLengthVal = assistantText.length + thinkingText.length;
1195
- const thinkingElapsedMs = accumulatedThinkingMs + (thinkingSegmentStartedAt ? Math.max(0, Date.now() - thinkingSegmentStartedAt) : 0);
1196
- // state.thinking stays a truthy sentinel while active; consumers read it
1197
- // as a boolean. Keep the value stable (thinkingText) so a late consumer
1198
- // still sees real text, but only push on transition.
1199
- const patch = { thinking: compactingActive ? null : thinkingText };
1200
- if (state.spinner) {
1201
- patch.spinner = compactingActive
1202
- ? { ...state.spinner, responseLength: responseLengthVal, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: state.spinner.thinkingLastEndedAt || 0, mode: 'compacting' }
1203
- : { ...state.spinner, responseLength: responseLengthVal, thinking: true, thinkingStartedAt, thinkingSegmentStartedAt, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingElapsedMs, thinkingLastEndedAt: 0, mode: 'thinking' };
1204
- }
1205
- set(patch);
1206
- _publishedThinkingActive = nextThinkingActive;
1207
- }
1208
- }
1209
- };
1210
-
1211
- const scheduleStreamFlush = () => {
1212
- if (_batchTimer !== null) return; // already scheduled; do not re-arm
1213
- _batchTimer = setTimeout(flushStreamBatch, STREAM_BATCH_INTERVAL_MS);
1214
- if (_batchTimer?.unref) _batchTimer.unref(); // don't prevent process exit
1215
- };
1216
-
1217
- // __earlyNotify: show 1-line summary + completedCount immediately; defer
1218
- // rawResult/expand and resultsDone to the history flush.
1219
- const markToolCardCompletedState = (callId, message) => {
1220
- const card = cardByCallId.get(callId);
1221
- if (!card) return;
1222
- // Early completion also clears the active-summary entry.
1223
- markToolCallDone(card.callId);
1224
- const aggregate = card.aggregate;
1225
- if (aggregate && card.itemId === aggregate.itemId) {
1226
- const callRec = aggregate.calls.get(callId);
1227
- if (!callRec || callRec.resolved || callRec.completedEarly) return;
1228
- aggregate.ensureVisible?.();
1229
- const rawText = toolResultText(message?.content);
1230
- const isError = message?.isError === true || message?.toolKind === 'error' || /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText));
1231
- const text = isError ? toolErrorDisplay(rawText, callRec.name || 'tool') : rawText;
1232
- callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
1233
- assignAggregateSummaryOrder(aggregate, callRec);
1234
- callRec.isError = isError;
1235
- callRec.resultText = text;
1236
- callRec.completedEarly = true;
1237
- const allCalls = [...aggregate.calls.values()];
1238
- const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
1239
- const errors = allCalls.filter((r) => r.isError).length;
1240
- const succeeded = completedCount - errors;
1241
- const rawResult = aggregateRawResult(allCalls);
1242
- // Collapsed detail carries the merged per-call count summary even on
1243
- // the early-notify path; patching '' here flipped the detail row back
1244
- // to the 'Running' placeholder between count updates (the visible
1245
- // jitter). Failures keep 'N Failed'. Raw preserved for ctrl+o expansion.
1246
- const displayDetail = errors > 0
1247
- ? (succeeded > 0 ? `${succeeded} Ok · ${errors} Failed` : `${errors} Failed`)
1248
- : formatAggregateDetail(aggregateSummaries(aggregate));
1249
- const currentItem = state.items.find((it) => it.id === card.itemId);
1250
- const visualCompleted = Math.max(
1251
- completedCount,
1252
- Math.min(allCalls.length, Number(currentItem?.completedCount || 0)),
1253
- );
1254
- const patch = {
1255
- result: displayDetail,
1256
- text: displayDetail,
1257
- isError: errors > 0,
1258
- errorCount: errors,
1259
- count: allCalls.length,
1260
- completedCount: visualCompleted,
1261
- };
1262
- if (visualCompleted >= allCalls.length) {
1263
- patch.completedAt = Number(currentItem?.completedAt) || Date.now();
1264
- }
1265
- patchItem(card.itemId, patch);
1266
- return;
1267
- }
1268
- // Non-aggregate eager tools are rare; flipping completedCount without
1269
- // result changes pending/detail rendering and risks row jitter — wait for
1270
- // the real history flush (unchanged behavior).
1271
- };
1272
-
1273
- const deliverToolResultMessage = (message) => {
1274
- if (message?.__earlyNotify === true) {
1275
- const earlyCallId = toolResultCallId(message);
1276
- if (earlyCallId) markToolCardCompletedState(earlyCallId, message);
1277
- return;
1278
- }
1279
- flushToolResults([message], toolCards, cardByCallId, toolGroups, resultsDone);
1280
- };
1281
-
1282
- try {
1283
- const { result, session } = await runtime.ask(userText, {
1284
- drainSteering: () => drainPendingSteering(),
1285
- onSteerMessage: (text) => {
1286
- // Steering can be injected after a terminal no-tool response has
1287
- // already streamed but before runTurn finalizes. Seal the current
1288
- // assistant segment first so the steered user turn and the next
1289
- // assistant response do not get visually merged into one bubble.
1290
- flushStreamBatch();
1291
- if (currentAssistantId) {
1292
- patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
1293
- closeAssistantSegment();
1294
- }
1295
- assistantText = '';
1296
- const value = String(text || '').trim();
1297
- if (value) {
1298
- // Any non-tool transcript item is a block boundary: seal the
1299
- // aggregate continuation (not just finalize headers) so a later
1300
- // same-category tool call opens a fresh card instead of reusing
1301
- // one whose count would then change ABOVE this steered user item.
1302
- clearAggregateContinuation();
1303
- pushUserOrSyntheticItem(value, undefined, 'injected');
1304
- }
1305
- },
1306
- onToolCall: async (_iter, calls) => {
1307
- markPromptCommitted();
1308
- // Always flush any buffered mid-turn assistant text before the tool
1309
- // card appears. Without this, when neither a thinking panel nor a
1310
- // spinner is active the buffered text was dropped by the following
1311
- // closeAssistantSegment(), so the message above the tool card vanished.
1312
- flushStreamBatch();
1313
- if (thinkingText && state.thinking) {
1314
- const thinkingLastEndedAt = closeThinkingSegment();
1315
- set({ thinking: null, spinner: state.spinner ? { ...state.spinner, thinking: false, thinkingAccumulatedMs: accumulatedThinkingMs, thinkingLastEndedAt, mode: 'tool-use' } : state.spinner });
1316
- _publishedThinkingActive = false;
1317
- } else if (state.spinner) {
1318
- set({ spinner: { ...state.spinner, mode: 'tool-use' } });
1319
- }
1320
- const batchCalls = (calls || []).filter(Boolean);
1321
- if (batchCalls.length === 0) return;
1322
- const committedAssistantSegment = commitAssistantSegment({ sealToolBlock: true });
1323
- if (committedAssistantSegment) {
1324
- // Let the pre-tool assistant preamble paint as its own frame before
1325
- // the tool card reserves/pushes rows. When both enter the transcript
1326
- // in the same render, the bottom-pinned viewport can appear to jump
1327
- // upward by the combined height ("preamble + tool card" at once).
1328
- await yieldToRenderer();
1329
- }
1330
-
1331
- const touchedAggregates = new Set();
1332
- // [jitter fix] Last standalone (Agent) card in this batch to reserve a
1333
- // row for. Flushed AFTER the syncAggregateHeader loop so any earlier-seq
1334
- // aggregate it would flush-through already has its pendingSpec built.
1335
- let standaloneReserve = null;
1336
- for (let i = 0; i < batchCalls.length; i++) {
1337
- const c = batchCalls[i];
1338
- const name = toolCallName(c);
1339
- const args = toolCallArgs(c);
1340
- // Category drives the aggregate bucket so only same-category calls
1341
- // merge into one card; classify first, then bucket by it.
1342
- const category = classifyToolCategory(name, args);
1343
- // Agent/bridge calls always get their own standalone card so
1344
- // ToolExecution's isAgentTool/agentActionTitle path renders a
1345
- // "Spawn <Agent> (model, tag)" header instead of being folded
1346
- // into the "Called N agent(s)" category-aggregate card.
1347
- const bucket = category === 'Agent' ? null : aggregateBucketForCategory(category);
1348
- const callId = toolCallId(c);
1349
- const callKey = callId || `__tool_${toolCards.length}_${i}`;
1350
- // The old App scan counted multi-pattern calls via
1351
- // aggregateToolCategoryEntry(...).count, not a flat 1. Derive the same
1352
- // count here so the incremental Explore/Search summary matches.
1353
- const activeCount = Number(aggregateToolCategoryEntry(name, args, category)?.count || 1);
1354
- // Track Explore/Search calls as active for the incremental prompt-
1355
- // line summary; cleared when their result lands or the turn ends.
1356
- markToolCallActive(callKey, category, activeCount, Date.now());
1357
-
1358
- if (!bucket) {
1359
- const itemId = nextId();
1360
- // Defer the visible push: hold the spec and only enter the
1361
- // transcript when the real header/detail will paint (delay
1362
- // elapsed) or its result lands first. Avoids reserving blank
1363
- // placeholder height that scrolls the body ahead of the glyphs.
1364
- const card = {
1365
- itemId,
1366
- callId: callKey,
1367
- done: false,
1368
- pushed: false,
1369
- spec: {
1370
- kind: 'tool',
1371
- id: itemId,
1372
- name,
1373
- args,
1374
- result: null,
1375
- isError: false,
1376
- expanded: false,
1377
- headerFinalized: false,
1378
- count: 1,
1379
- completedCount: 0,
1380
- startedAt: Date.now(),
1381
- },
1382
- };
1383
- registerDeferredCard(card);
1384
- if (callId) {
1385
- cardByCallId.set(callId, card);
1386
- }
1387
- toolCards.push(card);
1388
- // [jitter fix] Immediate row-reserve is deferred to after the
1389
- // syncAggregateHeader loop below (see standaloneReserve): calling
1390
- // ensureVisible() here would flushDeferredUpTo() every earlier-seq
1391
- // entry — including an aggregate whose pendingSpec syncAggregateHeader
1392
- // hasn't built yet — marking it pushed without inserting (lost/
1393
- // out-of-order card). Record it and flush once headers exist.
1394
- standaloneReserve = card;
1395
- // A standalone card (Agent) breaks the consecutive run too: a
1396
- // later same-bucket call must open a fresh card BELOW it, not
1397
- // merge into an aggregate above it.
1398
- tailAggregate = null;
1399
- continue;
1400
- }
1401
-
1402
- const categoryEntry = aggregateToolCategoryEntry(name, args, category);
1403
- const aggregateCard = ensureAggregateCard(bucket);
1404
- if (!aggregateCard.categories.has(categoryEntry.key)) aggregateCard.categoryOrder.push(categoryEntry.key);
1405
- const prevCategory = aggregateCard.categories.get(categoryEntry.key);
1406
- aggregateCard.categories.set(categoryEntry.key, {
1407
- ...categoryEntry,
1408
- count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
1409
- });
1410
- aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, resultText: null, resolved: false, completedEarly: false });
1411
- touchedAggregates.add(aggregateCard);
1412
- const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
1413
- if (callId) {
1414
- cardByCallId.set(callId, card);
1415
- }
1416
- toolCards.push(card);
1417
- }
1418
-
1419
- for (const aggregateCard of touchedAggregates) {
1420
- syncAggregateHeader(aggregateCard);
1421
- }
1422
- // [jitter fix] Now that every touched aggregate has its pendingSpec,
1423
- // reserve the standalone (Agent) card's row immediately. Its
1424
- // ensureVisible() flushes earlier-seq entries too, but those aggregates
1425
- // are now push-ready so none is marked pushed without inserting.
1426
- standaloneReserve?.ensureVisible?.();
1427
- if (committedAssistantSegment) {
1428
- // A pre-tool assistant preamble has already had one render frame to
1429
- // settle. Do not let the first grouped tool card sit off-screen until
1430
- // the normal 1s deferred timer: when it later inserts its real 3 rows,
1431
- // the already-wrapped preamble visibly jumps. Surface the first card
1432
- // now via the existing deferredDisplayReady path, so the post-
1433
- // preamble frame contains the intended Running tool card immediately
1434
- // (no blank placeholder, no delayed row insertion).
1435
- const firstTouchedAggregate = [...touchedAggregates][0] || null;
1436
- firstTouchedAggregate?.ensureVisible?.();
1437
- }
1438
- for (const [bufferedCallId, bufferedMessage] of earlyResultBuffer) {
1439
- if (!cardByCallId.has(bufferedCallId)) continue;
1440
- deliverToolResultMessage(bufferedMessage);
1441
- earlyResultBuffer.delete(bufferedCallId);
1442
- }
1443
- await yieldToRenderer();
1444
- },
1445
- onToolResult: (message) => {
1446
- const callId = toolResultCallId(message);
1447
- if (callId && !cardByCallId.has(callId) && !resultsDone.has(callId)) {
1448
- earlyResultBuffer.set(callId, message);
1449
- return;
1450
- }
1451
- deliverToolResultMessage(message);
1452
- },
1453
- onToolApproval: async (request) => {
1454
- markPromptCommitted();
1455
- flushStreamBatch();
1456
- if (state.spinner) set({ spinner: { ...state.spinner, mode: 'tool-approval' } });
1457
- return await requestToolApproval(request);
1458
- },
1459
- onCompactEvent: (event) => {
1460
- flushStreamBatch();
1461
- // Non-tool transcript item — same block-boundary rule as the
1462
- // steered user item above: seal any live aggregate first so a
1463
- // later same-category tool call doesn't reuse a card whose count
1464
- // would then change above this statusdone item.
1465
- clearAggregateContinuation();
1466
- pushItem({
1467
- kind: 'statusdone',
1468
- id: nextId(),
1469
- label: compactEventLabel(event),
1470
- detail: compactEventDetail(event),
1471
- });
1472
- },
1473
- onStageChange: async (stage) => {
1474
- if (!state.spinner) return;
1475
- const value = String(stage || '');
1476
- if (value === 'compacting') {
1477
- compactingActive = true;
1478
- const thinkingLastEndedAt = closeThinkingSegment();
1479
- _pendingThinkFlush = false;
1480
- _publishedThinkingActive = false; // compacting cleared the thinking flag
1481
- set({
1482
- thinking: null,
1483
- spinner: {
1484
- ...state.spinner,
1485
- thinking: false,
1486
- thinkingSegmentStartedAt: 0,
1487
- thinkingAccumulatedMs: accumulatedThinkingMs,
1488
- thinkingLastEndedAt: thinkingLastEndedAt || state.spinner.thinkingLastEndedAt || 0,
1489
- mode: 'compacting',
1490
- },
1491
- });
1492
- await yieldToRenderer();
1493
- return;
1494
- }
1495
- if (value === 'requesting' || value === 'streaming') compactingActive = false;
1496
- const mode = value === 'requesting'
1497
- ? 'requesting'
1498
- : value === 'streaming'
1499
- ? (state.spinner.thinking ? 'thinking' : 'responding')
1500
- : null;
1501
- if (!mode || state.spinner.mode === mode) return;
1502
- set({ spinner: { ...state.spinner, mode } });
1503
- },
1504
- onTextDelta: (chunk) => {
1505
- const textChunk = String(chunk ?? '');
1506
- if (!textChunk) return;
1507
- markPromptCommitted();
1508
- const thinkingLastEndedAt = closeThinkingSegment();
1509
- // Drop any queued think-flush too: it would otherwise re-publish
1510
- // spinner.thinking:true from flushStreamBatch and resurrect the
1511
- // indicator after we cleared it here.
1512
- _pendingThinkFlush = false;
1513
- if (state.thinking) { set({ thinking: null }); _publishedThinkingActive = false; } // collapse thinking panel immediately, no batch delay
1514
- assistantText += textChunk;
1515
- currentAssistantText += textChunk;
1516
- // Accumulate text and schedule a batched flush (≤1 render per
1517
- // STREAM_BATCH_INTERVAL_MS). Without scheduling, mid-turn text only
1518
- // surfaced via the tool-call/finalize flush, so a text→tool segment
1519
- // with no spinner/thinking dropped the message above the tool card.
1520
- _pendingTextFlush = true;
1521
- if (thinkingLastEndedAt) _pendingThinkingLastEndedAt = thinkingLastEndedAt;
1522
- scheduleStreamFlush();
1523
- },
1524
- onAssistantText: (text) => {
1525
- // Mid-turn assistant text that precedes a tool call. Providers that
1526
- // stream via onTextDelta already accumulated it into assistantText;
1527
- // providers that only return the final response.content (no deltas)
1528
- // never fired onTextDelta, so without this the preamble shows nothing
1529
- // before the tool card. De-dup against already-streamed text so the
1530
- // streaming path is unaffected.
1531
- const full = String(text ?? '');
1532
- if (!full.trim()) return;
1533
- // If the streaming path already produced text for THIS segment,
1534
- // onTextDelta owns the render — content is the same accumulated text
1535
- // (or a superset), so skip to avoid double-printing the preamble.
1536
- // Do not check turn-global assistantText: earlier closed preambles stay
1537
- // there across tool calls, and would suppress later non-streaming
1538
- // preambles even though currentAssistantText has been reset.
1539
- if (currentAssistantText.trim()) return;
1540
- markPromptCommitted();
1541
- closeThinkingSegment();
1542
- _pendingThinkFlush = false; // see onTextDelta: prevent a stale think flush resurrecting the indicator
1543
- if (state.thinking) { set({ thinking: null }); _publishedThinkingActive = false; }
1544
- assistantText += full;
1545
- currentAssistantText += full;
1546
- _pendingTextFlush = true;
1547
- flushStreamBatch();
1548
- },
1549
- onReasoningDelta: (chunk) => {
1550
- if (String(chunk ?? '')) markPromptCommitted();
1551
- startThinkingSegment();
1552
- thinkingText += String(chunk ?? '');
1553
- // Accumulate reasoning text; fire at most one render per STREAM_BATCH_INTERVAL_MS.
1554
- _pendingThinkFlush = true;
1555
- scheduleStreamFlush();
1556
- },
1557
- onUsageDelta: (delta) => {
1558
- applyUsageDelta(state.stats, delta);
1559
- syncContextStats({ allowEstimated: true });
1560
- const currentTurnInput = Math.max(0, state.stats.inputTokens - inputBaseline);
1561
- const currentTurnOutput = Math.max(0, state.stats.outputTokens - outputBaseline);
1562
- if (state.spinner) {
1563
- set({ stats: { ...state.stats }, spinner: { ...state.spinner, inputTokens: currentTurnInput, outputTokens: currentTurnOutput } });
1564
- } else {
1565
- set({ stats: { ...state.stats } });
1566
- }
1567
- },
1568
- });
1569
- askResult = result;
1570
- markPromptCommitted();
1571
-
1572
- flushToolResults(session?.messages || [], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true });
1573
- finalizeToolHeaders();
1574
- flushStreamBatch(); // force-flush any batched streaming text before finalization writes
1575
- syncContextStats({ allowEstimated: true });
1576
-
1577
- const finalText = result?.content != null ? String(result.content) : '';
1578
- if (finalText.trim()) {
1579
- // The persisted transcript is written from the provider's final content,
1580
- // while the live TUI row is fed by streaming deltas. If a provider/parser
1581
- // misses or suppresses an early delta, keeping the streamed buffer here
1582
- // leaves the final on-screen assistant row missing leading characters even
1583
- // though the transcript is correct. Always reconcile the active segment to
1584
- // the final provider text when it is available.
1585
- const id = currentAssistantId || ensureAssistant(finalText);
1586
- currentAssistantText = finalText;
1587
- patchItem(id, { text: finalText, streaming: false });
1588
- } else if (currentAssistantId && (currentAssistantText.trim() || assistantText.trim())) {
1589
- const streamedText = currentAssistantText || assistantText;
1590
- patchItem(currentAssistantId, { text: streamedText, streaming: false });
1591
- }
1592
- turnFinishedNormally = true;
1593
- } catch (error) {
1594
- flushStreamBatch(); // ensure any batched text lands before the error notice
1595
- if (error?.name === 'SessionClosedError') {
1596
- cancelled = true;
1597
- if (assistantText.trim() && currentAssistantId) {
1598
- patchItem(currentAssistantId, { text: currentAssistantText || assistantText, streaming: false });
1599
- }
1600
- // Finalize pending tool cards so they don't stay "Running..." forever
1601
- // after cancellation. Without this, the spinner vanishes and TurnDone
1602
- // shows "cancelled", but in-flight tool cards remain in a perpetual
1603
- // pending/blinking state because the normal finalize path (line 992)
1604
- // was skipped when the error interrupted the try block.
1605
- flushToolResults([], toolCards, cardByCallId, toolGroups, resultsDone, { finalize: true, cancelled: true });
1606
- finalizeToolHeaders();
1607
- } else {
1608
- finalizeToolHeaders();
1609
- pushNotice(toolErrorDisplay(error, 'turn'), 'error');
1610
- }
1611
- } finally {
1612
- denyAllToolApprovals(cancelled ? 'turn cancelled' : 'turn finished');
1613
- // Turn is unwinding normally (or via abort) — cancel the wall-clock
1614
- // watchdog + its force-release grace so they never fire on a live turn.
1615
- clearWatchdog();
1616
- // If the watchdog force-release already fired, a NEWER turn now owns the
1617
- // shared store (busy, activePromptRestore, turndone, drain). This stale
1618
- // unwind must NOT write shared state or it corrupts that turn. It still
1619
- // runs its own turn-local teardown (approvals, deferred cards) below.
1620
- const isStaleUnwind = leadTurnEpoch !== turnEpoch;
1621
- // Flush any still-deferred tool cards into the transcript and cancel their
1622
- // pending push timers so nothing fires (or leaks) after the turn ends. The
1623
- // finalize path above already patches results onto visible cards; this just
1624
- // guarantees every registered card is materialized before the turn closes.
1625
- // Collect (don't emit) the still-deferred cards so the turn-close flush and
1626
- // the turndone item append in ONE set() below instead of one render bounce
1627
- // per row. Order/ids are preserved (creation order, then turndone last).
1628
- let closingItems = [];
1629
- if (deferredEntries.length) {
1630
- const last = deferredEntries[deferredEntries.length - 1];
1631
- closingItems = collectDeferredUpTo(last);
1632
- clearDeferredTimers();
1633
- }
1634
- flushDeferredBeforeImmediatePush = null;
1635
- const producedTranscriptItem =
1636
- state.items.length + closingItems.length > itemsAtTurnStart;
1637
- const reclaimed = cancelled && activePromptRestore?.reclaimed === true;
1638
- if (!isStaleUnwind) activePromptRestore = null;
1639
- closeThinkingSegment();
1640
- const elapsedMs = Date.now() - startedAt;
1641
- const thinkingElapsedMs = thinkingStartedAt ? accumulatedThinkingMs : 0;
1642
- // responseLength is engine-local now (not pushed per-token), so compute the
1643
- // fallback from the live accumulator instead of the possibly-stale
1644
- // state.spinner.responseLength. Final-only / non-streaming turns never
1645
- // accumulate `assistantText` (only currentAssistantText is set at the
1646
- // finalize reconcile above), so take the larger of the two text sources so
1647
- // a no-usage turn still estimates tokens from the final content.
1648
- const finalAssistantLen = Math.max(assistantText.length, currentAssistantText.length);
1649
- const finalResponseLength = finalAssistantLen + thinkingText.length;
1650
- const finalOutputTokens = Math.max(0, Number(state.spinner?.outputTokens || 0), Math.round(finalResponseLength / 4));
1651
- const turnStatus = cancelled ? 'cancelled' : 'done';
1652
- const resultContent = askResult?.content != null ? String(askResult.content).trim() : '';
1653
- const assistantOutput = (currentAssistantText || assistantText || '').trim();
1654
- // Suppress only true pending-resume no-ops: no transcript items added and no model output; cancelled/error turns and any visible turn stay marked.
1655
- const isNoOpTurn = turnFinishedNormally
1656
- && !cancelled
1657
- && toolCards.length === 0
1658
- && !resultContent
1659
- && !assistantOutput
1660
- && !producedTranscriptItem;
1661
- if (isStaleUnwind) {
1662
- // Preserve prior behavior: a stale unwind still materializes its own
1663
- // deferred cards (turn-local teardown) but writes no other shared state.
1664
- if (closingItems.length) appendItemsBatch(closingItems);
1665
- tuiDebug(`runTurn STALE UNWIND turn=${turnIndex} — force-released; skipping shared-state writes`);
1666
- } else {
1667
- if (!isNoOpTurn) {
1668
- state.stats.turns = (state.stats.turns || 0) + 1;
1669
- }
1670
- // Pin the post-think summary into the transcript right after this turn's
1671
- // output so it scrolls up with the answer and stays in the scrollback,
1672
- // in scrollback. (Previously TurnDone rendered only in the
1673
- // bottom-fixed live-status slot and vanished on the next turn.)
1674
- if (!reclaimed && !isNoOpTurn) {
1675
- closingItems.push({ kind: 'turndone', id: nextId(), elapsedMs, status: turnStatus, outputTokens: finalOutputTokens, thinkingElapsedMs, verb: pickDoneVerb(turnIndex) });
1676
- }
1677
- // Deferred cards + turndone + status all land in ONE set() (one commit).
1678
- appendItemsBatch(closingItems, {
1679
- busy: false,
1680
- spinner: null,
1681
- thinking: null,
1682
- lastTurn: null,
1683
- stats: { ...state.stats },
1684
- ...routeState(),
1685
- toolMode: runtime.toolMode,
1686
- ...agentStatusState({ force: true }),
1687
- });
1688
- flushDeferredExecutionPendingResumeKick();
1689
- }
1690
- }
1691
- // Shared UI state: a stale unwind must not wipe a newer turn's live
1692
- // tool-summary line (same epoch rule as the shared-state block above).
1693
- if (leadTurnEpoch === turnEpoch) clearActiveToolSummary();
1694
- _publishedThinkingActive = false; // turn teardown cleared state.thinking
1695
- tuiDebug(`runTurn end turn=${turnIndex} status=${cancelled ? 'cancelled' : 'done'} elapsedMs=${Date.now() - startedAt}${watchdogTripped ? ' watchdogTripped=1' : ''} pending=${pending.length}`);
1696
- return cancelled ? 'cancelled' : 'done';
1697
- }
1698
-
1699
- const pending = [];
1700
- let draining = false;
1701
- let activePromptRestore = null;
1702
- // Monotonic per-lead-turn epoch (see runTurn). Bumped on watchdog
1703
- // force-release so a stale turn's late `finally` can detect it no longer owns
1704
- // the shared store and skip all shared-state writes.
1705
- let leadTurnEpoch = 0;
1706
-
1707
- const leadSessionId = () => runtime.id;
1708
-
1709
- function shouldMirrorSteeringEntry(entry) {
1710
- return isQueuedEntryEditable(entry) && !isSlashQueuedEntry(entry);
1711
- }
1712
-
1713
- function commitSteeringQueueEntries(entries) {
1714
- callCommitCallbacks(entries);
1715
- const mirrored = (Array.isArray(entries) ? entries : []).filter(
1716
- (entry) => shouldMirrorSteeringEntry(entry) && !entry.steeringPersistRestored,
1717
- );
1718
- if (mirrored.length > 0) dropTuiSteeringPersist(leadSessionId(), mirrored);
1719
- }
1720
-
1721
- function makeQueueEntry(text, options = {}) {
1722
- const mode = options.mode || 'prompt';
1723
- const priority = options.priority || defaultQueuePriority(mode);
1724
- const displayText = promptDisplayText(text, options);
1725
- return {
1726
- id: options.id || nextId(),
1727
- text: displayText,
1728
- content: text,
1729
- pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
1730
- pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
1731
- onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
1732
- mode,
1733
- priority,
1734
- key: options.key || null,
1735
- skipSlashCommands: options.skipSlashCommands === true,
1736
- displayText: mode === 'task-notification' ? notificationDisplayText(displayText) : String(displayText || ''),
1737
- steeringPersistId: options.steeringPersistId || null,
1738
- steeringPersistRestored: options.steeringPersistRestored === true,
1739
- };
1740
- }
1741
-
1742
- function removeQueuedEntries(entries) {
1743
- const ids = new Set(entries.map((entry) => entry.id));
1744
- const keys = entries.map((entry) => entry.key).filter(Boolean);
1745
- for (const key of keys) pendingNotificationKeys.delete(key);
1746
- for (const key of keys) displayedExecutionNotificationKeys.delete(key);
1747
- const queued = state.queued.filter((q) => !ids.has(q.id));
1748
- if (queued.length !== state.queued.length) set({ queued });
1749
- }
1750
-
1751
- function requeueEntriesFront(entries) {
1752
- const restored = [];
1753
- for (const entry of entries || []) {
1754
- if (!entry || !String(entry.text || '').trim()) continue;
1755
- const next = {
1756
- ...entry,
1757
- displayText: entry.displayText || (entry.mode === 'task-notification' ? notificationDisplayText(entry.text) : String(entry.text || '')),
1758
- };
1759
- if (next.mode === 'task-notification' && next.key) {
1760
- if (pendingNotificationKeys.has(next.key)) continue;
1761
- pendingNotificationKeys.add(next.key);
1762
- }
1763
- restored.push(next);
1764
- }
1765
- if (restored.length === 0) return false;
1766
- pending.unshift(...restored);
1767
- const visible = restored.filter(isQueuedEntryVisible);
1768
- if (visible.length > 0) set({ queued: [...visible, ...state.queued] });
1769
- return true;
1770
- }
1771
-
1772
- function dequeueQueueBatch(maxPriority = 'later', options = {}) {
1773
- if (pending.length === 0) return [];
1774
- const max = queuePriorityValue(maxPriority);
1775
- const predicate = typeof options.predicate === 'function' ? options.predicate : () => true;
1776
- const limit = Math.max(1, Number(options.limit) || Infinity);
1777
- let bestPriority = Infinity;
1778
- let targetMode = null;
1779
- for (const entry of pending) {
1780
- if (!predicate(entry)) continue;
1781
- const p = queuePriorityValue(entry.priority);
1782
- if (p > max) continue;
1783
- if (p < bestPriority) {
1784
- bestPriority = p;
1785
- targetMode = entry.mode || 'prompt';
1786
- }
1787
- }
1788
- if (!targetMode) return [];
1789
- const batch = [];
1790
- for (let i = 0; i < pending.length;) {
1791
- const entry = pending[i];
1792
- if (predicate(entry) && (entry.mode || 'prompt') === targetMode && queuePriorityValue(entry.priority) === bestPriority) {
1793
- batch.push(entry);
1794
- pending.splice(i, 1);
1795
- if (batch.length >= limit) break;
1796
- } else {
1797
- i += 1;
1798
- }
1799
- }
1800
- removeQueuedEntries(batch);
1801
- return batch;
1802
- }
1803
-
1804
- async function drain() {
1805
- if (draining) return;
1806
- if (autoClearRunning) return;
1807
- draining = true;
1808
- let firstBatch = true;
1809
- try {
1810
- while (pending.length > 0) {
1811
- // Drain one priority/mode bucket at a time (unified command queue):
1812
- // unified command queue semantics: prompt steering stays editable and
1813
- // task notifications stay non-editable but model-visible.
1814
- const batch = dequeueQueueBatch('later', { limit: firstBatch ? 1 : Infinity });
1815
- firstBatch = false;
1816
- if (batch.length === 0) break;
1817
- tuiDebug(`busy-queue drain batch=${batch.length} remaining=${pending.length}`);
1818
- const ids = new Set(batch.map((e) => e.id));
1819
- const merged = mergePromptContents(batch);
1820
- for (const entry of batch) {
1821
- if (entry.mode === 'pending-resume') continue;
1822
- pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? 'user' : 'injected');
1823
- }
1824
- const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
1825
- const batchPastedImages = mergePastedImages(batch);
1826
- const batchPastedTexts = mergePastedTexts(batch);
1827
- const turnStatus = await runTurn(merged, {
1828
- displayText: batch.map((entry) => entry.text).filter((text) => String(text || '').trim()).join('\n'),
1829
- pastedImages: batchPastedImages,
1830
- pastedTexts: batchPastedTexts,
1831
- onCommitted: () => commitSteeringQueueEntries(batch),
1832
- submittedIds: [...ids],
1833
- restorable: nonEditable.length === 0,
1834
- requeueOnAbort: nonEditable,
1835
- });
1836
- // session_manage tool: the model scheduled a reset during this turn.
1837
- // Run it now, at the turn boundary — same clear body as auto-clear.
1838
- // Cancelled/interrupted turns drop the reset (consume + discard): the
1839
- // user aborted the turn that asked for it, so the destructive clear
1840
- // must not fire. commandBusy guards against a concurrent session
1841
- // command (resume/new) racing the async clear.
1842
- const scheduledReset = runtime.consumePendingSessionReset?.();
1843
- if ((scheduledReset === 'clear' || scheduledReset === 'compact_clear')
1844
- && turnStatus !== 'cancelled'
1845
- && !state.commandBusy) {
1846
- await performSessionClear({
1847
- verb: scheduledReset === 'clear' ? 'Clearing conversation' : 'Compacting and clearing conversation',
1848
- doneLabel: scheduledReset === 'clear' ? 'Session cleared' : 'Session compacted and cleared',
1849
- skipLabel: 'Session reset skipped',
1850
- surface: 'session-manage',
1851
- useCompaction: scheduledReset === 'compact_clear',
1852
- });
1853
- }
1854
- // If the user re-submits the reclaimed prompt while the cancelled turn
1855
- // is still unwinding, enqueue() cannot start another drain because this
1856
- // drain loop is still active. Continue when pending work appeared during
1857
- // cancellation so the fresh submit does not get stuck in queued state.
1858
- if (turnStatus === 'cancelled' && pending.length === 0) break;
1859
- }
1860
- } finally {
1861
- draining = false;
1862
- if (pending.length > 0) void drain();
1863
- else flushDeferredExecutionPendingResumeKick();
1864
- }
1865
- }
1866
- function enqueue(text, options = {}) {
1867
- const entry = makeQueueEntry(text, options);
1868
- if (entry.mode === 'task-notification' && entry.key) {
1869
- if (pendingNotificationKeys.has(entry.key)) return false;
1870
- pendingNotificationKeys.add(entry.key);
1871
- }
1872
- pending.push(entry);
1873
- if (state.busy && shouldMirrorSteeringEntry(entry)) {
1874
- appendTuiSteeringPersist(leadSessionId(), entry);
1875
- }
1876
- if (isQueuedEntryVisible(entry)) set({ queued: [...state.queued, entry] });
1877
- if (state.busy) tuiDebug(`busy-queue enqueue mode=${entry.mode} pending=${pending.length}`);
1878
- void drain();
1879
- return true;
1880
- }
1881
-
1882
- function drainPendingSteering() {
1883
- // Mid-turn steering drain:
1884
- // Injects queued user prompts (steering) plus non-editable internal entries
1885
- // into the CURRENT provider pre-send window so the user can redirect a turn
1886
- // that is already running. Slash commands are still excluded: they must run
1887
- // through the normal command processor after the turn, not be sent as plain
1888
- // text. Consumed entries are spliced out of `pending` here, so the post-turn
1889
- // drain() loop will not re-execute them.
1890
- const batch = dequeueQueueBatch('next', { predicate: (entry) => !isSlashQueuedEntry(entry) });
1891
- if (batch.length === 0) return [];
1892
- const out = batch
1893
- .map((entry) => {
1894
- const content = entry.content;
1895
- if (typeof content === 'string') return content.trim();
1896
- return { text: String(entry.text || '').trim(), content };
1897
- })
1898
- .filter((entry) => {
1899
- if (typeof entry === 'string') return entry.length > 0;
1900
- if (Array.isArray(entry?.content)) return entry.content.length > 0;
1901
- return String(entry?.content ?? '').trim().length > 0;
1902
- });
1903
- commitSteeringQueueEntries(batch);
1904
- return out;
1905
- }
1906
-
1907
- async function restoreLeadSteeringFromDisk() {
1908
- const rows = await drainTuiSteeringPersist(leadSessionId());
1909
- if (!rows.length) return;
1910
- const restored = [];
1911
- for (const row of rows) {
1912
- const entry = makeQueueEntry(row.text, {
1913
- steeringPersistRestored: true,
1914
- steeringPersistId: row.steeringPersistId || undefined,
1915
- });
1916
- pending.push(entry);
1917
- if (isQueuedEntryVisible(entry)) restored.push(entry);
1918
- }
1919
- if (restored.length > 0) set({ queued: [...state.queued, ...restored] });
1920
- void drain();
1921
- }
1922
-
1923
- async function autoClearBeforeSubmit() {
1924
- const cfg = autoClearState();
1925
- const now = Date.now();
1926
- const activityAt = sessionActivityTimestamp(runtime.session, lastUserActivityAt);
1927
- const idleMs = activityAt ? now - activityAt : 0;
1928
- if (!cfg.enabled || state.busy || pending.length > 0 || autoClearRunning || idleMs < cfg.idleMs) {
1929
- if (!activityAt) lastUserActivityAt = now;
1930
- return false;
1931
- }
1932
- const minContextPercent = Number(cfg.minContextPercent ?? 10);
1933
- if (minContextPercent > 0) {
1934
- const status = runtime.contextStatus?.() || null;
1935
- const estimatedTokens = Math.max(0, Number(status?.currentEstimatedTokens ?? status?.usedTokens ?? 0));
1936
- const usedTokens = Math.max(0, Number(status?.usedTokens ?? estimatedTokens ?? 0));
1937
- const triggerTokens = Number(
1938
- status?.compaction?.triggerTokens
1939
- || status?.compaction?.autoCompactTokenLimit
1940
- || runtime.session?.autoCompactTokenLimit
1941
- || 0,
1942
- );
1943
- if (!(usedTokens > 0 && triggerTokens > 0)) {
1944
- if (!activityAt) lastUserActivityAt = now;
1945
- return false;
1946
- }
1947
- const usagePct = (usedTokens / triggerTokens) * 100;
1948
- if (usagePct < minContextPercent) {
1949
- if (!activityAt) lastUserActivityAt = now;
1950
- return false;
1951
- }
1952
- }
1953
- return performSessionClear({
1954
- verb: 'Auto-clearing idle conversation',
1955
- doneLabel: 'Auto-clear complete',
1956
- skipLabel: 'Auto-clear skipped',
1957
- surface: 'auto-clear',
1958
- useCompaction: true,
1959
- });
1960
- }
1961
-
1962
- // Shared clear body for idle auto-clear and the session_manage tool.
1963
- // useCompaction=true mirrors auto-clear (summarize via configured
1964
- // compactType, context carries forward); false is a plain /clear wipe.
1965
- async function performSessionClear({ verb, doneLabel, skipLabel, surface, useCompaction }) {
1966
- autoClearRunning = true;
1967
- const startedAt = Date.now();
1968
- // commandBusy blocks concurrent session commands (resume/newSession/
1969
- // setModel) AND new submits for the duration of the async clear — the
1970
- // clear swaps the live session object, so racing commands could act on
1971
- // the wrong session.
1972
- set({ commandBusy: true, commandStatus: { active: true, verb, startedAt, mode: 'auto-clear' } });
1973
- try {
1974
- // Give Ink one event-loop turn to paint the auto-clear status before the
1975
- // clear/compact path starts doing synchronous session/transcript work.
1976
- // Without this, long idle clears can look like a frozen prompt followed by
1977
- // an already-complete status row.
1978
- await new Promise((resolve) => setTimeout(resolve, 0));
1979
- let compactType = null;
1980
- if (useCompaction) {
1981
- const compaction = runtime.getCompactionSettings();
1982
- compactType = compaction.compactType || compaction.type || null;
1983
- }
1984
- await runtime.clear(compactType
1985
- ? { compactType, requireCompactSuccess: true }
1986
- : {});
1987
- resetStats();
1988
- clearUiActivityBeforeContextSync();
1989
- syncContextStats({ allowEstimated: true });
1990
- set({
1991
- items: replaceItems([]),
1992
- toasts: [],
1993
- queued: [],
1994
- thinking: null,
1995
- spinner: null,
1996
- lastTurn: null,
1997
- ...routeState(),
1998
- stats: { ...state.stats },
1999
- });
2000
- pushItem({
2001
- kind: 'statusdone',
2002
- id: nextId(),
2003
- label: doneLabel,
2004
- });
2005
- return true;
2006
- } catch (error) {
2007
- const message = presentErrorText(error, { surface });
2008
- pushItem({
2009
- kind: 'statusdone',
2010
- id: nextId(),
2011
- label: skipLabel,
2012
- detail: `conversation kept · ${message}`,
2013
- });
2014
- pushNotice(`${surface} skipped: ${message}`, 'error');
2015
- return false;
2016
- } finally {
2017
- lastUserActivityAt = Date.now();
2018
- autoClearRunning = false;
2019
- set({ commandBusy: false, commandStatus: null });
2020
- void drain();
2021
- }
2022
- }
2023
-
2024
- function restoreQueued(currentText = '') {
2025
- const queued = [];
2026
- for (let i = 0; i < pending.length;) {
2027
- const entry = pending[i];
2028
- if (isQueuedEntryEditable(entry)) {
2029
- queued.push(entry);
2030
- pending.splice(i, 1);
2031
- } else {
2032
- i += 1;
2033
- }
2034
- }
2035
- removeQueuedEntries(queued);
2036
- const queuedText = queued.map((item) => item.text).filter((text) => String(text || '').trim()).join('\n');
2037
- const combinedText = [queuedText, String(currentText || '')].filter((text) => text.trim()).join('\n');
2038
- return { count: queued.length, text: combinedText, pastedImages: mergePastedImages(queued), pastedTexts: mergePastedTexts(queued) };
2039
- }
2040
-
2041
- const resetStats = () => {
2042
- state.stats = createSessionStats();
2043
- return state.stats;
2044
- };
2045
- const clearUiActivityBeforeContextSync = () => {
2046
- state.items = replaceItems([]);
2047
- state.toasts = [];
2048
- state.queued = [];
2049
- state.thinking = null;
2050
- state.spinner = null;
2051
- state.lastTurn = null;
2052
- state.busy = false;
2053
- pendingNotificationKeys.clear();
2054
- displayedExecutionNotificationKeys.clear();
2055
- };
2056
- const resetTuiForPendingSessionReset = () => {
2057
- pendingSessionReset = true;
2058
- clearUiActivityBeforeContextSync();
2059
- resetStats();
2060
- state.stats.currentContextTokens = 0;
2061
- state.stats.currentEstimatedContextTokens = 0;
2062
- state.stats.currentContextSource = null;
2063
- state.stats.currentContextUpdatedAt = Date.now();
2064
- state.displayContextWindow = 0;
2065
- state.compactBoundaryTokens = 0;
2066
- state.autoCompactTokenLimit = 0;
2067
- };
2068
- const snapshotTuiBeforeSessionReset = () => ({
2069
- items: state.items.slice(),
2070
- toasts: state.toasts.slice(),
2071
- queued: state.queued.slice(),
2072
- thinking: state.thinking,
2073
- spinner: state.spinner,
2074
- lastTurn: state.lastTurn,
2075
- busy: state.busy,
2076
- stats: { ...state.stats },
2077
- sessionId: state.sessionId,
534
+ Object.assign(bag, {
535
+ runtime, nextId, tuiDebug, LEAD_TURN_TIMEOUT_MS,
536
+ flags, lifecycle, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, listeners, itemIndexById,
537
+ getState: () => state, set,
538
+ pushItem, patchItem, replaceItems, pushToast, pushNotice, removeNotice, setProgressHint,
539
+ pushUserOrSyntheticItem, upsertSyntheticToolItem,
540
+ markToolCallActive, markToolCallDone, clearActiveToolSummary, clearToastTimers,
541
+ autoClearState, agentStatusState, baseRouteState, routeState, syncContextStats,
542
+ presentNextToolApproval, finishToolApproval, denyAllToolApprovals, requestToolApproval,
543
+ patchToolCardResult, flushToolResults,
544
+ kickExecutionPendingResume, flushDeferredExecutionPendingResumeKick, scheduleExecutionPendingResumeKick, updateAgentJobCard, subscribeRuntimeNotifications,
2078
545
  });
2079
- const restoreTuiAfterFailedSessionReset = (snapshot) => {
2080
- if (!snapshot) return;
2081
- pendingSessionReset = false;
2082
- state.items = replaceItems(snapshot.items);
2083
- state.toasts = snapshot.toasts.slice();
2084
- state.queued = snapshot.queued.slice();
2085
- state.thinking = snapshot.thinking;
2086
- state.spinner = snapshot.spinner;
2087
- state.lastTurn = snapshot.lastTurn;
2088
- state.busy = snapshot.busy;
2089
- state.stats = { ...snapshot.stats };
2090
- syncContextStats({ allowEstimated: true });
2091
- set({
2092
- items: state.items,
2093
- toasts: state.toasts,
2094
- queued: state.queued,
2095
- thinking: state.thinking,
2096
- spinner: state.spinner,
2097
- lastTurn: state.lastTurn,
2098
- busy: state.busy,
2099
- ...routeState(),
2100
- stats: { ...state.stats },
2101
- ...agentStatusState(),
2102
- });
2103
- };
2104
- const resetStatsAndSyncContext = () => {
2105
- resetStats();
2106
- syncContextStats({ allowEstimated: true });
2107
- return state.stats;
2108
- };
2109
-
2110
- void restoreLeadSteeringFromDisk();
2111
-
2112
- return {
2113
- getState: () => state,
2114
- patchItem,
2115
- subscribe: (listener) => {
2116
- listeners.add(listener);
2117
- return () => listeners.delete(listener);
2118
- },
2119
- submit: (text, options = {}) => {
2120
- const t = promptDisplayText(text, options).trim();
2121
- if (!t) return false;
2122
- const mode = options.mode || 'prompt';
2123
- // Prompt input queued while a turn is active keeps the
2124
- // default `next` priority, so it is injected at the next tool/model
2125
- // boundary. Explicit options.priority still wins.
2126
- const priority = options.priority;
2127
- const queueOptions = {
2128
- ...options,
2129
- mode,
2130
- displayText: promptDisplayText(text, options),
2131
- priority,
2132
- };
2133
- // A running clear (idle auto-clear or session_manage) sets commandBusy;
2134
- // queue the prompt instead of dropping it — it drains after the clear.
2135
- if (autoClearRunning) {
2136
- enqueue(text, queueOptions);
2137
- return true;
2138
- }
2139
- if (state.commandBusy) return false;
2140
- if (state.busy) {
2141
- enqueue(text, queueOptions);
2142
- return true;
2143
- }
2144
- void autoClearBeforeSubmit().then(() => enqueue(text, queueOptions));
2145
- return true;
2146
- },
2147
- restoreQueued,
2148
- setModel: async (m) => {
2149
- if (state.commandBusy) return false;
2150
- set({ commandBusy: true });
2151
- try {
2152
- // Model changes apply to the NEXT session only (default setRoute
2153
- // behavior) — never rewrite the live session's provider/model, which
2154
- // would force a full prompt-cache rewrite mid-conversation.
2155
- await runtime.setRoute({ model: m });
2156
- set({ ...routeState(), stats: { ...state.stats } });
2157
- return true;
2158
- } finally {
2159
- set({ commandBusy: false });
2160
- }
2161
- },
2162
- setEffort: async (value) => {
2163
- if (state.commandBusy) return false;
2164
- set({ commandBusy: true });
2165
- try {
2166
- await runtime.setEffort(value);
2167
- set({ ...routeState() });
2168
- return runtime.effort || 'auto';
2169
- } finally {
2170
- set({ commandBusy: false });
2171
- }
2172
- },
2173
- setFast: async (value) => {
2174
- if (state.commandBusy) return null;
2175
- set({ commandBusy: true });
2176
- try {
2177
- const enabled = await runtime.setFast(value);
2178
- set({ ...routeState() });
2179
- return enabled;
2180
- } finally {
2181
- set({ commandBusy: false });
2182
- }
2183
- },
2184
- toggleFast: async () => {
2185
- if (state.commandBusy) return null;
2186
- set({ commandBusy: true });
2187
- try {
2188
- const enabled = await runtime.toggleFast();
2189
- set({ ...routeState() });
2190
- return enabled;
2191
- } finally {
2192
- set({ commandBusy: false });
2193
- }
2194
- },
2195
- setToolMode: (m) => {
2196
- void runtime.setToolMode(m)
2197
- .then(() => {
2198
- resetStatsAndSyncContext();
2199
- set({ ...routeState(), toolMode: runtime.toolMode, stats: { ...state.stats } });
2200
- })
2201
- .catch((error) => pushNotice(toolErrorDisplay(error, 'tool'), 'error'));
2202
- },
2203
- getAutoClear: () => autoClearState(),
2204
- setAutoClear: (input = {}) => {
2205
- const next = runtime.setAutoClear?.(input) || autoClearState();
2206
- set({ autoClear: next });
2207
- return next;
2208
- },
2209
- getUpdateSettings: () => runtime.getUpdateSettings?.() || null,
2210
- setAutoUpdate: (enabled) => runtime.setAutoUpdate?.(enabled),
2211
- checkForUpdate: (input = {}) => runtime.checkForUpdate?.(input),
2212
- runUpdateNow: () => runtime.runUpdateNow?.(),
2213
- getUpdateStatus: () => runtime.getUpdateStatus?.() || { phase: 'idle' },
2214
- getProfile: () => runtime.getProfile?.() || { title: '', language: 'system', languages: [] },
2215
- setProfile: (input = {}) => {
2216
- const next = runtime.setProfile?.(input) || runtime.getProfile?.() || null;
2217
- return next;
2218
- },
2219
- getCompactionSettings: () => {
2220
- return runtime.getCompactionSettings?.() || {};
2221
- },
2222
- setCompactionSettings: async (input = {}) => {
2223
- if (state.commandBusy) return null;
2224
- set({ commandBusy: true });
2225
- try {
2226
- const next = runtime.setCompactionSettings?.(input) || {};
2227
- set({ ...routeState(), stats: { ...state.stats } });
2228
- // Context-stats recompute (transcript scan + per-message JSON
2229
- // stringify) is the secondary hitch source on this toggle; defer it
2230
- // off the key-handler tick so Ink repaints the setting change first.
2231
- // Stats become eventually consistent on the next tick/repaint.
2232
- setTimeout(() => {
2233
- syncContextStats({ allowEstimated: true });
2234
- set({ stats: { ...state.stats } });
2235
- }, 0);
2236
- return next;
2237
- } finally {
2238
- set({ commandBusy: false });
2239
- }
2240
- },
2241
- getMemorySettings: () => {
2242
- return runtime.getMemorySettings?.() || { enabled: true };
2243
- },
2244
- setMemoryEnabled: async (enabled) => {
2245
- if (state.commandBusy) return null;
2246
- set({ commandBusy: true });
2247
- try {
2248
- const next = await runtime.setMemoryEnabled?.(enabled);
2249
- set({ ...routeState(), stats: { ...state.stats } });
2250
- // Deferred for the same reason as setCompactionSettings above: keep
2251
- // the recompute off the key-handler tick so the toggle repaints
2252
- // immediately; stats catch up right after.
2253
- setTimeout(() => {
2254
- syncContextStats({ allowEstimated: true });
2255
- set({ stats: { ...state.stats } });
2256
- }, 0);
2257
- return next;
2258
- } finally {
2259
- set({ commandBusy: false });
2260
- }
2261
- },
2262
- getChannelSettings: (options = {}) => {
2263
- return runtime.getChannelSettings?.(options) || {
2264
- enabled: true,
2265
- ...(options?.includeStatus === false ? {} : { status: runtime.getChannelWorkerStatus?.() }),
2266
- };
2267
- },
2268
- setChannelsEnabled: async (enabled) => {
2269
- if (state.commandBusy) return null;
2270
- set({ commandBusy: true });
2271
- try {
2272
- const next = await runtime.setChannelsEnabled?.(enabled);
2273
- set({ ...routeState(), stats: { ...state.stats } });
2274
- return next;
2275
- } finally {
2276
- set({ commandBusy: false });
2277
- }
2278
- },
2279
- agentControl: async (args = {}) => {
2280
- if (state.commandBusy) return null;
2281
- set({ commandBusy: true });
2282
- try {
2283
- const result = await runtime.agentControl(args);
2284
- const text = String(result ?? '').trim();
2285
- const itemId = nextId();
2286
- pushItem({
2287
- kind: 'tool',
2288
- id: itemId,
2289
- name: 'agent',
2290
- args,
2291
- result: null,
2292
- isError: false,
2293
- expanded: false,
2294
- count: 1,
2295
- completedCount: 0,
2296
- startedAt: Date.now(),
2297
- });
2298
- updateAgentJobCard(itemId, text, /^error:/i.test(text));
2299
- set(agentStatusState({ force: true }));
2300
- return result;
2301
- } finally {
2302
- set({ commandBusy: false });
2303
- }
2304
- },
2305
- toolsStatus: (query = '') => {
2306
- return runtime.toolsStatus?.(query) || { mode: state.toolMode, count: 0, activeCount: 0, tools: [] };
2307
- },
2308
- selectTools: (names) => {
2309
- const result = runtime.selectTools?.(names) || { added: [], already: [], blocked: [], missing: [] };
2310
- const added = result.added?.length ? `added ${result.added.join(', ')}` : '';
2311
- const already = result.already?.length ? `already ${result.already.join(', ')}` : '';
2312
- const blocked = result.blocked?.length ? `blocked ${result.blocked.map((row) => row.name).join(', ')}` : '';
2313
- const missing = result.missing?.length ? `missing ${result.missing.join(', ')}` : '';
2314
- pushNotice(
2315
- [added, already, blocked, missing].filter(Boolean).join(' - ') || 'no tool changes',
2316
- result.blocked?.length || result.missing?.length ? 'warn' : 'info',
2317
- );
2318
- return result;
2319
- },
2320
- setCwd: (path, options = {}) => {
2321
- const next = runtime.setCwd(path);
2322
- set({ cwd: next });
2323
- if (options?.notice !== false) {
2324
- pushNotice(options?.message || `Project set: ${projectNameFromPath(next)}`, 'info');
2325
- }
2326
- return next;
2327
- },
2328
- getSystemShell: () => {
2329
- return runtime.getSystemShell?.() || runtime.systemShell || { source: 'auto', command: '', effective: '' };
2330
- },
2331
- setSystemShell: (command) => {
2332
- const next = runtime.setSystemShell?.(command) || { source: 'auto', command: '', effective: '' };
2333
- set({ ...routeState(), systemShell: next });
2334
- pushNotice(`system shell -> ${next.effective || 'auto'}`, 'info');
2335
- return next;
2336
- },
2337
- mcpStatus: () => {
2338
- return runtime.mcpStatus?.() || { servers: [], configuredCount: 0, connectedCount: 0, failedCount: 0 };
2339
- },
2340
- reconnectMcp: async () => {
2341
- if (state.commandBusy) return null;
2342
- set({ commandBusy: true });
2343
- try {
2344
- const status = await runtime.reconnectMcp?.();
2345
- resetStatsAndSyncContext();
2346
- set({ ...routeState(), stats: { ...state.stats } });
2347
- pushNotice(
2348
- `mcp reconnect: ${status?.connectedCount || 0}/${status?.configuredCount || 0} connected${status?.failedCount ? ` - ${status.failedCount} failed` : ''}`,
2349
- status?.failedCount ? 'warn' : 'info',
2350
- );
2351
- return status;
2352
- } finally {
2353
- set({ commandBusy: false });
2354
- }
2355
- },
2356
- addMcpServer: async (input) => {
2357
- if (state.commandBusy) return null;
2358
- set({ commandBusy: true });
2359
- try {
2360
- const result = await runtime.addMcpServer?.(input);
2361
- resetStatsAndSyncContext();
2362
- set({ ...routeState(), stats: { ...state.stats } });
2363
- pushNotice(`mcp added: ${result?.name || input?.name || 'server'}`, 'info');
2364
- return result;
2365
- } finally {
2366
- set({ commandBusy: false });
2367
- }
2368
- },
2369
- removeMcpServer: async (name) => {
2370
- if (state.commandBusy) return null;
2371
- set({ commandBusy: true });
2372
- try {
2373
- const status = await runtime.removeMcpServer?.(name);
2374
- resetStatsAndSyncContext();
2375
- set({ ...routeState(), stats: { ...state.stats } });
2376
- pushNotice(`mcp removed: ${name}`, 'info');
2377
- return status;
2378
- } finally {
2379
- set({ commandBusy: false });
2380
- }
2381
- },
2382
- setMcpServerEnabled: async (name, enabled) => {
2383
- if (state.commandBusy) return null;
2384
- set({ commandBusy: true });
2385
- try {
2386
- const status = await runtime.setMcpServerEnabled?.(name, enabled);
2387
- resetStatsAndSyncContext();
2388
- set({ ...routeState(), stats: { ...state.stats } });
2389
- pushNotice(`mcp ${enabled ? 'enabled' : 'disabled'}: ${name}`, 'info');
2390
- return status;
2391
- } finally {
2392
- set({ commandBusy: false });
2393
- }
2394
- },
2395
- getDisabledSkills: () => runtime.getDisabledSkills?.() || { disabled: [] },
2396
- setDisabledSkills: (disabled) => runtime.setDisabledSkills?.(disabled) || { disabled: [] },
2397
- skillsStatus: () => {
2398
- return runtime.skillsStatus?.() || { cwd: state.cwd, count: 0, skills: [] };
2399
- },
2400
- skillContent: (name) => {
2401
- return runtime.skillContent?.(name);
2402
- },
2403
- addSkill: async (input) => {
2404
- if (state.commandBusy) return null;
2405
- set({ commandBusy: true });
2406
- try {
2407
- const result = await runtime.addSkill?.(input);
2408
- resetStatsAndSyncContext();
2409
- set({ ...routeState(), stats: { ...state.stats } });
2410
- pushNotice(`skill added: ${result?.skill?.name || input?.name || 'skill'}`, 'info');
2411
- return result;
2412
- } finally {
2413
- set({ commandBusy: false });
2414
- }
2415
- },
2416
- reloadSkills: async () => {
2417
- if (state.commandBusy) return null;
2418
- set({ commandBusy: true });
2419
- try {
2420
- const status = await runtime.reloadSkills?.();
2421
- resetStatsAndSyncContext();
2422
- set({ ...routeState(), stats: { ...state.stats } });
2423
- pushNotice(`skills reload: ${status?.count || 0} available`, 'info');
2424
- return status;
2425
- } finally {
2426
- set({ commandBusy: false });
2427
- }
2428
- },
2429
- pluginsStatus: () => {
2430
- return runtime.pluginsStatus?.() || { count: 0, plugins: [] };
2431
- },
2432
- reloadPlugins: async () => {
2433
- if (state.commandBusy) return null;
2434
- set({ commandBusy: true });
2435
- try {
2436
- const status = await runtime.reloadPlugins?.();
2437
- resetStatsAndSyncContext();
2438
- set({ ...routeState(), stats: { ...state.stats } });
2439
- pushNotice(`plugins reload: ${status?.count || 0} detected`, 'info');
2440
- return status;
2441
- } finally {
2442
- set({ commandBusy: false });
2443
- }
2444
- },
2445
- addPlugin: async (source) => {
2446
- if (state.commandBusy) return null;
2447
- set({ commandBusy: true });
2448
- try {
2449
- const result = await runtime.addPlugin?.(source);
2450
- resetStatsAndSyncContext();
2451
- set({ ...routeState(), stats: { ...state.stats } });
2452
- pushNotice(`plugin added: ${result?.plugin?.title || result?.plugin?.name || source}`, 'info');
2453
- return result;
2454
- } finally {
2455
- set({ commandBusy: false });
2456
- }
2457
- },
2458
- updatePlugin: async (plugin) => {
2459
- if (state.commandBusy) return null;
2460
- set({ commandBusy: true });
2461
- try {
2462
- const result = await runtime.updatePlugin?.(plugin);
2463
- resetStatsAndSyncContext();
2464
- set({ ...routeState(), stats: { ...state.stats } });
2465
- pushNotice(`plugin updated: ${result?.plugin?.title || result?.plugin?.name || plugin?.name || plugin}`, 'info');
2466
- return result;
2467
- } finally {
2468
- set({ commandBusy: false });
2469
- }
2470
- },
2471
- removePlugin: async (plugin) => {
2472
- if (state.commandBusy) return null;
2473
- set({ commandBusy: true });
2474
- try {
2475
- const result = await runtime.removePlugin?.(plugin);
2476
- resetStatsAndSyncContext();
2477
- set({ ...routeState(), stats: { ...state.stats } });
2478
- pushNotice(`plugin uninstalled: ${result?.plugin?.title || result?.plugin?.name || plugin?.name || plugin}`, 'info');
2479
- return result;
2480
- } finally {
2481
- set({ commandBusy: false });
2482
- }
2483
- },
2484
- enablePluginMcp: async (plugin) => {
2485
- if (state.commandBusy) return null;
2486
- set({ commandBusy: true });
2487
- try {
2488
- const result = await runtime.enablePluginMcp?.(plugin);
2489
- resetStatsAndSyncContext();
2490
- set({ ...routeState(), stats: { ...state.stats } });
2491
- pushNotice(`plugin MCP enabled: ${result?.serverName || plugin?.name || 'plugin'}`, 'info');
2492
- return result;
2493
- } finally {
2494
- set({ commandBusy: false });
2495
- }
2496
- },
2497
- hooksStatus: () => {
2498
- return runtime.hooksStatus?.() || { enabled: false, events: [], recent: [] };
2499
- },
2500
- contextStatus: () => {
2501
- return runtime.contextStatus?.() || null;
2502
- },
2503
- addHookRule: (rule) => {
2504
- const rules = runtime.addHookRule?.(rule) || [];
2505
- pushNotice(`hook rule added (${rules.length} total)`, 'info');
2506
- return rules;
2507
- },
2508
- setHookRuleEnabled: (index, enabled) => {
2509
- const rules = runtime.setHookRuleEnabled?.(index, enabled) || [];
2510
- pushNotice(`hook rule ${index + 1} ${enabled ? 'enabled' : 'disabled'}`, 'info');
2511
- return rules;
2512
- },
2513
- deleteHookRule: (index) => {
2514
- const rules = runtime.deleteHookRule?.(index) || [];
2515
- pushNotice(`hook rule ${index + 1} deleted`, 'info');
2516
- return rules;
2517
- },
2518
- memoryControl: async (args = {}, options = {}) => {
2519
- if (state.commandBusy) return null;
2520
- set({ commandBusy: true });
2521
- try {
2522
- const result = await runtime.memoryControl(args);
2523
- const text = String(result || '').trim() || '(empty memory result)';
2524
- if (!options.silent) pushNotice(text, 'info');
2525
- return result;
2526
- } finally {
2527
- set({ commandBusy: false });
2528
- }
2529
- },
2530
- recall: async (query, args = {}) => {
2531
- if (state.commandBusy) return null;
2532
- const startedAt = Date.now();
2533
- set({ commandBusy: true, commandStatus: { active: true, verb: 'Recalling memory', startedAt, mode: 'recalling' } });
2534
- try {
2535
- const result = await runtime.recall(query, args);
2536
- pushNotice(String(result || '').trim() || '(empty recall result)', 'info');
2537
- return result;
2538
- } finally {
2539
- set({ commandBusy: false, commandStatus: null });
2540
- }
2541
- },
2542
- compact: async () => {
2543
- if (state.commandBusy) return null;
2544
- if (state.busy) {
2545
- pushNotice('Compact skipped: turn in progress', 'info');
2546
- return { changed: false, reason: 'compact skipped: turn in progress' };
2547
- }
2548
- const startedAt = Date.now();
2549
- set({ commandBusy: true, commandStatus: { active: true, verb: 'Compacting conversation', startedAt, mode: 'compacting' } });
2550
- try {
2551
- // Give Ink one event-loop turn to paint the compacting spinner before
2552
- // runtime.compact() starts synchronous session/transcript work (same
2553
- // yield as the auto-clear path; without it /compact looks frozen with
2554
- // no spinner until the compact already finished).
2555
- await new Promise((resolve) => setTimeout(resolve, 0));
2556
- const result = await runtime.compact({ recoverAgent: true });
2557
- syncContextStats({ allowEstimated: true });
2558
- set({ ...routeState(), stats: { ...state.stats } });
2559
- if (result) {
2560
- pushItem({
2561
- kind: 'statusdone',
2562
- id: nextId(),
2563
- label: result.error ? 'Compact failed' : (result.changed === false ? 'Compact checked' : 'Compact complete'),
2564
- detail: compactEventDetail({
2565
- stage: 'manual',
2566
- trigger: 'manual',
2567
- status: result.error ? 'failed' : (result.changed === false ? 'no_change' : 'compacted'),
2568
- compactType: result.compactType,
2569
- beforeTokens: result.beforeTokens,
2570
- afterTokens: result.afterTokens,
2571
- beforeMessages: result.beforeMessages,
2572
- afterMessages: result.afterMessages,
2573
- semantic: result.semanticCompact,
2574
- recallFastTrack: result.recallFastTrack,
2575
- durationMs: Date.now() - startedAt,
2576
- error: result.error,
2577
- }),
2578
- });
2579
- } else {
2580
- // null = session missing/closed: still surface a done row so
2581
- // /compact never ends silently without a completion marker.
2582
- pushItem({
2583
- kind: 'statusdone',
2584
- id: nextId(),
2585
- label: 'Compact failed',
2586
- detail: 'no active session',
2587
- });
2588
- }
2589
- return result;
2590
- } finally {
2591
- set({ commandBusy: false, commandStatus: null });
2592
- }
2593
- },
2594
- abort: () => {
2595
- if (!state.busy) return false;
2596
- denyAllToolApprovals('interrupted by user');
2597
- const restoreState = activePromptRestore;
2598
- // A queued steering prompt means the user already redirected the turn:
2599
- // interrupting should just cancel the running turn and let the steering
2600
- // prompt run next, NOT resurrect the in-flight prompt back into the draft.
2601
- const hasPendingSteering = pending.some((entry) => isQueuedEntryEditable(entry));
2602
- const canRestore = restoreState?.restorable && !hasPendingSteering;
2603
- const restoreText = canRestore ? restoreState.text : '';
2604
- const restorePastedImages = canRestore && restoreState?.pastedImages ? restoreState.pastedImages : null;
2605
- const restorePastedTexts = canRestore && restoreState?.pastedTexts ? restoreState.pastedTexts : null;
2606
- // When steering suppresses the restore, the interrupted prompt's pasted
2607
- // images never get committed (onCommitted won't fire) nor re-installed into
2608
- // the draft, so hand them back for cleanup to avoid a stale `[Image #id]`
2609
- // lingering in the paste snapshot.
2610
- const discardPastedImages = restoreState?.restorable && hasPendingSteering && restoreState?.pastedImages
2611
- ? restoreState.pastedImages
2612
- : null;
2613
- const discardPastedTexts = restoreState?.restorable && hasPendingSteering && restoreState?.pastedTexts
2614
- ? restoreState.pastedTexts
2615
- : null;
2616
- const requeueEntries = restoreState && !restoreState.committed && Array.isArray(restoreState.requeueEntries)
2617
- ? restoreState.requeueEntries.slice()
2618
- : [];
2619
- const aborted = runtime.abort('cli-react-abort');
2620
- if (restoreState) {
2621
- if ((restoreText || requeueEntries.length > 0) && aborted !== false) {
2622
- restoreState.reclaimed = true;
2623
- const idSet = new Set((restoreState.submittedIds || []).filter((id) => id != null));
2624
- const patch = { spinner: null, thinking: null, lastTurn: null };
2625
- if (idSet.size > 0) {
2626
- const items = state.items.filter((item) => !idSet.has(item?.id));
2627
- if (items.length !== state.items.length) {
2628
- patch.items = replaceItems(items);
2629
- }
2630
- }
2631
- set(patch);
2632
- if (requeueEntries.length > 0) requeueEntriesFront(requeueEntries);
2633
- }
2634
- restoreState.restorable = false;
2635
- restoreState.requeueEntries = [];
2636
- }
2637
- return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages, pastedTexts: restorePastedTexts, discardPastedTexts };
2638
- },
2639
- resolveToolApproval: (id, decision = {}) => {
2640
- const approved = decision === true || decision?.approved === true;
2641
- return finishToolApproval(id, approved, decision?.reason || (approved ? 'approved by user' : 'denied by user'));
2642
- },
2643
- listPresets: () => {
2644
- return runtime.listPresets();
2645
- },
2646
- listProviderModels: (options = {}) => {
2647
- return runtime.listProviderModels(options);
2648
- },
2649
- getSearchRoute: () => {
2650
- return runtime.getSearchRoute?.() || runtime.searchRoute || null;
2651
- },
2652
- listSearchModels: (options = {}) => {
2653
- return runtime.listSearchModels?.(options) || [];
2654
- },
2655
- setSearchRoute: async (opts) => {
2656
- if (state.commandBusy) return null;
2657
- const beforeRouteState = routeState();
2658
- const optimisticSearchRoute = opts?.provider && opts?.model
2659
- ? {
2660
- provider: String(opts.provider).trim(),
2661
- model: String(opts.model).trim(),
2662
- ...(opts.effort ? { effort: opts.effort } : {}),
2663
- ...(opts.fast === true ? { fast: true } : {}),
2664
- ...(opts.toolType ? { toolType: opts.toolType } : {}),
2665
- }
2666
- : null;
2667
- set({ commandBusy: true });
2668
- try {
2669
- if (optimisticSearchRoute?.provider && optimisticSearchRoute.model) {
2670
- set({ searchRoute: optimisticSearchRoute });
2671
- }
2672
- const result = await runtime.setSearchRoute?.(opts);
2673
- set({ ...routeState(), stats: { ...state.stats } });
2674
- return result;
2675
- } catch (e) {
2676
- set({ searchRoute: beforeRouteState.searchRoute || null });
2677
- throw e;
2678
- } finally {
2679
- set({ commandBusy: false });
2680
- }
2681
- },
2682
- listAgents: () => {
2683
- return runtime.listAgents?.() || [];
2684
- },
2685
- listWorkflows: () => {
2686
- return runtime.listWorkflows?.() || [];
2687
- },
2688
- getOutputStyle: () => {
2689
- return runtime.getOutputStyle?.() || runtime.listOutputStyles?.() || null;
2690
- },
2691
- listOutputStyles: () => {
2692
- return runtime.listOutputStyles?.() || runtime.getOutputStyle?.() || { styles: [], current: null, configured: 'default' };
2693
- },
2694
- setOutputStyle: async (styleId) => {
2695
- if (state.commandBusy) return null;
2696
- set({ commandBusy: true });
2697
- try {
2698
- const result = await runtime.setOutputStyle?.(styleId);
2699
- resetStats();
2700
- set({ ...routeState(), stats: { ...state.stats } });
2701
- // Defer the context recompute (transcript scan) off this tick so
2702
- // the style change repaints immediately; stats settle right after.
2703
- setTimeout(() => {
2704
- syncContextStats({ allowEstimated: true });
2705
- set({ stats: { ...state.stats } });
2706
- }, 0);
2707
- return result;
2708
- } finally {
2709
- set({ commandBusy: false });
2710
- }
2711
- },
2712
- setWorkflow: async (workflowId) => {
2713
- if (state.commandBusy) return null;
2714
- set({ commandBusy: true });
2715
- try {
2716
- const result = await runtime.setWorkflow?.(workflowId);
2717
- set({ ...routeState(), stats: { ...state.stats } });
2718
- return result;
2719
- } finally {
2720
- set({ commandBusy: false });
2721
- }
2722
- },
2723
- // Toggle Discord remote mode for this session. Flips the runtime's
2724
- // remoteEnabled flag (booting/stopping the channel worker) and returns the
2725
- // NEW enabled state so the caller can render an ON/OFF notice.
2726
- toggleRemote: () => {
2727
- const enabled = runtime.isRemoteEnabled?.() === true;
2728
- if (enabled) runtime.stopRemote?.();
2729
- else runtime.startRemote?.();
2730
- const next = runtime.isRemoteEnabled?.() === true;
2731
- set({ remoteEnabled: next });
2732
- return next;
2733
- },
2734
- // Force-claim remote for this session (single-holder, last-wins). Always
2735
- // turns remote ON here and steals the bridge seat; the previous holder is
2736
- // superseded and flips itself OFF via onRemoteStateChange. Used by the
2737
- // `/remote` slash command — repeated /remote just re-claims (idempotent).
2738
- claimRemote: () => {
2739
- runtime.startRemote?.();
2740
- const next = runtime.isRemoteEnabled?.() === true;
2741
- set({ remoteEnabled: next });
2742
- return next;
2743
- },
2744
- isRemoteEnabled: () => runtime.isRemoteEnabled?.() === true,
2745
- // Theme is a TUI-local concern (no runtime round-trip). listThemes returns
2746
- // picker metadata; getTheme reports the active id; setTheme applies the
2747
- // palette in-place + persists ui.theme and bumps a themeEpoch so the React
2748
- // tree re-renders (markdown/status/spinner colorizers re-resolve).
2749
- listThemes: () => listThemes(),
2750
- getTheme: () => getThemeSetting(),
2751
- setTheme: (id, options = {}) => {
2752
- const applied = setThemeSetting(id, options);
2753
- set({ themeEpoch: (state.themeEpoch || 0) + 1 });
2754
- return applied;
2755
- },
2756
- setAgentRoute: async (agentId, opts) => {
2757
- return await runtime.setAgentRoute?.(agentId, opts);
2758
- },
2759
- setDefaultProvider: async (provider) => {
2760
- return await runtime.setDefaultProvider?.(provider);
2761
- },
2762
- listProviders: () => {
2763
- return runtime.listProviders();
2764
- },
2765
- getProviderSetup: () => {
2766
- return runtime.getProviderSetup();
2767
- },
2768
- getUsageDashboard: async (options = {}) => {
2769
- return await runtime.getUsageDashboard?.(options);
2770
- },
2771
- getOnboardingStatus: () => {
2772
- return runtime.getOnboardingStatus?.() || { completed: true, workflowRoutes: {} };
2773
- },
2774
- skipOnboarding: () => {
2775
- // Completed-marking only; no route/agent/provider writes.
2776
- return runtime.skipOnboarding?.() || null;
2777
- },
2778
- completeOnboarding: async (payload = {}) => {
2779
- if (state.commandBusy) return null;
2780
- set({ commandBusy: true });
2781
- try {
2782
- const result = await runtime.completeOnboarding?.(payload);
2783
- resetStatsAndSyncContext();
2784
- set({ ...routeState(), stats: { ...state.stats } });
2785
- pushNotice('first-run setup saved', 'info');
2786
- return result;
2787
- } finally {
2788
- set({ commandBusy: false });
2789
- }
2790
- },
2791
- loginOAuthProvider: async (provider) => {
2792
- if (state.commandBusy) return false;
2793
- set({ commandBusy: true });
2794
- try {
2795
- const result = await runtime.loginOAuthProvider(provider);
2796
- pushNotice(`provider oauth ok: ${result.provider}`, 'info');
2797
- return true;
2798
- } finally {
2799
- set({ commandBusy: false });
2800
- }
2801
- },
2802
- beginOAuthProviderLogin: async (provider) => {
2803
- if (state.commandBusy) throw new Error('command busy');
2804
- set({ commandBusy: true });
2805
- try {
2806
- const result = await runtime.beginOAuthProviderLogin(provider);
2807
- pushNotice(`provider oauth started: ${result.provider}`, 'info');
2808
- return result;
2809
- } finally {
2810
- set({ commandBusy: false });
2811
- }
2812
- },
2813
- saveProviderApiKey: (provider, secret) => {
2814
- const result = runtime.saveProviderApiKey(provider, secret);
2815
- pushNotice(`provider api key saved: ${result.provider}`, 'info');
2816
- return true;
2817
- },
2818
- saveOpenCodeGoUsageAuth: (opts) => {
2819
- const result = runtime.saveOpenCodeGoUsageAuth(opts);
2820
- pushNotice(result.workspaceId
2821
- ? `OpenCode Go usage auth saved: ${result.workspaceId}`
2822
- : 'OpenCode Go usage auth saved',
2823
- 'info');
2824
- return true;
2825
- },
2826
- loginOpenCodeGoUsage: async () => {
2827
- if (state.commandBusy) throw new Error('command busy');
2828
- set({ commandBusy: true });
2829
- try {
2830
- return await runtime.loginOpenCodeGoUsage();
2831
- } finally {
2832
- set({ commandBusy: false });
2833
- }
2834
- },
2835
- saveOpenAIUsageSessionKey: (secret) => {
2836
- runtime.saveOpenAIUsageSessionKey(secret);
2837
- pushNotice('OpenAI usage auth saved', 'info');
2838
- return true;
2839
- },
2840
- setLocalProvider: (provider, opts) => {
2841
- const result = runtime.setLocalProvider(provider, opts);
2842
- pushNotice(`local provider ${result.enabled ? 'enabled' : 'disabled'}: ${result.provider}`, 'info');
2843
- return true;
2844
- },
2845
- authenticateProvider: async (provider, secret) => {
2846
- if (state.commandBusy) return false;
2847
- set({ commandBusy: true });
2848
- try {
2849
- const result = await runtime.authenticateProvider(provider, secret);
2850
- pushNotice(`provider auth ok: ${result.provider} (${result.type})`, 'info');
2851
- return true;
2852
- } finally {
2853
- set({ commandBusy: false });
2854
- }
2855
- },
2856
- forgetProviderAuth: (provider) => {
2857
- const result = runtime.forgetProviderAuth(provider);
2858
- pushNotice(`provider auth forgotten: ${result.provider}`, 'info');
2859
- return true;
2860
- },
2861
- getChannelSetup: () => {
2862
- return runtime.getChannelSetup();
2863
- },
2864
- getChannelWorkerStatus: () => runtime.getChannelWorkerStatus?.(),
2865
- setBackend: (name) => runtime.setBackend?.(name),
2866
- saveDiscordToken: (token) => {
2867
- const result = runtime.saveDiscordToken(token);
2868
- pushNotice('discord token saved', 'info');
2869
- return result;
2870
- },
2871
- forgetDiscordToken: () => {
2872
- const result = runtime.forgetDiscordToken();
2873
- pushNotice('discord token forgotten', 'info');
2874
- return result;
2875
- },
2876
- saveTelegramToken: (token) => {
2877
- const result = runtime.saveTelegramToken?.(token);
2878
- pushNotice('telegram token saved', 'info');
2879
- return result;
2880
- },
2881
- forgetTelegramToken: () => {
2882
- const result = runtime.forgetTelegramToken?.();
2883
- pushNotice('telegram token forgotten', 'info');
2884
- return result;
2885
- },
2886
- saveWebhookAuthtoken: (token) => {
2887
- const result = runtime.saveWebhookAuthtoken(token);
2888
- pushNotice('webhook/ngrok authtoken saved', 'info');
2889
- return result;
2890
- },
2891
- forgetWebhookAuthtoken: () => {
2892
- const result = runtime.forgetWebhookAuthtoken();
2893
- pushNotice('webhook/ngrok authtoken forgotten', 'info');
2894
- return result;
2895
- },
2896
- setChannel: (entry) => {
2897
- const result = runtime.setChannel(entry);
2898
- pushNotice('channel saved', 'info');
2899
- return result;
2900
- },
2901
- setWebhookConfig: (patch) => {
2902
- const result = runtime.setWebhookConfig(patch);
2903
- pushNotice('webhook config updated', 'info');
2904
- return result;
2905
- },
2906
- saveSchedule: (entry) => {
2907
- const result = runtime.saveSchedule(entry);
2908
- pushNotice(`schedule saved: ${result.name}`, 'info');
2909
- return result;
2910
- },
2911
- deleteSchedule: (name) => {
2912
- const result = runtime.deleteSchedule(name);
2913
- pushNotice(`schedule deleted: ${name}`, 'info');
2914
- return result;
2915
- },
2916
- setScheduleEnabled: (name, enabled) => {
2917
- const result = runtime.setScheduleEnabled(name, enabled);
2918
- pushNotice(`schedule ${enabled ? 'enabled' : 'disabled'}: ${name}`, 'info');
2919
- return result;
2920
- },
2921
- saveWebhook: (entry) => {
2922
- const result = runtime.saveWebhook(entry);
2923
- pushNotice(`webhook saved: ${result.name}`, 'info');
2924
- return result;
2925
- },
2926
- deleteWebhook: (name) => {
2927
- const result = runtime.deleteWebhook(name);
2928
- pushNotice(`webhook deleted: ${name}`, 'info');
2929
- return result;
2930
- },
2931
- setWebhookEnabled: (name, enabled) => {
2932
- const result = runtime.setWebhookEnabled(name, enabled);
2933
- pushNotice(`webhook ${enabled ? 'enabled' : 'disabled'}: ${name}`, 'info');
2934
- return result;
2935
- },
2936
- setRoute: async (opts) => {
2937
- if (state.commandBusy) return false;
2938
- set({ commandBusy: true });
2939
- try {
2940
- const routeOpts = opts && typeof opts === 'object' ? opts : {};
2941
- // Default: apply to the NEXT session only. Only an explicit
2942
- // `applyToCurrentSession: true` rewrites the live session in place.
2943
- const applyToCurrentSession = routeOpts.applyToCurrentSession === true;
2944
- const { applyToCurrentSession: _drop, ...nextRoute } = routeOpts;
2945
- await runtime.setRoute(nextRoute, { applyToCurrentSession });
2946
- if (applyToCurrentSession) syncContextStats({ allowEstimated: true });
2947
- set({ ...routeState(), stats: { ...state.stats } });
2948
- return true;
2949
- } finally {
2950
- set({ commandBusy: false });
2951
- }
2952
- },
2953
- pushNotice,
2954
- removeNotice,
2955
- setProgressHint,
2956
- clear: async () => {
2957
- if (state.commandBusy) return false;
2958
- set({ commandBusy: true });
2959
- clearToastTimers();
2960
- resetAllStreamingMarkdownStablePrefixes();
2961
- const rollbackSnapshot = snapshotTuiBeforeSessionReset();
2962
- resetTuiForPendingSessionReset();
2963
- set({
2964
- items: state.items,
2965
- toasts: state.toasts,
2966
- queued: state.queued,
2967
- thinking: null,
2968
- spinner: null,
2969
- lastTurn: null,
2970
- sessionId: null,
2971
- stats: { ...state.stats },
2972
- });
2973
- try {
2974
- await runtime.clear({ recoverAgent: true });
2975
- clearUiActivityBeforeContextSync();
2976
- pendingSessionReset = false;
2977
- resetStatsAndSyncContext();
2978
- set({ items: replaceItems([]), toasts: [], queued: [], thinking: null, spinner: null, lastTurn: null, ...routeState(), stats: { ...state.stats } });
2979
- lastUserActivityAt = Date.now();
2980
- return true;
2981
- } catch (error) {
2982
- restoreTuiAfterFailedSessionReset(rollbackSnapshot);
2983
- throw error;
2984
- } finally {
2985
- pendingSessionReset = false;
2986
- set({ commandBusy: false });
2987
- }
2988
- },
2989
- listSessions: () => {
2990
- return runtime.listSessions();
2991
- },
2992
- newSession: async () => {
2993
- if (state.commandBusy) return false;
2994
- set({ commandBusy: true });
2995
- clearToastTimers();
2996
- resetAllStreamingMarkdownStablePrefixes();
2997
- const rollbackSnapshot = snapshotTuiBeforeSessionReset();
2998
- resetTuiForPendingSessionReset();
2999
- set({
3000
- items: state.items,
3001
- toasts: state.toasts,
3002
- queued: state.queued,
3003
- thinking: null,
3004
- spinner: null,
3005
- lastTurn: null,
3006
- sessionId: null,
3007
- stats: { ...state.stats },
3008
- });
3009
- try {
3010
- await runtime.newSession();
3011
- clearUiActivityBeforeContextSync();
3012
- pendingSessionReset = false;
3013
- resetStatsAndSyncContext();
3014
- set({ items: replaceItems([]), toasts: [], queued: [], thinking: null, spinner: null, lastTurn: null, ...routeState(), stats: { ...state.stats } });
3015
- return true;
3016
- } catch (error) {
3017
- restoreTuiAfterFailedSessionReset(rollbackSnapshot);
3018
- throw error;
3019
- } finally {
3020
- pendingSessionReset = false;
3021
- set({ commandBusy: false });
3022
- }
3023
- },
3024
- resume: async (id) => {
3025
- if (state.commandBusy) return false;
3026
- set({ commandBusy: true, commandStatus: { active: true, verb: 'Resuming conversation', startedAt: Date.now(), mode: 'resuming' } });
3027
- clearToastTimers();
3028
- try {
3029
- const r = await runtime.resume(id);
3030
- if (!r) return false;
3031
- resetStatsAndSyncContext();
3032
- const items = [];
3033
- for (const m of r.messages || []) {
3034
- if (m.role === 'user') {
3035
- // content may be a string OR an array of parts (text/tool-call
3036
- // interleaving) — toolResultText coerces both to readable text so
3037
- // array-content messages aren't silently dropped.
3038
- const text = (typeof m.content === 'string' ? m.content : toolResultText(m.content)).trim();
3039
- if (text) {
3040
- const synthetic = parseSyntheticAgentMessage(text);
3041
- if (synthetic) {
3042
- const label = synthetic.label || 'notification';
3043
- items.push({
3044
- kind: 'tool',
3045
- id: nextId(),
3046
- name: synthetic.name || 'agent',
3047
- args: synthetic.args || {
3048
- type: label,
3049
- task_id: synthetic.taskId || undefined,
3050
- description: synthetic.summary || 'agent notification',
3051
- },
3052
- result: synthetic.result,
3053
- rawResult: synthetic.rawResult ?? text,
3054
- isError: synthetic.isError ?? /^(failed|error|killed|cancelled)$/i.test(label),
3055
- expanded: false,
3056
- count: 1,
3057
- completedCount: 1,
3058
- startedAt: Date.now(),
3059
- completedAt: Date.now(),
3060
- });
3061
- } else {
3062
- items.push({ kind: 'user', id: nextId(), text });
3063
- }
3064
- }
3065
- } else if (m.role === 'assistant') {
3066
- const text = (typeof m.content === 'string' ? m.content : toolResultText(m.content)).trim();
3067
- if (text) items.push({ kind: 'assistant', id: nextId(), text });
3068
- }
3069
- }
3070
- set({
3071
- items: replaceItems(items),
3072
- toasts: [],
3073
- queued: [],
3074
- thinking: null,
3075
- spinner: null,
3076
- lastTurn: null,
3077
- ...routeState(),
3078
- stats: { ...state.stats },
3079
- });
3080
- void restoreLeadSteeringFromDisk();
3081
- return true;
3082
- } finally {
3083
- set({ commandBusy: false, commandStatus: null });
3084
- }
3085
- },
3086
-
3087
- dispose: async (reason = 'cli-react-exit', options = {}) => {
3088
- if (disposed) return;
3089
- disposed = true;
3090
- clearToastTimers();
3091
- try { clearInterval(runtimePulseTimer); } catch {}
3092
- try { unsubscribeRuntimeNotifications?.(); } catch {}
3093
- unsubscribeRuntimeNotifications = null;
3094
- try { unsubscribeRemoteState?.(); } catch {}
3095
- unsubscribeRemoteState = null;
3096
- denyAllToolApprovals('runtime closing');
3097
- await flushTuiSteeringPersist();
3098
- await runtime.close(reason, options);
3099
- listeners.clear();
3100
- },
3101
- };
546
+ Object.assign(bag, createSessionFlow(bag));
547
+ bag.runTurn = createRunTurn(bag);
548
+ void bag.restoreLeadSteeringFromDisk();
549
+ return createEngineApi(bag);
3102
550
  }