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.
- package/package.json +7 -2
- package/scripts/arg-guard-test.mjs +68 -0
- package/scripts/compacted-placeholder-scrub-test.mjs +77 -0
- package/src/app.mjs +14 -0
- package/src/cli.mjs +40 -4
- package/src/rules/shared/01-tool.md +3 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +3 -1
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +30 -0
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/staged-install-worker.mjs +14 -0
- package/src/runtime/shared/staged-update.mjs +530 -0
- package/src/runtime/shared/update-checker.mjs +1 -1
- package/src/session-runtime/lifecycle-api.mjs +9 -11
- package/src/session-runtime/runtime-core.mjs +61 -40
- package/src/standalone/agent-tool/tool-def.mjs +2 -2
- package/src/tui/App.jsx +2 -1
- package/src/tui/app/transcript-window.mjs +26 -6
- package/src/tui/components/Spinner.jsx +5 -2
- package/src/tui/components/StatusLine.jsx +9 -9
- package/src/tui/components/ToolExecution.jsx +34 -59
- package/src/tui/display-width.mjs +8 -4
- package/src/tui/dist/index.mjs +545 -425
- package/src/tui/engine/notification-plan.mjs +5 -1
- package/src/tui/hooks/useSharedTick.mjs +103 -0
- package/src/tui/index.jsx +2 -1
- package/src/tui/markdown/format-token.mjs +46 -8
- package/src/tui/markdown/render-ansi.mjs +24 -1
- package/src/tui/markdown/stream-fence.mjs +60 -0
- package/src/tui/markdown/streaming-markdown.mjs +22 -1
- package/src/workflows/default/WORKFLOW.md +12 -8
- 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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// the
|
|
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
|
|
43
|
+
const onProcessExit = () => {
|
|
44
44
|
if (!isProcessExit) return;
|
|
45
|
-
try {
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|