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.
- package/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
package/src/tui/engine.mjs
CHANGED
|
@@ -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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
if (
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
}
|