mixdog 0.9.85 → 0.9.87

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 (102) hide show
  1. package/README.md +7 -1
  2. package/package.json +4 -3
  3. package/src/defaults/agents.json +12 -0
  4. package/src/defaults/skills/setup/SKILL.md +44 -12
  5. package/src/help.mjs +40 -9
  6. package/src/lib/keychain-cjs.cjs +11 -1
  7. package/src/rules/agent/43-title-agent.md +22 -0
  8. package/src/rules/shared/01-tool.md +3 -3
  9. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -2
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
  11. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  12. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +125 -16
  13. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  15. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  16. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  18. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
  19. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  20. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
  21. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  22. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  23. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  24. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  25. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  26. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  27. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  28. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  32. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  35. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  36. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -3
  37. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  38. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  39. package/src/runtime/agent/orchestrator/tools/token-manifest.json +26 -0
  40. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  41. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  42. package/src/runtime/media/renditions.mjs +1 -1
  43. package/src/runtime/media/store.mjs +1 -1
  44. package/src/runtime/memory/lib/memory-action-handlers.mjs +4 -1
  45. package/src/runtime/memory/tool-defs.mjs +19 -3
  46. package/src/runtime/shared/atomic-file.mjs +25 -14
  47. package/src/runtime/shared/automation-attachments.mjs +3 -3
  48. package/src/runtime/shared/child-guardian.mjs +15 -5
  49. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  50. package/src/runtime/shared/resource-admission.mjs +7 -3
  51. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  52. package/src/session-runtime/lifecycle-api.mjs +49 -9
  53. package/src/session-runtime/prewarm.mjs +35 -0
  54. package/src/session-runtime/provider-auth-api.mjs +8 -0
  55. package/src/session-runtime/provider-usage.mjs +30 -5
  56. package/src/session-runtime/remote-control.mjs +8 -47
  57. package/src/session-runtime/remote-transcript.mjs +3 -7
  58. package/src/session-runtime/route-preparation.mjs +24 -0
  59. package/src/session-runtime/runtime-core.mjs +286 -548
  60. package/src/session-runtime/session-lifecycle.mjs +378 -0
  61. package/src/session-runtime/session-turn-api.mjs +9 -2
  62. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  63. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  64. package/src/standalone/agent-shard/shard-pool.mjs +443 -0
  65. package/src/standalone/agent-tool/job-views.mjs +329 -0
  66. package/src/standalone/agent-tool/spawn-flow.mjs +629 -0
  67. package/src/standalone/agent-tool/tag-registry.mjs +340 -0
  68. package/src/standalone/agent-tool.mjs +112 -941
  69. package/src/standalone/channel-daemon-transport.mjs +126 -185
  70. package/src/standalone/channel-daemon.mjs +9 -10
  71. package/src/standalone/usage-dashboard.mjs +23 -1
  72. package/src/tui/App.jsx +362 -2569
  73. package/src/tui/app/app-view.jsx +504 -0
  74. package/src/tui/app/channel-pickers.mjs +3 -8
  75. package/src/tui/app/create-app-pickers.mjs +313 -0
  76. package/src/tui/app/prompt-submit.mjs +501 -0
  77. package/src/tui/app/route-pickers.mjs +117 -152
  78. package/src/tui/app/settings-picker.mjs +90 -89
  79. package/src/tui/app/shell-layout.mjs +563 -0
  80. package/src/tui/app/slash-commands.mjs +7 -7
  81. package/src/tui/app/slash-dispatch.mjs +0 -45
  82. package/src/tui/app/usage-context-panels.mjs +288 -0
  83. package/src/tui/app/use-copy-selection.mjs +56 -0
  84. package/src/tui/app/use-global-key-input.mjs +151 -0
  85. package/src/tui/app/use-pasted-buffers.mjs +115 -0
  86. package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
  87. package/src/tui/app/use-prompt-hint.mjs +58 -0
  88. package/src/tui/app/use-prompt-queue-history.mjs +100 -0
  89. package/src/tui/app/use-terminal-chrome.mjs +72 -0
  90. package/src/tui/app/use-transcript-activity.mjs +131 -0
  91. package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
  92. package/src/tui/components/StatusLine.jsx +1 -1
  93. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  94. package/src/tui/dist/index.mjs +9857 -9159
  95. package/src/tui/engine/session-api-ext.mjs +47 -9
  96. package/src/tui/engine.mjs +7 -1
  97. package/src/tui/figures.mjs +0 -1
  98. package/src/ui/statusline-format.mjs +0 -1
  99. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  100. package/src/lib/config-cjs.cjs +0 -61
  101. package/src/runtime/shared/launcher-control.mjs +0 -258
  102. package/src/runtime/shared/workspace-router.mjs +0 -259
@@ -31,11 +31,9 @@ function isPidAlive(pid) {
31
31
  catch (error) { return error?.code === 'EPERM'; }
32
32
  }
33
33
 
34
- // Tool names whose successful call re-points the routing pointer at the caller.
35
- // These are the Lead-only "I am now the bound TUI" signals (bridge claim on
36
- // start, Lead-pushed transcript repoint). Per the routing ruling the pointer is
37
- // NOT a lease: last-bind-wins, pid-verified at delivery, no heartbeat expiry.
38
- const POINTER_TOOLS = new Set(['activate_channel_bridge', 'rebind_current_transcript']);
34
+ const ACTIVATE_TOOL = 'activate_channel_bridge';
35
+ const REBIND_TOOL = 'rebind_current_transcript';
36
+ const BINDING_TOOLS = new Set([ACTIVATE_TOOL, REBIND_TOOL]);
39
37
 
40
38
  export function createChannelDaemonTransport({
41
39
  handleCall,
@@ -46,7 +44,7 @@ export function createChannelDaemonTransport({
46
44
  sweepMs = 5_000,
47
45
  onClientsEmpty = null,
48
46
  getStatus = () => ({}),
49
- dispatchBind = null,
47
+ dispatchControl = null,
50
48
  registrationReplayTtlMs = 60_000,
51
49
  remoteStatePath = null,
52
50
  } = {}) {
@@ -56,10 +54,8 @@ export function createChannelDaemonTransport({
56
54
  || (discoveryPath ? join(dirname(discoveryPath), 'channel-remote-state.json') : null);
57
55
  // token -> { token, leadPid, cwd, sse, lastSeen, registeredAt }
58
56
  const clients = new Map();
59
- // leadPid of the last client to claim the bridge / repoint the transcript.
60
- // Under a single-owner bridge there is exactly one bound transcript at a
61
- // time, so the pointer client IS the transcript owner — this unifies notify
62
- // targeting for both inbound-message notifies and proactive injects.
57
+ // The routing pointer is set ONLY by an explicit manual Remote ON. Client
58
+ // registration, transcript rebind, and owner death never select a successor.
63
59
  let pointerToken = null;
64
60
  let boundPort = null;
65
61
  // Sticky replay cache for the bridge remote-state notify. The daemon emits
@@ -93,7 +89,7 @@ export function createChannelDaemonTransport({
93
89
  function nowMs() { return Date.now(); }
94
90
 
95
91
  function remoteSessionIdFromBinding(name, args) {
96
- if (!POINTER_TOOLS.has(name) || !args || typeof args !== 'object') return null;
92
+ if (!BINDING_TOOLS.has(name) || !args || typeof args !== 'object') return null;
97
93
  const explicit = String(args.sessionId || '').trim();
98
94
  if (/^[A-Za-z0-9_-]+$/.test(explicit)) return explicit;
99
95
  const transcriptPath = String(args.transcriptPath || '').trim();
@@ -153,15 +149,30 @@ export function createChannelDaemonTransport({
153
149
  const c = clients.get(token);
154
150
  if (!c) return;
155
151
  const wasPointer = pointerToken === token;
152
+ const shouldDeactivate = wasPointer && remoteAcquired;
156
153
  removeClientRecord(token);
157
154
  if (pointerToken === token) pointerToken = null;
158
155
  log(`client ${token} (lead=${c.leadPid}) removed: ${reason}`);
159
- // Pointer client death → hand the seat to a survivor (last-wins among the
160
- // living), replay its badge, and rebind the output forwarder to it. Skipped
161
- // during stop()/close (grace shutdown owns teardown) and when no live client
162
- // remains (grace shutdown path).
163
- if (wasPointer && !closed) failoverPointer(reason);
156
+ // Owner removal always means Remote OFF. Never auto-select or rebind a
157
+ // survivor; explicitly deactivate the shared backend if the owner vanished
158
+ // without first sending its normal active:false command.
159
+ if (wasPointer) {
160
+ remoteAcquired = false;
161
+ stickyRemoteFrame = null;
162
+ }
164
163
  publishRemoteOwnerState();
164
+ if (shouldDeactivate && !closed && typeof dispatchControl === 'function') {
165
+ try {
166
+ Promise.resolve()
167
+ .then(() => dispatchControl(ACTIVATE_TOOL, {
168
+ active: false,
169
+ sessionId: c.remoteSessionId || null,
170
+ }, { clientToken: c.token, leadPid: c.leadPid, cwd: c.cwd }))
171
+ .catch((err) => log(`owner deactivate failed for lead=${c.leadPid}: ${err?.message || err}`));
172
+ } catch (err) {
173
+ log(`owner deactivate failed for lead=${c.leadPid}: ${err?.message || err}`);
174
+ }
175
+ }
165
176
  maybeArmGrace('client removed');
166
177
  }
167
178
 
@@ -221,51 +232,6 @@ export function createChannelDaemonTransport({
221
232
  return 'cancelled';
222
233
  }
223
234
 
224
- // Pointer failover on owner death. Move the pointer to the most-recently-seen
225
- // LIVE client (reason 'failover'), deliver the sticky 'acquired' badge to it
226
- // (pending-buffered if it has no SSE yet), and re-dispatch ITS stored bind
227
- // intent so the output forwarder rebinds to the survivor's transcript. A
228
- // survivor with no stored bind still gets the pointer + badge (no rebind).
229
- function failoverPointer(reason) {
230
- let best = null;
231
- for (const [, c] of liveClients()) {
232
- if (!best || c.lastSeen > best.lastSeen) best = c;
233
- }
234
- if (!best) return; // no live clients — let grace shutdown run
235
- remoteAcquired = true;
236
- movePointer(best.token, 'failover');
237
- // Persist the acquired badge as the sticky frame so a later attachSse (or a
238
- // reconnect that follows the pointer) can replay it — matters when the
239
- // runtime never emitted 'acquired' since daemon boot (sticky still null).
240
- stickyRemoteFrame = JSON.stringify({ type: 'notify', method: REMOTE_STATE_METHOD, params: { state: 'acquired' } });
241
- writeRemoteStateTo(best, 'acquired');
242
- if (best.lastBind && typeof dispatchBind === 'function') {
243
- const { name, args } = best.lastBind;
244
- log(`failover rebind -> token=${best.token} lead=${best.leadPid} bind=${name} (${reason})`);
245
- try {
246
- Promise.resolve()
247
- .then(() => dispatchBind(name, args || {}, { clientToken: best.token, leadPid: best.leadPid, cwd: best.cwd }))
248
- .catch((err) => log(`failover rebind dispatch failed for lead=${best.leadPid}: ${err?.message || err}`));
249
- } catch (err) { log(`failover rebind dispatch failed for lead=${best.leadPid}: ${err?.message || err}`); }
250
- }
251
- }
252
-
253
- // Re-dispatch a client's stored bind intent so the output forwarder rebinds to
254
- // ITS transcript — the same mechanism failoverPointer uses on owner death,
255
- // applied when a client GAINS the pointer while already holding a bind (e.g.
256
- // migrated by pointer-follows-reconnect). Guarded, logged, non-throwing; does
257
- // not touch last-wins ownership or sticky replay.
258
- function redispatchPointerBind(client, reason) {
259
- if (!client?.lastBind || typeof dispatchBind !== 'function') return;
260
- const { name, args } = client.lastBind;
261
- log(`pointer rebind -> token=${client.token} lead=${client.leadPid} bind=${name} (${reason})`);
262
- try {
263
- Promise.resolve()
264
- .then(() => dispatchBind(name, args || {}, { clientToken: client.token, leadPid: client.leadPid, cwd: client.cwd }))
265
- .catch((err) => log(`pointer rebind dispatch failed for lead=${client.leadPid}: ${err?.message || err}`));
266
- } catch (err) { log(`pointer rebind dispatch failed for lead=${client.leadPid}: ${err?.message || err}`); }
267
- }
268
-
269
235
  function cancelGrace() {
270
236
  if (graceTimer) { try { clearTimeout(graceTimer); } catch {} graceTimer = null; }
271
237
  }
@@ -293,32 +259,16 @@ export function createChannelDaemonTransport({
293
259
  sweepTimer.unref?.();
294
260
  }
295
261
 
296
- // Resolve the ONE client that should receive a notify. Pointer client first
297
- // (pid-verified at delivery), then most-recently-seen live client, else drop.
298
- // Never broadcasts — a broadcast would inject one inbound Discord message into
299
- // every attached TUI's Lead session.
262
+ // Resolve the ONE manually enabled client. With no explicit owner, drop the
263
+ // notify; registration and liveness never invent a routing destination.
300
264
  function resolveTarget() {
301
265
  if (pointerToken) {
302
266
  const c = clients.get(pointerToken);
303
267
  if (c && isPidAlive(c.leadPid)) return c;
304
- // Dead pointer discovered at notify time (i.e. BEFORE the sweep prunes
305
- // it): route removal through dropClient so failoverPointer runs — the
306
- // survivor gets the pointer, sticky 'acquired' badge, and its bind
307
- // intent re-dispatched. Silently nulling the token here bypassed all of
308
- // that (pointer stayed null; survivor never got badge/rebind).
309
268
  if (c) dropClient(pointerToken, 'pid dead (notify-time)');
310
269
  else pointerToken = null;
311
- // failoverPointer (via dropClient) may have installed a new pointer.
312
- if (pointerToken) {
313
- const p = clients.get(pointerToken);
314
- if (p && isPidAlive(p.leadPid)) return p;
315
- }
316
270
  }
317
- let best = null;
318
- for (const [, c] of liveClients()) {
319
- if (!best || c.lastSeen > best.lastSeen) best = c;
320
- }
321
- return best;
271
+ return null;
322
272
  }
323
273
 
324
274
  // Write a targeted remote-state frame to ONE client's SSE. If that client has
@@ -333,39 +283,28 @@ export function createChannelDaemonTransport({
333
283
  catch (err) { log(`remote-state '${state}' write failed for lead=${client.leadPid}: ${err?.message || err}`); return false; }
334
284
  }
335
285
 
336
- // Unified last-wins ownership move. The pointer client owns ALL THREE of
337
- // inbound notify routing, outbound transcript binding, and the remote badge.
338
- // Every move (new TUI register OR a bind-intent /call) hands the seat to the
339
- // newcomer AND tells the DISPLACED owner it lost via a targeted 'superseded'
340
- // frame — so the old TUI drops its badge and stops pushing rebinds. Skip the
341
- // superseded when the old/new client share a leadPid (same TUI reconnecting /
342
- // re-binding to itself — it never "lost").
343
- function movePointer(newToken, reason) {
286
+ // Manual ON is the only ownership transition. It may temporarily point at
287
+ // the claimant before backend activation so a synchronous acquired notify is
288
+ // routed correctly; the displaced owner is notified only after activation
289
+ // succeeds.
290
+ function movePointer(newToken, reason, { notifyDisplaced = true } = {}) {
344
291
  const oldToken = pointerToken;
345
292
  if (oldToken === newToken) {
346
293
  pointerToken = newToken;
347
294
  publishRemoteOwnerState();
348
- return;
295
+ return oldToken;
349
296
  }
350
297
  pointerToken = newToken;
351
298
  const oldClient = oldToken ? clients.get(oldToken) : null;
352
299
  const newClient = clients.get(newToken);
353
300
  log(`routing pointer -> token=${newToken} lead=${newClient?.leadPid ?? '?'} via ${reason}`);
354
301
  publishRemoteOwnerState();
355
- if (!oldClient || oldClient === newClient) return;
356
- if (newClient && oldClient.leadPid === newClient.leadPid) return; // same TUI
357
- if (isPidAlive(oldClient.leadPid)) {
302
+ if (notifyDisplaced && oldClient && oldClient !== newClient && isPidAlive(oldClient.leadPid)) {
358
303
  if (writeRemoteStateTo(oldClient, 'superseded')) {
359
304
  log(`superseded -> displaced pointer token=${oldToken} lead=${oldClient.leadPid}`);
360
305
  }
361
306
  }
362
- // Newcomer gaining the seat with a stored bind must rebind the output
363
- // forwarder to ITS transcript, mirroring failoverPointer. Only for moves
364
- // that carry NO fresh bind of their own: a POINTER_TOOLS /call already
365
- // dispatches the new bind right after this returns (redispatching the stale
366
- // lastBind first would fire an out-of-date rebind), and 'failover'
367
- // self-dispatches best.lastBind. Last-wins ordering above is untouched.
368
- if (reason !== 'failover' && !POINTER_TOOLS.has(reason)) redispatchPointerBind(newClient, reason);
307
+ return oldToken;
369
308
  }
370
309
 
371
310
  function notify(method, params) {
@@ -377,11 +316,18 @@ export function createChannelDaemonTransport({
377
316
  // a late attach that IS the pointer can replay it. Under last-wins the
378
317
  // owner is exactly the pointer client, so deliver ONLY there — a
379
318
  // broadcast would light the badge on displaced/non-owner TUIs.
319
+ const target = resolveTarget();
320
+ if (!target) {
321
+ remoteAcquired = false;
322
+ stickyRemoteFrame = null;
323
+ publishRemoteOwnerState();
324
+ log('remote-state acquired ignored (no manual owner)');
325
+ return false;
326
+ }
380
327
  remoteAcquired = true;
381
328
  stickyRemoteFrame = frame;
382
329
  publishRemoteOwnerState();
383
- const target = resolveTarget();
384
- if (!target?.sse) { log('remote-state acquired not delivered (no live pointer SSE); sticky set'); return false; }
330
+ if (!target.sse) { log('remote-state acquired not delivered (manual owner has no SSE); sticky set'); return false; }
385
331
  try { target.sse.write(`data: ${frame}\n\n`); return true; }
386
332
  catch (err) { log(`remote-state acquired write failed for lead=${target.leadPid}: ${err?.message || err}`); return false; }
387
333
  }
@@ -474,51 +420,13 @@ export function createChannelDaemonTransport({
474
420
  // Token-scoped state belongs to the logical client, not only the owner.
475
421
  // A non-owner can hold a buffered superseded frame or stored bind intent.
476
422
  if (replaced.pending?.length) fresh.pending.push(...replaced.pending.splice(0));
477
- if (replaced.lastBind) fresh.lastBind = replaced.lastBind;
478
423
  if (replaced.remoteSessionId) fresh.remoteSessionId = replaced.remoteSessionId;
479
424
  if (replacedWasPointer) pointerToken = token;
480
425
  removeClientRecord(replaced.token);
481
426
  log(`client reconnect replaced token=${replaced.token} -> ${token} lead=${pid}`);
482
- if (replacedWasPointer) redispatchPointerBind(fresh, 'reconnect');
483
427
  return rememberReplacement(token);
484
428
  }
485
- // Pointer-follows-pid: if the current pointer client shares this leadPid AND
486
- // has no live SSE, it is the SAME terminal re-registering after its stream
487
- // dropped. That old entry is a dead-stream blackhole — pid-prune never reaps
488
- // it (pid still alive) and sse-close never drops entries, so resolveTarget
489
- // would write to a dead stream forever. Transfer the seat to the fresh
490
- // token, migrate its buffered pending frames + last bind intent, and remove
491
- // the old entry WITHOUT a failover or a self-'superseded'. A live-stream
492
- // pointer that happens to share the pid (e.g. co-located clients) is left
493
- // alone — a genuine fresh attach remains an observer until it binds.
494
- if (pointerToken && pointerToken !== token) {
495
- const old = clients.get(pointerToken);
496
- if (old && old.leadPid === pid && !old.sse) {
497
- const fresh = clients.get(token);
498
- if (old.pending?.length) fresh.pending.push(...old.pending.splice(0));
499
- if (old.lastBind) fresh.lastBind = old.lastBind;
500
- if (old.remoteSessionId) fresh.remoteSessionId = old.remoteSessionId;
501
- removeClientRecord(pointerToken);
502
- pointerToken = token;
503
- log(`pointer follows reconnect -> token=${token} lead=${pid} (old entry dropped)`);
504
- // The fresh token inherited the old entry's bind intent but never went
505
- // through movePointer — re-dispatch it so the forwarder rebinds to this
506
- // reconnected terminal's transcript (guarded/no-op when no lastBind).
507
- redispatchPointerBind(fresh, 'reconnect');
508
- return rememberReplacement(token);
509
- }
510
- }
511
- // Registration establishes transport connectivity only. Desktop engine
512
- // prewarm, automation, and remote auto-start may all attach to an existing
513
- // daemon without claiming its active terminal's relay seat. Only an
514
- // explicit pointer tool (/remote claim or transcript bind) may move an
515
- // occupied pointer and supersede its owner. Adopt a vacant pointer so the
516
- // first client still receives daemon notifications without a blackhole.
517
- if (!pointerToken) {
518
- pointerToken = token;
519
- publishRemoteOwnerState();
520
- log(`routing pointer initialized -> token=${token} lead=${pid}`);
521
- }
429
+ // Registration is transport-only, including same-pid fresh attaches.
522
430
  return rememberReplacement(token);
523
431
  }
524
432
 
@@ -621,30 +529,6 @@ export function createChannelDaemonTransport({
621
529
  }
622
530
  c.lastSeen = nowMs();
623
531
  const name = String(body.name || '');
624
- const pointerCall = Boolean(c && POINTER_TOOLS.has(name));
625
- const pointerOwner = pointerToken ? clients.get(pointerToken) : null;
626
- const claimIfVacant = name === 'activate_channel_bridge'
627
- && body.args?.active === true
628
- && body.args?.claimIfVacant === true;
629
- const claimBlocked = claimIfVacant
630
- && pointerToken !== clientToken
631
- && remoteAcquired === true
632
- && Boolean(pointerOwner?.remoteSessionId);
633
- const pointerMove = pointerCall && !claimBlocked;
634
- const previousRemoteSessionId = c?.remoteSessionId || null;
635
- const previousRemoteAcquired = remoteAcquired;
636
- // Bind-intent calls re-point routing at the caller BEFORE dispatch, so a
637
- // notify emitted synchronously during the call already targets it.
638
- if (pointerMove) {
639
- const bindingSessionId = remoteSessionIdFromBinding(name, body.args);
640
- if (bindingSessionId) c.remoteSessionId = bindingSessionId;
641
- if (name === 'activate_channel_bridge' && body.args?.active === false) {
642
- remoteAcquired = false;
643
- }
644
- // Explicit bind claims are last-wins. Auto claims reach here only
645
- // when no live bound remote owner occupies the pointer.
646
- movePointer(clientToken, name);
647
- }
648
532
  const callId = body.callId ? String(body.callId) : null;
649
533
  let dispatch;
650
534
  if (callId && callCache.has(callId)) {
@@ -652,16 +536,83 @@ export function createChannelDaemonTransport({
652
536
  // side-effect) instead of dispatching handleCall a second time.
653
537
  dispatch = callCache.get(callId).promise;
654
538
  } else {
655
- dispatch = claimBlocked
656
- ? Promise.resolve({
657
- content: [{ type: 'text', text: 'channel bridge claim skipped: live remote owner' }],
658
- claimSkipped: true,
659
- })
660
- : Promise.resolve().then(() => handleCall(name, body.args || {}, {
661
- clientToken,
662
- leadPid: c?.leadPid ?? null,
663
- cwd: c?.cwd ?? null,
664
- }));
539
+ dispatch = Promise.resolve().then(async () => {
540
+ const args = body.args || {};
541
+ const activationCall = name === ACTIVATE_TOOL;
542
+ const activating = activationCall && args.active === true;
543
+ const deactivating = activationCall && args.active === false;
544
+ const rebindCall = name === REBIND_TOOL;
545
+ const bindingSessionId = remoteSessionIdFromBinding(name, args);
546
+ // Auto/implicit activation is retired. A valid manual ON always
547
+ // carries its target session and explicitly overrides the old one.
548
+ if (activating && (args.claimIfVacant === true || !bindingSessionId)) {
549
+ return {
550
+ content: [{ type: 'text', text: 'channel bridge claim skipped: manual ON required' }],
551
+ claimSkipped: true,
552
+ };
553
+ }
554
+ // A stale displaced session can never turn the new manual owner off.
555
+ if (deactivating && (pointerToken !== clientToken || !bindingSessionId
556
+ || c.remoteSessionId !== bindingSessionId)) {
557
+ return {
558
+ content: [{ type: 'text', text: 'channel bridge release skipped: not current owner' }],
559
+ releaseSkipped: true,
560
+ };
561
+ }
562
+ // Transcript refresh is owner-only and session-matched. It may
563
+ // update forwarding but can never acquire or move the owner.
564
+ if (rebindCall && (pointerToken !== clientToken || !remoteAcquired
565
+ || !bindingSessionId || c.remoteSessionId !== bindingSessionId)) {
566
+ return {
567
+ content: [{ type: 'text', text: 'transcript rebind skipped: not current owner' }],
568
+ rebindSkipped: true,
569
+ };
570
+ }
571
+
572
+ const previousPointerToken = pointerToken;
573
+ const previousRemoteSessionId = c.remoteSessionId || null;
574
+ const previousRemoteAcquired = remoteAcquired;
575
+ const previousStickyRemoteFrame = stickyRemoteFrame;
576
+ if (activating) {
577
+ c.remoteSessionId = bindingSessionId;
578
+ movePointer(clientToken, 'manual remote ON', { notifyDisplaced: false });
579
+ }
580
+ try {
581
+ const result = await handleCall(name, args, {
582
+ clientToken,
583
+ leadPid: c?.leadPid ?? null,
584
+ cwd: c?.cwd ?? null,
585
+ });
586
+ if (activating && previousPointerToken && previousPointerToken !== clientToken) {
587
+ const displaced = clients.get(previousPointerToken);
588
+ if (displaced && isPidAlive(displaced.leadPid)
589
+ && writeRemoteStateTo(displaced, 'superseded')) {
590
+ log(`superseded -> displaced manual owner token=${previousPointerToken} lead=${displaced.leadPid}`);
591
+ }
592
+ }
593
+ if (deactivating && pointerToken === clientToken
594
+ && c.remoteSessionId === bindingSessionId) {
595
+ pointerToken = null;
596
+ c.remoteSessionId = null;
597
+ remoteAcquired = false;
598
+ stickyRemoteFrame = null;
599
+ }
600
+ publishRemoteOwnerState();
601
+ return result;
602
+ } catch (err) {
603
+ if (activating && pointerToken === clientToken
604
+ && c.remoteSessionId === bindingSessionId) {
605
+ pointerToken = previousPointerToken && clients.has(previousPointerToken)
606
+ ? previousPointerToken
607
+ : null;
608
+ c.remoteSessionId = previousRemoteSessionId;
609
+ remoteAcquired = previousRemoteAcquired;
610
+ stickyRemoteFrame = previousStickyRemoteFrame;
611
+ publishRemoteOwnerState();
612
+ }
613
+ throw err;
614
+ }
615
+ });
665
616
  if (callId) {
666
617
  callCache.set(callId, { promise: dispatch, at: nowMs() });
667
618
  // Start the TTL only once the call SETTLES: an in-flight call can
@@ -676,19 +627,8 @@ export function createChannelDaemonTransport({
676
627
  }
677
628
  try {
678
629
  const result = await dispatch;
679
- // Record the caller's last bind intent so a failover can rebind the
680
- // output forwarder to THIS client's transcript when it becomes owner.
681
- if (pointerMove) {
682
- c.lastBind = { name, args: body.args || {} };
683
- publishRemoteOwnerState();
684
- }
685
630
  sendJson(res, { result });
686
631
  } catch (err) {
687
- if (pointerMove) {
688
- c.remoteSessionId = previousRemoteSessionId;
689
- remoteAcquired = previousRemoteAcquired;
690
- publishRemoteOwnerState();
691
- }
692
632
  sendJson(res, { error: err?.message || String(err) }, 200);
693
633
  }
694
634
  return;
@@ -760,5 +700,6 @@ export function createChannelDaemonTransport({
760
700
  _clientsForTest: clients,
761
701
  _registrationReplaysForTest: registrationReplays,
762
702
  _resolveTargetForTest: resolveTarget,
703
+ get _pointerTokenForTest() { return pointerToken; },
763
704
  };
764
705
  }
@@ -136,9 +136,8 @@ async function main() {
136
136
  // skips runWorkerIpc; this triggers its boot side effects (config/backend).
137
137
  channels = await import('../runtime/channels/index.mjs');
138
138
 
139
- // Pointer-move tools re-point active-instance owner/cwd at the binding TUI
140
- // (mirrors the transport's routing pointer) so external readers see the
141
- // pointer TUI, not the daemon's spawner.
139
+ // Accepted owner controls refresh active-instance context. The transport
140
+ // admits rebind only for the current manual owner.
142
141
  const POINTER_TOOLS = new Set(['activate_channel_bridge', 'rebind_current_transcript']);
143
142
  const handleCall = (name, args, ctx) => {
144
143
  if (ctx && POINTER_TOOLS.has(name)) {
@@ -148,10 +147,9 @@ async function main() {
148
147
  };
149
148
  transport = createChannelDaemonTransport({
150
149
  handleCall,
151
- // Failover re-dispatch of the survivor's stored bind intent goes through the
152
- // SAME handleCall + setOwnerContext path a POINTER_TOOLS /call uses, so the
153
- // output forwarder rebinds to the survivor's transcript on pointer death.
154
- dispatchBind: (name, args, ctx) => handleCall(name, args, ctx),
150
+ // If a manual owner disappears without sending OFF, deactivate the backend.
151
+ // No survivor is selected or rebound.
152
+ dispatchControl: (name, args, ctx) => handleCall(name, args, ctx),
155
153
  discoveryPath: DISCOVERY_PATH,
156
154
  log,
157
155
  // Self-shutdown when the last attached TUI leaves (reuses the SSE/client
@@ -188,11 +186,12 @@ async function main() {
188
186
  }
189
187
  log(`ready port=${port} pid=${process.pid} in ${(performance.now() - startedAt).toFixed(0)}ms`);
190
188
 
191
- // Boot messaging only for an explicit/auto remote request. Automation may
189
+ // Boot messaging only for an explicit manual remote request. Automation may
192
190
  // spawn the shared daemon with no remote intent; keep schedules/webhooks live
193
- // without connecting the channel backend until a Remote claim arrives.
191
+ // without connecting the channel backend until a manual Remote ON arrives.
192
+ // Legacy `auto` intent is deliberately ignored.
194
193
  const remoteIntent = String(process.env.MIXDOG_REMOTE_INTENT || '');
195
- const messaging = remoteIntent === 'explicit' || remoteIntent === 'auto';
194
+ const messaging = remoteIntent === 'explicit';
196
195
  void Promise.resolve().then(() => channels.start({ messaging }))
197
196
  .catch((e) => log(`channels.start failed (non-fatal): ${e?.message || e}`));
198
197
 
@@ -82,6 +82,19 @@ function normaliseWindows(value, source = 'config') {
82
82
  }).filter(Boolean);
83
83
  }
84
84
 
85
+ function normaliseResetCredits(value) {
86
+ if (!value || typeof value !== 'object') return null;
87
+ const availableCount = num(value.availableCount ?? value.available_count, null);
88
+ const offerRevision = clean(value.offerRevision ?? value.offer_revision);
89
+ if (availableCount === null || !offerRevision) return null;
90
+ const nextExpiresAt = num(value.nextExpiresAt ?? value.next_expires_at, null);
91
+ return {
92
+ availableCount: Math.max(0, Math.floor(availableCount)),
93
+ offerRevision,
94
+ ...(nextExpiresAt && nextExpiresAt > 0 ? { nextExpiresAt } : {}),
95
+ };
96
+ }
97
+
85
98
  function localBudget(providerCfg = {}) {
86
99
  const limitUsd = num(
87
100
  providerCfg.limitUsd ?? providerCfg.limit_usd ?? providerCfg.budgetUsd ?? providerCfg.budget_usd
@@ -224,7 +237,14 @@ async function oauthSnapshot(providerId, { refresh, getProvider, log }) {
224
237
  if (typeof getProvider === 'function') {
225
238
  try {
226
239
  const providerObj = getProvider(providerId);
227
- if (providerObj) return await fetchOAuthUsageSnapshot({ provider: providerId, model: '' }, providerObj, log);
240
+ if (providerObj) {
241
+ return await fetchOAuthUsageSnapshot(
242
+ { provider: providerId, model: '' },
243
+ providerObj,
244
+ log,
245
+ { force: refresh === true },
246
+ );
247
+ }
228
248
  } catch {
229
249
  return null;
230
250
  }
@@ -544,7 +564,9 @@ export async function createUsageDashboard(config = {}, options = {}) {
544
564
  const snapshot = await oauthSnapshot(item.id, options);
545
565
  const known = snapshotRemaining(snapshot);
546
566
  const windows = normaliseWindows(snapshot?.quotaWindows, clean(snapshot?.source) || 'provider-api');
567
+ const resetCredits = normaliseResetCredits(snapshot?.resetCredits);
547
568
  row.windows = windows;
569
+ if (resetCredits) row.resetCredits = resetCredits;
548
570
  row.updatedAt = num(snapshot?.cachedAt, null);
549
571
  if (known) {
550
572
  applyKnownRemaining(row, known, { estimated: false });