mixdog 0.9.36 → 0.9.37
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/package.json +3 -2
- package/scripts/abort-recovery-test.mjs +118 -0
- package/scripts/compact-smoke.mjs +7 -7
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +36 -6
- package/scripts/explore-prompt-policy-test.mjs +27 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/openai-ws-early-settle-test.mjs +176 -0
- package/scripts/output-style-smoke.mjs +17 -17
- package/scripts/provider-toolcall-test.mjs +333 -14
- package/scripts/recall-bench-cases.json +3 -3
- package/scripts/recall-bench.mjs +1 -1
- package/scripts/recall-quality-cases.json +4 -4
- package/scripts/recall-usecase-cases.json +2 -2
- package/scripts/session-bench.mjs +13 -13
- package/scripts/steering-drain-buckets-test.mjs +72 -11
- package/scripts/submit-commandbusy-race-test.mjs +114 -0
- package/scripts/tool-smoke.mjs +83 -10
- package/src/app.mjs +2 -1
- package/src/defaults/cycle3-review-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +93 -293
- package/src/lib/rules-builder.cjs +3 -2
- package/src/output-styles/default.md +2 -2
- package/src/output-styles/extreme-minimal.md +1 -1
- package/src/output-styles/minimal.md +1 -1
- package/src/output-styles/simple.md +2 -3
- package/src/rules/agent/30-explorer.md +15 -7
- package/src/rules/lead/01-general.md +4 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
- package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/channels/lib/worker-main.mjs +5 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
- package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
- package/src/runtime/memory/lib/recall-format.mjs +3 -3
- package/src/runtime/shared/child-spawn-gate.mjs +15 -0
- package/src/runtime/shared/config.mjs +98 -12
- package/src/runtime/shared/process-listener-headroom.mjs +12 -0
- package/src/session-runtime/cwd-plugins.mjs +6 -3
- package/src/session-runtime/model-route-api.mjs +50 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +13 -2
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/standalone/agent-tool.mjs +15 -9
- package/src/standalone/explore-tool.mjs +4 -1
- package/src/tui/App.jsx +6 -5
- package/src/tui/app/transcript-window.mjs +60 -10
- package/src/tui/app/use-transcript-window.mjs +2 -1
- package/src/tui/components/ContextPanel.jsx +4 -1
- package/src/tui/components/ToolExecution.jsx +15 -12
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
- package/src/tui/dist/index.mjs +467 -151
- package/src/tui/engine/agent-job-feed.mjs +26 -6
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/render-timing.mjs +104 -8
- package/src/tui/engine/session-api.mjs +58 -3
- package/src/tui/engine/session-flow.mjs +78 -28
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +232 -156
- package/src/tui/engine.mjs +17 -8
- package/src/tui/index.jsx +10 -1
- package/src/workflows/default/WORKFLOW.md +8 -4
- package/src/workflows/solo/WORKFLOW.md +8 -4
|
@@ -94,18 +94,27 @@ export function createAgentJobFeed({
|
|
|
94
94
|
queueMicrotask(() => kickExecutionPendingResume(body));
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
// Pure builder for the agent-job card patch. Split out so callers that are
|
|
98
|
+
// already patching the same card in the same tick (see tool-card-results
|
|
99
|
+
// non-aggregate path) can MERGE these fields into their single patchItem
|
|
100
|
+
// instead of issuing a second set() — collapsing the L1/L2 double-update
|
|
101
|
+
// jitter into one visible item update.
|
|
102
|
+
function buildAgentJobCardPatch(itemId, text, isError = false) {
|
|
98
103
|
const parsed = parseAgentJob(text);
|
|
99
104
|
const current = getState().items.find((it) => it.id === itemId);
|
|
100
105
|
const rawDisplayText = agentJobResultText(text, parsed) || String(text ?? '').trim();
|
|
101
106
|
const displayText = isError ? toolErrorDisplay(rawDisplayText, 'agent') : rawDisplayText;
|
|
102
|
-
|
|
107
|
+
return {
|
|
103
108
|
result: displayText,
|
|
104
109
|
text: displayText,
|
|
105
110
|
isError,
|
|
106
111
|
errorCount: isError ? 1 : 0,
|
|
107
112
|
...(parsed ? { args: agentArgsWithResultMetadata(current?.args, parsed) } : {}),
|
|
108
|
-
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function updateAgentJobCard(itemId, text, isError = false) {
|
|
117
|
+
patchItem(itemId, buildAgentJobCardPatch(itemId, text, isError));
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
function subscribeRuntimeNotifications() {
|
|
@@ -119,7 +128,7 @@ export function createAgentJobFeed({
|
|
|
119
128
|
const delivery = resolveTuiRuntimeNotificationDelivery(event, text);
|
|
120
129
|
if (delivery.action === 'ignore') return;
|
|
121
130
|
if (delivery.action === 'notice') {
|
|
122
|
-
pushNotice?.(delivery.displayText, delivery.tone || 'info', { transcript: true });
|
|
131
|
+
pushNotice?.(delivery.displayText, delivery.tone || 'info', { transcript: delivery.transcript === true });
|
|
123
132
|
return true;
|
|
124
133
|
}
|
|
125
134
|
if (delivery.action === 'status-only') {
|
|
@@ -135,7 +144,15 @@ export function createAgentJobFeed({
|
|
|
135
144
|
if (parsed?.taskId) set(agentStatusState({ force: true }));
|
|
136
145
|
const resumeBody = String(delivery.modelContent || '').trim();
|
|
137
146
|
if (resumeBody) {
|
|
138
|
-
|
|
147
|
+
enqueue(resumeBody, {
|
|
148
|
+
mode: 'task-notification',
|
|
149
|
+
// Claude Code parity: live execution completions are queued as
|
|
150
|
+
// task notifications so the active loop can attach them after the
|
|
151
|
+
// next tool batch; no special pending-resume bypass.
|
|
152
|
+
priority: 'next',
|
|
153
|
+
key: notificationKey || undefined,
|
|
154
|
+
displayText: delivery.displayText || text,
|
|
155
|
+
});
|
|
139
156
|
}
|
|
140
157
|
return true;
|
|
141
158
|
}
|
|
@@ -147,7 +164,9 @@ export function createAgentJobFeed({
|
|
|
147
164
|
if (!modelContent && imagePaths.length === 0) return true;
|
|
148
165
|
const enqueueOpts = {
|
|
149
166
|
mode: 'task-notification',
|
|
150
|
-
|
|
167
|
+
// Claude Code parity: task/schedule notifications are lower-priority
|
|
168
|
+
// queue items and drain between turns, behind direct user input.
|
|
169
|
+
priority: 'later',
|
|
151
170
|
key: notificationKey || undefined,
|
|
152
171
|
displayText: delivery.displayText || text,
|
|
153
172
|
};
|
|
@@ -184,6 +203,7 @@ export function createAgentJobFeed({
|
|
|
184
203
|
flushDeferredExecutionPendingResumeKick,
|
|
185
204
|
scheduleExecutionPendingResumeKick,
|
|
186
205
|
updateAgentJobCard,
|
|
206
|
+
buildAgentJobCardPatch,
|
|
187
207
|
subscribeRuntimeNotifications,
|
|
188
208
|
};
|
|
189
209
|
}
|
|
@@ -61,14 +61,14 @@ export function resolveTuiRuntimeNotificationDelivery(event, text) {
|
|
|
61
61
|
if (!trimmed) return { action: 'ignore' };
|
|
62
62
|
const parsed = parseAgentJob(trimmed);
|
|
63
63
|
const meta = event?.meta && typeof event.meta === 'object' ? event.meta : {};
|
|
64
|
-
// UI-only notices (e.g. boot auto-update outcome): render as a
|
|
65
|
-
// notice, never enqueue anything model-visible.
|
|
64
|
+
// UI-only notices (e.g. boot auto-update outcome): render as a transient
|
|
65
|
+
// notice, never enqueue anything model-visible or transcript-persistent.
|
|
66
66
|
if (meta.kind === 'update-notice') {
|
|
67
67
|
// Wording lives here (the notice surface), not in the emitting runtime:
|
|
68
68
|
// the emitter only supplies meta.version; the sentence is composed here.
|
|
69
69
|
const ver = String(meta.version || '').trim();
|
|
70
70
|
const displayText = ver ? `mixdog v${ver} ready — restart to apply.` : trimmed;
|
|
71
|
-
return { action: 'notice', displayText, tone: meta.tone === 'warn' ? 'warn' : 'info' };
|
|
71
|
+
return { action: 'notice', displayText, tone: meta.tone === 'warn' ? 'warn' : 'info', transcript: false };
|
|
72
72
|
}
|
|
73
73
|
if (!isExecutionNotification(event, trimmed, parsed)) {
|
|
74
74
|
return { action: 'enqueue', displayText: trimmed, modelContent: trimmed };
|
|
@@ -1,17 +1,113 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/tui/engine/render-timing.mjs - render-throttle timing helper.
|
|
3
3
|
*
|
|
4
|
-
* Extracted from engine.mjs (no behavior change).
|
|
4
|
+
* Extracted from engine.mjs (no behavior change originally).
|
|
5
5
|
*
|
|
6
|
-
* Ink renders through a maxFps throttle (
|
|
6
|
+
* Ink renders through a maxFps throttle (60fps in index.jsx, ≈16.7ms). A plain
|
|
7
7
|
* setImmediate only yields to the event loop; if Ink already painted within the
|
|
8
8
|
* current throttle window, the next paint may still be queued and our following
|
|
9
|
-
* transcript mutation can coalesce into the same visible frame.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* transcript mutation can coalesce into the same visible frame. When we
|
|
10
|
+
* intentionally split transcript commits for visual stability (preamble frame →
|
|
11
|
+
* tool-card frame), we must wait for the split-off commit to actually paint.
|
|
12
|
+
*
|
|
13
|
+
* A fixed 12ms wait was below the 60fps frame budget, so the tool card could
|
|
14
|
+
* land in the same frame as the preamble and the bottom-pinned viewport jumped.
|
|
15
|
+
* Instead we wait for the next real Ink render frame: index.jsx routes its
|
|
16
|
+
* onRender hook through notifyRenderFrame(), which resolves any pending yields.
|
|
17
|
+
* The fixed timeout below is only a fallback ceiling for when no renderer ack is
|
|
18
|
+
* wired (or the renderer is idle) so a yield can never hang the turn.
|
|
12
19
|
*/
|
|
13
|
-
export const
|
|
20
|
+
export const RENDER_ACK_FALLBACK_MS = 32;
|
|
21
|
+
export const RENDER_ACK_HANG_GUARD_MS = 250;
|
|
22
|
+
export const RENDER_SETTLE_IDLE_MS = 64;
|
|
23
|
+
|
|
24
|
+
// Back-compat alias: previously the fixed wait duration, now the fallback only.
|
|
25
|
+
export const RENDER_THROTTLE_FLUSH_MS = RENDER_ACK_FALLBACK_MS;
|
|
26
|
+
|
|
27
|
+
let pendingRenderAcks = [];
|
|
28
|
+
let renderAckSeq = 0;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Called by the Ink onRender hook once per painted frame. The sequence is
|
|
32
|
+
* allocated synchronously at onRender time, BEFORE the deferred post-write ack,
|
|
33
|
+
* so a yield that starts after onRender but before setImmediate delivery can
|
|
34
|
+
* identify and ignore that stale/previous-frame ack.
|
|
35
|
+
*/
|
|
36
|
+
export const scheduleRenderFrameAck = () => {
|
|
37
|
+
const seq = ++renderAckSeq;
|
|
38
|
+
setImmediate(() => notifyRenderFrame(seq));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const notifyRenderFrame = (seq = ++renderAckSeq) => {
|
|
42
|
+
if (pendingRenderAcks.length === 0) return;
|
|
43
|
+
const acks = pendingRenderAcks;
|
|
44
|
+
pendingRenderAcks = [];
|
|
45
|
+
for (const ack of acks) ack(seq);
|
|
46
|
+
};
|
|
14
47
|
|
|
15
|
-
export const yieldToRenderer = () => new Promise((resolve) => {
|
|
16
|
-
|
|
48
|
+
export const yieldToRenderer = ({ frames = 1 } = {}) => new Promise((resolve) => {
|
|
49
|
+
const minSeq = renderAckSeq;
|
|
50
|
+
let remainingFrames = Math.max(1, Math.floor(Number(frames) || 1));
|
|
51
|
+
let settled = false;
|
|
52
|
+
let sawRealFrame = false;
|
|
53
|
+
let timer = null;
|
|
54
|
+
const finish = () => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
if (timer) clearTimeout(timer);
|
|
58
|
+
const idx = pendingRenderAcks.indexOf(onFrame);
|
|
59
|
+
if (idx !== -1) pendingRenderAcks.splice(idx, 1);
|
|
60
|
+
resolve();
|
|
61
|
+
};
|
|
62
|
+
const onTimeout = () => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
// A render has already reached onRender and scheduled its deferred
|
|
65
|
+
// post-write ack, but the event loop may run expired timers before the
|
|
66
|
+
// check-phase setImmediate. Do NOT let the first-frame hang guard beat that
|
|
67
|
+
// queued real-frame ack; keep waiting for it so frames:2 cannot collapse to
|
|
68
|
+
// zero/one actual paints under a slow preamble render.
|
|
69
|
+
if (!sawRealFrame && renderAckSeq > minSeq) {
|
|
70
|
+
// Yield exactly one check phase: a legitimate queued notifyRenderFrame(seq)
|
|
71
|
+
// was scheduled before this timeout callback and should run first. If it is
|
|
72
|
+
// lost/skipped, this follow-up finishes the hang guard instead of
|
|
73
|
+
// re-arming forever.
|
|
74
|
+
setImmediate(() => {
|
|
75
|
+
if (!settled && !sawRealFrame) finish();
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
finish();
|
|
80
|
+
};
|
|
81
|
+
const armWait = () => {
|
|
82
|
+
if (settled) return;
|
|
83
|
+
if (!pendingRenderAcks.includes(onFrame)) pendingRenderAcks.push(onFrame);
|
|
84
|
+
if (timer) clearTimeout(timer);
|
|
85
|
+
// Before the FIRST real frame, the timer is only a long hang guard for
|
|
86
|
+
// no-ack/no-render paths. After at least one frame painted, the timer becomes
|
|
87
|
+
// a short "settle idle" fallback: if no follow-up measurement/correction
|
|
88
|
+
// render arrives within roughly four 60fps frames, treat the preamble as
|
|
89
|
+
// stable instead of forcing a visible 250ms pause before the tool card.
|
|
90
|
+
timer = setTimeout(onTimeout, sawRealFrame ? RENDER_SETTLE_IDLE_MS : RENDER_ACK_HANG_GUARD_MS);
|
|
91
|
+
};
|
|
92
|
+
const onFrame = (seq = 0) => {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
if (seq <= minSeq) {
|
|
95
|
+
if (!pendingRenderAcks.includes(onFrame)) pendingRenderAcks.push(onFrame);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
sawRealFrame = true;
|
|
99
|
+
if (timer) {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
timer = null;
|
|
102
|
+
}
|
|
103
|
+
const idx = pendingRenderAcks.indexOf(onFrame);
|
|
104
|
+
if (idx !== -1) pendingRenderAcks.splice(idx, 1);
|
|
105
|
+
remainingFrames -= 1;
|
|
106
|
+
if (remainingFrames <= 0) finish();
|
|
107
|
+
else armWait();
|
|
108
|
+
};
|
|
109
|
+
// Count real render acks as frames. The pre-first-frame timeout is a hang
|
|
110
|
+
// guard, while the post-first-frame timeout is an idle-settle fallback so a
|
|
111
|
+
// missing second frame does not add a quarter-second tool-card delay.
|
|
112
|
+
armWait();
|
|
17
113
|
});
|
|
@@ -9,13 +9,22 @@ import { buildDoctorReport } from '../app/doctor.mjs';
|
|
|
9
9
|
import { recomputePromptHistory } from './prompt-history.mjs';
|
|
10
10
|
import { buildMergedPromptHistory, loadPromptHistory } from '../prompt-history-store.mjs';
|
|
11
11
|
|
|
12
|
+
// Upper bound on how long a manual Esc abort may leave the store busy while the
|
|
13
|
+
// in-flight runtime.ask() unwinds. runtime.abort() normally rejects the pending
|
|
14
|
+
// ask within a tick; this is the belt-and-suspenders window before the bounded
|
|
15
|
+
// recovery timer hard-releases busy so Esc can never wedge input forever.
|
|
16
|
+
const MANUAL_ABORT_RECOVERY_MS = (() => {
|
|
17
|
+
const v = Number(process.env.MIXDOG_MANUAL_ABORT_RECOVERY_MS);
|
|
18
|
+
return Number.isFinite(v) && v > 0 ? v : 4000;
|
|
19
|
+
})();
|
|
20
|
+
|
|
12
21
|
export function createEngineApi(bag) {
|
|
13
22
|
return { ...createEngineApiA(bag), ...createEngineApiB(bag) };
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
export function createEngineApiA(bag) {
|
|
17
26
|
const {
|
|
18
|
-
runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext,
|
|
27
|
+
runtime, nextId, flags, pending, listeners, getState, set, pushItem, patchItem, replaceItems, pushNotice, autoClearState, agentStatusState, routeState, syncContextStats, denyAllToolApprovals, updateAgentJobCard, requeueEntriesFront, enqueue, autoClearBeforeSubmit, restoreQueued, resetStatsAndSyncContext, drain, flushDeferredExecutionPendingResumeKick,
|
|
19
28
|
} = bag;
|
|
20
29
|
return {
|
|
21
30
|
getState: () => getState(),
|
|
@@ -44,12 +53,23 @@ export function createEngineApiA(bag) {
|
|
|
44
53
|
enqueue(text, queueOptions);
|
|
45
54
|
return true;
|
|
46
55
|
}
|
|
47
|
-
|
|
56
|
+
// Any in-flight session command (clear/setModel/newSession/resume/...)
|
|
57
|
+
// holds commandBusy. Previously the prompt was dropped here and only the
|
|
58
|
+
// prompt-history side effect survived. Queue it instead: drain bails while
|
|
59
|
+
// commandBusy, and the central release hook re-kicks drain once the
|
|
60
|
+
// command settles, so the prompt runs afterwards rather than vanishing.
|
|
61
|
+
if (getState().commandBusy) {
|
|
62
|
+
enqueue(text, queueOptions);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
48
65
|
if (getState().busy) {
|
|
49
66
|
enqueue(text, queueOptions);
|
|
50
67
|
return true;
|
|
51
68
|
}
|
|
52
|
-
|
|
69
|
+
// If autoClearBeforeSubmit rejects (e.g. compaction timeout throws), the
|
|
70
|
+
// prompt must still be queued — swallow the rejection so enqueue always
|
|
71
|
+
// runs and the submit is never silently lost.
|
|
72
|
+
void autoClearBeforeSubmit().catch(() => {}).then(() => enqueue(text, queueOptions));
|
|
53
73
|
return true;
|
|
54
74
|
},
|
|
55
75
|
restoreQueued,
|
|
@@ -573,6 +593,41 @@ export function createEngineApiA(bag) {
|
|
|
573
593
|
restoreState.restorable = false;
|
|
574
594
|
restoreState.requeueEntries = [];
|
|
575
595
|
}
|
|
596
|
+
// ── Bounded manual-abort recovery ───────────────────────────────────
|
|
597
|
+
// runtime.abort() above normally rejects the in-flight runtime.ask() so
|
|
598
|
+
// the turn's own finally clears busy within a tick. If that unwind is
|
|
599
|
+
// starved — e.g. a provider abort that never settles after a post-tool
|
|
600
|
+
// fetch stall — busy would stay true until the far-larger turn watchdog
|
|
601
|
+
// trips, wedging the TUI with dead input. Arm a short grace timer that
|
|
602
|
+
// hard-releases busy exactly like the watchdog force-release, but ONLY
|
|
603
|
+
// when this same turn is still the active owner (epoch unchanged) AND
|
|
604
|
+
// still busy — so a normal cancellation that settles in time is never
|
|
605
|
+
// masked and a newer turn's store is never corrupted.
|
|
606
|
+
const abortEpoch = flags.leadTurnEpoch;
|
|
607
|
+
const recoveryMs = Number(flags.manualAbortRecoveryMs) > 0
|
|
608
|
+
? Number(flags.manualAbortRecoveryMs)
|
|
609
|
+
: MANUAL_ABORT_RECOVERY_MS;
|
|
610
|
+
const recoveryTimer = setTimeout(() => {
|
|
611
|
+
if (flags.disposed) return;
|
|
612
|
+
if (!getState().busy) return; // normal abort settled
|
|
613
|
+
if (flags.leadTurnEpoch !== abortEpoch) return; // a newer turn owns the store
|
|
614
|
+
// Bump the epoch FIRST so the still-stuck turn's eventual finally becomes
|
|
615
|
+
// a no-op for shared getState() writes and cannot corrupt the handoff.
|
|
616
|
+
flags.leadTurnEpoch = (Number(flags.leadTurnEpoch) || 0) + 1;
|
|
617
|
+
set({ busy: false, spinner: null, thinking: null, lastTurn: null });
|
|
618
|
+
flags.activePromptRestore = null;
|
|
619
|
+
// Abandon the drain loop that is still awaiting the stuck turn before
|
|
620
|
+
// releasing the drain lock. Its eventual finally observes the stale
|
|
621
|
+
// epoch and cannot clear a newer drain's ownership or continue work.
|
|
622
|
+
flags.drainEpoch = (Number(flags.drainEpoch) || 0) + 1;
|
|
623
|
+
if (flags.draining) flags.draining = false;
|
|
624
|
+
pushNotice('Interrupt did not settle — input restored.', 'warn', { transcript: true });
|
|
625
|
+
if (pending.length > 0 && typeof drain === 'function') void drain();
|
|
626
|
+
// busy→false here bypasses the normal turn-end + drain-finally flushes,
|
|
627
|
+
// so re-arm any deferred completion kick explicitly (idempotent).
|
|
628
|
+
if (typeof flushDeferredExecutionPendingResumeKick === 'function') flushDeferredExecutionPendingResumeKick();
|
|
629
|
+
}, recoveryMs);
|
|
630
|
+
recoveryTimer.unref?.();
|
|
576
631
|
return { aborted, restoreText, pastedImages: restorePastedImages, discardPastedImages, pastedTexts: restorePastedTexts, discardPastedTexts };
|
|
577
632
|
},
|
|
578
633
|
};
|
|
@@ -60,9 +60,6 @@ export function createSessionFlow(bag) {
|
|
|
60
60
|
|
|
61
61
|
function removeQueuedEntries(entries) {
|
|
62
62
|
const ids = new Set(entries.map((entry) => entry.id));
|
|
63
|
-
const keys = entries.map((entry) => entry.key).filter(Boolean);
|
|
64
|
-
for (const key of keys) pendingNotificationKeys.delete(key);
|
|
65
|
-
for (const key of keys) displayedExecutionNotificationKeys.delete(key);
|
|
66
63
|
const queued = getState().queued.filter((q) => !ids.has(q.id));
|
|
67
64
|
if (queued.length !== getState().queued.length) set({ queued });
|
|
68
65
|
}
|
|
@@ -76,8 +73,9 @@ export function createSessionFlow(bag) {
|
|
|
76
73
|
displayText: entry.displayText || (entry.mode === 'task-notification' ? notificationDisplayText(entry.text) : String(entry.text || '')),
|
|
77
74
|
};
|
|
78
75
|
if (next.mode === 'task-notification' && next.key) {
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
const duplicateQueued = pending.some((entry) => entry?.mode === 'task-notification' && entry?.key === next.key);
|
|
77
|
+
if (duplicateQueued) continue;
|
|
78
|
+
if (!pendingNotificationKeys.has(next.key)) pendingNotificationKeys.add(next.key);
|
|
81
79
|
}
|
|
82
80
|
restored.push(next);
|
|
83
81
|
}
|
|
@@ -120,17 +118,67 @@ export function createSessionFlow(bag) {
|
|
|
120
118
|
return batch;
|
|
121
119
|
}
|
|
122
120
|
|
|
121
|
+
function scheduleBlockedDrainRetry() {
|
|
122
|
+
if (pending.length === 0) return;
|
|
123
|
+
if (flags.blockedDrainRetryTimer) return;
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
flags.blockedDrainRetryTimer = null;
|
|
126
|
+
if (pending.length > 0) void drain();
|
|
127
|
+
}, 50);
|
|
128
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
129
|
+
flags.blockedDrainRetryTimer = timer;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function clearBlockedDrainRetry() {
|
|
133
|
+
if (!flags.blockedDrainRetryTimer) return;
|
|
134
|
+
clearTimeout(flags.blockedDrainRetryTimer);
|
|
135
|
+
flags.blockedDrainRetryTimer = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function hasModelDrainablePending() {
|
|
139
|
+
return pending.some((entry) => !isSlashQueuedEntry(entry));
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
async function drain() {
|
|
124
143
|
if (flags.draining) return;
|
|
125
|
-
|
|
144
|
+
// Bail while any session command holds commandBusy (auto-clear implies it,
|
|
145
|
+
// but so do setModel/newSession/resume/etc). Running a turn concurrently
|
|
146
|
+
// with a command that swaps or reroutes the live session is a race; the
|
|
147
|
+
// commandBusy-release hook re-kicks drain once the command finishes.
|
|
148
|
+
if (flags.autoClearRunning || getState().commandBusy) {
|
|
149
|
+
scheduleBlockedDrainRetry();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Claude Code parity: a queued prompt/notification can arrive while a
|
|
153
|
+
// provider turn is already in flight (scheduled message, webhook, agent
|
|
154
|
+
// completion, or user input), but the unified queue only runs BETWEEN
|
|
155
|
+
// turns. Do NOT start a second Lead runTurn from the post-turn drain in
|
|
156
|
+
// that window: the active runtime.ask owns the session mutex/transcript.
|
|
157
|
+
// Anything pending is kicked again by runTurn.finally once busy flips
|
|
158
|
+
// false. Starting a parallel run here is what tangles turn order and can
|
|
159
|
+
// abort/interleave the active turn.
|
|
160
|
+
if (getState().busy) {
|
|
161
|
+
tuiDebug(`busy-queue drain deferred while active pending=${pending.length}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
clearBlockedDrainRetry();
|
|
165
|
+
const drainEpoch = (Number(flags.drainEpoch) || 0) + 1;
|
|
166
|
+
flags.drainEpoch = drainEpoch;
|
|
126
167
|
flags.draining = true;
|
|
127
168
|
let firstBatch = true;
|
|
128
169
|
try {
|
|
129
170
|
while (pending.length > 0) {
|
|
171
|
+
if (flags.drainEpoch !== drainEpoch) return;
|
|
130
172
|
// Drain one priority/mode bucket at a time (unified command queue):
|
|
131
173
|
// unified command queue semantics: prompt steering stays editable and
|
|
132
174
|
// task notifications stay non-editable but model-visible.
|
|
133
|
-
const batch = dequeueQueueBatch('later', {
|
|
175
|
+
const batch = dequeueQueueBatch('later', {
|
|
176
|
+
limit: firstBatch ? 1 : Infinity,
|
|
177
|
+
// Slash commands must run through the TUI command dispatcher, not be
|
|
178
|
+
// delivered to the model as plain text. Claude Code's queueProcessor
|
|
179
|
+
// similarly handles slash entries outside the queued_command drain.
|
|
180
|
+
predicate: (entry) => !isSlashQueuedEntry(entry),
|
|
181
|
+
});
|
|
134
182
|
firstBatch = false;
|
|
135
183
|
if (batch.length === 0) break;
|
|
136
184
|
tuiDebug(`busy-queue drain batch=${batch.length} remaining=${pending.length}`);
|
|
@@ -152,6 +200,7 @@ export function createSessionFlow(bag) {
|
|
|
152
200
|
restorable: nonEditable.length === 0,
|
|
153
201
|
requeueOnAbort: nonEditable,
|
|
154
202
|
});
|
|
203
|
+
if (flags.drainEpoch !== drainEpoch) return;
|
|
155
204
|
// A deferred cleared-session UI sync (from a late-settling abandoned
|
|
156
205
|
// compacting clear) applies here now that this turn has settled.
|
|
157
206
|
flushDeferredClearedSessionUi();
|
|
@@ -180,10 +229,12 @@ export function createSessionFlow(bag) {
|
|
|
180
229
|
if (turnStatus === 'cancelled' && pending.length === 0) break;
|
|
181
230
|
}
|
|
182
231
|
} finally {
|
|
183
|
-
flags.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
232
|
+
if (flags.drainEpoch === drainEpoch) {
|
|
233
|
+
flags.draining = false;
|
|
234
|
+
flushDeferredClearedSessionUi();
|
|
235
|
+
if (hasModelDrainablePending()) void drain();
|
|
236
|
+
else flushDeferredExecutionPendingResumeKick();
|
|
237
|
+
}
|
|
187
238
|
}
|
|
188
239
|
}
|
|
189
240
|
function enqueue(text, options = {}) {
|
|
@@ -202,25 +253,24 @@ export function createSessionFlow(bag) {
|
|
|
202
253
|
return true;
|
|
203
254
|
}
|
|
204
255
|
|
|
205
|
-
function drainPendingSteering() {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const predicate = (entry) => !isSlashQueuedEntry(entry);
|
|
256
|
+
function drainPendingSteering(_sessionIdOrOptions = null, maybeOptions = null) {
|
|
257
|
+
const options = maybeOptions && typeof maybeOptions === 'object'
|
|
258
|
+
? maybeOptions
|
|
259
|
+
: (_sessionIdOrOptions && typeof _sessionIdOrOptions === 'object' ? _sessionIdOrOptions : {});
|
|
260
|
+
const maxPriority = options.maxPriority || 'next';
|
|
261
|
+
// Claude Code parity: mid-chain drain converts queued prompt/task
|
|
262
|
+
// notification entries into model-visible "queued_command" style steering
|
|
263
|
+
// only at provider continuation boundaries. Slash commands stay queued for
|
|
264
|
+
// the post-turn command processor. `later` notifications (scheduled tasks)
|
|
265
|
+
// are skipped unless the runtime explicitly asks for a later flush.
|
|
266
|
+
const predicate = (entry) => {
|
|
267
|
+
if (isSlashQueuedEntry(entry)) return false;
|
|
268
|
+
const mode = entry?.mode || 'prompt';
|
|
269
|
+
return mode === 'prompt' || mode === 'task-notification';
|
|
270
|
+
};
|
|
221
271
|
const out = [];
|
|
222
272
|
for (;;) {
|
|
223
|
-
const batch = dequeueQueueBatch(
|
|
273
|
+
const batch = dequeueQueueBatch(maxPriority, { predicate });
|
|
224
274
|
if (batch.length === 0) break;
|
|
225
275
|
for (const entry of batch) {
|
|
226
276
|
const content = entry.content;
|
|
@@ -14,7 +14,7 @@ import { summarizeToolResult, aggregateDoneCategories, classifyToolCategory } fr
|
|
|
14
14
|
import { toolResultText, toolErrorDisplay, toolGroupedDisplayFallback } from './tool-result-text.mjs';
|
|
15
15
|
import { toolResultCallId } from './tool-call-fields.mjs';
|
|
16
16
|
import { memoryCoreResultErrorText } from '../app/input-parsers.mjs';
|
|
17
|
-
import { parseAgentJob,
|
|
17
|
+
import { parseAgentJob, toolResultStatus, isErrorToolStatus } from './agent-envelope.mjs';
|
|
18
18
|
import {
|
|
19
19
|
withCancelledResultMarker,
|
|
20
20
|
groupedToolResultText,
|
|
@@ -31,6 +31,7 @@ export function createToolCardResults({
|
|
|
31
31
|
patchItem,
|
|
32
32
|
markToolCallDone,
|
|
33
33
|
updateAgentJobCard,
|
|
34
|
+
buildAgentJobCardPatch,
|
|
34
35
|
agentStatusState,
|
|
35
36
|
}) {
|
|
36
37
|
function patchToolItem(id, patch) {
|
|
@@ -65,7 +66,16 @@ export function createToolCardResults({
|
|
|
65
66
|
// calls: the text-matcher's ^(error|failed) catch-all would otherwise
|
|
66
67
|
// misflag legitimate non-memory success output.
|
|
67
68
|
const isMemoryCall = classifyToolCategory(callRec?.name || card?.name || '', callRec?.args || {}) === 'Memory';
|
|
68
|
-
|
|
69
|
+
// Split the failure signal into two:
|
|
70
|
+
// isCallError — a REAL tool-call failure (backend isError / error
|
|
71
|
+
// toolKind). ONLY this paints the ● dot red.
|
|
72
|
+
// isResultError — a command/result failure (shell exit code, [error…]
|
|
73
|
+
// text, failed status text, flattened core memory-op
|
|
74
|
+
// failure). These still mark the card Failed in the L2
|
|
75
|
+
// detail but must NOT turn the dot red.
|
|
76
|
+
const isCallError = message?.isError === true || message?.toolKind === 'error';
|
|
77
|
+
const isResultError = /^\s*\[?error/i.test(rawText) || isErrorToolStatus(toolResultStatus(rawText)) || (isMemoryCall && memoryCoreResultErrorText(rawText) != null);
|
|
78
|
+
const isError = isCallError || isResultError;
|
|
69
79
|
const text = isError ? toolErrorDisplay(rawText, card?.name || 'tool') : rawText;
|
|
70
80
|
|
|
71
81
|
if (aggregate && card.itemId === aggregate.itemId) {
|
|
@@ -78,11 +88,13 @@ export function createToolCardResults({
|
|
|
78
88
|
callRec.summary = !isError ? summarizeToolResult(callRec.name, callRec.args, rawText, isError) : null;
|
|
79
89
|
assignAggregateSummaryOrder(aggregate, callRec);
|
|
80
90
|
callRec.isError = isError;
|
|
91
|
+
callRec.isCallError = isCallError;
|
|
81
92
|
callRec.resultText = text;
|
|
82
93
|
callRec.resolved = true;
|
|
83
94
|
const allCalls = [...aggregate.calls.values()];
|
|
84
95
|
const completed = allCalls.filter((r) => r.resolved).length;
|
|
85
96
|
const errors = allCalls.filter((r) => r.isError).length;
|
|
97
|
+
const callErrors = allCalls.filter((r) => r.isCallError).length;
|
|
86
98
|
// Collapsed detail carries the merged per-call count summary
|
|
87
99
|
// ("512 lines, 6 matches, 3 files") so the finished card answers "how
|
|
88
100
|
// much" without ctrl+o. Failures keep a bare 'N Ok · N Failed' status so
|
|
@@ -103,6 +115,7 @@ export function createToolCardResults({
|
|
|
103
115
|
rawResult: rawResult || null,
|
|
104
116
|
isError: errors > 0,
|
|
105
117
|
errorCount: errors,
|
|
118
|
+
callErrorCount: callErrors,
|
|
106
119
|
count: allCalls.length,
|
|
107
120
|
completedCount: visualCompleted,
|
|
108
121
|
doneCategories: aggregateDoneCategories(allCalls),
|
|
@@ -114,9 +127,10 @@ export function createToolCardResults({
|
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
// Non-aggregate (legacy agent-job cards, etc.)
|
|
117
|
-
const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, results: [] };
|
|
130
|
+
const group = toolGroups.get(card.itemId) || { count: 1, completed: 0, errors: 0, callErrors: 0, results: [] };
|
|
118
131
|
group.completed = Math.min(group.count, group.completed + 1);
|
|
119
132
|
group.errors += isError ? 1 : 0;
|
|
133
|
+
group.callErrors = (group.callErrors || 0) + (isCallError ? 1 : 0);
|
|
120
134
|
group.results.push({ text, isError });
|
|
121
135
|
toolGroups.set(card.itemId, group);
|
|
122
136
|
const resultText = groupedToolResultText(group);
|
|
@@ -126,6 +140,7 @@ export function createToolCardResults({
|
|
|
126
140
|
text: displayResult,
|
|
127
141
|
isError: group.errors > 0,
|
|
128
142
|
errorCount: group.errors,
|
|
143
|
+
callErrorCount: group.callErrors || 0,
|
|
129
144
|
count: group.count,
|
|
130
145
|
completedCount: group.completed,
|
|
131
146
|
completedAt: Date.now(),
|
|
@@ -135,19 +150,17 @@ export function createToolCardResults({
|
|
|
135
150
|
if (body) patch.rawResult = text || rawText;
|
|
136
151
|
const parsedAgent = parseAgentJob(rawText);
|
|
137
152
|
if (parsedAgent) {
|
|
138
|
-
patch.args = agentArgsWithResultMetadata(getState().items.find((it) => it.id === card.itemId)?.args, parsedAgent);
|
|
139
153
|
set(agentStatusState({ force: true }));
|
|
140
154
|
}
|
|
155
|
+
// Coalesce the agent-job card refresh (result/text/isError/errorCount/
|
|
156
|
+
// args) into THIS patch instead of a second updateAgentJobCard() call.
|
|
157
|
+
// The two calls previously wrote the same card back-to-back with
|
|
158
|
+
// different result/text strings, producing the visible L1/L2 flash.
|
|
159
|
+
// The agent fields win (final display) while patch keeps the completion
|
|
160
|
+
// metadata (count/completedCount/completedAt/rawResult) for expand.
|
|
161
|
+
Object.assign(patch, buildAgentJobCardPatch(card.itemId, rawText, isError));
|
|
141
162
|
}
|
|
142
163
|
patchToolItem(card.itemId, patch);
|
|
143
|
-
if (group.count <= 1) {
|
|
144
|
-
const beforeAgent = getState().items.find((it) => it.id === card.itemId);
|
|
145
|
-
updateAgentJobCard(card.itemId, rawText, isError);
|
|
146
|
-
const afterAgent = getState().items.find((it) => it.id === card.itemId);
|
|
147
|
-
if (afterAgent && beforeAgent && afterAgent !== beforeAgent) {
|
|
148
|
-
carryTranscriptMeasuredRowsCache(beforeAgent, afterAgent);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
164
|
card.done = true;
|
|
152
165
|
if (callId) done.add(callId);
|
|
153
166
|
return true;
|
|
@@ -209,6 +222,7 @@ export function createToolCardResults({
|
|
|
209
222
|
const completed = allCalls.filter((r) => r.resolved).length;
|
|
210
223
|
const totalCompleted = completed;
|
|
211
224
|
const errors = allCalls.filter((r) => r.isError).length;
|
|
225
|
+
const callErrors = allCalls.filter((r) => r.isCallError).length;
|
|
212
226
|
const succeeded = completed - errors;
|
|
213
227
|
const rawResult = aggregateRawResult(allCalls);
|
|
214
228
|
// Collapsed detail carries the merged per-call count summary; failures
|
|
@@ -229,6 +243,7 @@ export function createToolCardResults({
|
|
|
229
243
|
rawResult: rawResult || null,
|
|
230
244
|
isError: errors > 0,
|
|
231
245
|
errorCount: errors,
|
|
246
|
+
callErrorCount: callErrors,
|
|
232
247
|
count: allCalls.length,
|
|
233
248
|
completedCount: totalCompleted,
|
|
234
249
|
doneCategories: aggregateDoneCategories(allCalls),
|
|
@@ -250,7 +265,7 @@ export function createToolCardResults({
|
|
|
250
265
|
const currentItem = getState().items.find((it) => it.id === card.itemId);
|
|
251
266
|
resultText = withCancelledResultMarker(resultText, currentItem);
|
|
252
267
|
}
|
|
253
|
-
patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, count: group.count, completedCount: group.completed, completedAt: Date.now() });
|
|
268
|
+
patchToolItem(card.itemId, { result: resultText, text: resultText, isError: group.errors > 0, errorCount: group.errors, callErrorCount: group.callErrors || 0, count: group.count, completedCount: group.completed, completedAt: Date.now() });
|
|
254
269
|
card.done = true;
|
|
255
270
|
if (card.callId) done.add(card.callId);
|
|
256
271
|
}
|