mixdog 0.9.20 → 0.9.22

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 (35) hide show
  1. package/README.md +14 -12
  2. package/package.json +1 -1
  3. package/src/defaults/skills/setup/SKILL.md +327 -0
  4. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  6. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  7. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  8. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  9. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  10. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +10 -10
  11. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -6
  12. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +14 -1
  13. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +23 -13
  14. package/src/runtime/channels/lib/owned-runtime.mjs +95 -15
  15. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  16. package/src/runtime/channels/lib/worker-main.mjs +7 -1
  17. package/src/runtime/memory/index.mjs +90 -0
  18. package/src/runtime/memory/lib/http-router.mjs +39 -0
  19. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  20. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  21. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  22. package/src/runtime/memory/lib/memory.mjs +39 -0
  23. package/src/runtime/shared/atomic-file.mjs +161 -85
  24. package/src/runtime/shared/child-guardian.mjs +61 -3
  25. package/src/session-runtime/runtime-core.mjs +75 -17
  26. package/src/standalone/channel-worker.mjs +63 -7
  27. package/src/standalone/folder-dialog.mjs +4 -1
  28. package/src/standalone/memory-runtime-proxy.mjs +98 -11
  29. package/src/standalone/seeds.mjs +28 -1
  30. package/src/tui/App.jsx +1 -0
  31. package/src/tui/app/doctor.mjs +175 -0
  32. package/src/tui/app/slash-commands.mjs +1 -0
  33. package/src/tui/app/slash-dispatch.mjs +9 -0
  34. package/src/tui/dist/index.mjs +314 -74
  35. package/src/tui/engine/session-api.mjs +19 -0
@@ -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) {
@@ -211,8 +278,24 @@ function _reclaimGuardMatchesToken(guardPath, token) {
211
278
  }
212
279
  }
213
280
 
214
- function _unlinkReclaimGuardIfPidMatches(guardPath, pid) {
215
- if (_readLockOwnerPid(guardPath) !== pid) return false;
281
+ function _readGuardContent(guardPath) {
282
+ try { return readFileSync(guardPath, 'utf8'); } catch { return null; }
283
+ }
284
+
285
+ function _parseStampPid(raw) {
286
+ if (raw === null) return null;
287
+ const tok = String(raw).trim().split(/\s+/)[0];
288
+ const pid = Number.parseInt(tok, 10);
289
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
290
+ }
291
+
292
+ // Unlink the guard only if its FULL content (pid ts token) is still the
293
+ // exact content the stale/dead decision was made against. A regenerated
294
+ // guard — even one whose pid was recycled to the same value (same-pid
295
+ // ABA) — carries a fresh ts+token and therefore never matches, so a
296
+ // fresh guard is never removed by a stale decision.
297
+ function _unlinkReclaimGuardIfContentMatches(guardPath, content) {
298
+ if (_readGuardContent(guardPath) !== content) return false;
216
299
  try { unlinkSync(guardPath); return true; } catch { return false; }
217
300
  }
218
301
 
@@ -222,14 +305,25 @@ function _unlinkUnreadableStaleReclaimGuard(guardPath, staleMs) {
222
305
  }
223
306
 
224
307
  function _tryClearStaleReclaimGuard(guardPath, staleMs) {
225
- const guardPid = _readLockOwnerPid(guardPath);
308
+ const guardContent = _readGuardContent(guardPath);
309
+ const guardPid = _parseStampPid(guardContent);
226
310
  if (guardPid !== null) {
227
- // Re-read the guard immediately before unlinking so a regenerated
228
- // guard with a different owner is not removed by a stale decision.
311
+ // Re-read the guard's full content immediately before unlinking so a
312
+ // regenerated guard (different owner OR same-pid ABA) is not removed
313
+ // by a stale decision.
229
314
  // This is still not a serialized primitive: correctness comes from
230
315
  // the lockPath owner-token reverify before unlinking lockPath, not
231
316
  // from making guard cleanup itself authoritative.
232
- if (_pidIsDead(guardPid)) _unlinkReclaimGuardIfPidMatches(guardPath, guardPid);
317
+ // A guard whose pid was recycled onto a LIVE process would otherwise
318
+ // never clear, starving reclaim permanently; so also clear a guard
319
+ // past staleMs regardless of apparent pid-liveness. Safe because the
320
+ // guard is best-effort — the lockPath owner-token reverify still gates
321
+ // the actual lock unlink.
322
+ let guardStale = false;
323
+ try { guardStale = Date.now() - statSync(guardPath).mtimeMs > staleMs; } catch {}
324
+ if (_pidIsDead(guardPid) || guardStale) {
325
+ _unlinkReclaimGuardIfContentMatches(guardPath, guardContent);
326
+ }
233
327
  return;
234
328
  }
235
329
  // A crash between openSync('wx') and the pid write can leave an empty
@@ -294,29 +388,11 @@ export async function withFileLock(lockPath, fn, opts = {}) {
294
388
  contErr.cause = err;
295
389
  throw contErr;
296
390
  }
391
+ // Stale-lock reclaim (shared with the sync variant): dead owner pid,
392
+ // a same-pid-but-foreign-token corpse from a reused pid, or a
393
+ // pidless/empty corpse lock. A live foreign holder is never stolen.
297
394
  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
- }
395
+ if (_tryReclaimStaleLock(lockPath, staleMs)) continue;
320
396
  } catch {}
321
397
  if (Date.now() >= deadline) break;
322
398
  const base = DEFAULT_BACKOFFS_MS[Math.min(attempt, DEFAULT_BACKOFFS_MS.length - 1)];
@@ -325,14 +401,14 @@ export async function withFileLock(lockPath, fn, opts = {}) {
325
401
  attempt += 1;
326
402
  continue;
327
403
  }
328
- try { writeFileSync(fd, `${process.pid} ${Date.now()}\n`, 'utf8'); } catch {}
404
+ try { writeFileSync(fd, `${process.pid} ${Date.now()} ${_OWNER_TOKEN}\n`, 'utf8'); } catch {}
329
405
  try {
330
406
  if (opts.secret === true) _enforceOwnerOnlyAclWin32(lockPath);
331
407
  return await fn();
332
408
  } finally {
333
409
  try { closeSync(fd); } catch {}
334
410
  try {
335
- if (_lockOwnedByPid(lockPath, process.pid)) unlinkSync(lockPath);
411
+ if (_lockOwnedBySelf(lockPath)) unlinkSync(lockPath);
336
412
  } catch {}
337
413
  }
338
414
  }
@@ -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)),
@@ -322,6 +322,13 @@ export async function createMixdogSessionRuntime({
322
322
  } = {}) {
323
323
  bootProfile('session-runtime:start', { provider, model, toolMode, cwd });
324
324
  let remoteEnabled = remote === true;
325
+ // Transient marker: an AUTO start (config/delayed autoStart) has forked the
326
+ // worker to ATTEMPT a claim-if-vacant but has NOT asserted remote for this
327
+ // session yet. remoteEnabled flips only if the worker reports it acquired the
328
+ // seat (the 'acquired' notification). Lets the deferred start chain proceed
329
+ // past its remoteEnabled guards without prematurely showing this session as
330
+ // remote (single-holder: a live owner must not be stolen by autoStart).
331
+ let remoteClaimPending = false;
325
332
  // Remote-mode transcript writer (Discord outbound). Lazily created per
326
333
  // session.id + cwd inside ask(); only active while remoteEnabled.
327
334
  let _transcriptWriter = null;
@@ -882,6 +889,8 @@ export async function createMixdogSessionRuntime({
882
889
  // newer remote session. Drop remote mode entirely on this session (no
883
890
  // handover, no retry) and tell UI listeners so the indicator updates.
884
891
  if (msg?.method === 'notifications/mixdog/remote') {
892
+ // Any acquire/supersede verdict resolves an in-flight auto claim attempt.
893
+ remoteClaimPending = false;
885
894
  if (msg?.params?.state === 'superseded' && remoteEnabled) {
886
895
  stopRemote('superseded-by-newer-remote-session');
887
896
  emitRemoteStateChange(false, 'superseded');
@@ -1521,8 +1530,23 @@ export async function createMixdogSessionRuntime({
1521
1530
  // rebinds output forwarding to this session, even when the worker is
1522
1531
  // already running (e.g. `/remote` re-issued after another session took the
1523
1532
  // seat, or to re-pin forwarding onto the current transcript).
1524
- function startRemote() {
1525
- remoteEnabled = true;
1533
+ function startRemote(options = {}) {
1534
+ const intent = options?.intent === 'auto' ? 'auto' : 'explicit';
1535
+ // Claim intent reaches the worker's boot claim via MIXDOG_REMOTE_INTENT
1536
+ // (last-wins for explicit, claim-if-vacant for auto). It is set transiently
1537
+ // around the worker fork below (see invokeChannelStart) and restored right
1538
+ // after — NOT here on the shared process.env — so unrelated children forked
1539
+ // during the boot window never inherit a stale intent.
1540
+ if (intent === 'auto') {
1541
+ // Auto-start (config/delayed): do NOT flip this session to remote up
1542
+ // front. Boot the worker to ATTEMPT a claim-if-vacant; remoteEnabled is
1543
+ // set only when the worker reports it actually acquired the seat (the
1544
+ // 'acquired' notification). A live owner already holding the seat makes
1545
+ // the worker back off silently and this session stays non-remote.
1546
+ remoteClaimPending = true;
1547
+ } else {
1548
+ remoteEnabled = true;
1549
+ }
1526
1550
  // Boot the memory daemon eagerly. The channels worker forwards
1527
1551
  // transcript ingests/entries to the memory HTTP service, whose port is
1528
1552
  // published to active-instance.json by getMemoryModule().init(). Without
@@ -1616,23 +1640,46 @@ export async function createMixdogSessionRuntime({
1616
1640
  // Re-check after the awaits above: stopRemote()/superseded or runtime
1617
1641
  // close may have landed mid-chain — do not boot/claim for a session
1618
1642
  // that already turned remote off.
1619
- if (!remoteEnabled || closeRequested) return;
1620
- await invokeChannelStart();
1621
- if (!remoteEnabled || closeRequested) return;
1622
- // Unconditional claim + forwarder rebind. A freshly-forked worker
1623
- // already claims in its own start(), so this is idempotent there; the
1624
- // already-running case is where it matters (last-wins seat overwrite +
1625
- // transcript rebind onto this session).
1626
- await channels.execute('activate_channel_bridge', { active: true });
1627
- })().catch((error) => bootProfile('channels:claim-failed', { error: error?.message || String(error) }));
1643
+ if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
1644
+ // Set the fork-inherited intent immediately before the worker fork (the
1645
+ // fork reads process.env synchronously inside invokeChannelStart) and
1646
+ // restore the prior value the instant it resolves, so the pollution
1647
+ // window is just this fork rather than the whole boot chain.
1648
+ const _prevIntent = process.env.MIXDOG_REMOTE_INTENT;
1649
+ try {
1650
+ process.env.MIXDOG_REMOTE_INTENT = intent;
1651
+ await invokeChannelStart();
1652
+ } finally {
1653
+ if (_prevIntent === undefined) delete process.env.MIXDOG_REMOTE_INTENT;
1654
+ else process.env.MIXDOG_REMOTE_INTENT = _prevIntent;
1655
+ }
1656
+ if ((!remoteEnabled && !remoteClaimPending) || closeRequested) { remoteClaimPending = false; return; }
1657
+ // Explicit start: unconditional claim + forwarder rebind (last-wins seat
1658
+ // overwrite + transcript rebind onto this session). AUTO start SKIPS this
1659
+ // — the freshly-forked worker already ran its claim-if-vacant boot claim,
1660
+ // and forcing activate here would steal a live owner that autoStart is
1661
+ // meant to yield to. The worker's acquire notification drives remote ON.
1662
+ if (intent !== 'auto') {
1663
+ await channels.execute('activate_channel_bridge', { active: true });
1664
+ }
1665
+ // Claim attempt dispatched; the worker's acquire/supersede notification
1666
+ // now owns the remoteEnabled transition. Drop the transient marker.
1667
+ remoteClaimPending = false;
1668
+ })().catch((error) => { remoteClaimPending = false; bootProfile('channels:claim-failed', { error: error?.message || String(error) }); });
1628
1669
  return true;
1629
1670
  }
1630
1671
 
1631
1672
  function stopRemote(reason) {
1632
1673
  remoteEnabled = false;
1674
+ // A pending auto-claim is abandoned by an explicit stop/supersede.
1675
+ remoteClaimPending = false;
1633
1676
  // Cancel any pending deferred start so it can't fire after remote is off.
1634
1677
  if (prewarmTimers.channelStartTimer) { clearTimeout(prewarmTimers.channelStartTimer); prewarmTimers.channelStartTimer = null; }
1635
- channels.stop(reason || 'remote-disabled', { waitForExit: false }).catch(() => {});
1678
+ // Route /remote-off and supersede through a WAITING stop: the runtime keeps
1679
+ // running, so we don't block on it (no await), but the waiting path runs the
1680
+ // full SIGTERM -> taskkill /T /F escalation ladder to guarantee the worker
1681
+ // dies rather than lingering as a zombie holding the bridge seat.
1682
+ channels.stop(reason || 'remote-disabled').catch(() => {});
1636
1683
  return true;
1637
1684
  }
1638
1685
 
@@ -1685,11 +1732,18 @@ export async function createMixdogSessionRuntime({
1685
1732
  // cfgMod.loadConfig returns — read it via the shared whole-file reader.
1686
1733
  // `config?.remote?.autoStart` is kept for back-compat with agent-section
1687
1734
  // placement.
1735
+ // `remote` (from `mixdog --remote`) is EXPLICIT force-takeover. Config
1736
+ // `remote.autoStart` is AUTO: it must claim the seat ONLY if no live owner
1737
+ // exists (claim-if-vacant) and back off silently otherwise — so it does NOT
1738
+ // set remoteEnabled up front (that would assert remote before the claim is
1739
+ // known to have won). Track it as a separate deferred auto request; the
1740
+ // worker's acquire notification flips remoteEnabled if/when it wins the seat.
1741
+ let remoteAutoStartRequested = false;
1688
1742
  if (!remoteEnabled) {
1689
1743
  try {
1690
1744
  if (config?.remote?.autoStart === true
1691
1745
  || sharedCfgMod?.readSection?.('remote')?.autoStart === true) {
1692
- remoteEnabled = true;
1746
+ remoteAutoStartRequested = true;
1693
1747
  }
1694
1748
  } catch { /* unreadable config never blocks boot */ }
1695
1749
  }
@@ -1702,13 +1756,17 @@ export async function createMixdogSessionRuntime({
1702
1756
  // early /remote (startRemote clears it), stopRemote(), and close() all
1703
1757
  // cancel it through the existing clearTimeout paths. Runtime /remote calls
1704
1758
  // still start immediately (user-initiated, UI already painted).
1705
- if (remoteEnabled) {
1759
+ if (remoteEnabled || remoteAutoStartRequested) {
1760
+ const remoteStartIntent = remoteEnabled ? 'explicit' : 'auto';
1706
1761
  const remoteAutoStartDelayMs = envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 });
1707
- bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs });
1762
+ bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs, intent: remoteStartIntent });
1708
1763
  prewarmTimers.channelStartTimer = setTimeout(() => {
1709
1764
  prewarmTimers.channelStartTimer = null;
1710
- if (closeRequested || !remoteEnabled) return;
1711
- startRemote();
1765
+ if (closeRequested) return;
1766
+ // Explicit: a /remote-off before the timer clears remoteEnabled — skip.
1767
+ // Auto: always attempt (claim-if-vacant is safe when a live owner exists).
1768
+ if (remoteStartIntent === 'explicit' && !remoteEnabled) return;
1769
+ startRemote({ intent: remoteStartIntent });
1712
1770
  }, remoteAutoStartDelayMs);
1713
1771
  prewarmTimers.channelStartTimer.unref?.();
1714
1772
  }