mixdog 0.9.50 → 0.9.52
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 +5 -3
- package/scripts/abort-recovery-test.mjs +17 -1
- package/scripts/agent-model-liveness-test.mjs +79 -2
- package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
- package/scripts/anthropic-transport-policy-test.mjs +466 -0
- package/scripts/atomic-lock-tryonce-test.mjs +60 -1
- package/scripts/build-tui.mjs +13 -1
- package/scripts/channel-daemon-smoke.mjs +630 -10
- package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
- package/scripts/code-graph-disk-hit-test.mjs +11 -0
- package/scripts/code-graph-root-federation-test.mjs +273 -0
- package/scripts/compact-pressure-test.mjs +55 -1
- package/scripts/compact-smoke.mjs +80 -0
- package/scripts/context-mcp-metering-test.mjs +1350 -19
- package/scripts/deferred-tool-loading-test.mjs +17 -0
- package/scripts/gemini-provider-test.mjs +1053 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/interrupted-turn-history-test.mjs +371 -0
- package/scripts/lifecycle-api-test.mjs +76 -0
- package/scripts/max-output-recovery-test.mjs +55 -0
- package/scripts/mcp-grace-deferred-test.mjs +89 -13
- package/scripts/memory-pg-recovery-test.mjs +59 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
- package/scripts/process-lifecycle-test.mjs +389 -0
- package/scripts/provider-admission-scheduler-test.mjs +582 -0
- package/scripts/provider-contract-test.mjs +268 -0
- package/scripts/provider-toolcall-test.mjs +520 -3
- package/scripts/reactive-compact-persist-smoke.mjs +59 -0
- package/scripts/resource-admission-test.mjs +789 -0
- 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/shell-jobs-windows-hide-test.mjs +1 -1
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +2 -7
- package/scripts/steering-drain-buckets-test.mjs +18 -0
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/toolcall-args-test.mjs +14 -6
- package/scripts/tui-transcript-perf-test.mjs +43 -7
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/cli.mjs +15 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
- 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/admission-scheduler.mjs +331 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
- 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/ask-session.mjs +69 -37
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
- package/src/runtime/memory/index.mjs +22 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
- package/src/runtime/memory/lib/pg/process.mjs +91 -47
- package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
- 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/runtime/shared/atomic-file.mjs +28 -150
- package/src/runtime/shared/process-lifecycle.mjs +363 -0
- package/src/runtime/shared/process-shutdown.mjs +27 -4
- package/src/runtime/shared/resource-admission.mjs +359 -0
- package/src/runtime/shared/staged-child-result.mjs +19 -0
- package/src/session-runtime/context-status.mjs +38 -27
- package/src/session-runtime/lifecycle-api.mjs +26 -6
- package/src/session-runtime/provider-request-tools.mjs +72 -0
- package/src/session-runtime/runtime-core.mjs +43 -18
- package/src/session-runtime/session-turn-api.mjs +5 -4
- package/src/session-runtime/tool-catalog.mjs +375 -15
- package/src/standalone/agent-tool.mjs +17 -38
- package/src/standalone/channel-daemon-client.mjs +200 -38
- package/src/standalone/channel-daemon-transport.mjs +136 -9
- package/src/standalone/channel-worker.mjs +79 -12
- 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 +101 -281
- package/src/tui/engine/session-api-ext.mjs +13 -2
- package/src/tui/engine/session-api.mjs +1 -1
- package/src/tui/engine/turn.mjs +10 -6
- package/src/tui/engine.mjs +13 -4
- package/src/tui/index.jsx +4 -1
- package/src/tui/lib/voice-setup.mjs +16 -0
|
@@ -46,6 +46,7 @@ export function createChannelDaemonTransport({
|
|
|
46
46
|
onClientsEmpty = null,
|
|
47
47
|
getStatus = () => ({}),
|
|
48
48
|
dispatchBind = null,
|
|
49
|
+
registrationReplayTtlMs = 60_000,
|
|
49
50
|
} = {}) {
|
|
50
51
|
if (typeof handleCall !== 'function') throw new Error('handleCall is required');
|
|
51
52
|
|
|
@@ -72,6 +73,11 @@ export function createChannelDaemonTransport({
|
|
|
72
73
|
// retry never double-runs a non-idempotent tool (e.g. reply). Short TTL.
|
|
73
74
|
const callCache = new Map();
|
|
74
75
|
const CALL_CACHE_TTL_MS = 60_000;
|
|
76
|
+
// Reconnect register replay: a server may commit replacement just before its
|
|
77
|
+
// HTTP response is lost. The retry supplies this stable id and receives the
|
|
78
|
+
// already-created fresh token instead of creating an orphan replacement.
|
|
79
|
+
const registrationReplays = new Map(); // registrationId -> { token, leadPid, cwd, replaceToken, responseFinished }
|
|
80
|
+
const registrationReplayTtl = Math.max(1, Number(registrationReplayTtlMs) || 60_000);
|
|
75
81
|
let server = null;
|
|
76
82
|
let graceTimer = null;
|
|
77
83
|
let sweepTimer = null;
|
|
@@ -103,8 +109,7 @@ export function createChannelDaemonTransport({
|
|
|
103
109
|
const c = clients.get(token);
|
|
104
110
|
if (!c) return;
|
|
105
111
|
const wasPointer = pointerToken === token;
|
|
106
|
-
|
|
107
|
-
try { c.sse?.end?.(); } catch {}
|
|
112
|
+
removeClientRecord(token);
|
|
108
113
|
if (pointerToken === token) pointerToken = null;
|
|
109
114
|
log(`client ${token} (lead=${c.leadPid}) removed: ${reason}`);
|
|
110
115
|
// Pointer client death → hand the seat to a survivor (last-wins among the
|
|
@@ -115,6 +120,62 @@ export function createChannelDaemonTransport({
|
|
|
115
120
|
maybeArmGrace('client removed');
|
|
116
121
|
}
|
|
117
122
|
|
|
123
|
+
function removeRegistrationReplay(registrationId, replay = registrationReplays.get(registrationId)) {
|
|
124
|
+
if (!replay || registrationReplays.get(registrationId) !== replay) return;
|
|
125
|
+
registrationReplays.delete(registrationId);
|
|
126
|
+
try { clearTimeout(replay.timer); } catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Every removal path uses this primitive, including replacement's deliberate
|
|
130
|
+
// no-failover retirement, so replay records/timers never target absent tokens.
|
|
131
|
+
function removeClientRecord(token) {
|
|
132
|
+
const c = clients.get(token);
|
|
133
|
+
if (!c) return null;
|
|
134
|
+
clients.delete(token);
|
|
135
|
+
for (const [registrationId, replay] of registrationReplays) {
|
|
136
|
+
if (replay.token === token) removeRegistrationReplay(registrationId, replay);
|
|
137
|
+
}
|
|
138
|
+
try { c.sse?.end?.(); } catch {}
|
|
139
|
+
return c;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clearRegistrationReplays() {
|
|
143
|
+
for (const [registrationId, replay] of registrationReplays) removeRegistrationReplay(registrationId, replay);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function armRegistrationReplay(replayId, replay) {
|
|
147
|
+
try { clearTimeout(replay.timer); } catch {}
|
|
148
|
+
replay.timer = setTimeout(() => {
|
|
149
|
+
if (registrationReplays.get(replayId) !== replay) return;
|
|
150
|
+
// A successfully flushed register response creates a valid client even
|
|
151
|
+
// if its SSE/call is delayed. TTL only bounds cancellation metadata.
|
|
152
|
+
removeRegistrationReplay(replayId, replay);
|
|
153
|
+
if (!replay.responseFinished && clients.has(replay.token)) {
|
|
154
|
+
dropClient(replay.token, 'unflushed registration replay expired');
|
|
155
|
+
}
|
|
156
|
+
}, registrationReplayTtl);
|
|
157
|
+
replay.timer.unref?.();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function markRegistrationResponseFinished(registrationId, token) {
|
|
161
|
+
const replay = registrationId ? registrationReplays.get(registrationId) : null;
|
|
162
|
+
if (replay && replay.token === token) replay.responseFinished = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A response-loss close knows only its retired token + stable registration id.
|
|
166
|
+
// Bind cancellation to every logical-client field before retiring the fresh
|
|
167
|
+
// token; malformed/mismatched cancellation can never affect another client.
|
|
168
|
+
function cancelReplacementRegistration({ token, registrationId, replaceToken, leadPid, cwd }) {
|
|
169
|
+
const replayId = registrationId ? String(registrationId).slice(0, 200) : null;
|
|
170
|
+
const replay = replayId ? registrationReplays.get(replayId) : null;
|
|
171
|
+
if (!replay) return 'missing';
|
|
172
|
+
const retiredToken = token ? String(token) : null;
|
|
173
|
+
if (retiredToken !== replay.replaceToken || String(replaceToken || '') !== replay.replaceToken ||
|
|
174
|
+
parsePid(leadPid) !== replay.leadPid || (cwd || null) !== replay.cwd) return 'forbidden';
|
|
175
|
+
dropClient(replay.token, 'replacement deregister');
|
|
176
|
+
return 'cancelled';
|
|
177
|
+
}
|
|
178
|
+
|
|
118
179
|
// Pointer failover on owner death. Move the pointer to the most-recently-seen
|
|
119
180
|
// LIVE client (reason 'failover'), deliver the sticky 'acquired' badge to it
|
|
120
181
|
// (pending-buffered if it has no SSE yet), and re-dispatch ITS stored bind
|
|
@@ -304,8 +365,25 @@ export function createChannelDaemonTransport({
|
|
|
304
365
|
}
|
|
305
366
|
}
|
|
306
367
|
|
|
307
|
-
function registerClient({ leadPid, cwd, reattach = false }) {
|
|
368
|
+
function registerClient({ leadPid, cwd, reattach = false, replaceToken = null, registrationId = null }) {
|
|
308
369
|
const pid = parsePid(leadPid) ?? 0;
|
|
370
|
+
const replacementToken = replaceToken ? String(replaceToken) : null;
|
|
371
|
+
const replayId = reattach && registrationId ? String(registrationId).slice(0, 200) : null;
|
|
372
|
+
const existingReplay = replayId ? registrationReplays.get(replayId) : null;
|
|
373
|
+
if (existingReplay) {
|
|
374
|
+
if (existingReplay.leadPid === pid && existingReplay.cwd === (cwd || null) &&
|
|
375
|
+
existingReplay.replaceToken === replacementToken && clients.has(existingReplay.token)) {
|
|
376
|
+
armRegistrationReplay(replayId, existingReplay);
|
|
377
|
+
log(`client reconnect replay token=${existingReplay.token} lead=${pid}`);
|
|
378
|
+
return existingReplay.token;
|
|
379
|
+
}
|
|
380
|
+
if (clients.has(existingReplay.token)) {
|
|
381
|
+
const err = new Error('registration replay identity mismatch');
|
|
382
|
+
err.statusCode = 409;
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
removeRegistrationReplay(replayId, existingReplay);
|
|
386
|
+
}
|
|
309
387
|
const token = randomUUID();
|
|
310
388
|
clients.set(token, {
|
|
311
389
|
token,
|
|
@@ -320,6 +398,34 @@ export function createChannelDaemonTransport({
|
|
|
320
398
|
cancelGrace();
|
|
321
399
|
startSweep();
|
|
322
400
|
log(`client registered token=${token} lead=${pid} cwd=${cwd || '-'}`);
|
|
401
|
+
const rememberReplacement = (freshToken) => {
|
|
402
|
+
if (!replayId) return freshToken;
|
|
403
|
+
const replay = {
|
|
404
|
+
token: freshToken, leadPid: pid, cwd: cwd || null, replaceToken: replacementToken,
|
|
405
|
+
responseFinished: false, timer: null,
|
|
406
|
+
};
|
|
407
|
+
registrationReplays.set(replayId, replay);
|
|
408
|
+
armRegistrationReplay(replayId, replay);
|
|
409
|
+
return freshToken;
|
|
410
|
+
};
|
|
411
|
+
// A reconnect names the exact token it replaces. Retire it even when it is
|
|
412
|
+
// not the pointer: retaining a non-owner old token would let it later call,
|
|
413
|
+
// steal ownership, or accumulate a buffered frame after the fresh client
|
|
414
|
+
// has gone away. Token replacement never crosses leadPid boundaries.
|
|
415
|
+
const replaced = reattach && replacementToken ? clients.get(replacementToken) : null;
|
|
416
|
+
if (replaced && replaced.leadPid === pid) {
|
|
417
|
+
const fresh = clients.get(token);
|
|
418
|
+
const replacedWasPointer = pointerToken === replaced.token;
|
|
419
|
+
// Token-scoped state belongs to the logical client, not only the owner.
|
|
420
|
+
// A non-owner can hold a buffered superseded frame or stored bind intent.
|
|
421
|
+
if (replaced.pending?.length) fresh.pending.push(...replaced.pending.splice(0));
|
|
422
|
+
if (replaced.lastBind) fresh.lastBind = replaced.lastBind;
|
|
423
|
+
if (replacedWasPointer) pointerToken = token;
|
|
424
|
+
removeClientRecord(replaced.token);
|
|
425
|
+
log(`client reconnect replaced token=${replaced.token} -> ${token} lead=${pid}`);
|
|
426
|
+
if (replacedWasPointer) redispatchPointerBind(fresh, 'reconnect');
|
|
427
|
+
return rememberReplacement(token);
|
|
428
|
+
}
|
|
323
429
|
// Pointer-follows-pid: if the current pointer client shares this leadPid AND
|
|
324
430
|
// has no live SSE, it is the SAME terminal re-registering after its stream
|
|
325
431
|
// dropped. That old entry is a dead-stream blackhole — pid-prune never reaps
|
|
@@ -335,15 +441,14 @@ export function createChannelDaemonTransport({
|
|
|
335
441
|
const fresh = clients.get(token);
|
|
336
442
|
if (old.pending?.length) fresh.pending.push(...old.pending.splice(0));
|
|
337
443
|
if (old.lastBind) fresh.lastBind = old.lastBind;
|
|
338
|
-
|
|
339
|
-
try { old.sse?.end?.(); } catch {}
|
|
444
|
+
removeClientRecord(pointerToken);
|
|
340
445
|
pointerToken = token;
|
|
341
446
|
log(`pointer follows reconnect -> token=${token} lead=${pid} (old entry dropped)`);
|
|
342
447
|
// The fresh token inherited the old entry's bind intent but never went
|
|
343
448
|
// through movePointer — re-dispatch it so the forwarder rebinds to this
|
|
344
449
|
// reconnected terminal's transcript (guarded/no-op when no lastBind).
|
|
345
450
|
redispatchPointerBind(fresh, 'reconnect');
|
|
346
|
-
return token;
|
|
451
|
+
return rememberReplacement(token);
|
|
347
452
|
}
|
|
348
453
|
}
|
|
349
454
|
if (reattach) {
|
|
@@ -356,12 +461,17 @@ export function createChannelDaemonTransport({
|
|
|
356
461
|
// ownership seat, and the displaced owner is told it lost (superseded).
|
|
357
462
|
movePointer(token, 'register');
|
|
358
463
|
}
|
|
359
|
-
return token;
|
|
464
|
+
return rememberReplacement(token);
|
|
360
465
|
}
|
|
361
466
|
|
|
362
467
|
function attachSse(token, res) {
|
|
363
468
|
const c = clients.get(token);
|
|
364
469
|
if (!c) return false;
|
|
470
|
+
// A live stream proves the client learned its fresh token, so response-loss
|
|
471
|
+
// cancellation is no longer needed for this logical registration.
|
|
472
|
+
for (const [registrationId, replay] of registrationReplays) {
|
|
473
|
+
if (replay.token === token) removeRegistrationReplay(registrationId, replay);
|
|
474
|
+
}
|
|
365
475
|
res.writeHead(200, {
|
|
366
476
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
367
477
|
'Cache-Control': 'no-cache, no-transform',
|
|
@@ -417,12 +527,23 @@ export function createChannelDaemonTransport({
|
|
|
417
527
|
|
|
418
528
|
if (req.method === 'POST' && pathName === '/client/register') {
|
|
419
529
|
const body = await readBody(req);
|
|
420
|
-
const clientToken = registerClient({
|
|
530
|
+
const clientToken = registerClient({
|
|
531
|
+
leadPid: body.leadPid, cwd: body.cwd, reattach: body.reattach === true,
|
|
532
|
+
replaceToken: body.replaceToken, registrationId: body.registrationId,
|
|
533
|
+
});
|
|
534
|
+
const replayId = body.reattach === true && body.registrationId ? String(body.registrationId).slice(0, 200) : null;
|
|
535
|
+
res.once('finish', () => markRegistrationResponseFinished(replayId, clientToken));
|
|
421
536
|
sendJson(res, { token: clientToken, pid: process.pid });
|
|
422
537
|
return;
|
|
423
538
|
}
|
|
424
539
|
if (req.method === 'POST' && pathName === '/client/deregister') {
|
|
425
540
|
const body = await readBody(req);
|
|
541
|
+
if (body.registrationId) {
|
|
542
|
+
const cancelled = cancelReplacementRegistration(body);
|
|
543
|
+
if (cancelled === 'forbidden') { sendError(res, 'forbidden replacement deregister', 403); return; }
|
|
544
|
+
sendJson(res, { ok: true, cancelled: cancelled === 'cancelled' });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
426
547
|
if (body.token) dropClient(body.token, 'deregister');
|
|
427
548
|
sendJson(res, { ok: true });
|
|
428
549
|
return;
|
|
@@ -436,7 +557,11 @@ export function createChannelDaemonTransport({
|
|
|
436
557
|
const body = await readBody(req);
|
|
437
558
|
const clientToken = body.token || null;
|
|
438
559
|
const c = clientToken ? clients.get(clientToken) : null;
|
|
439
|
-
if (c)
|
|
560
|
+
if (!c) { sendError(res, 'unknown client token', 404); return; }
|
|
561
|
+
for (const [registrationId, replay] of registrationReplays) {
|
|
562
|
+
if (replay.token === clientToken) removeRegistrationReplay(registrationId, replay);
|
|
563
|
+
}
|
|
564
|
+
c.lastSeen = nowMs();
|
|
440
565
|
const name = String(body.name || '');
|
|
441
566
|
// Bind-intent calls re-point routing at the caller BEFORE dispatch, so a
|
|
442
567
|
// notify emitted synchronously during the call already targets it.
|
|
@@ -526,6 +651,7 @@ export function createChannelDaemonTransport({
|
|
|
526
651
|
cancelGrace();
|
|
527
652
|
if (sweepTimer) { try { clearInterval(sweepTimer); } catch {} sweepTimer = null; }
|
|
528
653
|
for (const [token] of clients) dropClient(token, 'transport stop');
|
|
654
|
+
clearRegistrationReplays();
|
|
529
655
|
if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
|
|
530
656
|
if (server) {
|
|
531
657
|
await new Promise((resolve) => { try { server.close(() => resolve()); } catch { resolve(); } });
|
|
@@ -540,6 +666,7 @@ export function createChannelDaemonTransport({
|
|
|
540
666
|
get port() { return boundPort; },
|
|
541
667
|
get token() { return serverToken; },
|
|
542
668
|
_clientsForTest: clients,
|
|
669
|
+
_registrationReplaysForTest: registrationReplays,
|
|
543
670
|
_resolveTargetForTest: resolveTarget,
|
|
544
671
|
};
|
|
545
672
|
}
|
|
@@ -108,6 +108,7 @@ export function createStandaloneChannelWorker({
|
|
|
108
108
|
// as an unreachable legacy path while useProcessWorker is true.
|
|
109
109
|
let daemonClient = null;
|
|
110
110
|
let attachPromise = null;
|
|
111
|
+
let attachGeneration = 0;
|
|
111
112
|
let daemonPid = null;
|
|
112
113
|
let nextCallId = 1;
|
|
113
114
|
// Per-proxy unique prefix so a callId can never collide across TUIs sharing
|
|
@@ -408,6 +409,7 @@ export function createStandaloneChannelWorker({
|
|
|
408
409
|
// CAS: only tear down when the live handle is still the one that failed, so
|
|
409
410
|
// a concurrent call that already re-attached a fresh daemon isn't clobbered.
|
|
410
411
|
if (expected && daemonClient !== expected) return;
|
|
412
|
+
attachGeneration++;
|
|
411
413
|
const client = daemonClient;
|
|
412
414
|
daemonClient = null;
|
|
413
415
|
attachPromise = null;
|
|
@@ -476,31 +478,81 @@ export function createStandaloneChannelWorker({
|
|
|
476
478
|
t.unref?.();
|
|
477
479
|
});
|
|
478
480
|
}
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
+
function attachCancelledError() {
|
|
482
|
+
const err = new Error('channels daemon attach superseded');
|
|
483
|
+
err.daemonAttachCancelled = true;
|
|
484
|
+
return err;
|
|
485
|
+
}
|
|
486
|
+
function discoveryMatchesHealth(discovery, health) {
|
|
487
|
+
return Number(health?.pid) === Number(discovery?.pid);
|
|
488
|
+
}
|
|
489
|
+
async function doAttach(discovery, generation) {
|
|
490
|
+
let client = null;
|
|
491
|
+
let fatalDuringAttach = false;
|
|
492
|
+
client = await attachToDaemon({
|
|
481
493
|
discovery,
|
|
482
494
|
leadPid: daemonLeadPid,
|
|
483
495
|
cwd,
|
|
484
496
|
onNotify: (msg) => { try { onNotify?.(msg); } catch {} },
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
497
|
+
// A stale/dead SSE endpoint drops this attach immediately. Do not revive
|
|
498
|
+
// it after this worker has explicitly stopped; otherwise proactively
|
|
499
|
+
// re-read discovery so notifies resume without waiting for the next call.
|
|
500
|
+
onFatal: () => {
|
|
501
|
+
fatalDuringAttach = true;
|
|
502
|
+
invalidateDaemonClient('sse fatal', client);
|
|
503
|
+
if (!stopRequested) void ensureDaemonAttached().catch(() => {});
|
|
504
|
+
},
|
|
488
505
|
log: (line) => logLine(logPath, line),
|
|
489
506
|
});
|
|
507
|
+
if (stopRequested || generation !== attachGeneration || fatalDuringAttach) {
|
|
508
|
+
await client.close('attach superseded');
|
|
509
|
+
if (fatalDuringAttach && !stopRequested && generation === attachGeneration) {
|
|
510
|
+
const err = attachCancelledError();
|
|
511
|
+
err.daemonDiscoveryStale = true;
|
|
512
|
+
throw err;
|
|
513
|
+
}
|
|
514
|
+
throw attachCancelledError();
|
|
515
|
+
}
|
|
490
516
|
daemonClient = client;
|
|
491
517
|
daemonPid = discovery.pid;
|
|
492
518
|
return client;
|
|
493
519
|
}
|
|
494
520
|
async function ensureDaemonAttached() {
|
|
521
|
+
if (stopRequested) throw attachCancelledError();
|
|
495
522
|
if (daemonClient) return daemonClient;
|
|
496
523
|
if (attachPromise) return attachPromise;
|
|
497
|
-
|
|
524
|
+
const generation = attachGeneration;
|
|
525
|
+
const promise = (async () => {
|
|
498
526
|
const deadline = Date.now() + 30_000;
|
|
527
|
+
const MAX_AUTH_REJECTIONS = 5;
|
|
528
|
+
let authRejections = 0;
|
|
529
|
+
const retryStaleDiscovery = async (err) => {
|
|
530
|
+
if (!err?.daemonAuthRejected) {
|
|
531
|
+
await daemonDelay(200);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
authRejections++;
|
|
535
|
+
if (authRejections >= MAX_AUTH_REJECTIONS || Date.now() >= deadline) {
|
|
536
|
+
throw new Error(`channels daemon register rejected discovery auth ${authRejections} times`);
|
|
537
|
+
}
|
|
538
|
+
await daemonDelay(Math.min(200 * (2 ** (authRejections - 1)), 2000));
|
|
539
|
+
};
|
|
499
540
|
for (let attempt = 0; ; attempt++) {
|
|
541
|
+
if (stopRequested || generation !== attachGeneration) throw attachCancelledError();
|
|
500
542
|
let discovery = readDaemonDiscovery(discoveryPath);
|
|
501
543
|
if (discovery) {
|
|
502
544
|
const health = await probeDaemonHealth({ port: discovery.port, token: discovery.token, timeoutMs: attempt === 0 ? 800 : 2000 });
|
|
503
|
-
if (
|
|
545
|
+
if (stopRequested || generation !== attachGeneration) throw attachCancelledError();
|
|
546
|
+
if (discoveryMatchesHealth(discovery, health)) {
|
|
547
|
+
try {
|
|
548
|
+
return await doAttach(discovery, generation);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
if (!err?.daemonDiscoveryStale) throw err;
|
|
551
|
+
logLine(logPath, `daemon attach discovery stale: ${err.message}; re-reading`);
|
|
552
|
+
await retryStaleDiscovery(err);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
504
556
|
discovery = null; // published but unhealthy → respawn
|
|
505
557
|
}
|
|
506
558
|
// No live daemon (absent / dead pid / unhealthy): spawn a candidate. A
|
|
@@ -509,16 +561,27 @@ export function createStandaloneChannelWorker({
|
|
|
509
561
|
const after = readDaemonDiscovery(discoveryPath);
|
|
510
562
|
if (after) {
|
|
511
563
|
const health = await probeDaemonHealth({ port: after.port, token: after.token, timeoutMs: 3000 });
|
|
512
|
-
if (
|
|
564
|
+
if (stopRequested || generation !== attachGeneration) throw attachCancelledError();
|
|
565
|
+
if (discoveryMatchesHealth(after, health)) {
|
|
566
|
+
try {
|
|
567
|
+
return await doAttach(after, generation);
|
|
568
|
+
} catch (err) {
|
|
569
|
+
if (!err?.daemonDiscoveryStale) throw err;
|
|
570
|
+
logLine(logPath, `daemon attach discovery stale: ${err.message}; re-reading`);
|
|
571
|
+
await retryStaleDiscovery(err);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
513
575
|
}
|
|
514
576
|
if (Date.now() > deadline) throw new Error('channels daemon did not become ready');
|
|
515
577
|
await daemonDelay(200);
|
|
516
578
|
}
|
|
517
579
|
})();
|
|
580
|
+
attachPromise = promise;
|
|
518
581
|
try {
|
|
519
|
-
return await
|
|
582
|
+
return await promise;
|
|
520
583
|
} finally {
|
|
521
|
-
attachPromise = null;
|
|
584
|
+
if (attachPromise === promise) attachPromise = null;
|
|
522
585
|
}
|
|
523
586
|
}
|
|
524
587
|
async function _startInProcess() {
|
|
@@ -676,12 +739,16 @@ export function createStandaloneChannelWorker({
|
|
|
676
739
|
// Daemon path: detach this TUI's client only. The shared daemon reaps
|
|
677
740
|
// itself via client-grace once the last TUI leaves; never kill it here.
|
|
678
741
|
if (useProcessWorker) {
|
|
742
|
+
const inFlightAttach = attachPromise;
|
|
743
|
+
attachGeneration++;
|
|
679
744
|
const client = daemonClient;
|
|
680
745
|
daemonClient = null;
|
|
681
746
|
attachPromise = null;
|
|
682
747
|
daemonPid = null;
|
|
683
|
-
|
|
684
|
-
|
|
748
|
+
return Promise.all([
|
|
749
|
+
client ? client.close(reason).then(() => true).catch(() => true) : Promise.resolve(false),
|
|
750
|
+
Promise.resolve(inFlightAttach).catch(() => null),
|
|
751
|
+
]).then(([detached]) => detached);
|
|
685
752
|
}
|
|
686
753
|
if (!child) {
|
|
687
754
|
return Promise.resolve(false);
|
|
@@ -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
|
+
}
|