mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -20,6 +20,14 @@ const LOCK_WAIT_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
20
20
  const DEFAULT_BACKOFFS_MS = Object.freeze([25, 50, 100, 200, 400, 800, 1200, 1600]);
21
21
  const DEFAULT_LOCK_TIMEOUT_MS = 8000;
22
22
 
23
+ // Per-process owner identity. A bare pid is not a durable holder identity:
24
+ // after a holder crashes the OS can recycle its pid onto an unrelated live
25
+ // process (or onto THIS process), making a corpse lock look "held by a live
26
+ // pid" — including "held by me" — which starves every waiter. Stamping a
27
+ // random per-instance token alongside the pid lets reclaim/release tell our
28
+ // current lock apart from a same-pid prior/other instance's leftover.
29
+ const _OWNER_TOKEN = randomBytes(12).toString('hex');
30
+
23
31
  function sleepSync(ms) {
24
32
  try {
25
33
  const buf = new SharedArrayBuffer(4);
@@ -73,55 +81,11 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
73
81
  contErr.cause = err;
74
82
  throw contErr;
75
83
  }
84
+ // Stale-lock reclaim (shared with the async variant). Covers a dead
85
+ // owner pid, a same-pid-but-foreign-token corpse from a reused pid, and
86
+ // a pidless/empty corpse lock; a live foreign holder is never stolen.
76
87
  try {
77
- const st = statSync(lockPath);
78
- // Dead-owner fast path: if the lock's recorded owner pid is gone,
79
- // force-release immediately instead of waiting out staleMs. A crashed
80
- // owner would otherwise hold the path until the 30s stale window,
81
- // starving waiters past the 8s acquire timeout (the restart stall).
82
- const ownerPidEarly = _readLockOwnerPid(lockPath);
83
- const ownerDead = ownerPidEarly !== null && _pidIsDead(ownerPidEarly);
84
- if (ownerDead || Date.now() - st.mtimeMs > staleMs) {
85
- // Only steal a stale lock when we can prove the owner is
86
- // gone. Reading the pid from the lock file and probing it
87
- // with kill(pid, 0) protects a slow-but-live holder from
88
- // having its lock yanked out from under it; the next
89
- // process that acquires the lock would otherwise race
90
- // against the original holder and clobber its write.
91
- //
92
- // Two-waiter stale-reclaim race: a pure rename "claim" is
93
- // not enough. A waiter that proved pid D dead can stall;
94
- // another waiter can then remove D and acquire a live lock,
95
- // and the stalled waiter would rename that live lock away.
96
- // Instead, serialize stale removal behind a tiny pid-stamped
97
- // reclaim guard, then re-read the lock while the stale file
98
- // is still present at lockPath. If the owner token no longer
99
- // matches the pid we proved dead, treat the holder as live
100
- // and back off. We never move or unlink a mismatched live-
101
- // token file. Guard cleanup is only a best-effort unblocker:
102
- // path unlink is not a portable unlink-if-token-still-matches
103
- // primitive, so live-lock safety rests on the lockPath token
104
- // reverify immediately before the lockPath unlink below.
105
- const deadPid = ownerPidEarly;
106
- if (ownerDead) {
107
- const reclaim = _tryAcquireReclaimGuard(lockPath, staleMs);
108
- if (reclaim !== null) {
109
- let reclaimed = false;
110
- try {
111
- const currentSt = statSync(lockPath);
112
- if (ownerDead || Date.now() - currentSt.mtimeMs > staleMs) {
113
- const currentPid = _readLockOwnerPid(lockPath);
114
- if (currentPid === deadPid && _pidIsDead(currentPid)) {
115
- try { unlinkSync(lockPath); reclaimed = true; } catch {}
116
- }
117
- }
118
- } finally {
119
- _releaseReclaimGuard(reclaim);
120
- }
121
- if (reclaimed) continue;
122
- }
123
- }
124
- }
88
+ if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
125
89
  } catch {}
126
90
  if (Date.now() >= deadline) break;
127
91
  const base = DEFAULT_BACKOFFS_MS[Math.min(attempt, DEFAULT_BACKOFFS_MS.length - 1)];
@@ -130,10 +94,12 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
130
94
  attempt += 1;
131
95
  continue;
132
96
  }
133
- // Stamp our pid into the lock so stale-lock recovery and the
134
- // finally-unlink below can verify ownership instead of blindly
135
- // deleting whatever lock file happens to be at this path.
136
- try { writeFileSync(fd, `${process.pid} ${Date.now()}\n`, 'utf8'); } catch {}
97
+ // Stamp pid + per-instance token so stale-lock recovery and the
98
+ // finally-unlink below can verify OUR identity instead of blindly
99
+ // deleting whatever lock file happens to be at this path. Format
100
+ // `<pid> <ts> <token>` is a superset of the old `<pid> <ts>`; a
101
+ // tokenless (old-format) lock is still read as pid-authoritative.
102
+ try { writeFileSync(fd, `${process.pid} ${Date.now()} ${_OWNER_TOKEN}\n`, 'utf8'); } catch {}
137
103
  // For secret-bearing critical sections, the lock file sits beside
138
104
  // the secret in the same (shared-home) directory; clamp it owner-only
139
105
  // too. Fail-closed: an unenforceable ACL aborts before fn() runs.
@@ -148,7 +114,7 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
148
114
  // owner's lock and break mutual exclusion for whoever is
149
115
  // queued behind it.
150
116
  try {
151
- if (_lockOwnedByPid(lockPath, process.pid)) unlinkSync(lockPath);
117
+ if (_lockOwnedBySelf(lockPath)) unlinkSync(lockPath);
152
118
  } catch {}
153
119
  }
154
120
  }
@@ -189,8 +155,109 @@ function _pidIsDead(pid) {
189
155
  }
190
156
  }
191
157
 
192
- function _lockOwnedByPid(lockPath, pid) {
193
- return _readLockOwnerPid(lockPath) === pid;
158
+ // Full owner identity: pid plus per-instance token. token is null for an
159
+ // old-format (`<pid> <ts>`) lock, in which case callers fall back to
160
+ // pid-authoritative handling so a still-running old-format holder is
161
+ // respected. A read/parse error yields {pid:null, token:null} ("unknown").
162
+ function _readLockOwner(lockPath) {
163
+ try {
164
+ const raw = readFileSync(lockPath, 'utf8');
165
+ const parts = String(raw).trim().split(/\s+/);
166
+ const pid = Number.parseInt(parts[0], 10);
167
+ return {
168
+ pid: Number.isFinite(pid) && pid > 0 ? pid : null,
169
+ token: parts.length >= 3 ? parts[2] : null,
170
+ };
171
+ } catch {
172
+ return { pid: null, token: null };
173
+ }
174
+ }
175
+
176
+ // Is this recorded owner a genuinely live holder?
177
+ // - pid null (empty/unparseable): not live (unknown owner).
178
+ // - pid === our pid: treated as LIVE. A foreign/absent token on our own
179
+ // pid is ambiguous — it may be a recycled-pid corpse, but it may also
180
+ // be a live sibling worker_thread (which shares process.pid yet owns a
181
+ // separate _OWNER_TOKEN module instance). We cannot distinguish them
182
+ // from the token alone, so we never call a same-pid owner instantly
183
+ // dead; the reclaim path gates a same-pid foreign-token steal behind
184
+ // stale mtime (see _ownerIsSelfForeign / _tryReclaimStaleLock) so a
185
+ // live sibling is never yanked, while a real corpse still frees once
186
+ // mtime goes stale.
187
+ // - foreign pid: probed with kill(pid, 0); ESRCH ⇒ dead, else live.
188
+ function _ownerIsLive(owner) {
189
+ if (owner.pid === null) return false;
190
+ if (owner.pid === process.pid) return true;
191
+ try {
192
+ process.kill(owner.pid, 0);
193
+ return true;
194
+ } catch (err) {
195
+ return err?.code !== 'ESRCH';
196
+ }
197
+ }
198
+
199
+ // Our own pid but a foreign (present, non-matching) token: either a
200
+ // recycled-pid corpse or a live sibling worker_thread. Reclaimable only
201
+ // once mtime is past staleMs (handled by the pidless-style stale gate).
202
+ function _ownerIsSelfForeign(owner) {
203
+ return owner.pid === process.pid
204
+ && owner.token !== null
205
+ && owner.token !== _OWNER_TOKEN;
206
+ }
207
+
208
+ // Release-time guard: unlink only if the lock still carries OUR identity
209
+ // (our pid AND our token). Protects against deleting a lock another
210
+ // instance stole+recreated, and against a same-pid sibling.
211
+ function _lockOwnedBySelf(lockPath) {
212
+ const owner = _readLockOwner(lockPath);
213
+ if (owner.pid !== process.pid) return false;
214
+ return owner.token === null ? true : owner.token === _OWNER_TOKEN;
215
+ }
216
+
217
+ // Shared stale-lock reclaim used by BOTH withFileLockSync and withFileLock.
218
+ // The whole operation is synchronous; the two variants differ only in how
219
+ // they wait between attempts. Returns true iff lockPath was reclaimed.
220
+ //
221
+ // Reclaim decision table (owner = identity recorded in the lock file):
222
+ // owner pid DEAD (ESRCH / self-pid+foreign-token corpse) → reclaim
223
+ // owner pid NULL (empty/unparseable) AND mtime past staleMs → reclaim
224
+ // owner token MISMATCH on guarded re-read (new holder took over) → back off
225
+ // owner LIVE (foreign pid alive, or our own current token) → back off
226
+ function _tryReclaimStaleLock(lockPath, staleMs) {
227
+ let st;
228
+ try { st = statSync(lockPath); } catch { return false; }
229
+ const owner = _readLockOwner(lockPath);
230
+ const stale = Date.now() - st.mtimeMs > staleMs;
231
+ const dead = owner.pid !== null && !_ownerIsLive(owner);
232
+ const pidless = owner.pid === null;
233
+ const selfForeign = _ownerIsSelfForeign(owner);
234
+ // A dead foreign owner reclaims immediately. A pidless corpse, or a
235
+ // same-pid foreign-token owner (which may still be a live sibling
236
+ // worker_thread), reclaims only once mtime is past staleMs; a live
237
+ // foreign holder is never stolen even after mtime goes stale.
238
+ if (!dead && !((pidless || selfForeign) && stale)) return false;
239
+ const reclaim = _tryAcquireReclaimGuard(lockPath, staleMs);
240
+ if (reclaim === null) return false;
241
+ try {
242
+ let cur;
243
+ try { cur = statSync(lockPath); } catch { return false; }
244
+ const curOwner = _readLockOwner(lockPath);
245
+ // Re-verify the SAME identity (pid AND token) we decided to reclaim is
246
+ // still present. A reused-pid new holder that grabbed the lock during
247
+ // the guard window has a different token and is not mistaken for the
248
+ // corpse — so we never yank a freshly-acquired live lock.
249
+ if (curOwner.pid !== owner.pid || curOwner.token !== owner.token) return false;
250
+ const curStale = Date.now() - cur.mtimeMs > staleMs;
251
+ const curDead = curOwner.pid !== null && !_ownerIsLive(curOwner);
252
+ const curPidless = curOwner.pid === null;
253
+ const curSelfForeign = _ownerIsSelfForeign(curOwner);
254
+ if (curDead || ((curPidless || curSelfForeign) && curStale)) {
255
+ try { unlinkSync(lockPath); return true; } catch { return false; }
256
+ }
257
+ return false;
258
+ } finally {
259
+ _releaseReclaimGuard(reclaim);
260
+ }
194
261
  }
195
262
 
196
263
  function _reclaimGuardIsUnreadableAndStale(guardPath, staleMs) {
@@ -229,7 +296,16 @@ function _tryClearStaleReclaimGuard(guardPath, staleMs) {
229
296
  // This is still not a serialized primitive: correctness comes from
230
297
  // the lockPath owner-token reverify before unlinking lockPath, not
231
298
  // from making guard cleanup itself authoritative.
232
- if (_pidIsDead(guardPid)) _unlinkReclaimGuardIfPidMatches(guardPath, guardPid);
299
+ // A guard whose pid was recycled onto a LIVE process would otherwise
300
+ // never clear, starving reclaim permanently; so also clear a guard
301
+ // past staleMs regardless of apparent pid-liveness. Safe because the
302
+ // guard is best-effort — the lockPath owner-token reverify still gates
303
+ // the actual lock unlink.
304
+ let guardStale = false;
305
+ try { guardStale = Date.now() - statSync(guardPath).mtimeMs > staleMs; } catch {}
306
+ if (_pidIsDead(guardPid) || guardStale) {
307
+ _unlinkReclaimGuardIfPidMatches(guardPath, guardPid);
308
+ }
233
309
  return;
234
310
  }
235
311
  // A crash between openSync('wx') and the pid write can leave an empty
@@ -294,29 +370,11 @@ export async function withFileLock(lockPath, fn, opts = {}) {
294
370
  contErr.cause = err;
295
371
  throw contErr;
296
372
  }
373
+ // Stale-lock reclaim (shared with the sync variant): dead owner pid,
374
+ // a same-pid-but-foreign-token corpse from a reused pid, or a
375
+ // pidless/empty corpse lock. A live foreign holder is never stolen.
297
376
  try {
298
- const st = statSync(lockPath);
299
- if (Date.now() - st.mtimeMs > staleMs) {
300
- const deadPid = _readLockOwnerPid(lockPath);
301
- if (deadPid !== null && _pidIsDead(deadPid)) {
302
- const reclaim = _tryAcquireReclaimGuard(lockPath, staleMs);
303
- if (reclaim !== null) {
304
- let reclaimed = false;
305
- try {
306
- const currentSt = statSync(lockPath);
307
- if (Date.now() - currentSt.mtimeMs > staleMs) {
308
- const currentPid = _readLockOwnerPid(lockPath);
309
- if (currentPid === deadPid && _pidIsDead(currentPid)) {
310
- try { unlinkSync(lockPath); reclaimed = true; } catch {}
311
- }
312
- }
313
- } finally {
314
- _releaseReclaimGuard(reclaim);
315
- }
316
- if (reclaimed) continue;
317
- }
318
- }
319
- }
377
+ if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
320
378
  } catch {}
321
379
  if (Date.now() >= deadline) break;
322
380
  const base = DEFAULT_BACKOFFS_MS[Math.min(attempt, DEFAULT_BACKOFFS_MS.length - 1)];
@@ -325,14 +383,14 @@ export async function withFileLock(lockPath, fn, opts = {}) {
325
383
  attempt += 1;
326
384
  continue;
327
385
  }
328
- try { writeFileSync(fd, `${process.pid} ${Date.now()}\n`, 'utf8'); } catch {}
386
+ try { writeFileSync(fd, `${process.pid} ${Date.now()} ${_OWNER_TOKEN}\n`, 'utf8'); } catch {}
329
387
  try {
330
388
  if (opts.secret === true) _enforceOwnerOnlyAclWin32(lockPath);
331
389
  return await fn();
332
390
  } finally {
333
391
  try { closeSync(fd); } catch {}
334
392
  try {
335
- if (_lockOwnedByPid(lockPath, process.pid)) unlinkSync(lockPath);
393
+ if (_lockOwnedBySelf(lockPath)) unlinkSync(lockPath);
336
394
  } catch {}
337
395
  }
338
396
  }
@@ -1,18 +1,70 @@
1
1
  'use strict';
2
2
 
3
- import { spawn } from 'node:child_process';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
4
 
5
5
  function positiveInt(value) {
6
6
  const n = Number(value);
7
7
  return Number.isInteger(n) && n > 0 ? n : null;
8
8
  }
9
9
 
10
- function guardianScript({ parentPid, childPid, childGroupPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
10
+ // On Windows a windowless, orphaned pwsh->cli chain keeps the immediate parent
11
+ // PID alive after its terminal window is closed, so parent-liveness alone never
12
+ // releases the worker. When the session is genuinely interactive (a TTY), the
13
+ // controlling console window is owned by a host process (conhost.exe) that dies
14
+ // with the terminal — monitoring that PID turns "terminal closed" into a fatal
15
+ // orphan signal. Returns null for non-interactive/service/hidden-console starts
16
+ // (no TTY, or no console window) so those are never treated as terminal loss.
17
+ function detectControllingTerminalPid() {
18
+ if (process.platform !== 'win32') return null;
19
+ // Gate on a real TTY: a service/pipe/hidden launch has no controlling
20
+ // terminal, and probing one there would spawn a transient console whose host
21
+ // dies immediately — a false "terminal lost" that would kill a healthy worker.
22
+ const interactive = Boolean(
23
+ (process.stdin && process.stdin.isTTY) || (process.stdout && process.stdout.isTTY),
24
+ );
25
+ if (!interactive) return null;
26
+ try {
27
+ const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
28
+ const powershell = systemRoot + '\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
29
+ const script = [
30
+ 'Add-Type @"',
31
+ 'using System;',
32
+ 'using System.Runtime.InteropServices;',
33
+ 'public class MixdogTerm {',
34
+ ' [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();',
35
+ ' [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr h, out int pid);',
36
+ '}',
37
+ '"@',
38
+ '$h=[MixdogTerm]::GetConsoleWindow()',
39
+ 'if($h -eq [IntPtr]::Zero){ Write-Output 0 } else { $p=0; [void][MixdogTerm]::GetWindowThreadProcessId($h,[ref]$p); Write-Output $p }',
40
+ ].join('\n');
41
+ // NO windowsHide: CREATE_NO_WINDOW gives the probe its own/absent console so
42
+ // GetConsoleWindow() returns 0 or a foreign hwnd. A plain console app spawned
43
+ // from a console-attached parent (this runs at the guardian start site, in the
44
+ // guarded parent) inherits that console with no window flash. Under Windows
45
+ // Terminal/ConPTY the owner is the tab's conhost (dies on tab close), which is
46
+ // exactly the pid we want to monitor.
47
+ const res = spawnSync(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
48
+ timeout: 4000,
49
+ encoding: 'utf8',
50
+ stdio: ['ignore', 'pipe', 'ignore'],
51
+ });
52
+ const pid = positiveInt(String((res && res.stdout) || '').trim());
53
+ // Ignore our own PID: that would make the guardian its own kill trigger.
54
+ if (pid && pid !== process.pid) return pid;
55
+ return null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function guardianScript({ parentPid, childPid, childGroupPid, terminalPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
11
62
  return `
12
63
  const { spawnSync } = require('node:child_process');
13
64
  const parentPid = ${JSON.stringify(parentPid)};
14
65
  const childPid = ${JSON.stringify(childPid)};
15
66
  const childGroupPid = ${JSON.stringify(childGroupPid || childPid)};
67
+ const terminalPid = ${JSON.stringify(terminalPid || 0)};
16
68
  const platform = ${JSON.stringify(platform)};
17
69
  const pollMs = ${JSON.stringify(pollMs)};
18
70
  const orphanGraceMs = ${JSON.stringify(orphanGraceMs)};
@@ -42,7 +94,12 @@ let killing = false;
42
94
  let orphanedAt = 0;
43
95
  const timer = setInterval(() => {
44
96
  if (!alive(childPid)) process.exit(0);
45
- if (alive(parentPid)) {
97
+ // Orphaned when the parent PID dies OR (interactive only) the controlling
98
+ // terminal's console host dies — the latter catches a windowless pwsh->cli
99
+ // chain that outlives its closed terminal window. terminalPid is 0 for
100
+ // non-interactive starts, so those fall back to pure parent-liveness.
101
+ const terminalLost = terminalPid > 0 && !alive(terminalPid);
102
+ if (alive(parentPid) && !terminalLost) {
46
103
  orphanedAt = 0;
47
104
  return;
48
105
  }
@@ -77,6 +134,7 @@ export function startChildGuardian({
77
134
  parentPid: parent,
78
135
  childPid: child,
79
136
  childGroupPid: positiveInt(childGroupPid) || child,
137
+ terminalPid: detectControllingTerminalPid() || 0,
80
138
  platform: process.platform,
81
139
  pollMs: Math.max(100, Math.floor(Number(pollMs) || 750)),
82
140
  orphanGraceMs: Math.max(100, Math.floor(Number(orphanGraceMs) || Number(graceMs) || 3000)),
@@ -0,0 +1,36 @@
1
+ import { performance } from 'node:perf_hooks';
2
+ import { envFlag } from './env.mjs';
3
+
4
+ // Boot-timing profiler + instrumented dynamic import. Extracted verbatim from
5
+ // mixdog-session-runtime.mjs during the facade split. `profiledImport` resolves
6
+ // relative specifiers against this module's directory (src/session-runtime/),
7
+ // which matches runtime-core.mjs, so callers keep passing the same specifiers.
8
+ const BOOT_PROFILE_ENABLED = envFlag('MIXDOG_BOOT_PROFILE');
9
+ const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
10
+
11
+ export function bootProfile(event, fields = {}) {
12
+ if (!BOOT_PROFILE_ENABLED) return;
13
+ const elapsedMs = performance.now() - BOOT_PROFILE_START;
14
+ const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, event];
15
+ for (const [key, value] of Object.entries(fields || {})) {
16
+ if (value === undefined || value === null || value === '') continue;
17
+ parts.push(`${key}=${String(value).replace(/\s+/g, '_')}`);
18
+ }
19
+ try { process.stderr.write(`${parts.join(' ')}\n`); } catch {}
20
+ }
21
+
22
+ export async function profiledImport(label, spec, { optional = false } = {}) {
23
+ const startedAt = performance.now();
24
+ try {
25
+ const mod = await import(spec);
26
+ bootProfile(`import:${label}`, { ms: (performance.now() - startedAt).toFixed(1) });
27
+ return mod;
28
+ } catch (error) {
29
+ bootProfile(`import:${label}:failed`, {
30
+ ms: (performance.now() - startedAt).toFixed(1),
31
+ error: error?.message || String(error),
32
+ });
33
+ if (optional) return null;
34
+ throw error;
35
+ }
36
+ }
@@ -0,0 +1,70 @@
1
+ import {
2
+ channelSetup,
3
+ deleteSchedule,
4
+ deleteWebhook,
5
+ setChannel,
6
+ saveSchedule,
7
+ saveWebhook,
8
+ setScheduleEnabled,
9
+ setWebhookEnabled,
10
+ setWebhookConfig,
11
+ } from '../standalone/channel-admin.mjs';
12
+
13
+ // Channel/webhook/schedule config surface. Extracted verbatim from the runtime
14
+ // API object; the mutating admin helpers are imported directly here and the
15
+ // runtime injects only the closure-owned callbacks (backend flush, channel
16
+ // worker handle, soft reload).
17
+ export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon }) {
18
+ return {
19
+ getChannelSetup() {
20
+ // Flush a pending debounced backend switch first so setup readers
21
+ // (Settings → Channel Setting, remote toggles) never observe the
22
+ // previous backend during the 150ms debounce window.
23
+ try { flushBackendSave(); } catch {}
24
+ return channelSetup();
25
+ },
26
+ getChannelWorkerStatus() {
27
+ return channels.status();
28
+ },
29
+ setChannel(entry) {
30
+ const result = setChannel(entry);
31
+ reloadChannelsSoon();
32
+ return result;
33
+ },
34
+ setWebhookConfig(patch) {
35
+ const result = setWebhookConfig(patch);
36
+ reloadChannelsSoon();
37
+ return result;
38
+ },
39
+ saveSchedule(entry) {
40
+ const result = saveSchedule(entry);
41
+ reloadChannelsSoon();
42
+ return result;
43
+ },
44
+ deleteSchedule(name) {
45
+ const result = deleteSchedule(name);
46
+ reloadChannelsSoon();
47
+ return result;
48
+ },
49
+ setScheduleEnabled(name, enabled) {
50
+ const result = setScheduleEnabled(name, enabled);
51
+ reloadChannelsSoon();
52
+ return result;
53
+ },
54
+ saveWebhook(entry) {
55
+ const result = saveWebhook(entry);
56
+ reloadChannelsSoon();
57
+ return result;
58
+ },
59
+ deleteWebhook(name) {
60
+ const result = deleteWebhook(name);
61
+ reloadChannelsSoon();
62
+ return result;
63
+ },
64
+ setWebhookEnabled(name, enabled) {
65
+ const result = setWebhookEnabled(name, enabled);
66
+ reloadChannelsSoon();
67
+ return result;
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,181 @@
1
+ import {
2
+ estimateRequestReserveTokens,
3
+ estimateToolSchemaTokens,
4
+ estimateTranscriptContextUsage,
5
+ resolveCompactBufferTokens,
6
+ resolveCompactTriggerTokens,
7
+ summarizeContextMessages,
8
+ } from '../runtime/agent/orchestrator/session/context-utils.mjs';
9
+ import { estimateToolSchemaBreakdown } from './tool-catalog.mjs';
10
+
11
+ // Live /context gauge computation + its self-owned memoization cache. Extracted
12
+ // verbatim from the runtime API object; the runtime injects live getters for
13
+ // the mutable session/route/cwd/mode locals. The cache (key + value) is owned
14
+ // here now, so invalidateContextStatusCache() is returned for the runtime to
15
+ // call from the same places it used to clear the inline locals.
16
+ export function createContextStatus({ getSession, getRoute, getCurrentCwd, getMode }) {
17
+ let contextStatusCacheKey = null;
18
+ let contextStatusCacheValue = null;
19
+
20
+ function contextStatusCacheKeyFor({ messages, tools }) {
21
+ const session = getSession();
22
+ const route = getRoute();
23
+ const compaction = session?.compaction || {};
24
+ const lastMessage = messages[messages.length - 1] || null;
25
+ return {
26
+ session,
27
+ sessionId: session?.id || null,
28
+ provider: route.provider,
29
+ model: route.model,
30
+ cwd: getCurrentCwd(),
31
+ mode: getMode(),
32
+ messages,
33
+ messageCount: messages.length,
34
+ lastMessage,
35
+ lastMessageRole: lastMessage?.role || null,
36
+ lastMessageContent: lastMessage?.content || null,
37
+ tools,
38
+ toolCount: tools.length,
39
+ contextWindow: session?.contextWindow || null,
40
+ rawContextWindow: session?.rawContextWindow || null,
41
+ effectiveContextWindowPercent: session?.effectiveContextWindowPercent || null,
42
+ autoCompactTokenLimit: Number(session?.autoCompactTokenLimit || 0),
43
+ lastContextTokens: Number(session?.lastContextTokens || 0),
44
+ lastContextTokensUpdatedAt: Number(session?.lastContextTokensUpdatedAt || 0),
45
+ lastContextTokensStaleAfterCompact: session?.lastContextTokensStaleAfterCompact === true,
46
+ lastInputTokens: Number(session?.lastInputTokens || 0),
47
+ lastUncachedInputTokens: Number(session?.lastUncachedInputTokens || 0),
48
+ lastOutputTokens: Number(session?.lastOutputTokens || 0),
49
+ lastCachedReadTokens: Number(session?.lastCachedReadTokens || 0),
50
+ lastCacheWriteTokens: Number(session?.lastCacheWriteTokens || 0),
51
+ totalInputTokens: Number(session?.totalInputTokens || 0),
52
+ totalUncachedInputTokens: Number(session?.totalUncachedInputTokens || 0),
53
+ totalOutputTokens: Number(session?.totalOutputTokens || 0),
54
+ totalCachedReadTokens: Number(session?.totalCachedReadTokens || 0),
55
+ totalCacheWriteTokens: Number(session?.totalCacheWriteTokens || 0),
56
+ compactBoundaryTokens: Number(session?.compactBoundaryTokens || 0),
57
+ compactionBoundaryTokens: Number(compaction.boundaryTokens || 0),
58
+ compactionTriggerTokens: Number(compaction.triggerTokens || 0),
59
+ compactionLastChangedAt: Number(compaction.lastChangedAt || 0),
60
+ compactionLastCompactAt: Number(compaction.lastCompactAt || 0),
61
+ };
62
+ }
63
+
64
+ function sameContextStatusCacheKey(a, b) {
65
+ if (!a || !b) return false;
66
+ for (const key of Object.keys(a)) {
67
+ if (!Object.is(a[key], b[key])) return false;
68
+ }
69
+ return true;
70
+ }
71
+
72
+ function invalidateContextStatusCache() {
73
+ contextStatusCacheKey = null;
74
+ contextStatusCacheValue = null;
75
+ }
76
+
77
+ function contextStatus() {
78
+ const session = getSession();
79
+ const route = getRoute();
80
+ // Prefer the in-flight working transcript while a turn is running so the
81
+ // context gauge reflects LIVE growth (user turn + tool calls/results) as
82
+ // it accumulates, instead of freezing at the pre-turn committed snapshot.
83
+ // askSession() sets session.liveTurnMessages for the turn duration and
84
+ // clears it on commit/cancel/error, after which we fall back to the
85
+ // authoritative committed transcript.
86
+ const liveTurnMessages = Array.isArray(session?.liveTurnMessages) ? session.liveTurnMessages : null;
87
+ const messages = liveTurnMessages || (Array.isArray(session?.messages) ? session.messages : []);
88
+ const tools = Array.isArray(session?.tools) ? session.tools : [];
89
+ const cacheKey = contextStatusCacheKeyFor({ messages, tools });
90
+ if (contextStatusCacheValue && sameContextStatusCacheKey(cacheKey, contextStatusCacheKey)) {
91
+ return contextStatusCacheValue;
92
+ }
93
+
94
+ const messageSummary = summarizeContextMessages(messages);
95
+ const toolSchemaTokens = estimateToolSchemaTokens(tools);
96
+ const toolSchemaBreakdown = estimateToolSchemaBreakdown(tools);
97
+ const requestReserveTokens = estimateRequestReserveTokens(tools);
98
+ const requestOverheadTokens = Math.max(0, requestReserveTokens - toolSchemaTokens);
99
+ const rawWindow = Number(session?.rawContextWindow || session?.contextWindow || 0);
100
+ const effectiveWindow = Number(session?.contextWindow || rawWindow || 0);
101
+ const lastContextTokens = Number(session?.lastContextTokens || 0);
102
+ const estimatedContextTokens = estimateTranscriptContextUsage(messages, tools, {
103
+ messageCount: messageSummary.count,
104
+ });
105
+ const compactAt = Number(session?.compaction?.lastChangedAt || session?.compaction?.lastCompactAt || 0);
106
+ const usageAt = Number(session?.lastContextTokensUpdatedAt || 0);
107
+ const lastUsageStale = !!lastContextTokens && (
108
+ session?.lastContextTokensStaleAfterCompact === true
109
+ || (compactAt > 0 && usageAt > 0 && usageAt <= compactAt)
110
+ || (compactAt > 0 && usageAt <= 0)
111
+ );
112
+ const compactBoundaryTokens = Number(session?.compactBoundaryTokens || session?.compaction?.boundaryTokens || 0);
113
+ const displayWindow = compactBoundaryTokens || effectiveWindow;
114
+ // The transcript estimate is the single source of truth for the displayed
115
+ // context footprint. Provider-reported input_tokens (lastContextTokens)
116
+ // swing non-monotonically and are not window-bounded on some providers
117
+ // (e.g. OpenAI gpt-5.5 Responses API), so they are kept only as secondary
118
+ // metadata (lastApiRequestTokens / usage.lastContextTokens) and never feed
119
+ // the gauge numerator.
120
+ const usedTokens = estimatedContextTokens;
121
+ const freeTokens = displayWindow ? Math.max(0, displayWindow - usedTokens) : 0;
122
+ // Use the same shared compact-policy math as manager/loop. Do not trust
123
+ // persisted trigger telemetry as an independent policy input: it is an
124
+ // output snapshot and was the source of repeated /context false positives.
125
+ const compactTriggerTokens = resolveCompactTriggerTokens(session || {}, compactBoundaryTokens) || 0;
126
+ const compactBufferTokens = compactBoundaryTokens
127
+ ? Math.max(0, compactBoundaryTokens - compactTriggerTokens)
128
+ : resolveCompactBufferTokens(compactBoundaryTokens, session?.compaction || {});
129
+ const value = {
130
+ sessionId: session?.id || null,
131
+ provider: route.provider,
132
+ model: route.model,
133
+ cwd: getCurrentCwd(),
134
+ toolMode: getMode(),
135
+ contextWindow: displayWindow || effectiveWindow || null,
136
+ effectiveContextWindow: effectiveWindow || null,
137
+ rawContextWindow: rawWindow || null,
138
+ effectiveContextWindowPercent: session?.effectiveContextWindowPercent || null,
139
+ usedTokens,
140
+ usedSource: 'estimated',
141
+ currentEstimatedTokens: estimatedContextTokens,
142
+ lastApiRequestTokens: lastContextTokens || 0,
143
+ lastApiRequestStale: lastUsageStale,
144
+ freeTokens,
145
+ compaction: {
146
+ ...(session?.compaction || {}),
147
+ boundaryTokens: compactBoundaryTokens || null,
148
+ triggerTokens: compactTriggerTokens || null,
149
+ bufferTokens: compactBufferTokens || null,
150
+ currentEstimatedTokens: estimatedContextTokens,
151
+ lastApiRequestTokens: lastContextTokens || 0,
152
+ lastApiRequestStale: lastUsageStale,
153
+ },
154
+ messages: messageSummary,
155
+ request: {
156
+ toolSchemaTokens,
157
+ toolSchemaBreakdown,
158
+ requestOverheadTokens,
159
+ reserveTokens: requestReserveTokens,
160
+ },
161
+ usage: {
162
+ lastInputTokens: Number(session?.lastInputTokens || 0),
163
+ lastUncachedInputTokens: Number(session?.lastUncachedInputTokens || 0),
164
+ lastOutputTokens: Number(session?.lastOutputTokens || 0),
165
+ lastCachedReadTokens: Number(session?.lastCachedReadTokens || 0),
166
+ lastCacheWriteTokens: Number(session?.lastCacheWriteTokens || 0),
167
+ lastContextTokens,
168
+ totalInputTokens: Number(session?.totalInputTokens || 0),
169
+ totalUncachedInputTokens: Number(session?.totalUncachedInputTokens || 0),
170
+ totalOutputTokens: Number(session?.totalOutputTokens || 0),
171
+ totalCachedReadTokens: Number(session?.totalCachedReadTokens || 0),
172
+ totalCacheWriteTokens: Number(session?.totalCacheWriteTokens || 0),
173
+ },
174
+ };
175
+ contextStatusCacheKey = cacheKey;
176
+ contextStatusCacheValue = value;
177
+ return value;
178
+ }
179
+
180
+ return { contextStatus, invalidateContextStatusCache };
181
+ }