mixdog 0.9.26 → 0.9.28

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 (36) hide show
  1. package/package.json +7 -2
  2. package/scripts/arg-guard-test.mjs +68 -0
  3. package/scripts/compacted-placeholder-scrub-test.mjs +77 -0
  4. package/src/app.mjs +14 -0
  5. package/src/cli.mjs +40 -4
  6. package/src/rules/shared/01-tool.md +3 -0
  7. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +3 -1
  8. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +30 -0
  9. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +5 -0
  10. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +14 -1
  11. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  12. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  13. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  14. package/src/runtime/memory/tool-defs.mjs +3 -3
  15. package/src/runtime/shared/staged-install-worker.mjs +14 -0
  16. package/src/runtime/shared/staged-update.mjs +530 -0
  17. package/src/runtime/shared/update-checker.mjs +1 -1
  18. package/src/session-runtime/lifecycle-api.mjs +9 -11
  19. package/src/session-runtime/runtime-core.mjs +61 -40
  20. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  21. package/src/tui/App.jsx +2 -1
  22. package/src/tui/app/transcript-window.mjs +26 -6
  23. package/src/tui/components/Spinner.jsx +5 -2
  24. package/src/tui/components/StatusLine.jsx +9 -9
  25. package/src/tui/components/ToolExecution.jsx +34 -59
  26. package/src/tui/display-width.mjs +8 -4
  27. package/src/tui/dist/index.mjs +545 -425
  28. package/src/tui/engine/notification-plan.mjs +5 -1
  29. package/src/tui/hooks/useSharedTick.mjs +103 -0
  30. package/src/tui/index.jsx +2 -1
  31. package/src/tui/markdown/format-token.mjs +46 -8
  32. package/src/tui/markdown/render-ansi.mjs +24 -1
  33. package/src/tui/markdown/stream-fence.mjs +60 -0
  34. package/src/tui/markdown/streaming-markdown.mjs +22 -1
  35. package/src/workflows/default/WORKFLOW.md +12 -8
  36. package/vendor/ink/build/display-width.js +5 -4
@@ -0,0 +1,530 @@
1
+ /**
2
+ * staged-update.mjs — staged background self-update for the globally-installed
3
+ * `mixdog` CLI, replacing the old shutdown-time `npm install -g mixdog@latest`.
4
+ *
5
+ * The problem with installing at shutdown (or while live) is that
6
+ * `npm install -g` overwrites the very .mjs files the running node process has
7
+ * loaded → Windows TAR_ENTRY_ERROR file-locks / ENOENT for anything importing
8
+ * mid-install. This module splits the update into two phases that never touch
9
+ * files a live session holds:
10
+ *
11
+ * 1. STAGE (background, during a live session): a hidden, shell-less child
12
+ * runs `npm install mixdog@<ver> --prefix <stagingDir>` into a private
13
+ * per-version dir under ~/.mixdog/data/staging/<ver>, relocates the
14
+ * installed package into a SELF-CONTAINED `<stagingDir>/mixdog` (its
15
+ * hoisted deps nested underneath), verifies the staged package.json
16
+ * version, and only then writes a completion marker. The global npm dir
17
+ * is never touched here.
18
+ *
19
+ * 2. SWAP (cli.mjs, pre-import, next clean launch): if a completed staged
20
+ * version newer than the running one exists AND no other live mixdog
21
+ * session is running, the global package dir (`node_modules/mixdog`) is
22
+ * atomically renamed aside (backup) and the staged dir renamed into its
23
+ * place — bin shims in the parent prefix are untouched. Any failure
24
+ * (EBUSY/EPERM/EACCES, non-writable global) aborts silently and the
25
+ * current version runs; the swap simply retries next launch.
26
+ *
27
+ * Everything here is best-effort: no path in this module may ever block or
28
+ * break launch/teardown.
29
+ */
30
+
31
+ import {
32
+ existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync,
33
+ readdirSync, unlinkSync, openSync, writeSync, closeSync, statSync,
34
+ } from 'node:fs';
35
+ import { dirname, basename, join } from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { spawn } from 'node:child_process';
38
+ import { resolvePluginData } from './plugin-paths.mjs';
39
+ import { detachedSpawnOpts, hiddenSpawnOpts } from './spawn-flags.mjs';
40
+ import { renameWithRetrySync } from './atomic-file.mjs';
41
+ import {
42
+ isDevInstall, localPackageVersion, compareSemver, isNewerVersion,
43
+ npmCliJsPath, UPDATE_PACKAGE_NAME as PACKAGE_NAME,
44
+ } from './update-checker.mjs';
45
+
46
+ const _MODULE_DIR = dirname(fileURLToPath(import.meta.url));
47
+ // Package root = two levels up from src/runtime/shared → src/.. (the mixdog
48
+ // package dir, i.e. <prefix>/node_modules/mixdog for a global install).
49
+ function packageRoot() {
50
+ return join(_MODULE_DIR, '..', '..', '..');
51
+ }
52
+
53
+ const MARKER_NAME = '.staged-complete.json';
54
+ // Name of the self-contained package dir inside a staging version folder.
55
+ const PKG_SUBDIR = 'mixdog';
56
+ const STALE_INPROGRESS_MS = 10 * 60 * 1000;
57
+
58
+ function stagingRootDir() {
59
+ return join(resolvePluginData(), 'staging');
60
+ }
61
+
62
+ function liveSessionsDir() {
63
+ return join(resolvePluginData(), 'live-sessions');
64
+ }
65
+
66
+ function rmDir(dir) {
67
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
68
+ }
69
+
70
+ function pidAlive(pid) {
71
+ if (!Number.isInteger(pid) || pid <= 0) return false;
72
+ if (pid === process.pid) return true;
73
+ try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ } catch (err) {
77
+ // EPERM = exists but not signalable → alive; ESRCH = gone.
78
+ return err?.code === 'EPERM';
79
+ }
80
+ }
81
+
82
+ function sleepSync(ms) {
83
+ const dur = Math.max(1, Number(ms) || 1);
84
+ try {
85
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, dur);
86
+ } catch {
87
+ const end = Date.now() + dur;
88
+ while (Date.now() < end) { /* spin fallback */ }
89
+ }
90
+ }
91
+
92
+ // ── Live-session refcount (pid files) ─────────────────────────────────────
93
+ // Each runtime registers a `<pid>.pid` file on boot and removes it on close.
94
+ // The swap consults these to skip while another mixdog is running. Liveness is
95
+ // a single process.kill(pid,0) per stale candidate (no process-table scan);
96
+ // dead pid files are reaped opportunistically so a crashed session never
97
+ // wedges updates forever.
98
+ let _exitHooked = false;
99
+ function selfPidFile() {
100
+ return join(liveSessionsDir(), `${process.pid}.pid`);
101
+ }
102
+
103
+ export function registerLiveSession() {
104
+ try {
105
+ const dir = liveSessionsDir();
106
+ mkdirSync(dir, { recursive: true });
107
+ writeFileSync(selfPidFile(), `${process.pid} ${Date.now()}\n`, 'utf8');
108
+ if (!_exitHooked) {
109
+ _exitHooked = true;
110
+ try { process.on('exit', unregisterLiveSession); } catch { /* ignore */ }
111
+ }
112
+ } catch { /* best-effort: liveness tracking is advisory */ }
113
+ }
114
+
115
+ export function unregisterLiveSession() {
116
+ try { unlinkSync(selfPidFile()); } catch { /* already gone */ }
117
+ }
118
+
119
+ export function otherLiveSessionExists() {
120
+ let entries;
121
+ try { entries = readdirSync(liveSessionsDir()); } catch { return false; }
122
+ let alive = false;
123
+ for (const name of entries) {
124
+ if (!name.endsWith('.pid')) continue;
125
+ const pid = Number.parseInt(name.slice(0, -4), 10);
126
+ if (!Number.isInteger(pid) || pid <= 0) continue;
127
+ if (pid === process.pid) continue;
128
+ if (pidAlive(pid)) {
129
+ alive = true;
130
+ } else {
131
+ try { unlinkSync(join(liveSessionsDir(), name)); } catch { /* stale reap best-effort */ }
132
+ }
133
+ }
134
+ return alive;
135
+ }
136
+
137
+ // ── Staging (background npm install into a private per-version dir) ────────
138
+ function markerPath(verDir) {
139
+ return join(verDir, MARKER_NAME);
140
+ }
141
+
142
+ export function isStagedComplete(version) {
143
+ const v = String(version || '').trim();
144
+ if (!v) return false;
145
+ const verDir = join(stagingRootDir(), v);
146
+ try {
147
+ const m = JSON.parse(readFileSync(markerPath(verDir), 'utf8'));
148
+ if (!m || m.version !== v) return false;
149
+ const pkgDir = m.pkgDir || join(verDir, PKG_SUBDIR);
150
+ return existsSync(join(pkgDir, 'package.json')) && existsSync(join(pkgDir, 'src', 'cli.mjs'));
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ function inProgressLock(verDir) {
157
+ return join(verDir, '.staging.lock');
158
+ }
159
+
160
+ // Try to claim the staging lock for verDir. Returns true if claimed (caller
161
+ // must release), false if another live worker holds a fresh lock.
162
+ function claimStagingLock(verDir) {
163
+ const lock = inProgressLock(verDir);
164
+ mkdirSync(verDir, { recursive: true });
165
+ const write = () => {
166
+ const fd = openSync(lock, 'wx');
167
+ try { writeSync(fd, `${process.pid} ${Date.now()}`); } finally { closeSync(fd); }
168
+ };
169
+ try { write(); return true; } catch (err) {
170
+ if (err?.code !== 'EEXIST') return false;
171
+ // Existing lock: steal only if stale (dead owner or old mtime).
172
+ let owner = 0; let ageMs = Infinity;
173
+ try { owner = Number.parseInt(String(readFileSync(lock, 'utf8')).trim().split(/\s+/)[0], 10); } catch {}
174
+ try { ageMs = Date.now() - statSync(lock).mtimeMs; } catch {}
175
+ if (pidAlive(owner) && ageMs < STALE_INPROGRESS_MS) return false;
176
+ try { unlinkSync(lock); } catch {}
177
+ try { write(); return true; } catch { return false; }
178
+ }
179
+ }
180
+
181
+ function releaseStagingLock(verDir) {
182
+ try { unlinkSync(inProgressLock(verDir)); } catch { /* best-effort */ }
183
+ }
184
+
185
+ /**
186
+ * runStagedInstall(version) — perform the full staging install for one version
187
+ * into ~/.mixdog/data/staging/<version>. Runs to completion (awaits the npm
188
+ * child), relocates deps under a self-contained `mixdog/` dir, verifies the
189
+ * package.json version, then writes the completion marker. Never throws.
190
+ */
191
+ export async function runStagedInstall(version) {
192
+ const v = String(version || '').trim();
193
+ if (!v) return { ok: false, error: 'no version' };
194
+ if (isStagedComplete(v)) {
195
+ return { ok: true, version: v, dir: join(stagingRootDir(), v, PKG_SUBDIR), alreadyStaged: true };
196
+ }
197
+ const verDir = join(stagingRootDir(), v);
198
+ if (!claimStagingLock(verDir)) return { ok: false, inProgress: true, error: 'staging in progress' };
199
+ try {
200
+ // Fresh staging: clear any partial prior attempt (keep the lock file).
201
+ const installPrefix = join(verDir, 'install');
202
+ rmDir(installPrefix);
203
+ rmDir(join(verDir, PKG_SUBDIR));
204
+ mkdirSync(installPrefix, { recursive: true });
205
+
206
+ const installArgs = [
207
+ 'install', `${PACKAGE_NAME}@${v}`, '--prefix', installPrefix,
208
+ '--no-save', '--no-audit', '--no-fund', '--loglevel=error',
209
+ ];
210
+ // Shell-less only: node runs npm-cli.js directly so Windows never opens a
211
+ // console window (no cmd/PowerShell, no -WindowStyle flags). If npm-cli.js
212
+ // cannot be resolved we SKIP staging silently rather than fall back to a
213
+ // shell spawn (AV constraint — no shell spawns).
214
+ const cliJs = npmCliJsPath();
215
+ if (!cliJs) return { ok: false, error: 'npm-cli.js unresolved — staging skipped' };
216
+ const code = await new Promise((res) => {
217
+ let child;
218
+ try {
219
+ child = spawn(process.execPath, [cliJs, ...installArgs], { stdio: 'ignore', shell: false, ...hiddenSpawnOpts });
220
+ } catch { res(-1); return; }
221
+ child.once('error', () => res(-1));
222
+ child.once('exit', (c) => res(typeof c === 'number' ? c : -1));
223
+ });
224
+ if (code !== 0) return { ok: false, error: `npm install exited with code ${code}` };
225
+
226
+ const installedNM = join(installPrefix, 'node_modules');
227
+ const installedPkg = join(installedNM, PACKAGE_NAME);
228
+ const pkgJson = join(installedPkg, 'package.json');
229
+ if (!existsSync(pkgJson)) return { ok: false, error: 'staged package.json missing' };
230
+ let stagedVer;
231
+ try { stagedVer = String(JSON.parse(readFileSync(pkgJson, 'utf8')).version || ''); } catch { stagedVer = ''; }
232
+ if (stagedVer !== v) return { ok: false, error: `staged version ${stagedVer || '?'} != ${v}` };
233
+
234
+ // Relocate into a self-contained package dir: move mixdog OUT of the
235
+ // install node_modules, then nest the remaining (hoisted) deps under it so
236
+ // the swapped-in global dir resolves its own deps and never depends on the
237
+ // parent prefix's node_modules. Both renames are same-volume (instant).
238
+ const pkgDir = join(verDir, PKG_SUBDIR);
239
+ renameSync(installedPkg, pkgDir);
240
+ renameSync(installedNM, join(pkgDir, 'node_modules'));
241
+ rmDir(installPrefix);
242
+
243
+ if (!existsSync(join(pkgDir, 'src', 'cli.mjs'))) return { ok: false, error: 'staged cli.mjs missing' };
244
+ // Marker written LAST — its presence is the completion signal for the swap.
245
+ writeFileSync(markerPath(verDir), JSON.stringify({ version: v, pkgDir, stagedAt: Date.now() }), 'utf8');
246
+ return { ok: true, version: v, dir: pkgDir };
247
+ } catch (err) {
248
+ return { ok: false, error: err?.message || String(err) };
249
+ } finally {
250
+ releaseStagingLock(verDir);
251
+ }
252
+ }
253
+
254
+ /**
255
+ * spawnStagedInstall(version) — fire-and-forget a hidden, detached background
256
+ * worker that stages the given version. Detached so it survives the launching
257
+ * session quitting mid-install (the swap happens on a later clean launch).
258
+ * No-op if already staged. Never throws.
259
+ */
260
+ export function spawnStagedInstall(version) {
261
+ const v = String(version || '').trim();
262
+ if (!v) return false;
263
+ if (process.env.MIXDOG_DISABLE_STAGED_INSTALL) return false;
264
+ if (isStagedComplete(v)) return false;
265
+ try {
266
+ const worker = join(_MODULE_DIR, 'staged-install-worker.mjs');
267
+ const child = spawn(process.execPath, [worker, v], { stdio: 'ignore', ...detachedSpawnOpts });
268
+ child.once?.('error', () => {});
269
+ child.unref?.();
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
276
+ // ── Swap (pre-import, next clean launch) ──────────────────────────────────
277
+ /**
278
+ * bestStagedVersion(currentVersion) — highest completed staged version strictly
279
+ * newer than current, with a valid self-contained package dir; else null.
280
+ */
281
+ export function bestStagedVersion(currentVersion) {
282
+ const cur = String(currentVersion || localPackageVersion());
283
+ let best = null;
284
+ let entries;
285
+ try { entries = readdirSync(stagingRootDir()); } catch { return null; }
286
+ for (const name of entries) {
287
+ const verDir = join(stagingRootDir(), name);
288
+ let m;
289
+ try { m = JSON.parse(readFileSync(markerPath(verDir), 'utf8')); } catch { continue; }
290
+ if (!m || !m.version) continue;
291
+ const pkgDir = m.pkgDir || join(verDir, PKG_SUBDIR);
292
+ if (!existsSync(join(pkgDir, 'package.json')) || !existsSync(join(pkgDir, 'src', 'cli.mjs'))) continue;
293
+ if (!isNewerVersion(m.version, cur)) continue;
294
+ if (!best || compareSemver(m.version, best.version) > 0) best = { version: m.version, pkgDir };
295
+ }
296
+ return best;
297
+ }
298
+
299
+ /**
300
+ * swapStagedIntoGlobal({ globalPkgRoot, pkgDir, expectedVersion }) — atomically
301
+ * replace the global package dir with a staged self-contained package via
302
+ * rename. Old dir kept as a `.old-<ts>` backup and removed best-effort on
303
+ * success; on failure of the second rename the backup is rolled back into
304
+ * place. Returns true only when the new version is live. Never throws.
305
+ */
306
+ export function swapStagedIntoGlobal({ globalPkgRoot, pkgDir, expectedVersion, _rename } = {}) {
307
+ if (!globalPkgRoot || !pkgDir) return false;
308
+ // `_rename` is a test seam; production always uses renameWithRetrySync.
309
+ const rename = typeof _rename === 'function' ? _rename : renameWithRetrySync;
310
+ // Safety: only ever swap a `.../node_modules/<name>` layout — never a dev
311
+ // checkout or an unexpected root.
312
+ const norm = String(globalPkgRoot).replace(/\\/g, '/');
313
+ if (!/\/node_modules\/[^/]+$/.test(norm)) return false;
314
+ if (!existsSync(join(pkgDir, 'package.json')) || !existsSync(join(pkgDir, 'src', 'cli.mjs'))) return false;
315
+ if (expectedVersion) {
316
+ try {
317
+ const sv = String(JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')).version || '');
318
+ if (sv !== String(expectedVersion)) return false;
319
+ } catch { return false; }
320
+ }
321
+ const backup = `${globalPkgRoot}.old-${Date.now()}`;
322
+ try {
323
+ rename(globalPkgRoot, backup);
324
+ } catch {
325
+ // Non-writable / locked global (EBUSY/EPERM/EACCES): abort, run current.
326
+ return false;
327
+ }
328
+ // From here the global path is momentarily EMPTY until a rename lands there.
329
+ let swappedForward = false;
330
+ try {
331
+ rename(pkgDir, globalPkgRoot);
332
+ swappedForward = true;
333
+ } catch {
334
+ // 2nd rename failed. Repopulate the global path: prefer restoring the
335
+ // original (backup); if that keeps failing, drive the swap forward
336
+ // (staged→global) instead.
337
+ for (let attempt = 0; attempt < 5 && !globalPopulated(globalPkgRoot); attempt++) {
338
+ try { rename(backup, globalPkgRoot); break; } catch {}
339
+ try { rename(pkgDir, globalPkgRoot); swappedForward = true; break; } catch {}
340
+ }
341
+ }
342
+ // Hard invariant: NEVER return with the global path unpopulated. If both
343
+ // directions keep failing, exit(1) with the backup preserved on disk so the
344
+ // install is recoverable — do not fall through to import a missing package.
345
+ if (!globalPopulated(globalPkgRoot)) {
346
+ process.stderr.write(`mixdog: update swap failed and could not be rolled back — backup preserved at ${backup}\n`);
347
+ process.exit(1);
348
+ }
349
+ // Backup removal is gated on a FRESH verification that global is populated,
350
+ // so it is unreachable from any failure path above.
351
+ if (globalPopulated(globalPkgRoot)) rmDir(backup);
352
+ return swappedForward;
353
+ }
354
+
355
+ // True only when the global path holds a usable package (its package.json and
356
+ // entrypoint both present). The single source of truth for "populated".
357
+ function globalPopulated(root) {
358
+ try {
359
+ return existsSync(join(root, 'package.json')) && existsSync(join(root, 'src', 'cli.mjs'));
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * cleanupStaging(currentVersion) — remove superseded/stale staging dirs and any
367
+ * leftover swap backups. Best-effort; never throws.
368
+ */
369
+ export function cleanupStaging(currentVersion) {
370
+ const cur = String(currentVersion || localPackageVersion());
371
+ try {
372
+ for (const name of readdirSync(stagingRootDir())) {
373
+ const dir = join(stagingRootDir(), name);
374
+ let st;
375
+ try { st = statSync(dir); } catch { continue; }
376
+ if (!st.isDirectory()) continue;
377
+ let ver = null;
378
+ try { ver = JSON.parse(readFileSync(markerPath(dir), 'utf8')).version; } catch {}
379
+ if (ver) {
380
+ if (compareSemver(ver, cur) <= 0) rmDir(dir);
381
+ } else if (Date.now() - st.mtimeMs > 60 * 60 * 1000) {
382
+ rmDir(dir);
383
+ }
384
+ }
385
+ } catch { /* staging dir absent */ }
386
+ // Backup sweep is gated on a FRESH check that the global package is
387
+ // populated — a `.old-*` dir is only ever stale/deletable once a real
388
+ // package sits at the global path (never reachable while a swap left it bare).
389
+ try {
390
+ const root = packageRoot();
391
+ if (globalPopulated(root)) {
392
+ const parent = dirname(root);
393
+ const prefix = `${basename(root)}.old-`;
394
+ for (const name of readdirSync(parent)) {
395
+ if (name.startsWith(prefix)) rmDir(join(parent, name));
396
+ }
397
+ }
398
+ } catch { /* best-effort */ }
399
+ }
400
+
401
+ /**
402
+ * performPendingSwap() — the pre-import entrypoint called from cli.mjs. Returns
403
+ * true only if the global package dir was actually swapped to a newer staged
404
+ * version (the caller should then re-exec so the new files load cleanly).
405
+ * Synchronous, silent, and safe: any obstacle → false, run current version.
406
+ */
407
+ export function performPendingSwap() {
408
+ try {
409
+ if (process.env.MIXDOG_SWAP_REEXEC) return false;
410
+ if (process.env.MIXDOG_DISABLE_STAGED_SWAP) return false;
411
+ if (isDevInstall()) return false;
412
+ const lock = join(stagingRootDir(), '.swap.lock');
413
+ // Two rounds: round 0 is the normal attempt; if another launcher owns the
414
+ // swap lock (a live swap may be renaming the global dir right now) we WAIT
415
+ // for it to settle, then round 1 re-evaluates against the now-updated
416
+ // on-disk version (it may swap again, or find nothing left to do).
417
+ for (let round = 0; round < 2; round++) {
418
+ const current = currentGlobalVersion();
419
+ const best = bestStagedVersion(current);
420
+ if (!best) { cleanupStaging(current); return false; }
421
+ // Other live session → defer; the swap re-applies on the next clean launch.
422
+ if (otherLiveSessionExists()) return false;
423
+ if (claimSwapLock(lock)) {
424
+ let done = false;
425
+ try {
426
+ if (!otherLiveSessionExists()) {
427
+ done = swapStagedIntoGlobal({
428
+ globalPkgRoot: packageRoot(),
429
+ pkgDir: best.pkgDir,
430
+ expectedVersion: best.version,
431
+ });
432
+ }
433
+ } finally {
434
+ try { unlinkSync(lock); } catch {}
435
+ }
436
+ cleanupStaging(done ? best.version : current);
437
+ return done;
438
+ }
439
+ // Lost the lock. Do NOT import mid-swap — wait for the owner to finish.
440
+ const cleared = waitForSwapLockClear(lock, 5000);
441
+ if (!cleared) {
442
+ // Timed out with a still-live owner mid-swap. Hard invariant: never
443
+ // fall through to imports unless the global dir is verified present +
444
+ // stable; otherwise the owner is still renaming — fail this launch
445
+ // cleanly (exit 1) rather than load a half-renamed tree.
446
+ ensureGlobalStableOrExit(packageRoot(), 3000);
447
+ return false;
448
+ }
449
+ // Lock cleared → loop and re-evaluate against the winner's result.
450
+ }
451
+ return false;
452
+ } catch {
453
+ return false;
454
+ }
455
+ }
456
+
457
+ // Hard gate for the loser-timeout path: return only when `root` holds a
458
+ // present + size-stable package; otherwise print a one-line notice and
459
+ // process.exit(1). Exported so the swap-safety proof can exercise the exact
460
+ // production decision.
461
+ export function ensureGlobalStableOrExit(root, timeoutMs = 3000) {
462
+ if (ensureGlobalStable(root, timeoutMs) && globalPopulated(root)) return true;
463
+ process.stderr.write('mixdog: update in progress — retry in a moment.\n');
464
+ process.exit(1);
465
+ }
466
+
467
+ // Read the CURRENT on-disk global version fresh (bypassing the cached
468
+ // localPackageVersion): after a peer's swap the package.json on disk has
469
+ // already changed, and the loser must compare staged versions against the new
470
+ // baseline to avoid a redundant re-swap.
471
+ function currentGlobalVersion() {
472
+ try {
473
+ return String(JSON.parse(readFileSync(join(packageRoot(), 'package.json'), 'utf8')).version || localPackageVersion());
474
+ } catch {
475
+ return localPackageVersion();
476
+ }
477
+ }
478
+
479
+ // Block until the swap lock file disappears (owner finished) or the deadline
480
+ // passes. A dead/abandoned owner is reaped and treated as cleared. Returns true
481
+ // if the lock is gone, false if it timed out with a live owner still holding.
482
+ export function waitForSwapLockClear(lock, timeoutMs) {
483
+ const deadline = Date.now() + Math.max(0, timeoutMs);
484
+ while (Date.now() < deadline) {
485
+ if (!existsSync(lock)) return true;
486
+ let owner = 0;
487
+ try { owner = Number.parseInt(String(readFileSync(lock, 'utf8')).trim().split(/\s+/)[0], 10); } catch {}
488
+ if (!pidAlive(owner)) { try { unlinkSync(lock); } catch {} return true; }
489
+ sleepSync(50);
490
+ }
491
+ return !existsSync(lock);
492
+ }
493
+
494
+ // Wait until the global package.json exists and its size is stable across two
495
+ // consecutive samples — a cheap proxy for "the rename settled". Best-effort.
496
+ function ensureGlobalStable(root, timeoutMs) {
497
+ const pj = join(root, 'package.json');
498
+ const deadline = Date.now() + Math.max(0, timeoutMs);
499
+ let lastSize = -1;
500
+ let stable = 0;
501
+ while (Date.now() < deadline) {
502
+ let size = -1;
503
+ try { size = statSync(pj).size; } catch { size = -1; }
504
+ if (size >= 0 && size === lastSize) { if (++stable >= 2) return true; } else { stable = 0; }
505
+ lastSize = size;
506
+ sleepSync(60);
507
+ }
508
+ return existsSync(pj);
509
+ }
510
+
511
+ function claimSwapLock(lock) {
512
+ try { mkdirSync(dirname(lock), { recursive: true }); } catch {}
513
+ try {
514
+ const fd = openSync(lock, 'wx');
515
+ try { writeSync(fd, `${process.pid} ${Date.now()}`); } finally { closeSync(fd); }
516
+ return true;
517
+ } catch (err) {
518
+ if (err?.code !== 'EEXIST') return false;
519
+ let owner = 0; let ageMs = Infinity;
520
+ try { owner = Number.parseInt(String(readFileSync(lock, 'utf8')).trim().split(/\s+/)[0], 10); } catch {}
521
+ try { ageMs = Date.now() - statSync(lock).mtimeMs; } catch {}
522
+ if (pidAlive(owner) && ageMs < STALE_INPROGRESS_MS) return false;
523
+ try { unlinkSync(lock); } catch {}
524
+ try {
525
+ const fd = openSync(lock, 'wx');
526
+ try { writeSync(fd, `${process.pid} ${Date.now()}`); } finally { closeSync(fd); }
527
+ return true;
528
+ } catch { return false; }
529
+ }
530
+ }
@@ -198,7 +198,7 @@ export async function checkLatestVersion({ force = false, dataDir } = {}) {
198
198
  * Windows: no shell process means no console window can appear (and no
199
199
  * `-WindowStyle Hidden` style flags that antivirus heuristics flag).
200
200
  */
201
- function npmCliJsPath() {
201
+ export function npmCliJsPath() {
202
202
  const execDir = dirname(process.execPath);
203
203
  const candidates = [
204
204
  // Windows: npm ships beside node.exe.
@@ -10,6 +10,7 @@ import {
10
10
  hasOwn,
11
11
  } from './session-text.mjs';
12
12
  import { toolSpecForMode, deferredSurfaceModeForLead } from './effort.mjs';
13
+ import { unregisterLiveSession } from '../runtime/shared/staged-update.mjs';
13
14
 
14
15
  // Session lifecycle surface: teardown (close/abort), resume/new, and the
15
16
  // resumable-session listing. Extracted verbatim from the runtime API object;
@@ -29,20 +30,19 @@ export function createLifecycleApi(deps) {
29
30
  invalidateContextStatusCache, invalidatePreSessionToolSurface,
30
31
  applyResolvedCwd, resolveRoute, applyDeferredToolSurface, standaloneTools,
31
32
  pushTranscriptRebind,
32
- flushPendingUpdate,
33
33
  } = deps;
34
34
  return {
35
35
  async close(reason = 'cli-exit', options = {}) {
36
36
  const detach = options?.detach === true || options?.wait === false || options?.waitForExit === false;
37
37
  setCloseRequested(true);
38
- // Deferred self-update: arm-at-boot, install-on-quit. Only a real
39
- // process exit (not a /clear session reset) triggers the npm install;
40
- // the spawn itself is deferred to AFTER teardown below so npm overwrites
41
- // the install once this process has released its file handles.
38
+ // Self-update now stages in the background and swaps on the next clean
39
+ // launch (see staged-update.mjs) nothing installs at shutdown. On a
40
+ // real process exit we just drop this session's live-refcount pid file so
41
+ // a pending swap on the next launch is no longer blocked by us.
42
42
  const isProcessExit = /exit|quit|shutdown|sighup|sigint|sigterm/.test(String(reason || '').toLowerCase());
43
- const flushUpdateOnExit = () => {
43
+ const onProcessExit = () => {
44
44
  if (!isProcessExit) return;
45
- try { flushPendingUpdate?.(); } catch { /* exit must never wedge on the update spawn */ }
45
+ try { unregisterLiveSession(); } catch { /* advisory refcount only */ }
46
46
  };
47
47
  // SessionEnd: bridge teardown to the standard hook bus. reason mapped to
48
48
  // standard values ('clear'/'exit' where applicable, else 'other'). Short
@@ -150,7 +150,7 @@ export function createLifecycleApi(deps) {
150
150
  for (const stop of [mcpStop, openaiWsStop, patchStop]) {
151
151
  Promise.resolve(stop).catch(() => {});
152
152
  }
153
- flushUpdateOnExit();
153
+ onProcessExit();
154
154
  return ok;
155
155
  }
156
156
  await Promise.allSettled([
@@ -162,9 +162,7 @@ export function createLifecycleApi(deps) {
162
162
  withTeardownDeadline(shellJobsStop, 1500, false),
163
163
  withTeardownDeadline(bashSessionsStop, 1500, false),
164
164
  ]);
165
- // After teardown: file handles (channels/mcp/memory/shell/patch) are
166
- // released, so npm can now overwrite the install without TAR_ENTRY_ERROR.
167
- flushUpdateOnExit();
165
+ onProcessExit();
168
166
  return ok;
169
167
  },
170
168
  abort(reason = 'cli-abort') {