mixdog 0.9.20 → 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.
- package/README.md +14 -12
- package/package.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/channels/lib/owned-runtime.mjs +95 -15
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/worker-main.mjs +7 -1
- package/src/runtime/memory/index.mjs +90 -0
- package/src/runtime/memory/lib/http-router.mjs +39 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/runtime-core.mjs +75 -17
- package/src/standalone/channel-worker.mjs +63 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +98 -11
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +1 -0
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/dist/index.mjs +297 -71
- 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
|
-
|
|
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
|
|
134
|
-
// finally-unlink below can verify
|
|
135
|
-
// deleting whatever lock file happens to be at this path.
|
|
136
|
-
|
|
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 (
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1621
|
-
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1711
|
-
|
|
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
|
}
|
|
@@ -252,6 +252,13 @@ export function createStandaloneChannelWorker({
|
|
|
252
252
|
MIXDOG_STANDALONE: '1',
|
|
253
253
|
MIXDOG_WORKER_MODE: '1',
|
|
254
254
|
MIXDOG_CLI_OWNED: '0',
|
|
255
|
+
// Preserve the real terminal-lead PID (host TUI, or an outer run-mcp
|
|
256
|
+
// supervisor if one injected MIXDOG_SUPERVISOR_PID) through the fork.
|
|
257
|
+
// Without this the worker resolves getTerminalLeadPid() to its OWN pid,
|
|
258
|
+
// so the seat's ownerLeadPid tracks the headless worker instead of the
|
|
259
|
+
// terminal that owns it — and the seat can never be evicted when the
|
|
260
|
+
// owning terminal/TUI dies while the worker stays alive.
|
|
261
|
+
MIXDOG_SUPERVISOR_PID: process.env.MIXDOG_SUPERVISOR_PID || String(process.pid),
|
|
255
262
|
MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
|
|
256
263
|
},
|
|
257
264
|
windowsHide: true,
|
|
@@ -416,6 +423,12 @@ export function createStandaloneChannelWorker({
|
|
|
416
423
|
|
|
417
424
|
function uninstallParentExitHook() {
|
|
418
425
|
if (!parentExitCleanup) return;
|
|
426
|
+
// Refcount-aware: never strip the shared parent-exit protection while any
|
|
427
|
+
// owned child PID is still tracked. A newer worker may have been spawned
|
|
428
|
+
// (new PID added, hook install is a no-op because it is already present)
|
|
429
|
+
// before an older worker finishes its async teardown; letting that old
|
|
430
|
+
// teardown uninstall here would leave the live newer PID unprotected.
|
|
431
|
+
if (ownedChildPids.size > 0) return;
|
|
419
432
|
const unregister = parentExitCleanup;
|
|
420
433
|
parentExitCleanup = null;
|
|
421
434
|
unregister();
|
|
@@ -458,27 +471,70 @@ export function createStandaloneChannelWorker({
|
|
|
458
471
|
child = null;
|
|
459
472
|
if (!waitForExit) {
|
|
460
473
|
rejectPending(new Error(`channels runtime shutdown requested (${reason})`));
|
|
461
|
-
|
|
474
|
+
// Fast/detached path is still TERMINAL: the caller does not block on the
|
|
475
|
+
// full teardown, but a background escalation ladder guarantees the worker
|
|
476
|
+
// dies so no zombie survives. Exit-hook + PID tracking stay installed
|
|
477
|
+
// until the process ACTUALLY exits (not on IPC ack), so a parent that
|
|
478
|
+
// force-exits mid-grace still force-kills the tree via the exit cleanup.
|
|
479
|
+
let torn = false;
|
|
480
|
+
const teardown = () => {
|
|
481
|
+
if (torn) return;
|
|
482
|
+
torn = true;
|
|
483
|
+
clearTimeout(termTimer);
|
|
484
|
+
clearTimeout(killTimer);
|
|
485
|
+
if (targetPid) ownedChildPids.delete(targetPid);
|
|
486
|
+
unrefChildHandles(target);
|
|
487
|
+
uninstallParentExitHook();
|
|
488
|
+
};
|
|
489
|
+
// Bounded grace after IPC shutdown, then SIGTERM.
|
|
490
|
+
const termTimer = setTimeout(() => {
|
|
491
|
+
try {
|
|
492
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
|
|
493
|
+
} catch {}
|
|
494
|
+
}, 1500);
|
|
495
|
+
// Hard fallback: taskkill /T /F the whole tree, then SIGKILL the handle.
|
|
496
|
+
const killTimer = setTimeout(() => {
|
|
497
|
+
try {
|
|
498
|
+
if (target.exitCode == null) forceKillTree(targetPid);
|
|
499
|
+
} catch {}
|
|
500
|
+
try {
|
|
501
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGKILL');
|
|
502
|
+
} catch {}
|
|
503
|
+
teardown();
|
|
504
|
+
}, 3000);
|
|
505
|
+
// Unref so these background escalation timers never keep the event loop
|
|
506
|
+
// (or a pending /exit) alive waiting out the grace/hard-kill window.
|
|
507
|
+
termTimer.unref?.();
|
|
508
|
+
killTimer.unref?.();
|
|
509
|
+
// Actual exit (or spawn error) is the only thing that tears down tracking.
|
|
510
|
+
target.once('exit', teardown);
|
|
511
|
+
target.once('error', teardown);
|
|
462
512
|
stopPromise = new Promise((resolve) => {
|
|
463
513
|
let settled = false;
|
|
464
|
-
const
|
|
514
|
+
const settle = (ok) => {
|
|
465
515
|
if (settled) return;
|
|
466
516
|
settled = true;
|
|
467
517
|
clearTimeout(sendTimer);
|
|
468
518
|
stopPromise = null;
|
|
469
|
-
unrefChildHandles(target);
|
|
470
|
-
uninstallParentExitHook();
|
|
471
519
|
resolve(ok);
|
|
472
520
|
};
|
|
473
|
-
|
|
521
|
+
// Resolve the caller quickly once the IPC shutdown is acked/timed out;
|
|
522
|
+
// the escalation ladder above keeps running in the background.
|
|
523
|
+
const sendTimer = setTimeout(() => settle(true), 250);
|
|
524
|
+
sendTimer.unref?.();
|
|
525
|
+
target.once('exit', () => settle(true));
|
|
474
526
|
try {
|
|
475
527
|
target.send?.({ type: 'shutdown', reason }, () => {
|
|
476
528
|
try { target.disconnect?.(); } catch {}
|
|
477
|
-
|
|
529
|
+
settle(true);
|
|
478
530
|
});
|
|
479
531
|
} catch {
|
|
480
532
|
try { target.disconnect?.(); } catch {}
|
|
481
|
-
|
|
533
|
+
// IPC unavailable: skip the grace window and escalate now.
|
|
534
|
+
try {
|
|
535
|
+
if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
|
|
536
|
+
} catch {}
|
|
537
|
+
settle(false);
|
|
482
538
|
}
|
|
483
539
|
});
|
|
484
540
|
return stopPromise;
|
|
@@ -232,7 +232,10 @@ namespace Mixdog {
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
'@`,
|
|
235
|
-
|
|
235
|
+
// System.Drawing is required too: GetDialogOwnerCenter uses
|
|
236
|
+
// Screen.PrimaryScreen.WorkingArea (System.Drawing.Rectangle) — without it
|
|
237
|
+
// the Add-Type compile fails and the dialog silently falls back to typing.
|
|
238
|
+
'Add-Type -TypeDefinition $code -ReferencedAssemblies System.Windows.Forms,System.Drawing | Out-Null',
|
|
236
239
|
// Invisible TopMost owner anchored to the TUI console (or foreground window)
|
|
237
240
|
// so IFileOpenDialog is modal, centered, and not detached on another monitor.
|
|
238
241
|
'$owner = New-Object System.Windows.Forms.Form',
|