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
@@ -0,0 +1,485 @@
1
+ /**
2
+ * src/tui/engine/session-flow.mjs - prompt queue drain + session clear/reset. Extracted from engine.mjs.
3
+ */
4
+ import { presentErrorText } from '../../runtime/shared/err-text.mjs';
5
+ import { createSessionStats } from './session-stats.mjs';
6
+ import { queuePriorityValue, defaultQueuePriority, isQueuedEntryEditable, isQueuedEntryVisible, isSlashQueuedEntry, notificationDisplayText, sessionActivityTimestamp, promptDisplayText, mergePromptContents, mergePastedImages, mergePastedTexts, callCommitCallbacks } from './queue-helpers.mjs';
7
+ import { appendTuiSteeringPersist, dropTuiSteeringPersist, drainTuiSteeringPersist } from './tui-steering-persist.mjs';
8
+
9
+ export function createSessionFlow(bag) {
10
+ const {
11
+ runtime, nextId, tuiDebug, flags, pending, pendingNotificationKeys, displayedExecutionNotificationKeys, getState, set, pushItem, replaceItems, pushNotice, pushUserOrSyntheticItem, autoClearState, agentStatusState, routeState, syncContextStats, flushDeferredExecutionPendingResumeKick,
12
+ } = bag;
13
+
14
+ // Upper bound on the awaited compacting clear. requireCompactSuccess makes
15
+ // runtime.clear() resolve only after compaction finishes; without a bound a
16
+ // stalled compaction wedges autoClearRunning/commandBusy forever, which
17
+ // suppresses the input drain. On timeout we abandon this attempt.
18
+ const AUTO_CLEAR_COMPACT_TIMEOUT_MS = 60_000;
19
+
20
+ const leadSessionId = () => runtime.id;
21
+
22
+ function shouldMirrorSteeringEntry(entry) {
23
+ return isQueuedEntryEditable(entry) && !isSlashQueuedEntry(entry);
24
+ }
25
+
26
+ function commitSteeringQueueEntries(entries) {
27
+ callCommitCallbacks(entries);
28
+ const mirrored = (Array.isArray(entries) ? entries : []).filter(
29
+ (entry) => shouldMirrorSteeringEntry(entry) && !entry.steeringPersistRestored,
30
+ );
31
+ if (mirrored.length > 0) dropTuiSteeringPersist(leadSessionId(), mirrored);
32
+ }
33
+
34
+ function makeQueueEntry(text, options = {}) {
35
+ const mode = options.mode || 'prompt';
36
+ const priority = options.priority || defaultQueuePriority(mode);
37
+ const displayText = promptDisplayText(text, options);
38
+ return {
39
+ id: options.id || nextId(),
40
+ text: displayText,
41
+ content: text,
42
+ pastedImages: options.pastedImages && typeof options.pastedImages === 'object' ? options.pastedImages : null,
43
+ pastedTexts: options.pastedTexts && typeof options.pastedTexts === 'object' ? options.pastedTexts : null,
44
+ onCommitted: typeof options.onCommitted === 'function' ? options.onCommitted : null,
45
+ mode,
46
+ priority,
47
+ key: options.key || null,
48
+ skipSlashCommands: options.skipSlashCommands === true,
49
+ displayText: mode === 'task-notification' ? notificationDisplayText(displayText) : String(displayText || ''),
50
+ steeringPersistId: options.steeringPersistId || null,
51
+ steeringPersistRestored: options.steeringPersistRestored === true,
52
+ };
53
+ }
54
+
55
+ function removeQueuedEntries(entries) {
56
+ const ids = new Set(entries.map((entry) => entry.id));
57
+ const keys = entries.map((entry) => entry.key).filter(Boolean);
58
+ for (const key of keys) pendingNotificationKeys.delete(key);
59
+ for (const key of keys) displayedExecutionNotificationKeys.delete(key);
60
+ const queued = getState().queued.filter((q) => !ids.has(q.id));
61
+ if (queued.length !== getState().queued.length) set({ queued });
62
+ }
63
+
64
+ function requeueEntriesFront(entries) {
65
+ const restored = [];
66
+ for (const entry of entries || []) {
67
+ if (!entry || !String(entry.text || '').trim()) continue;
68
+ const next = {
69
+ ...entry,
70
+ displayText: entry.displayText || (entry.mode === 'task-notification' ? notificationDisplayText(entry.text) : String(entry.text || '')),
71
+ };
72
+ if (next.mode === 'task-notification' && next.key) {
73
+ if (pendingNotificationKeys.has(next.key)) continue;
74
+ pendingNotificationKeys.add(next.key);
75
+ }
76
+ restored.push(next);
77
+ }
78
+ if (restored.length === 0) return false;
79
+ pending.unshift(...restored);
80
+ const visible = restored.filter(isQueuedEntryVisible);
81
+ if (visible.length > 0) set({ queued: [...visible, ...getState().queued] });
82
+ return true;
83
+ }
84
+
85
+ function dequeueQueueBatch(maxPriority = 'later', options = {}) {
86
+ if (pending.length === 0) return [];
87
+ const max = queuePriorityValue(maxPriority);
88
+ const predicate = typeof options.predicate === 'function' ? options.predicate : () => true;
89
+ const limit = Math.max(1, Number(options.limit) || Infinity);
90
+ let bestPriority = Infinity;
91
+ let targetMode = null;
92
+ for (const entry of pending) {
93
+ if (!predicate(entry)) continue;
94
+ const p = queuePriorityValue(entry.priority);
95
+ if (p > max) continue;
96
+ if (p < bestPriority) {
97
+ bestPriority = p;
98
+ targetMode = entry.mode || 'prompt';
99
+ }
100
+ }
101
+ if (!targetMode) return [];
102
+ const batch = [];
103
+ for (let i = 0; i < pending.length;) {
104
+ const entry = pending[i];
105
+ if (predicate(entry) && (entry.mode || 'prompt') === targetMode && queuePriorityValue(entry.priority) === bestPriority) {
106
+ batch.push(entry);
107
+ pending.splice(i, 1);
108
+ if (batch.length >= limit) break;
109
+ } else {
110
+ i += 1;
111
+ }
112
+ }
113
+ removeQueuedEntries(batch);
114
+ return batch;
115
+ }
116
+
117
+ async function drain() {
118
+ if (flags.draining) return;
119
+ if (flags.autoClearRunning) return;
120
+ flags.draining = true;
121
+ let firstBatch = true;
122
+ try {
123
+ while (pending.length > 0) {
124
+ // Drain one priority/mode bucket at a time (unified command queue):
125
+ // unified command queue semantics: prompt steering stays editable and
126
+ // task notifications stay non-editable but model-visible.
127
+ const batch = dequeueQueueBatch('later', { limit: firstBatch ? 1 : Infinity });
128
+ firstBatch = false;
129
+ if (batch.length === 0) break;
130
+ tuiDebug(`busy-queue drain batch=${batch.length} remaining=${pending.length}`);
131
+ const ids = new Set(batch.map((e) => e.id));
132
+ const merged = mergePromptContents(batch);
133
+ for (const entry of batch) {
134
+ if (entry.mode === 'pending-resume') continue;
135
+ pushUserOrSyntheticItem(entry.text, entry.id, isQueuedEntryEditable(entry) ? 'user' : 'injected');
136
+ }
137
+ const nonEditable = batch.filter((entry) => !isQueuedEntryEditable(entry));
138
+ const batchPastedImages = mergePastedImages(batch);
139
+ const batchPastedTexts = mergePastedTexts(batch);
140
+ const turnStatus = await bag.runTurn(merged, {
141
+ displayText: batch.map((entry) => entry.text).filter((text) => String(text || '').trim()).join('\n'),
142
+ pastedImages: batchPastedImages,
143
+ pastedTexts: batchPastedTexts,
144
+ onCommitted: () => commitSteeringQueueEntries(batch),
145
+ submittedIds: [...ids],
146
+ restorable: nonEditable.length === 0,
147
+ requeueOnAbort: nonEditable,
148
+ });
149
+ // A deferred cleared-session UI sync (from a late-settling abandoned
150
+ // compacting clear) applies here now that this turn has settled.
151
+ flushDeferredClearedSessionUi();
152
+ // session_manage tool: the model scheduled a reset during this turn.
153
+ // Run it now, at the turn boundary — same clear body as auto-clear.
154
+ // Cancelled/interrupted turns drop the reset (consume + discard): the
155
+ // user aborted the turn that asked for it, so the destructive clear
156
+ // must not fire. commandBusy guards against a concurrent session
157
+ // command (resume/new) racing the async clear.
158
+ const scheduledReset = runtime.consumePendingSessionReset?.();
159
+ if ((scheduledReset === 'clear' || scheduledReset === 'compact_clear')
160
+ && turnStatus !== 'cancelled'
161
+ && !getState().commandBusy) {
162
+ await performSessionClear({
163
+ verb: scheduledReset === 'clear' ? 'Clearing conversation' : 'Compacting and clearing conversation',
164
+ doneLabel: scheduledReset === 'clear' ? 'Session cleared' : 'Session compacted and cleared',
165
+ skipLabel: 'Session reset skipped',
166
+ surface: 'session-manage',
167
+ useCompaction: scheduledReset === 'compact_clear',
168
+ });
169
+ }
170
+ // If the user re-submits the reclaimed prompt while the cancelled turn
171
+ // is still unwinding, enqueue() cannot start another drain because this
172
+ // drain loop is still active. Continue when pending work appeared during
173
+ // cancellation so the fresh submit does not get stuck in queued getState().
174
+ if (turnStatus === 'cancelled' && pending.length === 0) break;
175
+ }
176
+ } finally {
177
+ flags.draining = false;
178
+ flushDeferredClearedSessionUi();
179
+ if (pending.length > 0) void drain();
180
+ else flushDeferredExecutionPendingResumeKick();
181
+ }
182
+ }
183
+ function enqueue(text, options = {}) {
184
+ const entry = makeQueueEntry(text, options);
185
+ if (entry.mode === 'task-notification' && entry.key) {
186
+ if (pendingNotificationKeys.has(entry.key)) return false;
187
+ pendingNotificationKeys.add(entry.key);
188
+ }
189
+ pending.push(entry);
190
+ if (getState().busy && shouldMirrorSteeringEntry(entry)) {
191
+ appendTuiSteeringPersist(leadSessionId(), entry);
192
+ }
193
+ if (isQueuedEntryVisible(entry)) set({ queued: [...getState().queued, entry] });
194
+ if (getState().busy) tuiDebug(`busy-queue enqueue mode=${entry.mode} pending=${pending.length}`);
195
+ void drain();
196
+ return true;
197
+ }
198
+
199
+ function drainPendingSteering() {
200
+ // Mid-turn steering drain:
201
+ // Injects queued user prompts (steering) plus non-editable internal entries
202
+ // into the CURRENT provider pre-send window so the user can redirect a turn
203
+ // that is already running. Slash commands are still excluded: they must run
204
+ // through the normal command processor after the turn, not be sent as plain
205
+ // text. Consumed entries are spliced out of `pending` here, so the post-turn
206
+ // drain() loop will not re-execute them.
207
+ const batch = dequeueQueueBatch('next', { predicate: (entry) => !isSlashQueuedEntry(entry) });
208
+ if (batch.length === 0) return [];
209
+ const out = batch
210
+ .map((entry) => {
211
+ const content = entry.content;
212
+ if (typeof content === 'string') return content.trim();
213
+ return { text: String(entry.text || '').trim(), content };
214
+ })
215
+ .filter((entry) => {
216
+ if (typeof entry === 'string') return entry.length > 0;
217
+ if (Array.isArray(entry?.content)) return entry.content.length > 0;
218
+ return String(entry?.content ?? '').trim().length > 0;
219
+ });
220
+ commitSteeringQueueEntries(batch);
221
+ return out;
222
+ }
223
+
224
+ async function restoreLeadSteeringFromDisk() {
225
+ const rows = await drainTuiSteeringPersist(leadSessionId());
226
+ if (!rows.length) return;
227
+ const restored = [];
228
+ for (const row of rows) {
229
+ const entry = makeQueueEntry(row.text, {
230
+ steeringPersistRestored: true,
231
+ steeringPersistId: row.steeringPersistId || undefined,
232
+ });
233
+ pending.push(entry);
234
+ if (isQueuedEntryVisible(entry)) restored.push(entry);
235
+ }
236
+ if (restored.length > 0) set({ queued: [...getState().queued, ...restored] });
237
+ void drain();
238
+ }
239
+
240
+ async function autoClearBeforeSubmit() {
241
+ flushDeferredClearedSessionUi();
242
+ const cfg = autoClearState();
243
+ const now = Date.now();
244
+ const activityAt = sessionActivityTimestamp(runtime.session, flags.lastUserActivityAt);
245
+ const idleMs = activityAt ? now - activityAt : 0;
246
+ if (!cfg.enabled || getState().busy || pending.length > 0 || flags.autoClearRunning || flags.autoClearInFlight || idleMs < cfg.idleMs) {
247
+ if (!activityAt) flags.lastUserActivityAt = now;
248
+ return false;
249
+ }
250
+ const minContextPercent = Number(cfg.minContextPercent ?? 10);
251
+ if (minContextPercent > 0) {
252
+ const status = runtime.contextStatus?.() || null;
253
+ const estimatedTokens = Math.max(0, Number(status?.currentEstimatedTokens ?? status?.usedTokens ?? 0));
254
+ const usedTokens = Math.max(0, Number(status?.usedTokens ?? estimatedTokens ?? 0));
255
+ const triggerTokens = Number(
256
+ status?.compaction?.triggerTokens
257
+ || status?.compaction?.autoCompactTokenLimit
258
+ || runtime.session?.autoCompactTokenLimit
259
+ || 0,
260
+ );
261
+ if (!(usedTokens > 0 && triggerTokens > 0)) {
262
+ if (!activityAt) flags.lastUserActivityAt = now;
263
+ return false;
264
+ }
265
+ const usagePct = (usedTokens / triggerTokens) * 100;
266
+ if (usagePct < minContextPercent) {
267
+ if (!activityAt) flags.lastUserActivityAt = now;
268
+ return false;
269
+ }
270
+ }
271
+ return performSessionClear({
272
+ verb: 'Auto-clearing idle conversation',
273
+ doneLabel: 'Auto-clear complete',
274
+ skipLabel: 'Auto-clear skipped',
275
+ surface: 'auto-clear',
276
+ useCompaction: true,
277
+ });
278
+ }
279
+
280
+ // Shared clear body for idle auto-clear and the session_manage tool.
281
+ // useCompaction=true mirrors auto-clear (summarize via configured
282
+ // compactType, context carries forward); false is a plain /clear wipe.
283
+ async function performSessionClear({ verb, doneLabel, skipLabel, surface, useCompaction }) {
284
+ flags.autoClearRunning = true;
285
+ const startedAt = Date.now();
286
+ // commandBusy blocks concurrent session commands (resume/newSession/
287
+ // setModel) AND new submits for the duration of the async clear — the
288
+ // clear swaps the live session object, so racing commands could act on
289
+ // the wrong session.
290
+ set({ commandBusy: true, commandStatus: { active: true, verb, startedAt, mode: 'auto-clear' } });
291
+ try {
292
+ // Give Ink one event-loop turn to paint the auto-clear status before the
293
+ // clear/compact path starts doing synchronous session/transcript work.
294
+ // Without this, long idle clears can look like a frozen prompt followed by
295
+ // an already-complete status row.
296
+ await new Promise((resolve) => setTimeout(resolve, 0));
297
+ let compactType = null;
298
+ if (useCompaction) {
299
+ const compaction = runtime.getCompactionSettings();
300
+ compactType = compaction.compactType || compaction.type || null;
301
+ }
302
+ if (compactType) {
303
+ // Bounded watchdog around the compacting clear. On timeout we throw so
304
+ // the catch below keeps the conversation, surfaces a user-visible
305
+ // notice, and the finally releases autoClearRunning/commandBusy so
306
+ // input drains. The runtime clear cannot be cancelled, so we do NOT
307
+ // walk away blind: an in-flight latch (autoClearInFlight) suppresses
308
+ // new auto-clear attempts until the abandoned promise settles, and on
309
+ // late fulfillment we run the same post-success UI sync as the normal
310
+ // path so the UI cannot diverge from a runtime session that actually
311
+ // got cleared. Late rejection is a no-op.
312
+ const clearPromise = runtime.clear({ compactType, requireCompactSuccess: true });
313
+ let timer = null;
314
+ const timeout = new Promise((_, reject) => {
315
+ timer = setTimeout(
316
+ () => reject(new Error(`compaction timed out after ${AUTO_CLEAR_COMPACT_TIMEOUT_MS}ms; auto-clear deferred to next idle`)),
317
+ AUTO_CLEAR_COMPACT_TIMEOUT_MS,
318
+ );
319
+ });
320
+ try {
321
+ await Promise.race([clearPromise, timeout]);
322
+ } catch (raceError) {
323
+ flags.autoClearInFlight = true;
324
+ clearPromise.then(
325
+ () => {
326
+ if (getState().busy) {
327
+ // A turn started after commandBusy released; applying the
328
+ // cleared-session UI now would wipe items/queued and force
329
+ // busy=false mid-turn. Defer until the current turn settles.
330
+ flags.pendingClearedSessionUi = { doneLabel, surface };
331
+ } else {
332
+ applyClearedSessionUi(doneLabel);
333
+ pushNotice(`${surface} completed late; session cleared`, 'info');
334
+ }
335
+ },
336
+ () => {},
337
+ ).finally(() => {
338
+ // Keep suppressing new auto-clears until any deferred UI sync is
339
+ // flushed at turn completion.
340
+ if (!flags.pendingClearedSessionUi) flags.autoClearInFlight = false;
341
+ });
342
+ throw raceError;
343
+ } finally {
344
+ if (timer) clearTimeout(timer);
345
+ }
346
+ } else {
347
+ await runtime.clear({});
348
+ }
349
+ applyClearedSessionUi(doneLabel);
350
+ return true;
351
+ } catch (error) {
352
+ const message = presentErrorText(error, { surface });
353
+ pushItem({
354
+ kind: 'statusdone',
355
+ id: nextId(),
356
+ label: skipLabel,
357
+ detail: `conversation kept · ${message}`,
358
+ });
359
+ pushNotice(`${surface} skipped: ${message}`, 'error');
360
+ return false;
361
+ } finally {
362
+ flags.lastUserActivityAt = Date.now();
363
+ flags.autoClearRunning = false;
364
+ set({ commandBusy: false, commandStatus: null });
365
+ void drain();
366
+ }
367
+ }
368
+
369
+ function restoreQueued(currentText = '') {
370
+ const queued = [];
371
+ for (let i = 0; i < pending.length;) {
372
+ const entry = pending[i];
373
+ if (isQueuedEntryEditable(entry)) {
374
+ queued.push(entry);
375
+ pending.splice(i, 1);
376
+ } else {
377
+ i += 1;
378
+ }
379
+ }
380
+ removeQueuedEntries(queued);
381
+ const queuedText = queued.map((item) => item.text).filter((text) => String(text || '').trim()).join('\n');
382
+ const combinedText = [queuedText, String(currentText || '')].filter((text) => text.trim()).join('\n');
383
+ return { count: queued.length, text: combinedText, pastedImages: mergePastedImages(queued), pastedTexts: mergePastedTexts(queued) };
384
+ }
385
+
386
+ const resetStats = () => {
387
+ getState().stats = createSessionStats();
388
+ return getState().stats;
389
+ };
390
+ const clearUiActivityBeforeContextSync = () => {
391
+ getState().items = replaceItems([]);
392
+ getState().toasts = [];
393
+ getState().queued = [];
394
+ getState().thinking = null;
395
+ getState().spinner = null;
396
+ getState().lastTurn = null;
397
+ getState().busy = false;
398
+ pendingNotificationKeys.clear();
399
+ displayedExecutionNotificationKeys.clear();
400
+ };
401
+ // Post-success UI sync shared by the normal clear path and a late-fulfilling
402
+ // abandoned compacting clear, so the UI always matches the cleared runtime
403
+ // session (no divergence / kept-items message loss).
404
+ const applyClearedSessionUi = (doneLabel) => {
405
+ resetStats();
406
+ clearUiActivityBeforeContextSync();
407
+ syncContextStats({ allowEstimated: true });
408
+ set({
409
+ items: replaceItems([]),
410
+ toasts: [],
411
+ queued: [],
412
+ thinking: null,
413
+ spinner: null,
414
+ lastTurn: null,
415
+ ...routeState(),
416
+ stats: { ...getState().stats },
417
+ });
418
+ pushItem({ kind: 'statusdone', id: nextId(), label: doneLabel });
419
+ };
420
+ // Flush a deferred cleared-session UI sync once the active turn has settled.
421
+ // Never forces busy=false mid-turn: bails while a turn is in flight.
422
+ const flushDeferredClearedSessionUi = () => {
423
+ if (!flags.pendingClearedSessionUi || getState().busy) return;
424
+ const { doneLabel, surface } = flags.pendingClearedSessionUi;
425
+ flags.pendingClearedSessionUi = null;
426
+ flags.autoClearInFlight = false;
427
+ applyClearedSessionUi(doneLabel);
428
+ pushNotice(`${surface} completed late; session cleared`, 'info');
429
+ };
430
+ const resetTuiForPendingSessionReset = () => {
431
+ flags.pendingSessionReset = true;
432
+ clearUiActivityBeforeContextSync();
433
+ resetStats();
434
+ getState().stats.currentContextTokens = 0;
435
+ getState().stats.currentEstimatedContextTokens = 0;
436
+ getState().stats.currentContextSource = null;
437
+ getState().stats.currentContextUpdatedAt = Date.now();
438
+ getState().displayContextWindow = 0;
439
+ getState().compactBoundaryTokens = 0;
440
+ getState().autoCompactTokenLimit = 0;
441
+ };
442
+ const snapshotTuiBeforeSessionReset = () => ({
443
+ items: getState().items.slice(),
444
+ toasts: getState().toasts.slice(),
445
+ queued: getState().queued.slice(),
446
+ thinking: getState().thinking,
447
+ spinner: getState().spinner,
448
+ lastTurn: getState().lastTurn,
449
+ busy: getState().busy,
450
+ stats: { ...getState().stats },
451
+ sessionId: getState().sessionId,
452
+ });
453
+ const restoreTuiAfterFailedSessionReset = (snapshot) => {
454
+ if (!snapshot) return;
455
+ flags.pendingSessionReset = false;
456
+ getState().items = replaceItems(snapshot.items);
457
+ getState().toasts = snapshot.toasts.slice();
458
+ getState().queued = snapshot.queued.slice();
459
+ getState().thinking = snapshot.thinking;
460
+ getState().spinner = snapshot.spinner;
461
+ getState().lastTurn = snapshot.lastTurn;
462
+ getState().busy = snapshot.busy;
463
+ getState().stats = { ...snapshot.stats };
464
+ syncContextStats({ allowEstimated: true });
465
+ set({
466
+ items: getState().items,
467
+ toasts: getState().toasts,
468
+ queued: getState().queued,
469
+ thinking: getState().thinking,
470
+ spinner: getState().spinner,
471
+ lastTurn: getState().lastTurn,
472
+ busy: getState().busy,
473
+ ...routeState(),
474
+ stats: { ...getState().stats },
475
+ ...agentStatusState(),
476
+ });
477
+ };
478
+ const resetStatsAndSyncContext = () => {
479
+ resetStats();
480
+ syncContextStats({ allowEstimated: true });
481
+ return getState().stats;
482
+ };
483
+
484
+ return { leadSessionId, shouldMirrorSteeringEntry, commitSteeringQueueEntries, makeQueueEntry, removeQueuedEntries, requeueEntriesFront, dequeueQueueBatch, drain, enqueue, drainPendingSteering, restoreLeadSteeringFromDisk, autoClearBeforeSubmit, performSessionClear, restoreQueued, resetStats, clearUiActivityBeforeContextSync, resetTuiForPendingSessionReset, snapshotTuiBeforeSessionReset, restoreTuiAfterFailedSessionReset, resetStatsAndSyncContext };
485
+ }