mixdog 0.9.49 → 0.9.51
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/README.md +6 -6
- package/package.json +7 -7
- package/scripts/embedding-worker-exit-test.mjs +76 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +67 -1
- package/scripts/provider-toolcall-test.mjs +200 -2
- package/scripts/session-bench-cache-break-test.mjs +102 -0
- package/scripts/session-bench.mjs +101 -42
- package/scripts/shell-failure-diagnostics-test.mjs +73 -4
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +4 -3
- package/scripts/smoke.mjs +5 -106
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/tui-transcript-perf-test.mjs +38 -0
- package/scripts/verify-release-assets-test.mjs +15 -381
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +14 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +75 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
- package/src/runtime/search/index.mjs +41 -0
- package/src/runtime/search/lib/http-fetch.mjs +154 -0
- package/src/runtime/search/tool-defs.mjs +23 -0
- package/src/session-runtime/runtime-core.mjs +37 -15
- package/src/standalone/hook-bus/handlers.mjs +4 -0
- package/src/standalone/hook-bus/rules.mjs +7 -1
- package/src/standalone/hook-bus.mjs +10 -2
- package/src/tui/App.jsx +17 -11
- package/src/tui/app/live-spinner-visibility.mjs +20 -0
- package/src/tui/dist/index.mjs +28 -3
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'fs'
|
|
2
|
+
import dns from 'node:dns'
|
|
3
|
+
import { Agent, fetch as undiciFetch } from 'undici'
|
|
2
4
|
|
|
3
5
|
import {
|
|
4
6
|
assertPublicUrl,
|
|
@@ -128,6 +130,158 @@ export async function readBodyBytesWithCap(response, maxBytes) {
|
|
|
128
130
|
return Buffer.concat(chunks.map((c) => Buffer.from(c)))
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
const SAFE_IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
|
|
134
|
+
|
|
135
|
+
function loopbackHost(hostname) {
|
|
136
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
137
|
+
if (host === 'localhost' || host === '::1') return true
|
|
138
|
+
const match = host.match(/^(\d{1,3})(?:\.(\d{1,3})){3}$/)
|
|
139
|
+
return Boolean(match && Number(match[1]) === 127)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function abortRace(promise, signal) {
|
|
143
|
+
if (!signal) return promise
|
|
144
|
+
if (signal.aborted) return Promise.reject(signal.reason || new Error('local_fetch aborted'))
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const onAbort = () => reject(signal.reason || new Error('local_fetch aborted'))
|
|
147
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
148
|
+
promise.then(
|
|
149
|
+
(value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
|
|
150
|
+
(error) => { signal.removeEventListener('abort', onAbort); reject(error) },
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function pinnedLoopbackFetch(url, options = {}) {
|
|
156
|
+
const parsed = assertLoopbackUrl(url)
|
|
157
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
158
|
+
const addresses = host === 'localhost'
|
|
159
|
+
? await abortRace(dns.promises.lookup(host, { all: true }), options.signal)
|
|
160
|
+
: [{ address: host, family: host.includes(':') ? 6 : 4 }]
|
|
161
|
+
if (!addresses.length || addresses.some((entry) => !loopbackHost(entry.address))) {
|
|
162
|
+
throw new Error(`Blocked non-loopback local_fetch resolution: ${host}`)
|
|
163
|
+
}
|
|
164
|
+
const pinned = addresses[0]
|
|
165
|
+
const dispatcher = new Agent({
|
|
166
|
+
connect: {
|
|
167
|
+
lookup: (_hostname, opts, cb) => opts?.all
|
|
168
|
+
? cb(null, [{ address: pinned.address, family: pinned.family }])
|
|
169
|
+
: cb(null, pinned.address, pinned.family),
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
let response
|
|
173
|
+
try {
|
|
174
|
+
response = await undiciFetch(url, { ...options, dispatcher })
|
|
175
|
+
} catch (error) {
|
|
176
|
+
dispatcher.destroy().catch(() => {})
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
if (!response.body) {
|
|
180
|
+
dispatcher.destroy().catch(() => {})
|
|
181
|
+
return response
|
|
182
|
+
}
|
|
183
|
+
const reader = response.body.getReader()
|
|
184
|
+
let cleaned = false
|
|
185
|
+
const cleanup = () => {
|
|
186
|
+
if (cleaned) return
|
|
187
|
+
cleaned = true
|
|
188
|
+
dispatcher.destroy().catch(() => {})
|
|
189
|
+
}
|
|
190
|
+
const monitored = new ReadableStream({
|
|
191
|
+
async pull(controller) {
|
|
192
|
+
try {
|
|
193
|
+
const { done, value } = await reader.read()
|
|
194
|
+
if (done) {
|
|
195
|
+
controller.close()
|
|
196
|
+
cleanup()
|
|
197
|
+
} else {
|
|
198
|
+
controller.enqueue(value)
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
controller.error(error)
|
|
202
|
+
cleanup()
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
cancel(reason) {
|
|
206
|
+
reader.cancel(reason).catch(() => {})
|
|
207
|
+
cleanup()
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
return new Response(monitored, {
|
|
211
|
+
status: response.status,
|
|
212
|
+
statusText: response.statusText,
|
|
213
|
+
headers: response.headers,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function assertLoopbackUrl(url) {
|
|
218
|
+
const parsed = new URL(url)
|
|
219
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
220
|
+
throw new Error(`Blocked non-HTTP protocol: ${parsed.protocol}`)
|
|
221
|
+
}
|
|
222
|
+
if (parsed.username || parsed.password) throw new Error('Blocked loopback URL with userinfo credentials')
|
|
223
|
+
if (!loopbackHost(parsed.hostname)) throw new Error(`Blocked non-loopback local_fetch target: ${parsed.hostname}`)
|
|
224
|
+
return parsed
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function boundedManualFetch(url, { signal, fetchImpl, validateUrl, sameHost = false } = {}) {
|
|
228
|
+
const original = validateUrl(url)
|
|
229
|
+
let currentUrl = original.toString()
|
|
230
|
+
for (let hops = 0; ; hops++) {
|
|
231
|
+
const current = validateUrl(currentUrl)
|
|
232
|
+
if (sameHost && current.hostname.toLowerCase() !== original.hostname.toLowerCase()) {
|
|
233
|
+
throw new Error(`cross-host redirect blocked (redirected_to: ${currentUrl})`)
|
|
234
|
+
}
|
|
235
|
+
const response = await fetchImpl(currentUrl, {
|
|
236
|
+
signal,
|
|
237
|
+
headers: buildHeaders(),
|
|
238
|
+
redirect: 'manual',
|
|
239
|
+
})
|
|
240
|
+
if (!REDIRECT_STATUSES.has(response.status)) return response
|
|
241
|
+
try { await response.body?.cancel() } catch {}
|
|
242
|
+
if (hops >= MAX_REDIRECTS) throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`)
|
|
243
|
+
const location = response.headers.get('location')
|
|
244
|
+
if (!location) throw new Error(`Redirect ${response.status} without Location header`)
|
|
245
|
+
currentUrl = new URL(location, currentUrl).toString()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function fetchLoopbackText(url, { signal, fetchImpl = pinnedLoopbackFetch } = {}) {
|
|
250
|
+
const response = await boundedManualFetch(url, {
|
|
251
|
+
signal,
|
|
252
|
+
fetchImpl,
|
|
253
|
+
validateUrl: assertLoopbackUrl,
|
|
254
|
+
})
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
try { await response.body?.cancel() } catch {}
|
|
257
|
+
throw new Error(`HTTP ${response.status}`)
|
|
258
|
+
}
|
|
259
|
+
return readBodyWithCap(response, MAX_BODY_BYTES)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function fetchPublicImage(url, { signal, fetchImpl = pinnedFetch } = {}) {
|
|
263
|
+
const response = await boundedManualFetch(url, {
|
|
264
|
+
signal,
|
|
265
|
+
fetchImpl,
|
|
266
|
+
validateUrl: (value) => {
|
|
267
|
+
assertPublicUrl(value)
|
|
268
|
+
return new URL(value)
|
|
269
|
+
},
|
|
270
|
+
sameHost: true,
|
|
271
|
+
})
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
try { await response.body?.cancel() } catch {}
|
|
274
|
+
throw new Error(`HTTP ${response.status}`)
|
|
275
|
+
}
|
|
276
|
+
const mimeType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
|
277
|
+
if (!SAFE_IMAGE_MIMES.has(mimeType)) {
|
|
278
|
+
try { await response.body?.cancel() } catch {}
|
|
279
|
+
throw new Error(`Blocked unsupported image content-type: ${mimeType || '(missing)'}`)
|
|
280
|
+
}
|
|
281
|
+
const bytes = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
|
|
282
|
+
return { mimeType, data: bytes.toString('base64'), bytes: bytes.byteLength }
|
|
283
|
+
}
|
|
284
|
+
|
|
131
285
|
const CDP_FORBIDDEN_RESPONSE_HEADERS = new Set([
|
|
132
286
|
'content-length',
|
|
133
287
|
'transfer-encoding',
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
TOOL_SYNC_EXECUTION_CONTRACT,
|
|
3
3
|
} from '../shared/tool-execution-contract.mjs';
|
|
4
4
|
|
|
5
|
+
const TOOL_DEFS_PLACEHOLDER = Symbol('web-fetch-schema')
|
|
6
|
+
|
|
5
7
|
export const TOOL_DEFS = [
|
|
6
8
|
{
|
|
7
9
|
name: 'search',
|
|
@@ -54,4 +56,25 @@ export const TOOL_DEFS = [
|
|
|
54
56
|
},
|
|
55
57
|
annotations: { title: 'Mixdog Web Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
56
58
|
},
|
|
59
|
+
{
|
|
60
|
+
name: 'local_fetch',
|
|
61
|
+
title: 'Mixdog Loopback Fetch',
|
|
62
|
+
public: false,
|
|
63
|
+
description: 'Runtime-only loopback HTTP(S) fetch target.',
|
|
64
|
+
inputSchema: TOOL_DEFS_PLACEHOLDER,
|
|
65
|
+
annotations: { title: 'Mixdog Loopback Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'image_fetch',
|
|
69
|
+
title: 'Mixdog Image Fetch',
|
|
70
|
+
public: false,
|
|
71
|
+
description: 'Runtime-only bounded public image fetch target.',
|
|
72
|
+
inputSchema: TOOL_DEFS_PLACEHOLDER,
|
|
73
|
+
annotations: { title: 'Mixdog Image Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
74
|
+
},
|
|
57
75
|
]
|
|
76
|
+
|
|
77
|
+
const webFetchSchema = TOOL_DEFS.find((tool) => tool.name === 'web_fetch').inputSchema
|
|
78
|
+
for (const tool of TOOL_DEFS) {
|
|
79
|
+
if (tool.inputSchema === TOOL_DEFS_PLACEHOLDER) tool.inputSchema = webFetchSchema
|
|
80
|
+
}
|
|
@@ -331,6 +331,32 @@ export function shouldMirrorCompletionToPendingQueue({
|
|
|
331
331
|
return shouldPersistModelVisibleToolCompletion(text, meta);
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
export async function dispatchSearchRuntimeTool(name, args, callerCtx = {}, {
|
|
335
|
+
getSearchModule,
|
|
336
|
+
getCurrentCwd,
|
|
337
|
+
getSession,
|
|
338
|
+
notifyFnForSession,
|
|
339
|
+
runNativeWebSearch,
|
|
340
|
+
} = {}) {
|
|
341
|
+
const currentSession = typeof getSession === 'function' ? getSession() : null;
|
|
342
|
+
const callerCwd = callerCtx?.callerCwd || (typeof getCurrentCwd === 'function' ? getCurrentCwd() : process.cwd());
|
|
343
|
+
const callerSessionId = callerCtx?.callerSessionId || currentSession?.id || null;
|
|
344
|
+
const callerSignal = callerCtx?.signal || currentSession?.controller?.signal;
|
|
345
|
+
const searchMod = await getSearchModule();
|
|
346
|
+
if (!searchMod?.handleToolCall) throw new Error('search runtime is not available');
|
|
347
|
+
return await searchMod.handleToolCall(name, args || {}, {
|
|
348
|
+
callerCwd,
|
|
349
|
+
callerSessionId,
|
|
350
|
+
routingSessionId: callerSessionId,
|
|
351
|
+
clientHostPid: callerCtx?.clientHostPid || currentSession?.clientHostPid || process.pid,
|
|
352
|
+
notifyFn: notifyFnForSession(callerSessionId),
|
|
353
|
+
signal: callerSignal,
|
|
354
|
+
nativeSearch: name === 'search'
|
|
355
|
+
? async (searchArgs) => runNativeWebSearch(searchArgs, { signal: callerSignal })
|
|
356
|
+
: undefined,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
334
360
|
export async function createMixdogSessionRuntime({
|
|
335
361
|
provider,
|
|
336
362
|
model,
|
|
@@ -1049,13 +1075,15 @@ export async function createMixdogSessionRuntime({
|
|
|
1049
1075
|
});
|
|
1050
1076
|
bootProfile('channels:worker-ready', { ms: (performance.now() - channelsStartedAt).toFixed(1) });
|
|
1051
1077
|
const toolsStartedAt = performance.now();
|
|
1078
|
+
const searchRuntimeTools = (searchToolDefs?.TOOL_DEFS || [])
|
|
1079
|
+
.filter((tool) => ['search', 'web_fetch', 'local_fetch', 'image_fetch'].includes(tool?.name));
|
|
1052
1080
|
const standaloneTools = [
|
|
1053
1081
|
TOOL_SEARCH_TOOL,
|
|
1054
1082
|
...(envFlag('MIXDOG_DISABLE_SKILLS') ? [] : [SKILL_TOOL]),
|
|
1055
1083
|
CWD_TOOL,
|
|
1056
1084
|
SESSION_MANAGE_TOOL,
|
|
1057
1085
|
EXPLORE_TOOL,
|
|
1058
|
-
...
|
|
1086
|
+
...searchRuntimeTools.filter((tool) => tool?.public !== false),
|
|
1059
1087
|
...(memoryToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'recall' || tool?.name === 'memory'),
|
|
1060
1088
|
...(channelToolDefs?.TOOL_DEFS || []).filter((tool) => channels.isChannelTool(tool?.name)),
|
|
1061
1089
|
...(codeGraphToolDefs?.CODE_GRAPH_TOOL_DEFS || []).filter((tool) => tool?.name === 'code_graph'),
|
|
@@ -1102,22 +1130,16 @@ export async function createMixdogSessionRuntime({
|
|
|
1102
1130
|
if (replay.length) selectDeferredTools(session, replay, deferredSurfaceModeForLead(mode));
|
|
1103
1131
|
}
|
|
1104
1132
|
internalTools.setInternalToolsProvider({
|
|
1105
|
-
tools: standaloneTools,
|
|
1133
|
+
tools: [...standaloneTools, ...searchRuntimeTools.filter((tool) => tool?.public === false)],
|
|
1106
1134
|
executor: async (name, args, callerCtx = {}) => {
|
|
1107
1135
|
const callerCwd = callerCtx?.callerCwd || currentCwd;
|
|
1108
|
-
if (name === 'search' || name === 'web_fetch') {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
routingSessionId: callerSessionId,
|
|
1116
|
-
clientHostPid: callerCtx?.clientHostPid || session?.clientHostPid || process.pid,
|
|
1117
|
-
notifyFn: notifyFnForSession(callerSessionId),
|
|
1118
|
-
nativeSearch: name === 'search'
|
|
1119
|
-
? async (searchArgs) => runNativeWebSearch(searchArgs, { signal: callerCtx?.signal || session?.controller?.signal })
|
|
1120
|
-
: undefined,
|
|
1136
|
+
if (name === 'search' || name === 'web_fetch' || name === 'local_fetch' || name === 'image_fetch') {
|
|
1137
|
+
return dispatchSearchRuntimeTool(name, args, callerCtx, {
|
|
1138
|
+
getSearchModule,
|
|
1139
|
+
getCurrentCwd: () => currentCwd,
|
|
1140
|
+
getSession: () => session,
|
|
1141
|
+
notifyFnForSession,
|
|
1142
|
+
runNativeWebSearch,
|
|
1121
1143
|
});
|
|
1122
1144
|
}
|
|
1123
1145
|
if (name === 'recall' || name === 'memory' || name === 'search_memories') {
|
|
@@ -498,6 +498,7 @@ export function parseHandlerOutput(run, eventName) {
|
|
|
498
498
|
reason: null,
|
|
499
499
|
permissionDecision: null,
|
|
500
500
|
updatedInput: null,
|
|
501
|
+
updatedToolName: null,
|
|
501
502
|
updatedToolOutput: null,
|
|
502
503
|
additionalContext: null,
|
|
503
504
|
systemMessage: null,
|
|
@@ -545,6 +546,9 @@ export function parseHandlerOutput(run, eventName) {
|
|
|
545
546
|
out.updatedInput = hso.updatedInput;
|
|
546
547
|
}
|
|
547
548
|
if (hso.updatedToolOutput != null) out.updatedToolOutput = hso.updatedToolOutput;
|
|
549
|
+
if (typeof hso.updatedToolName === 'string' && hso.updatedToolName.trim()) {
|
|
550
|
+
out.updatedToolName = hso.updatedToolName.trim();
|
|
551
|
+
}
|
|
548
552
|
if (hso.permissionDecision) out.permissionDecision = String(hso.permissionDecision).toLowerCase();
|
|
549
553
|
if (hso.permissionDecisionReason) out.reason = out.reason || limitText(hso.permissionDecisionReason);
|
|
550
554
|
if (eventName === 'PermissionRequest' && hso.decision && typeof hso.decision === 'object') {
|
|
@@ -35,7 +35,13 @@ export function decisionFromRule(rule, input) {
|
|
|
35
35
|
const nextArgs = rule.args && typeof rule.args === 'object'
|
|
36
36
|
? rule.args
|
|
37
37
|
: { ...(input.args || {}), ...(rule.patch || {}) };
|
|
38
|
-
|
|
38
|
+
const nextName = rule.updatedToolName ?? rule.replaceTool ?? rule.targetTool;
|
|
39
|
+
return {
|
|
40
|
+
action: 'modify',
|
|
41
|
+
args: nextArgs,
|
|
42
|
+
...(typeof nextName === 'string' && nextName.trim() ? { name: nextName.trim() } : {}),
|
|
43
|
+
reason: rule.reason || rule.message || `modified by hook rule for ${input.name}`,
|
|
44
|
+
};
|
|
39
45
|
}
|
|
40
46
|
if (action === 'ask') {
|
|
41
47
|
return { action: 'ask', reason: rule.reason || rule.message || `approval requested by hook rule for ${input.name}` };
|
|
@@ -268,6 +268,7 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
|
|
|
268
268
|
blocked: false,
|
|
269
269
|
reason: null,
|
|
270
270
|
updatedInput: null,
|
|
271
|
+
updatedToolName: null,
|
|
271
272
|
updatedToolOutput: null,
|
|
272
273
|
additionalContext: [],
|
|
273
274
|
systemMessage: null,
|
|
@@ -304,6 +305,7 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
|
|
|
304
305
|
if (parsed.additionalContext) agg.additionalContext.push(parsed.additionalContext);
|
|
305
306
|
if (parsed.systemMessage && !agg.systemMessage) agg.systemMessage = parsed.systemMessage;
|
|
306
307
|
if (parsed.updatedInput && !agg.updatedInput) agg.updatedInput = parsed.updatedInput;
|
|
308
|
+
if (parsed.updatedToolName && !agg.updatedToolName) agg.updatedToolName = parsed.updatedToolName;
|
|
307
309
|
if (parsed.updatedToolOutput != null && agg.updatedToolOutput == null) agg.updatedToolOutput = parsed.updatedToolOutput;
|
|
308
310
|
if (parsed.askReason && !agg.ask && !agg.blocked) {
|
|
309
311
|
agg.ask = true;
|
|
@@ -452,9 +454,14 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
|
|
|
452
454
|
emit('tool:deny', { sessionId: input.sessionId || input.session_id || null, name: input.name || input.tool_name || 'tool', reason: agg.reason });
|
|
453
455
|
return { action: 'deny', reason: agg.reason };
|
|
454
456
|
}
|
|
455
|
-
if (agg.updatedInput) {
|
|
457
|
+
if (agg.updatedInput || agg.updatedToolName) {
|
|
456
458
|
emit('tool:modify', { sessionId: input.sessionId || input.session_id || null, name: input.name || input.tool_name || 'tool', reason: agg.reason });
|
|
457
|
-
return {
|
|
459
|
+
return {
|
|
460
|
+
action: 'modify',
|
|
461
|
+
...(agg.updatedInput ? { args: agg.updatedInput } : {}),
|
|
462
|
+
...(agg.updatedToolName ? { name: agg.updatedToolName } : {}),
|
|
463
|
+
reason: agg.reason,
|
|
464
|
+
};
|
|
458
465
|
}
|
|
459
466
|
if (agg.ask) {
|
|
460
467
|
emit('tool:ask', { sessionId: input.sessionId || input.session_id || null, name: input.name || input.tool_name || 'tool', reason: agg.askReason });
|
|
@@ -504,6 +511,7 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
|
|
|
504
511
|
additionalContext: agg.additionalContext.length ? agg.additionalContext : undefined,
|
|
505
512
|
systemMessage: agg.systemMessage || undefined,
|
|
506
513
|
updatedInput: agg.updatedInput || undefined,
|
|
514
|
+
updatedToolName: agg.updatedToolName || undefined,
|
|
507
515
|
handlersRun: agg.handlersRun || undefined,
|
|
508
516
|
};
|
|
509
517
|
if (agg.updatedToolOutput != null) out.updatedToolOutput = agg.updatedToolOutput;
|
package/src/tui/App.jsx
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
slashCommandForName,
|
|
78
78
|
slashArgumentHint,
|
|
79
79
|
} from './app/slash-commands.mjs';
|
|
80
|
+
import { isCompletedTranscriptTailAppendedThisCommit, isLiveSpinnerMetaVisible } from './app/live-spinner-visibility.mjs';
|
|
80
81
|
import {
|
|
81
82
|
parseHookRuleInput,
|
|
82
83
|
parseMcpServerInput,
|
|
@@ -2564,7 +2565,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2564
2565
|
? promptHintTone
|
|
2565
2566
|
: (latestToast?.tone || progressHint?.tone || 'info');
|
|
2566
2567
|
const latestTranscriptItem = state.items[state.items.length - 1] || null;
|
|
2567
|
-
const latestDoneAtTail = latestTranscriptItem?.kind === 'turndone' || latestTranscriptItem?.kind === 'statusdone';
|
|
2568
2568
|
// Bottom meta band ownership is LIVE-SPINNER ONLY. A finished turn's done row
|
|
2569
2569
|
// (turndone/statusdone) is a normal transcript item and flows into scrollback
|
|
2570
2570
|
// like anything else, so the area directly above the prompt is CLEAR when the
|
|
@@ -2621,13 +2621,17 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2621
2621
|
// While the slash palette is open it owns the area above the prompt, so the
|
|
2622
2622
|
// live spinner/meta row is suppressed entirely — no reservation and no render.
|
|
2623
2623
|
// Normalize the spinner → TurnDone handoff by making them occupy the SAME
|
|
2624
|
-
// two-row slot.
|
|
2625
|
-
// a
|
|
2626
|
-
//
|
|
2627
|
-
//
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2624
|
+
// two-row slot. Completion appends turndone and clears spinner in one commit,
|
|
2625
|
+
// so a completed row replaces the spinner slot with no transient jump. A
|
|
2626
|
+
// statusdone can be emitted mid-turn (for example after compaction), so it
|
|
2627
|
+
// must not suppress the still-active spinner.
|
|
2628
|
+
const promptMetaVisible = isLiveSpinnerMetaVisible({
|
|
2629
|
+
inputBoxHidden,
|
|
2630
|
+
slashPaletteOpen,
|
|
2631
|
+
liveSpinner,
|
|
2632
|
+
liveSpinnerIsCommand,
|
|
2633
|
+
latestTranscriptItem,
|
|
2634
|
+
});
|
|
2631
2635
|
const promptMetaRows = promptMetaVisible ? 2 : 0;
|
|
2632
2636
|
// Toast/error text without a live spinner uses the existing transcript guard
|
|
2633
2637
|
// row directly above the prompt. Do NOT reserve another row here: that made a
|
|
@@ -2718,7 +2722,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2718
2722
|
// transcript bounce. Exempt exactly the meta-collapse rows from the ink mask
|
|
2719
2723
|
// when the done row is the transcript tail. Every other prompt-row-only
|
|
2720
2724
|
// shrink (typing newline removal, queued-row churn) AND the reclaimed/no-op
|
|
2721
|
-
// path (engine.mjs skips turndone, so
|
|
2725
|
+
// path (engine.mjs skips turndone, so the completed-tail check stays false and no
|
|
2722
2726
|
// row replaces the height) keep the mask so they still reclaim smoothly.
|
|
2723
2727
|
const prevMetaRows = Number(String(panelTransition.signature).split('|')[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
|
|
2724
2728
|
const nextMetaRows = Number(String(panelLayoutSignature).split('|')[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
|
|
@@ -2727,8 +2731,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2727
2731
|
// tail and then clear without appending statusdone (e.g. /recall — see
|
|
2728
2732
|
// engine.mjs), collapsing the meta band with NO same-commit backfill; masking
|
|
2729
2733
|
// must stay on for that path or the vacated rows overpaint the stale row.
|
|
2730
|
-
const doneTailAppendedThisCommit =
|
|
2731
|
-
|
|
2734
|
+
const doneTailAppendedThisCommit = isCompletedTranscriptTailAppendedThisCommit(
|
|
2735
|
+
latestTranscriptItem,
|
|
2736
|
+
panelTransition.tailId,
|
|
2737
|
+
);
|
|
2732
2738
|
const spinnerMetaCollapseRows = doneTailAppendedThisCommit
|
|
2733
2739
|
? Math.max(0, prevMetaRows - nextMetaRows)
|
|
2734
2740
|
: 0;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function isCompletedTranscriptTail(latestTranscriptItem) {
|
|
2
|
+
return latestTranscriptItem?.kind === 'turndone'
|
|
3
|
+
|| latestTranscriptItem?.kind === 'statusdone';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isCompletedTranscriptTailAppendedThisCommit(latestTranscriptItem, previousTailId) {
|
|
7
|
+
return isCompletedTranscriptTail(latestTranscriptItem)
|
|
8
|
+
&& (latestTranscriptItem?.id ?? null) !== previousTailId;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isLiveSpinnerMetaVisible({
|
|
12
|
+
inputBoxHidden,
|
|
13
|
+
slashPaletteOpen,
|
|
14
|
+
liveSpinner,
|
|
15
|
+
liveSpinnerIsCommand,
|
|
16
|
+
latestTranscriptItem,
|
|
17
|
+
}) {
|
|
18
|
+
return !inputBoxHidden && !slashPaletteOpen && !!liveSpinner
|
|
19
|
+
&& (liveSpinnerIsCommand || latestTranscriptItem?.kind !== 'turndone');
|
|
20
|
+
}
|
package/src/tui/dist/index.mjs
CHANGED
|
@@ -8083,6 +8083,23 @@ function slashArgumentHint(value) {
|
|
|
8083
8083
|
return command?.params ? `${command.usage} ${command.params}` : "";
|
|
8084
8084
|
}
|
|
8085
8085
|
|
|
8086
|
+
// src/tui/app/live-spinner-visibility.mjs
|
|
8087
|
+
function isCompletedTranscriptTail(latestTranscriptItem) {
|
|
8088
|
+
return latestTranscriptItem?.kind === "turndone" || latestTranscriptItem?.kind === "statusdone";
|
|
8089
|
+
}
|
|
8090
|
+
function isCompletedTranscriptTailAppendedThisCommit(latestTranscriptItem, previousTailId) {
|
|
8091
|
+
return isCompletedTranscriptTail(latestTranscriptItem) && (latestTranscriptItem?.id ?? null) !== previousTailId;
|
|
8092
|
+
}
|
|
8093
|
+
function isLiveSpinnerMetaVisible({
|
|
8094
|
+
inputBoxHidden,
|
|
8095
|
+
slashPaletteOpen,
|
|
8096
|
+
liveSpinner,
|
|
8097
|
+
liveSpinnerIsCommand,
|
|
8098
|
+
latestTranscriptItem
|
|
8099
|
+
}) {
|
|
8100
|
+
return !inputBoxHidden && !slashPaletteOpen && !!liveSpinner && (liveSpinnerIsCommand || latestTranscriptItem?.kind !== "turndone");
|
|
8101
|
+
}
|
|
8102
|
+
|
|
8086
8103
|
// src/tui/app/input-parsers.mjs
|
|
8087
8104
|
function parseHookRuleInput(text) {
|
|
8088
8105
|
const parts = String(text || "").split("|").map((part) => part.trim());
|
|
@@ -21453,7 +21470,6 @@ function App({ store, initialStatusLine = "", forceOnboarding = false }) {
|
|
|
21453
21470
|
const inputHint = promptHint || toastHint || (progressHint?.text || "");
|
|
21454
21471
|
const inputHintTone = promptHint ? promptHintTone : latestToast?.tone || progressHint?.tone || "info";
|
|
21455
21472
|
const latestTranscriptItem = state.items[state.items.length - 1] || null;
|
|
21456
|
-
const latestDoneAtTail = latestTranscriptItem?.kind === "turndone" || latestTranscriptItem?.kind === "statusdone";
|
|
21457
21473
|
const latestDoneItem = null;
|
|
21458
21474
|
const SCROLL_HINT_ROWS = 0;
|
|
21459
21475
|
const LIVE_STATUS_ROWS = 0;
|
|
@@ -21473,7 +21489,13 @@ function App({ store, initialStatusLine = "", forceOnboarding = false }) {
|
|
|
21473
21489
|
const TEXT_ENTRY_ROWS = PANEL_CHROME_ROWS + textEntryContentRows + textEntryDetailRows;
|
|
21474
21490
|
const OPTION_PANEL_EXTRA_ROWS = expandedOptionPanel ? 3 : 0;
|
|
21475
21491
|
const queuedVisible = !hasFloatingPanel && !inputBoxHidden && state.queued?.length > 0;
|
|
21476
|
-
const promptMetaVisible =
|
|
21492
|
+
const promptMetaVisible = isLiveSpinnerMetaVisible({
|
|
21493
|
+
inputBoxHidden,
|
|
21494
|
+
slashPaletteOpen,
|
|
21495
|
+
liveSpinner,
|
|
21496
|
+
liveSpinnerIsCommand,
|
|
21497
|
+
latestTranscriptItem
|
|
21498
|
+
});
|
|
21477
21499
|
const promptMetaRows = promptMetaVisible ? 2 : 0;
|
|
21478
21500
|
const overlayHintRequested = !inputBoxHidden && !hasFloatingPanel && !liveSpinner && !!inputHint && !queuedVisible;
|
|
21479
21501
|
const overlayHintRows = 0;
|
|
@@ -21516,7 +21538,10 @@ function App({ store, initialStatusLine = "", forceOnboarding = false }) {
|
|
|
21516
21538
|
const promptRowsOnlyChange = panelShrinkRows > 0 && panelKindSignature(panelTransition.signature) === panelKindSignature(panelLayoutSignature);
|
|
21517
21539
|
const prevMetaRows = Number(String(panelTransition.signature).split("|")[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
|
|
21518
21540
|
const nextMetaRows = Number(String(panelLayoutSignature).split("|")[PANEL_LAYOUT_SIG.PROMPT_META]) || 0;
|
|
21519
|
-
const doneTailAppendedThisCommit =
|
|
21541
|
+
const doneTailAppendedThisCommit = isCompletedTranscriptTailAppendedThisCommit(
|
|
21542
|
+
latestTranscriptItem,
|
|
21543
|
+
panelTransition.tailId
|
|
21544
|
+
);
|
|
21520
21545
|
const spinnerMetaCollapseRows = doneTailAppendedThisCommit ? Math.max(0, prevMetaRows - nextMetaRows) : 0;
|
|
21521
21546
|
const prevQueuedSigRows = Number(String(panelTransition.signature).split("|")[PANEL_LAYOUT_SIG.QUEUED]) || 0;
|
|
21522
21547
|
const nextQueuedSigRows = Number(String(panelLayoutSignature).split("|")[PANEL_LAYOUT_SIG.QUEUED]) || 0;
|