mixdog 0.9.84 → 0.9.86
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 +3 -2
- package/scripts/channel-daemon-stub.mjs +2 -1
- 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/mcp/client.mjs +24 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +30 -3
- 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/steering.mjs +38 -4
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +183 -41
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +12 -2
- 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/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 +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +44 -73
- 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/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 +5 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +6 -8
- package/src/runtime/channels/lib/parent-bridge.mjs +27 -15
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
- package/src/runtime/channels/lib/worker-ipc.mjs +17 -4
- 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/index.mjs +14 -5
- package/src/runtime/memory/lib/agent-ipc.mjs +11 -12
- package/src/runtime/shared/atomic-file.mjs +76 -38
- 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/err-text.mjs +1 -0
- package/src/runtime/shared/resource-admission.mjs +7 -3
- package/src/runtime/shared/safe-ipc-send.mjs +34 -0
- package/src/runtime/shared/turn-snapshot.mjs +0 -3
- package/src/session-runtime/lifecycle-api.mjs +43 -12
- package/src/session-runtime/prewarm.mjs +35 -0
- 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 +42 -86
- 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 +442 -0
- package/src/standalone/agent-tool.mjs +222 -0
- package/src/standalone/channel-daemon-transport.mjs +126 -185
- package/src/standalone/channel-daemon.mjs +11 -11
- package/src/standalone/channel-worker.mjs +21 -19
- package/src/standalone/memory-runtime-proxy.mjs +98 -23
- package/src/tui/App.jsx +16 -9
- package/src/tui/app/channel-pickers.mjs +3 -8
- package/src/tui/app/route-pickers.mjs +117 -152
- package/src/tui/app/settings-picker.mjs +90 -89
- package/src/tui/app/slash-commands.mjs +7 -7
- package/src/tui/app/slash-dispatch.mjs +23 -58
- package/src/tui/app/theme-effort-pickers.mjs +3 -1
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
- package/src/tui/dist/index.mjs +358 -380
- package/src/tui/engine/live-share.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +24 -9
- package/src/tui/engine/session-flow.mjs +20 -10
- package/src/tui/engine/turn.mjs +28 -16
- package/src/tui/engine.mjs +24 -2
- 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
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
extractPowerShellCommandInner,
|
|
47
47
|
} from './shell-powershell.mjs';
|
|
48
48
|
import { spawnShellWithRetry as _spawnShellWithRetry } from './lib/shell-spawn-retry.mjs';
|
|
49
|
+
import { takePwshStandby } from './lib/pwsh-standby-pool.mjs';
|
|
49
50
|
|
|
50
51
|
export {
|
|
51
52
|
_maybeEncodePowerShellCommand,
|
|
@@ -312,6 +313,11 @@ export function execShellCommand({
|
|
|
312
313
|
const _isBackground = /(^|[^&|])&$/.test(_trimmed);
|
|
313
314
|
|
|
314
315
|
let child;
|
|
316
|
+
let _standby = null;
|
|
317
|
+
let _viaMarker = false;
|
|
318
|
+
let _standbyDetach = null;
|
|
319
|
+
let _markerFilters = null;
|
|
320
|
+
let _onChildErrorRef = null;
|
|
315
321
|
try {
|
|
316
322
|
resourceLease = await _acquireShellLeaseBounded(admission, {
|
|
317
323
|
abortSignal,
|
|
@@ -339,6 +345,25 @@ export function execShellCommand({
|
|
|
339
345
|
const argv = Array.isArray(shellArgs) && shellArgs.length > 0
|
|
340
346
|
? [...shellArgs, _spawnCommand]
|
|
341
347
|
: [shellArg, _spawnCommand];
|
|
348
|
+
const _onChildError = (err) => {
|
|
349
|
+
spawnError = spawnError || err;
|
|
350
|
+
failurePhase = 'tool';
|
|
351
|
+
failureReason = 'spawn failed';
|
|
352
|
+
if (settle) settle(1, null);
|
|
353
|
+
else pendingChildError = pendingChildError || err;
|
|
354
|
+
};
|
|
355
|
+
_onChildErrorRef = _onChildError;
|
|
356
|
+
// Warm standby adoption (Windows/pwsh only, see pwsh-standby-pool.mjs):
|
|
357
|
+
// a pre-spawned pwsh blocked on stdin replaces the cold spawn. The
|
|
358
|
+
// command is fed via stdin AFTER the stdio/exit wiring below, so no
|
|
359
|
+
// output can be produced before the capture handlers are attached.
|
|
360
|
+
// Standby children always use pipe capture — identical to the win32
|
|
361
|
+
// default (SHELL_DIRECT_CAPTURE is false on win32).
|
|
362
|
+
_standby = takePwshStandby({ shell, shellArgs, env });
|
|
363
|
+
if (_standby) {
|
|
364
|
+
child = _standby.child;
|
|
365
|
+
child.on('error', _onChildError);
|
|
366
|
+
} else {
|
|
342
367
|
// Direct capture (CC file-mode parity): the child writes stdout/stderr
|
|
343
368
|
// into the spill files via inherited fds. No parent pipes exist, so
|
|
344
369
|
// 'close' fires with 'exit' and grandchildren cannot hold the capture
|
|
@@ -371,13 +396,8 @@ export function execShellCommand({
|
|
|
371
396
|
},
|
|
372
397
|
});
|
|
373
398
|
child = spawned.child;
|
|
374
|
-
spawned.adoptErrorHandler(
|
|
375
|
-
|
|
376
|
-
failurePhase = 'tool';
|
|
377
|
-
failureReason = 'spawn failed';
|
|
378
|
-
if (settle) settle(1, null);
|
|
379
|
-
else pendingChildError = pendingChildError || err;
|
|
380
|
-
});
|
|
399
|
+
spawned.adoptErrorHandler(_onChildError);
|
|
400
|
+
}
|
|
381
401
|
startChildGuardian({
|
|
382
402
|
childPid: child.pid,
|
|
383
403
|
childGroupPid: child.pid,
|
|
@@ -385,7 +405,12 @@ export function execShellCommand({
|
|
|
385
405
|
});
|
|
386
406
|
// Windows has no process-group handle. Begin observing while the owned
|
|
387
407
|
// root still exists so descendants can be proven before root exit.
|
|
388
|
-
|
|
408
|
+
// Standby children skip the eager tracker: their root stays ALIVE across
|
|
409
|
+
// commands (waitForRootExit could never confirm and its CIM polling
|
|
410
|
+
// would run for the standby's whole lifetime). Marker settle releases
|
|
411
|
+
// the lease through the recycle path instead; kill/exit fallbacks below
|
|
412
|
+
// create the tracker lazily once the root is really gone.
|
|
413
|
+
if (!_standby) releaseResourceLeaseWhenTreeQuiescent(child.pid, { waitForRootExit: true });
|
|
389
414
|
} catch (err) {
|
|
390
415
|
const cleanupError = await releaseResourceLease();
|
|
391
416
|
const spawnText = String((err && err.message) || err);
|
|
@@ -418,15 +443,77 @@ export function execShellCommand({
|
|
|
418
443
|
}
|
|
419
444
|
|
|
420
445
|
// Pipe-capture wiring only; direct mode has no parent-side streams.
|
|
446
|
+
// Standby children speak the marker protocol: per-command completion
|
|
447
|
+
// markers on BOTH streams are stripped from the capture and drive settle()
|
|
448
|
+
// (the process stays alive for the next command), while kill/exit paths
|
|
449
|
+
// still settle through 'close' as before.
|
|
450
|
+
let _stdoutData = (chunk) => { taskOutput.writeStdout(chunk); };
|
|
451
|
+
let _stderrData = (chunk) => taskOutput.writeStderr(chunk);
|
|
452
|
+
if (_standby) {
|
|
453
|
+
const donePrefix = _standby.doneMarkerPrefix;
|
|
454
|
+
const _mk = { outDone: false, errDone: false, code: 1 };
|
|
455
|
+
const tryComplete = () => {
|
|
456
|
+
if (!_mk.outDone || !_mk.errDone) return;
|
|
457
|
+
if (settled || autoBackgrounded || killed) return;
|
|
458
|
+
// Both streams delivered their marker — everything the command wrote
|
|
459
|
+
// is already in the capture. Detach BEFORE settle so late output from
|
|
460
|
+
// an undiscovered descendant can never pollute this task, then settle
|
|
461
|
+
// through the marker path (recycle + direct lease release).
|
|
462
|
+
_standbyDetach?.();
|
|
463
|
+
_viaMarker = true;
|
|
464
|
+
settle(_mk.code, null);
|
|
465
|
+
};
|
|
466
|
+
const makeFilter = (write, onMarkerLine) => {
|
|
467
|
+
let tail = '';
|
|
468
|
+
return {
|
|
469
|
+
data: (chunk) => {
|
|
470
|
+
tail += chunk;
|
|
471
|
+
let idx;
|
|
472
|
+
let out = '';
|
|
473
|
+
while ((idx = tail.indexOf('\n')) !== -1) {
|
|
474
|
+
const line = tail.slice(0, idx + 1);
|
|
475
|
+
tail = tail.slice(idx + 1);
|
|
476
|
+
if (line.startsWith(donePrefix)) { onMarkerLine(line); continue; }
|
|
477
|
+
out += line;
|
|
478
|
+
}
|
|
479
|
+
if (out) write(out);
|
|
480
|
+
// A marker always ends with a newline, so only a pathological
|
|
481
|
+
// marker-free run can grow the tail; spill it but keep enough to
|
|
482
|
+
// recognize a marker split across chunks.
|
|
483
|
+
if (tail.length > 65536) { write(tail.slice(0, -256)); tail = tail.slice(-256); }
|
|
484
|
+
},
|
|
485
|
+
flush: () => { if (tail) { write(tail); tail = ''; } },
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
const outFilter = makeFilter((s) => taskOutput.writeStdout(s), (line) => {
|
|
489
|
+
const m = /^:(-?\d+)>>>/.exec(line.slice(donePrefix.length));
|
|
490
|
+
_mk.code = m ? Number(m[1]) : 1;
|
|
491
|
+
_mk.outDone = true;
|
|
492
|
+
tryComplete();
|
|
493
|
+
});
|
|
494
|
+
const errFilter = makeFilter((s) => taskOutput.writeStderr(s), () => {
|
|
495
|
+
_mk.errDone = true;
|
|
496
|
+
tryComplete();
|
|
497
|
+
});
|
|
498
|
+
_markerFilters = [outFilter, errFilter];
|
|
499
|
+
_stdoutData = outFilter.data;
|
|
500
|
+
_stderrData = errFilter.data;
|
|
501
|
+
}
|
|
421
502
|
if (child.stdout) {
|
|
422
503
|
child.stdout.setEncoding('utf-8');
|
|
423
|
-
child.stdout.on('data',
|
|
424
|
-
taskOutput.writeStdout(chunk);
|
|
425
|
-
});
|
|
504
|
+
child.stdout.on('data', _stdoutData);
|
|
426
505
|
}
|
|
427
506
|
if (child.stderr) {
|
|
428
507
|
child.stderr.setEncoding('utf-8');
|
|
429
|
-
child.stderr.on('data',
|
|
508
|
+
child.stderr.on('data', _stderrData);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Standby path: the capture handlers are attached — release the command
|
|
512
|
+
// into the waiting pwsh now. Skipped when a pre-fired abort already
|
|
513
|
+
// tree-killed the child (its close still settles the call).
|
|
514
|
+
if (_standby && !killed) {
|
|
515
|
+
const _spawnCommandForStandby = _maybeEncodePowerShellCommand(command);
|
|
516
|
+
_standby.run(_spawnCommandForStandby, cwd);
|
|
430
517
|
}
|
|
431
518
|
|
|
432
519
|
// If the spill writer hits an I/O failure (full disk, EBADF after
|
|
@@ -456,6 +543,11 @@ export function execShellCommand({
|
|
|
456
543
|
autoBgTimer = null;
|
|
457
544
|
}
|
|
458
545
|
detachAbortHandler();
|
|
546
|
+
// Marker-free paths (kill/exit) may hold a partial last line in the
|
|
547
|
+
// standby stream filters — flush it so the capture keeps the tail.
|
|
548
|
+
if (_markerFilters) {
|
|
549
|
+
for (const f of _markerFilters) { try { f.flush(); } catch { /* best-effort */ } }
|
|
550
|
+
}
|
|
459
551
|
// getStdout/getStderr can throw on a spilled-file read failure (EBADF
|
|
460
552
|
// after unlink race, EACCES). Without this catch the rejection bubbles
|
|
461
553
|
// up and leaves the outer settle promise unresolved, hanging the call.
|
|
@@ -480,12 +572,34 @@ export function execShellCommand({
|
|
|
480
572
|
} else {
|
|
481
573
|
taskOutput.closeFds();
|
|
482
574
|
}
|
|
575
|
+
if (_viaMarker && _standby) {
|
|
576
|
+
// Marker-settled standby: the root pwsh is ALIVE by design, so the
|
|
577
|
+
// waitForRootExit tracker could never confirm. recycle() probes for
|
|
578
|
+
// leftover descendants off the result path (returns the standby to the
|
|
579
|
+
// pool when clean, tree-kills it otherwise) and the lease frees after.
|
|
580
|
+
const lease = resourceLease;
|
|
581
|
+
resourceLease = null;
|
|
582
|
+
void _standby.recycle()
|
|
583
|
+
.catch(() => false)
|
|
584
|
+
.then(() => { try { return Promise.resolve(lease?.release()).catch(() => {}); } catch { return null; } });
|
|
585
|
+
} else {
|
|
483
586
|
const leaseSettlement = resourceLeaseSettlement
|
|
484
587
|
|| releaseResourceLeaseWhenTreeQuiescent(child.pid);
|
|
485
588
|
leaseSettlement.tracker?.rootExited?.();
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
589
|
+
// Decoupled lease settlement: the tool RESULT resolves immediately —
|
|
590
|
+
// the output is fully captured above and nothing in ExecResult depends
|
|
591
|
+
// on admission accounting. The quiescence proof (a Windows CIM process
|
|
592
|
+
// snapshot costing ~200ms per call) and the lease release continue in
|
|
593
|
+
// the background: exactly-once release is owned by the tracker/lease,
|
|
594
|
+
// and the linger cap still bounds a stuck descendant tree.
|
|
595
|
+
void (async () => {
|
|
596
|
+
try {
|
|
597
|
+
await leaseSettlement.tracker?.afterRootExitCheck?.();
|
|
598
|
+
if (!leaseSettlement.pending) await leaseSettlement.promise;
|
|
599
|
+
else _armLeaseLingerCap(() => leaseSettlement.lease, taskId);
|
|
600
|
+
} catch { /* background release must never throw */ }
|
|
601
|
+
})();
|
|
602
|
+
}
|
|
489
603
|
resolveResult(
|
|
490
604
|
new ExecResult({
|
|
491
605
|
stdout,
|
|
@@ -513,14 +627,15 @@ export function execShellCommand({
|
|
|
513
627
|
// bytes; settling there can lose the tail of the output. 'close' fires
|
|
514
628
|
// after stdio is fully drained, so getStdout()/getStderr() see the
|
|
515
629
|
// complete capture.
|
|
516
|
-
|
|
630
|
+
const _onChildClose = (code, signal) => settle(code, signal);
|
|
631
|
+
child.once('close', _onChildClose);
|
|
517
632
|
if (pendingChildError) settle(1, null);
|
|
518
633
|
// 'close' only fires after stdio drains; a forked grandchild that
|
|
519
634
|
// inherited stdout/stderr fds can hold them open past direct-child
|
|
520
635
|
// exit and stall settle() until timeoutMs. 'exit' fires on direct
|
|
521
636
|
// child termination regardless — give 'close' a 2 s grace then
|
|
522
637
|
// settle anyway.
|
|
523
|
-
|
|
638
|
+
const _onChildExit = (code, signal) => {
|
|
524
639
|
if (_isBackground) {
|
|
525
640
|
setImmediate(() => settle(code == null ? 1 : code, signal));
|
|
526
641
|
return;
|
|
@@ -531,7 +646,21 @@ export function execShellCommand({
|
|
|
531
646
|
settle(code == null ? 1 : code, signal);
|
|
532
647
|
}, 2000);
|
|
533
648
|
if (grace.unref) grace.unref();
|
|
534
|
-
}
|
|
649
|
+
};
|
|
650
|
+
child.once('exit', _onChildExit);
|
|
651
|
+
// Standby recycle prerequisite: THIS command's listeners must come off the
|
|
652
|
+
// long-lived child before it can serve another command (otherwise every
|
|
653
|
+
// reuse would stack another close/exit/data listener and stale handlers
|
|
654
|
+
// would keep writing into this task's finished capture).
|
|
655
|
+
_standbyDetach = () => {
|
|
656
|
+
try { child.stdout?.removeListener('data', _stdoutData); } catch { /* best-effort */ }
|
|
657
|
+
try { child.stderr?.removeListener('data', _stderrData); } catch { /* best-effort */ }
|
|
658
|
+
try { child.removeListener('close', _onChildClose); } catch { /* best-effort */ }
|
|
659
|
+
try { child.removeListener('exit', _onChildExit); } catch { /* best-effort */ }
|
|
660
|
+
if (_onChildErrorRef) {
|
|
661
|
+
try { child.removeListener('error', _onChildErrorRef); } catch { /* best-effort */ }
|
|
662
|
+
}
|
|
663
|
+
};
|
|
535
664
|
|
|
536
665
|
// Auto-background transition (CC startBackgrounding analogue). Two triggers
|
|
537
666
|
// resolve the call immediately with a 'backgrounded' result while the
|
|
@@ -552,6 +681,11 @@ export function execShellCommand({
|
|
|
552
681
|
if (settled || autoBackgrounded || killed || timedOut) return;
|
|
553
682
|
if (child.exitCode != null || child.signalCode != null) return;
|
|
554
683
|
autoBackgrounded = true;
|
|
684
|
+
// Standby handoff: stop feeding this process. Once the in-flight
|
|
685
|
+
// command completes, the marker loop reads stdin EOF and exits with
|
|
686
|
+
// that command's code — the adopted job's close-wired exit file then
|
|
687
|
+
// records the real command exit, and the standby is never reused.
|
|
688
|
+
if (_standby) { try { _standby.endStdin(); } catch { /* best-effort */ } }
|
|
555
689
|
// The foreground capture is over; stop the local watchdogs/timers so
|
|
556
690
|
// they cannot treeKill the now-adopted child.
|
|
557
691
|
if (timer) { clearTimeout(timer); timer = null; }
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// token-binary-fetcher.mjs — fetches the prebuilt mixdog-token native binary
|
|
2
|
+
// from the GitHub release manifest. Token counting has an in-process WASM
|
|
3
|
+
// fallback, so callers treat fetch failures as a soft degrade (never a tool
|
|
4
|
+
// error). Caches under <dataDir>/token-bin/. Mirrors graph-binary-fetcher.mjs.
|
|
5
|
+
//
|
|
6
|
+
// Public API:
|
|
7
|
+
// ensureTokenBinary(dataDir) -> absolute path to the verified binary.
|
|
8
|
+
// Throws on no-asset / download / verify failure.
|
|
9
|
+
// findCachedTokenBinary(dataDir) -> path | null (sync, no network).
|
|
10
|
+
// Both accept an optional dependency object used by deterministic tests:
|
|
11
|
+
// { bundledManifest, download }.
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import {
|
|
15
|
+
chmodSync, createWriteStream, existsSync, mkdirSync,
|
|
16
|
+
readFileSync, readdirSync, renameSync, rmSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { pipeline } from 'node:stream/promises';
|
|
22
|
+
|
|
23
|
+
const BUNDLED_MANIFEST_PATH = fileURLToPath(new URL('./token-manifest.json', import.meta.url));
|
|
24
|
+
const MANIFEST_URL = 'https://raw.githubusercontent.com/tribgames/mixdog/main/src/runtime/agent/orchestrator/tools/token-manifest.json';
|
|
25
|
+
|
|
26
|
+
function binSuffix() {
|
|
27
|
+
return process.platform === 'win32' ? '.exe' : '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function platformKey() {
|
|
31
|
+
const os = process.platform === 'win32' ? 'win32' : process.platform;
|
|
32
|
+
return `${os}-${process.arch}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tokenBinDir(dataDir) {
|
|
36
|
+
return join(dataDir, 'token-bin');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJson(path) {
|
|
40
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function manifestVersion(manifest) {
|
|
44
|
+
const value = String(manifest?.version || '');
|
|
45
|
+
if (!/^\d+\.\d+\.\d+$/.test(value)) return null;
|
|
46
|
+
return value.split('.').map(Number);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compareManifestVersions(a, b) {
|
|
50
|
+
const av = manifestVersion(a);
|
|
51
|
+
const bv = manifestVersion(b);
|
|
52
|
+
if (!av || !bv) return null;
|
|
53
|
+
for (let i = 0; i < 3; i++) {
|
|
54
|
+
if (av[i] !== bv[i]) return av[i] > bv[i] ? 1 : -1;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validSha256(value) {
|
|
60
|
+
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validCachedUpgrade(manifest, pkey) {
|
|
64
|
+
if (!manifestVersion(manifest)) return false;
|
|
65
|
+
const asset = manifest.assets?.[pkey];
|
|
66
|
+
if (!asset || !validSha256(asset.sha256) || typeof asset.url !== 'string') return false;
|
|
67
|
+
const expectedUrl = `https://github.com/tribgames/mixdog/releases/download/token-v${manifest.version}`
|
|
68
|
+
+ `/mixdog-token-${pkey}${binSuffix()}`;
|
|
69
|
+
return asset.url === expectedUrl;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function bundledManifest(options) {
|
|
73
|
+
if (options.bundledManifest) return options.bundledManifest;
|
|
74
|
+
return existsSync(BUNDLED_MANIFEST_PATH) ? readJson(BUNDLED_MANIFEST_PATH) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function selectLocalManifest(dataDir, options = {}) {
|
|
78
|
+
const bundled = bundledManifest(options);
|
|
79
|
+
const cached = join(tokenBinDir(dataDir), 'manifest.json');
|
|
80
|
+
const cachedManifest = existsSync(cached) ? readJson(cached) : null;
|
|
81
|
+
if (bundled) {
|
|
82
|
+
// The installed manifest is the minimum policy. A cache may advance it,
|
|
83
|
+
// but only with a strict newer semver and a trusted, fully hashed asset.
|
|
84
|
+
if (compareManifestVersions(cachedManifest, bundled) === 1
|
|
85
|
+
&& validCachedUpgrade(cachedManifest, platformKey())) {
|
|
86
|
+
return cachedManifest;
|
|
87
|
+
}
|
|
88
|
+
return bundled;
|
|
89
|
+
}
|
|
90
|
+
return validCachedUpgrade(cachedManifest, platformKey()) ? cachedManifest : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function loadManifest(dataDir, options = {}) {
|
|
94
|
+
const local = selectLocalManifest(dataDir, options);
|
|
95
|
+
if (local) return local;
|
|
96
|
+
const fetchFn = options.fetch || fetch;
|
|
97
|
+
const res = await fetchFn(MANIFEST_URL, { signal: AbortSignal.timeout(30_000) });
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
throw new Error(`[token-fetcher] manifest fetch failed: ${res.status} ${res.statusText}`);
|
|
100
|
+
}
|
|
101
|
+
return res.json();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function sha256File(filePath) {
|
|
105
|
+
return createHash('sha256').update(await readFile(filePath)).digest('hex');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function downloadWithRetry(url, destPath) {
|
|
109
|
+
const delays = [1000, 3000, 9000];
|
|
110
|
+
let lastErr;
|
|
111
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(180_000) });
|
|
114
|
+
if (res.status >= 400 && res.status < 500) {
|
|
115
|
+
throw new Error(`[token-fetcher] asset HTTP ${res.status} (terminal) — ${url}`);
|
|
116
|
+
}
|
|
117
|
+
if (!res.ok) throw new Error(`[token-fetcher] asset HTTP ${res.status} — ${url}`);
|
|
118
|
+
await pipeline(res.body, createWriteStream(destPath));
|
|
119
|
+
return;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
lastErr = err;
|
|
122
|
+
if (String(err?.message || '').includes('(terminal)')) throw err;
|
|
123
|
+
if (attempt < 3) {
|
|
124
|
+
process.stderr.write(`[token-fetcher] download attempt ${attempt + 1} failed (${err?.message}), retrying in ${delays[attempt]}ms…\n`);
|
|
125
|
+
await new Promise((r) => setTimeout(r, delays[attempt]));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw lastErr;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function gcTokenBin(dir, keepFile) {
|
|
133
|
+
try {
|
|
134
|
+
for (const name of readdirSync(dir)) {
|
|
135
|
+
if (name === 'manifest.json' || name === keepFile) continue;
|
|
136
|
+
if (name.startsWith('mixdog-token')) {
|
|
137
|
+
try { rmSync(join(dir, name), { force: true }); } catch { /* best-effort */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch { /* dir may not exist yet */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function findCachedTokenBinary(dataDir, options = {}) {
|
|
144
|
+
try {
|
|
145
|
+
const dir = tokenBinDir(dataDir);
|
|
146
|
+
const manifest = selectLocalManifest(dataDir, options);
|
|
147
|
+
const asset = manifest?.assets?.[platformKey()];
|
|
148
|
+
const version = manifestVersion(manifest);
|
|
149
|
+
if (!version || !validSha256(asset?.sha256)) return null;
|
|
150
|
+
const fileName = `mixdog-token-${manifest.version}${binSuffix()}`;
|
|
151
|
+
const hit = join(dir, fileName);
|
|
152
|
+
if (!existsSync(hit)) return null;
|
|
153
|
+
const actual = createHash('sha256').update(readFileSync(hit)).digest('hex');
|
|
154
|
+
return actual === asset.sha256.toLowerCase() ? hit : null;
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let _inflight = null;
|
|
161
|
+
|
|
162
|
+
export function ensureTokenBinary(dataDir, options = {}) {
|
|
163
|
+
if (_inflight) return _inflight;
|
|
164
|
+
_inflight = (async () => {
|
|
165
|
+
const manifest = await loadManifest(dataDir, options);
|
|
166
|
+
const pkey = platformKey();
|
|
167
|
+
const asset = manifest.assets?.[pkey];
|
|
168
|
+
if (!asset || !asset.url || !validSha256(asset.sha256) || !manifestVersion(manifest)) {
|
|
169
|
+
// Unsupported platform/arch, or no token-v release published yet. The
|
|
170
|
+
// caller degrades to the in-process WASM worker, so the message only
|
|
171
|
+
// needs to explain why the accelerator is absent.
|
|
172
|
+
const supported = Object.keys(manifest.assets || {}).join(', ') || '(none)';
|
|
173
|
+
throw new Error(
|
|
174
|
+
`[token-fetcher] no prebuilt mixdog-token binary for platform ${pkey} `
|
|
175
|
+
+ `(token counting degrades to the in-process WASM worker). `
|
|
176
|
+
+ `Manifest platforms: ${supported}. `
|
|
177
|
+
+ `Build it locally: cargo build --release in native/mixdog-token.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const version = String(manifest.version || '0');
|
|
181
|
+
const dir = tokenBinDir(dataDir);
|
|
182
|
+
mkdirSync(dir, { recursive: true });
|
|
183
|
+
const fileName = `mixdog-token-${version}${binSuffix()}`;
|
|
184
|
+
const destPath = join(dir, fileName);
|
|
185
|
+
if (existsSync(destPath)) {
|
|
186
|
+
try { if (await sha256File(destPath) === asset.sha256.toLowerCase()) return destPath; } catch { /* re-download */ }
|
|
187
|
+
}
|
|
188
|
+
const tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}`;
|
|
189
|
+
await (options.download || downloadWithRetry)(asset.url, tmpPath);
|
|
190
|
+
const actual = await sha256File(tmpPath);
|
|
191
|
+
if (actual !== asset.sha256.toLowerCase()) {
|
|
192
|
+
try { rmSync(tmpPath, { force: true }); } catch { /* best-effort */ }
|
|
193
|
+
throw new Error(`[token-fetcher] sha256 mismatch for ${pkey}: expected ${asset.sha256}, got ${actual}`);
|
|
194
|
+
}
|
|
195
|
+
renameSync(tmpPath, destPath);
|
|
196
|
+
if (process.platform !== 'win32') { try { chmodSync(destPath, 0o755); } catch { /* best-effort */ } }
|
|
197
|
+
gcTokenBin(dir, fileName);
|
|
198
|
+
return destPath;
|
|
199
|
+
})().finally(() => { _inflight = null; });
|
|
200
|
+
return _inflight;
|
|
201
|
+
}
|
|
@@ -3,6 +3,7 @@ import { loadConfig, createBackend } from "./config.mjs";
|
|
|
3
3
|
import { WebhookServer } from "./webhook.mjs";
|
|
4
4
|
import { EventPipeline } from "./event-pipeline.mjs";
|
|
5
5
|
import { startSnapshotWriter, stopSnapshotWriter } from "./status-snapshot.mjs";
|
|
6
|
+
import { safeIpcSend } from "../../shared/safe-ipc-send.mjs";
|
|
6
7
|
import { initProviders } from "../../agent/orchestrator/providers/registry.mjs";
|
|
7
8
|
import { loadConfig as loadAgentConfig } from "../../agent/orchestrator/config.mjs";
|
|
8
9
|
import {
|
|
@@ -423,14 +424,11 @@ function armBridgeOwnershipTimer() {}
|
|
|
423
424
|
// error-callback path of ERR_IPC_CHANNEL_CLOSED (channel closing between the
|
|
424
425
|
// connected check and delivery). Log-and-continue — never crash the worker.
|
|
425
426
|
function sendToParent(message) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
} catch (err) {
|
|
432
|
-
process.stderr.write(`[channels] parent IPC send threw: ${err?.message || err}\n`);
|
|
433
|
-
}
|
|
427
|
+
safeIpcSend(process, message, {
|
|
428
|
+
onError: (err) => {
|
|
429
|
+
try { process.stderr.write(`[channels] parent IPC send failed: ${err?.message || err}\n`); } catch {}
|
|
430
|
+
},
|
|
431
|
+
});
|
|
434
432
|
}
|
|
435
433
|
// Tell the parent session this worker ACQUIRED the bridge so it flips remote
|
|
436
434
|
// mode ON (badge/transcript writer). Callers fire this only on a genuine
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// (behavior-preserving). Groups the notify-to-parent path and the
|
|
3
3
|
// worker → parent → memory call bridge. Bound to live getters
|
|
4
4
|
// (getInstanceId) so runtime identity stays consistent.
|
|
5
|
+
import { safeIpcSend } from '../../shared/safe-ipc-send.mjs';
|
|
6
|
+
|
|
5
7
|
function normalizeChannelNotifyParams(method, params) {
|
|
6
8
|
if (method === 'notifications/claude/channel' && params && params.meta) {
|
|
7
9
|
const m = {};
|
|
@@ -24,7 +26,7 @@ function setChannelNotifySink(fn) {
|
|
|
24
26
|
_notifySink = typeof fn === 'function' ? fn : null;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function createParentBridge({ getInstanceId }) {
|
|
29
|
+
function createParentBridge({ getInstanceId, ipcProcess = process }) {
|
|
28
30
|
function sendNotifyToParent(method, params) {
|
|
29
31
|
// CC channel schema requires meta: Record<string,string> (channelNotification.ts).
|
|
30
32
|
// Coerce every meta value to string so a non-string (e.g. a Discord
|
|
@@ -37,15 +39,15 @@ function createParentBridge({ getInstanceId }) {
|
|
|
37
39
|
catch (err) { try { process.stderr.write(`mixdog channels: notify sink failed: ${err && err.message || err}\n`); } catch {} }
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
40
|
-
if (!
|
|
42
|
+
if (!ipcProcess?.send || ipcProcess.connected !== true) {
|
|
41
43
|
try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
46
|
+
safeIpcSend(ipcProcess, { type: 'notify', method, params: outParams }, {
|
|
47
|
+
onError: (err) => {
|
|
48
|
+
try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
// ── Memory worker bridge (worker → parent → memory) ─────────────────
|
|
@@ -56,10 +58,24 @@ function createParentBridge({ getInstanceId }) {
|
|
|
56
58
|
// integrated into the main IPC handler below (not a second listener).
|
|
57
59
|
const _memoryCallPending = new Map();
|
|
58
60
|
let _memoryCallSeq = 0;
|
|
61
|
+
const failMemoryCall = (callId, error) => {
|
|
62
|
+
const pending = _memoryCallPending.get(callId);
|
|
63
|
+
if (!pending) return;
|
|
64
|
+
_memoryCallPending.delete(callId);
|
|
65
|
+
pending.reject(error instanceof Error ? error : new Error(String(error || 'memory_call IPC failed')));
|
|
66
|
+
};
|
|
67
|
+
const rejectAllMemoryCalls = (error) => {
|
|
68
|
+
for (const callId of Array.from(_memoryCallPending.keys())) failMemoryCall(callId, error);
|
|
69
|
+
};
|
|
70
|
+
ipcProcess?.once?.('disconnect', () => {
|
|
71
|
+
rejectAllMemoryCalls(new Error('memory_call parent IPC disconnected'));
|
|
72
|
+
});
|
|
59
73
|
|
|
60
74
|
function callMemoryAction(action, args, timeoutMs) {
|
|
61
75
|
return new Promise((resolve, reject) => {
|
|
62
|
-
if (!
|
|
76
|
+
if (!ipcProcess?.send || ipcProcess.connected !== true) {
|
|
77
|
+
return reject(new Error('not a connected worker process'));
|
|
78
|
+
}
|
|
63
79
|
const callId = `mc_${getInstanceId()}_${++_memoryCallSeq}_${Math.random().toString(36).slice(2, 8)}`;
|
|
64
80
|
const timer = setTimeout(() => {
|
|
65
81
|
_memoryCallPending.delete(callId);
|
|
@@ -69,13 +85,9 @@ function createParentBridge({ getInstanceId }) {
|
|
|
69
85
|
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
70
86
|
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
71
87
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
_memoryCallPending.delete(callId);
|
|
76
|
-
clearTimeout(timer);
|
|
77
|
-
reject(e);
|
|
78
|
-
}
|
|
88
|
+
safeIpcSend(ipcProcess, { type: 'memory_call_request', callId, action, args: args || {} }, {
|
|
89
|
+
onError: (error) => failMemoryCall(callId, error),
|
|
90
|
+
});
|
|
79
91
|
});
|
|
80
92
|
}
|
|
81
93
|
|
|
@@ -28,14 +28,14 @@ export function resolveHookRelayUrl(env = process.env) {
|
|
|
28
28
|
return raw || DEFAULT_RELAY_URL;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
function hookIdentityPath() {
|
|
32
32
|
return join(DATA_DIR, "relay-hook-device.json");
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// Stable per-install identity (trust-on-first-use at the relay, mirroring
|
|
36
36
|
// the desktop leg). The secret never leaves this machine except toward the
|
|
37
37
|
// relay; the deviceId doubles as the public URL path segment.
|
|
38
|
-
|
|
38
|
+
function loadOrCreateHookIdentity() {
|
|
39
39
|
const path = hookIdentityPath();
|
|
40
40
|
try {
|
|
41
41
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -58,7 +58,7 @@ export function loadOrCreateHookIdentity() {
|
|
|
58
58
|
return identity;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
function hookPublicBase(relayUrl, deviceId) {
|
|
62
62
|
const url = new URL(relayUrl);
|
|
63
63
|
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
64
64
|
url.pathname = `/hook/${deviceId}`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { performance } from "perf_hooks";
|
|
2
|
+
import { safeIpcSend } from "../../shared/safe-ipc-send.mjs";
|
|
2
3
|
// IPC worker-mode message loop extracted from channels/index.mjs
|
|
3
4
|
// (behavior-preserving). Installs shutdown handlers, the parent->worker message
|
|
4
5
|
// router, and the retrying start() bootstrap. Call once from the worker entry
|
|
@@ -19,6 +20,11 @@ export function runWorkerIpc({
|
|
|
19
20
|
handleToolCallWithBridgeRetry,
|
|
20
21
|
bootProfile,
|
|
21
22
|
}) {
|
|
23
|
+
const sendToParent = (message) => safeIpcSend(process, message, {
|
|
24
|
+
onError: (error) => {
|
|
25
|
+
try { process.stderr.write(`[channels-worker] parent IPC send failed: ${error?.message || error}\n`); } catch {}
|
|
26
|
+
},
|
|
27
|
+
});
|
|
22
28
|
// SIGTERM/SIGINT/IPC shutdown handler — mirrors src/memory/index.mjs pattern.
|
|
23
29
|
// Cleans up in-progress webhook/scheduler state, removes runtime files, then exits.
|
|
24
30
|
let _channelsStopInFlight = false
|
|
@@ -29,6 +35,10 @@ export function runWorkerIpc({
|
|
|
29
35
|
return
|
|
30
36
|
}
|
|
31
37
|
_channelsStopInFlight = true
|
|
38
|
+
for (const controller of _inFlightChannelCalls.values()) {
|
|
39
|
+
try { controller.abort() } catch {}
|
|
40
|
+
}
|
|
41
|
+
_inFlightChannelCalls.clear()
|
|
32
42
|
process.stderr.write(`[channels-worker] received ${sig} — shutting down cleanly\n`)
|
|
33
43
|
_channelsForceExitTimer = setTimeout(() => {
|
|
34
44
|
process.stderr.write(`[channels-worker] stop() timed out after 6000ms — forcing exit(2)\n`)
|
|
@@ -47,6 +57,7 @@ export function runWorkerIpc({
|
|
|
47
57
|
}
|
|
48
58
|
process.on('SIGTERM', () => _channelsShutdownHandler('SIGTERM'))
|
|
49
59
|
process.on('SIGINT', () => _channelsShutdownHandler('SIGINT'))
|
|
60
|
+
process.once('disconnect', () => _channelsShutdownHandler('IPC:disconnect'))
|
|
50
61
|
|
|
51
62
|
// Map of callId → AbortController for in-flight IPC calls.
|
|
52
63
|
const _inFlightChannelCalls = new Map()
|
|
@@ -144,7 +155,7 @@ export function runWorkerIpc({
|
|
|
144
155
|
entry.abort()
|
|
145
156
|
_inFlightChannelCalls.delete(msg.callId)
|
|
146
157
|
}
|
|
147
|
-
|
|
158
|
+
sendToParent({ type: 'result', callId: msg.callId, error: 'cancelled' })
|
|
148
159
|
return
|
|
149
160
|
}
|
|
150
161
|
if (msg.type !== 'call' || !msg.callId) return
|
|
@@ -157,9 +168,9 @@ export function runWorkerIpc({
|
|
|
157
168
|
} finally {
|
|
158
169
|
_inFlightChannelCalls.delete(msg.callId)
|
|
159
170
|
}
|
|
160
|
-
|
|
171
|
+
sendToParent({ type: 'result', callId: msg.callId, result })
|
|
161
172
|
} catch (e) {
|
|
162
|
-
|
|
173
|
+
sendToParent({ type: 'result', callId: msg.callId, error: e.message })
|
|
163
174
|
}
|
|
164
175
|
})
|
|
165
176
|
void (async () => {
|
|
@@ -174,7 +185,9 @@ export function runWorkerIpc({
|
|
|
174
185
|
try {
|
|
175
186
|
await start()
|
|
176
187
|
bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1), attempt })
|
|
177
|
-
|
|
188
|
+
if (!sendToParent({ type: 'ready' })) {
|
|
189
|
+
void _channelsShutdownHandler('IPC:ready-send-failed')
|
|
190
|
+
}
|
|
178
191
|
return
|
|
179
192
|
} catch (e) {
|
|
180
193
|
lastErr = e
|