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.
Files changed (134) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. 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
- clients.delete(token);
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
- clients.delete(pointerToken);
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({ leadPid: body.leadPid, cwd: body.cwd, reattach: body.reattach === true });
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) c.lastSeen = nowMs();
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
- async function doAttach(discovery) {
480
- const client = await attachToDaemon({
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
- // SSE gave up (daemon dead/restarted): drop this attach and proactively
486
- // re-attach so notifies resume without waiting for the next call().
487
- onFatal: () => { invalidateDaemonClient('sse fatal', client); void ensureDaemonAttached().catch(() => {}); },
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
- attachPromise = (async () => {
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 (health) return await doAttach(discovery);
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 (health) return await doAttach(after);
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 attachPromise;
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
- if (!client) return Promise.resolve(false);
684
- return client.close(reason).then(() => true).catch(() => true);
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
- return { action: 'modify', args: nextArgs, reason: rule.reason || rule.message || `modified by hook rule for ${input.name}` };
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 { action: 'modify', args: agg.updatedInput, reason: agg.reason };
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. Engine appends turndone/statusdone before clearing spinner, so
2625
- // a transient frame can otherwise contain BOTH: transcript grows by two rows
2626
- // while the bottom spinner still reserves two rows, making the viewport visibly
2627
- // jump. As soon as the done row is the transcript tail, drop the spinner slot;
2628
- // the new done row replaces that height in the same frame, with no ms timer.
2629
- const promptMetaVisible = !inputBoxHidden && !slashPaletteOpen && !!liveSpinner
2630
- && (liveSpinnerIsCommand || !latestDoneAtTail);
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 latestDoneAtTail stays false and no
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 = latestDoneAtTail
2731
- && (latestTranscriptItem?.id ?? null) !== panelTransition.tailId;
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
+ }