mixdog 0.9.25 → 0.9.27

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 (87) hide show
  1. package/package.json +2 -1
  2. package/scripts/arg-guard-test.mjs +68 -0
  3. package/scripts/compacted-placeholder-scrub-test.mjs +77 -0
  4. package/scripts/steering-fold-provenance-test.mjs +71 -0
  5. package/scripts/webhook-smoke.mjs +46 -53
  6. package/src/cli.mjs +40 -4
  7. package/src/defaults/skills/setup/SKILL.md +6 -1
  8. package/src/rules/shared/01-tool.md +11 -4
  9. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +3 -1
  10. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  11. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  13. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  15. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  16. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  17. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  18. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +75 -0
  19. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +5 -0
  20. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  21. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  22. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +14 -1
  23. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  28. package/src/runtime/channels/lib/config.mjs +13 -11
  29. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  30. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  31. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  32. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  33. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  34. package/src/runtime/channels/lib/webhook.mjs +98 -150
  35. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  36. package/src/runtime/memory/index.mjs +50 -25
  37. package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
  38. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  39. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  40. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  41. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  42. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  43. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  44. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  45. package/src/runtime/memory/tool-defs.mjs +3 -3
  46. package/src/runtime/shared/child-guardian.mjs +2 -2
  47. package/src/runtime/shared/open-url.mjs +2 -1
  48. package/src/runtime/shared/schedules-db.mjs +350 -0
  49. package/src/runtime/shared/service-discovery.mjs +169 -0
  50. package/src/runtime/shared/spawn-flags.mjs +27 -0
  51. package/src/runtime/shared/staged-install-worker.mjs +14 -0
  52. package/src/runtime/shared/staged-update.mjs +530 -0
  53. package/src/runtime/shared/tool-primitives.mjs +3 -1
  54. package/src/runtime/shared/tool-surface.mjs +19 -3
  55. package/src/runtime/shared/update-checker.mjs +54 -10
  56. package/src/runtime/shared/webhooks-db.mjs +405 -0
  57. package/src/session-runtime/channel-config-api.mjs +13 -13
  58. package/src/session-runtime/lifecycle-api.mjs +12 -0
  59. package/src/session-runtime/runtime-core.mjs +41 -23
  60. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  61. package/src/standalone/agent-tool.mjs +42 -11
  62. package/src/standalone/channel-admin.mjs +173 -121
  63. package/src/standalone/channel-worker.mjs +2 -2
  64. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  65. package/src/tui/App.jsx +32 -10
  66. package/src/tui/app/channel-pickers.mjs +9 -9
  67. package/src/tui/app/clipboard.mjs +14 -0
  68. package/src/tui/app/doctor.mjs +1 -1
  69. package/src/tui/app/maintenance-pickers.mjs +17 -7
  70. package/src/tui/app/settings-picker.mjs +2 -2
  71. package/src/tui/app/transcript-window.mjs +63 -7
  72. package/src/tui/app/use-mouse-input.mjs +19 -6
  73. package/src/tui/components/Spinner.jsx +5 -2
  74. package/src/tui/components/StatusLine.jsx +7 -7
  75. package/src/tui/components/ToolExecution.jsx +36 -62
  76. package/src/tui/display-width.mjs +18 -8
  77. package/src/tui/dist/index.mjs +502 -324
  78. package/src/tui/engine/session-api-ext.mjs +12 -12
  79. package/src/tui/hooks/useSharedTick.mjs +103 -0
  80. package/src/tui/index.jsx +12 -2
  81. package/src/tui/markdown/format-token.mjs +46 -8
  82. package/src/tui/markdown/render-ansi.mjs +24 -1
  83. package/src/tui/markdown/stream-fence.mjs +60 -0
  84. package/src/tui/markdown/streaming-markdown.mjs +22 -1
  85. package/src/ui/statusline-segments.mjs +12 -1
  86. package/vendor/ink/build/display-width.js +10 -8
  87. package/src/runtime/shared/schedules-store.mjs +0 -82
@@ -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
+ }
@@ -54,7 +54,9 @@ export function titleCaseMcpServer(server) {
54
54
  return String(server || '')
55
55
  .split(/[_\s-]+/)
56
56
  .filter(Boolean)
57
- .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1).toLowerCase()}`)
57
+ .map((part) => (part === part.toLowerCase()
58
+ ? `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`
59
+ : part))
58
60
  .join(' ');
59
61
  }
60
62
 
@@ -492,8 +492,14 @@ export function toolWorkUnit(name, args = {}, category = '') {
492
492
  case 'web_search_call':
493
493
  return unitDescriptor('Web Research', { count: queryCount(a, 'query', 'queries', 'keywords') || 1, noun: 'query', pluralNoun: 'queries' });
494
494
  case 'web_fetch':
495
- case 'fetch':
496
495
  return unitDescriptor('Web Research', { count: queryCount(a, 'url', 'urls', 'uri', 'uris') || 1, active: 'Fetching', done: 'Fetched', noun: 'URL', pluralNoun: 'URLs' });
496
+ case 'fetch': {
497
+ const fetchLimit = Number(a.limit ?? a.messages);
498
+ const fetchCount = Number.isFinite(fetchLimit) && fetchLimit > 0
499
+ ? Math.floor(fetchLimit)
500
+ : queryCount(a, 'messages') || 1;
501
+ return unitDescriptor('Web Research', { count: fetchCount, active: 'Fetching', done: 'Fetched', noun: 'message' });
502
+ }
497
503
  case 'recall':
498
504
  case 'recall_memory':
499
505
  case 'search_memories':
@@ -501,8 +507,15 @@ export function toolWorkUnit(name, args = {}, category = '') {
501
507
  case 'remember':
502
508
  case 'save_memory':
503
509
  case 'update_memory':
504
- case 'memory':
505
510
  return unitDescriptor('Memory', { count: queryCount(a, 'entries', 'items', 'memories', 'query', 'text', 'value') || 1, active: 'Writing', done: 'Wrote', noun: 'memory item' });
511
+ case 'memory': {
512
+ const action = String(a.action || '').toLowerCase();
513
+ const op = String(a.op || '').toLowerCase();
514
+ const isMutation = op === 'add' || op === 'edit' || op === 'delete' || op === 'promote' || op === 'dismiss'
515
+ || (action === 'core' && !op);
516
+ if (isMutation) return unitDescriptor('Memory', { count: queryCount(a, 'entries', 'items', 'memories', 'query', 'text', 'value') || 1, active: 'Writing', done: 'Wrote', noun: 'memory item' });
517
+ return unitDescriptor('Memory', { count: queryCount(a, 'entries', 'items', 'memories', 'query', 'text', 'value') || 1, active: 'Checking', done: 'Checked', noun: 'memory item' });
518
+ }
506
519
  case 'explore':
507
520
  return unitDescriptor('Explore', { count: queryCount(a, 'query', 'queries', 'prompt', 'task', 'goal') || 1, noun: 'query', pluralNoun: 'queries' });
508
521
  case 'shell':
@@ -514,8 +527,11 @@ export function toolWorkUnit(name, args = {}, category = '') {
514
527
  case 'agent':
515
528
  case 'bridge':
516
529
  return unitDescriptor('Agent', { count: queryCount(a, 'agents', 'roles', 'role', 'tag', 'task_id', 'sessionId') || 1, noun: 'agent' });
517
- case 'task':
530
+ case 'task': {
531
+ const action = String(a.action || '').toLowerCase();
532
+ if (action === 'cancel') return unitDescriptor('Task', { count: queryCount(a, 'task_id', 'task_ids', 'id', 'ids') || 1, active: 'Cancelling', done: 'Cancelled', noun: 'task' });
518
533
  return unitDescriptor('Task', { count: queryCount(a, 'task_id', 'task_ids', 'id', 'ids') || 1, noun: 'task' });
534
+ }
519
535
  case 'skill':
520
536
  case 'skill_execute':
521
537
  case 'skill_view':
@@ -13,11 +13,12 @@
13
13
  * the child exits, reporting the resolved version on success.
14
14
  */
15
15
 
16
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { spawn } from 'node:child_process';
20
20
  import { resolvePluginData } from './plugin-paths.mjs';
21
+ import { detachedSpawnOpts } from './spawn-flags.mjs';
21
22
 
22
23
  const PACKAGE_NAME = 'mixdog';
23
24
  const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
@@ -29,6 +30,22 @@ const CACHE_FILE_NAME = 'update-check-cache.json';
29
30
  // src/runtime/shared/).
30
31
  const _MODULE_DIR = dirname(fileURLToPath(import.meta.url));
31
32
  const _PACKAGE_JSON_PATH = join(_MODULE_DIR, '..', '..', '..', 'package.json');
33
+ const _PACKAGE_ROOT = dirname(_PACKAGE_JSON_PATH);
34
+
35
+ /**
36
+ * isDevInstall() — true when the running mixdog is a git checkout / clone (or
37
+ * otherwise not a normal npm install), so auto-update must be skipped: an
38
+ * `npm install -g mixdog@latest` would fight a linked/working-tree package.
39
+ * Heuristics: a `.git` entry at the package root, OR the package directory not
40
+ * living under any `node_modules/` path (global & local installs always do).
41
+ */
42
+ export function isDevInstall() {
43
+ try {
44
+ if (existsSync(join(_PACKAGE_ROOT, '.git'))) return true;
45
+ } catch { /* fall through to path heuristic */ }
46
+ const norm = _PACKAGE_ROOT.replace(/\\/g, '/').toLowerCase();
47
+ return !/\/node_modules\//.test(`/${norm}/`);
48
+ }
32
49
 
33
50
  let _localVersionCache = null;
34
51
  export function localPackageVersion() {
@@ -175,23 +192,50 @@ export async function checkLatestVersion({ force = false, dataDir } = {}) {
175
192
  };
176
193
  }
177
194
 
195
+ /**
196
+ * Resolve npm's JS entrypoint (npm-cli.js) next to the running node binary.
197
+ * Running `node npm-cli.js …` directly avoids cmd.exe/PowerShell entirely on
198
+ * Windows: no shell process means no console window can appear (and no
199
+ * `-WindowStyle Hidden` style flags that antivirus heuristics flag).
200
+ */
201
+ export function npmCliJsPath() {
202
+ const execDir = dirname(process.execPath);
203
+ const candidates = [
204
+ // Windows: npm ships beside node.exe.
205
+ join(execDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
206
+ // Unix layout: <prefix>/bin/node → <prefix>/lib/node_modules/npm.
207
+ join(execDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
208
+ ];
209
+ // When launched via npm itself, npm_execpath is authoritative.
210
+ const envPath = process.env.npm_execpath;
211
+ if (envPath && /npm-cli\.js$/i.test(envPath)) candidates.unshift(envPath);
212
+ for (const candidate of candidates) {
213
+ try { if (existsSync(candidate)) return candidate; } catch { /* keep looking */ }
214
+ }
215
+ return null;
216
+ }
217
+
178
218
  /**
179
219
  * runGlobalUpdate() — `npm install -g mixdog@latest` in a background child
180
- * process (windowsHide so no console flash on Windows). Resolves once the
181
- * child exits; never throws failures come back as {ok:false, error}.
220
+ * process. Prefers spawning node.exe with npm-cli.js directly (shell-less, so
221
+ * Windows never opens a console window); falls back to npm.cmd via shell when
222
+ * npm-cli.js cannot be located. Resolves once the child exits; never throws —
223
+ * failures come back as {ok:false, error}.
182
224
  */
183
225
  export function runGlobalUpdate() {
184
226
  return new Promise((resolvePromise) => {
185
227
  let child;
186
228
  try {
187
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
188
- child = spawn(npmCmd, ['install', '-g', `${PACKAGE_NAME}@latest`], {
229
+ const installArgs = ['install', '-g', `${PACKAGE_NAME}@latest`];
230
+ const cliJs = npmCliJsPath();
231
+ const isWin = process.platform === 'win32';
232
+ const [cmd, args, useShell] = cliJs
233
+ ? [process.execPath, [cliJs, ...installArgs], false]
234
+ : [isWin ? 'npm.cmd' : 'npm', installArgs, isWin];
235
+ child = spawn(cmd, args, {
189
236
  stdio: 'ignore',
190
- windowsHide: true,
191
- // detached: survive parent exit — quitting the TUI mid-install must
192
- // not kill npm halfway and leave the global install corrupted.
193
- detached: true,
194
- shell: process.platform === 'win32',
237
+ shell: useShell,
238
+ ...detachedSpawnOpts,
195
239
  });
196
240
  } catch (err) {
197
241
  resolvePromise({ ok: false, error: err?.message || String(err) });