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.
- package/README.md +7 -1
- package/package.json +4 -3
- package/src/defaults/agents.json +12 -0
- package/src/defaults/skills/setup/SKILL.md +44 -12
- package/src/help.mjs +40 -9
- package/src/lib/keychain-cjs.cjs +11 -1
- package/src/rules/agent/43-title-agent.md +22 -0
- package/src/rules/shared/01-tool.md +3 -3
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +125 -16
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
- package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
- package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
- package/src/runtime/agent/orchestrator/tools/token-manifest.json +26 -0
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
- package/src/runtime/media/adapters/gemini-video.mjs +1 -1
- package/src/runtime/media/renditions.mjs +1 -1
- package/src/runtime/media/store.mjs +1 -1
- package/src/runtime/memory/lib/memory-action-handlers.mjs +4 -1
- package/src/runtime/memory/tool-defs.mjs +19 -3
- package/src/runtime/shared/atomic-file.mjs +25 -14
- package/src/runtime/shared/automation-attachments.mjs +3 -3
- package/src/runtime/shared/child-guardian.mjs +15 -5
- package/src/runtime/shared/child-spawn-gate.mjs +6 -12
- package/src/runtime/shared/resource-admission.mjs +7 -3
- package/src/runtime/shared/turn-snapshot.mjs +0 -3
- package/src/session-runtime/lifecycle-api.mjs +49 -9
- package/src/session-runtime/prewarm.mjs +35 -0
- package/src/session-runtime/provider-auth-api.mjs +8 -0
- package/src/session-runtime/provider-usage.mjs +30 -5
- package/src/session-runtime/remote-control.mjs +8 -47
- package/src/session-runtime/remote-transcript.mjs +3 -7
- package/src/session-runtime/route-preparation.mjs +24 -0
- package/src/session-runtime/runtime-core.mjs +286 -548
- package/src/session-runtime/session-lifecycle.mjs +378 -0
- package/src/session-runtime/session-turn-api.mjs +9 -2
- package/src/session-runtime/workflow-agents-api.mjs +3 -0
- package/src/standalone/agent-shard/shard-child.mjs +300 -0
- package/src/standalone/agent-shard/shard-pool.mjs +443 -0
- package/src/standalone/agent-tool/job-views.mjs +329 -0
- package/src/standalone/agent-tool/spawn-flow.mjs +629 -0
- package/src/standalone/agent-tool/tag-registry.mjs +340 -0
- package/src/standalone/agent-tool.mjs +112 -941
- package/src/standalone/channel-daemon-transport.mjs +126 -185
- package/src/standalone/channel-daemon.mjs +9 -10
- package/src/standalone/usage-dashboard.mjs +23 -1
- package/src/tui/App.jsx +362 -2569
- package/src/tui/app/app-view.jsx +504 -0
- package/src/tui/app/channel-pickers.mjs +3 -8
- package/src/tui/app/create-app-pickers.mjs +313 -0
- package/src/tui/app/prompt-submit.mjs +501 -0
- package/src/tui/app/route-pickers.mjs +117 -152
- package/src/tui/app/settings-picker.mjs +90 -89
- package/src/tui/app/shell-layout.mjs +563 -0
- package/src/tui/app/slash-commands.mjs +7 -7
- package/src/tui/app/slash-dispatch.mjs +0 -45
- package/src/tui/app/usage-context-panels.mjs +288 -0
- package/src/tui/app/use-copy-selection.mjs +56 -0
- package/src/tui/app/use-global-key-input.mjs +151 -0
- package/src/tui/app/use-pasted-buffers.mjs +115 -0
- package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
- package/src/tui/app/use-prompt-hint.mjs +58 -0
- package/src/tui/app/use-prompt-queue-history.mjs +100 -0
- package/src/tui/app/use-terminal-chrome.mjs +72 -0
- package/src/tui/app/use-transcript-activity.mjs +131 -0
- package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
- package/src/tui/dist/index.mjs +9857 -9159
- package/src/tui/engine/session-api-ext.mjs +47 -9
- package/src/tui/engine.mjs +7 -1
- package/src/tui/figures.mjs +0 -1
- package/src/ui/statusline-format.mjs +0 -1
- package/src/hooks/lib/permission-evaluator.cjs +0 -24
- package/src/lib/config-cjs.cjs +0 -61
- package/src/runtime/shared/launcher-control.mjs +0 -258
- 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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
60
|
-
//
|
|
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 (!
|
|
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
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
297
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
//
|
|
140
|
-
//
|
|
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
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
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
|
|
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
|
|
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'
|
|
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)
|
|
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 });
|