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