mixdog 0.9.24 → 0.9.26

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 (84) hide show
  1. package/package.json +2 -1
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/explore-bench-tmp.mjs +1 -1
  4. package/scripts/recall-usecase-cases.json +1 -1
  5. package/scripts/smoke-runtime-negative.ps1 +106 -106
  6. package/scripts/steering-fold-provenance-test.mjs +71 -0
  7. package/scripts/tool-efficiency-diag.mjs +1 -1
  8. package/scripts/webhook-smoke.mjs +46 -53
  9. package/src/defaults/cycle3-review-prompt.md +11 -4
  10. package/src/defaults/memory-promote-prompt.md +9 -0
  11. package/src/defaults/skills/setup/SKILL.md +6 -1
  12. package/src/rules/lead/02-channels.md +3 -3
  13. package/src/rules/shared/01-tool.md +8 -4
  14. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  15. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  17. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  18. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  19. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  20. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  22. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
  23. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  26. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  27. package/src/runtime/channels/backends/discord.mjs +10 -3
  28. package/src/runtime/channels/lib/config.mjs +13 -11
  29. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  30. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  31. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  32. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  33. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  34. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  35. package/src/runtime/channels/lib/webhook.mjs +98 -150
  36. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  37. package/src/runtime/memory/index.mjs +50 -25
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
  39. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  40. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  41. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  42. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  43. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  44. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  45. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  46. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  47. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  48. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  49. package/src/runtime/memory/tool-defs.mjs +1 -1
  50. package/src/runtime/shared/child-guardian.mjs +2 -2
  51. package/src/runtime/shared/open-url.mjs +2 -1
  52. package/src/runtime/shared/schedules-db.mjs +350 -0
  53. package/src/runtime/shared/service-discovery.mjs +169 -0
  54. package/src/runtime/shared/spawn-flags.mjs +27 -0
  55. package/src/runtime/shared/tool-primitives.mjs +3 -1
  56. package/src/runtime/shared/tool-surface.mjs +19 -3
  57. package/src/runtime/shared/update-checker.mjs +54 -10
  58. package/src/runtime/shared/webhooks-db.mjs +405 -0
  59. package/src/session-runtime/channel-config-api.mjs +13 -13
  60. package/src/session-runtime/lifecycle-api.mjs +14 -0
  61. package/src/session-runtime/runtime-core.mjs +39 -20
  62. package/src/standalone/agent-tool.mjs +42 -11
  63. package/src/standalone/channel-admin.mjs +173 -121
  64. package/src/standalone/channel-worker.mjs +2 -2
  65. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  66. package/src/tui/App.jsx +32 -10
  67. package/src/tui/app/channel-pickers.mjs +9 -9
  68. package/src/tui/app/clipboard.mjs +14 -0
  69. package/src/tui/app/doctor.mjs +1 -1
  70. package/src/tui/app/maintenance-pickers.mjs +17 -7
  71. package/src/tui/app/settings-picker.mjs +2 -2
  72. package/src/tui/app/transcript-window.mjs +37 -1
  73. package/src/tui/app/use-mouse-input.mjs +19 -6
  74. package/src/tui/components/ToolExecution.jsx +12 -4
  75. package/src/tui/display-width.mjs +10 -4
  76. package/src/tui/dist/index.mjs +166 -58
  77. package/src/tui/engine/agent-job-feed.mjs +42 -3
  78. package/src/tui/engine/session-api-ext.mjs +12 -12
  79. package/src/tui/index.jsx +12 -2
  80. package/src/ui/statusline-segments.mjs +12 -1
  81. package/src/workflows/default/WORKFLOW.md +29 -38
  82. package/src/workflows/solo/WORKFLOW.md +15 -20
  83. package/vendor/ink/build/display-width.js +5 -4
  84. package/src/runtime/shared/schedules-store.mjs +0 -82
@@ -0,0 +1,169 @@
1
+ // Per-service, SINGLE-WRITER port-advert files under RUNTIME_ROOT/discovery/.
2
+ //
3
+ // Motivation: service port discovery (memory_port, pg_port, ...) used to ride
4
+ // inside the shared active-instance.json, whose write path takes a .lock. With
5
+ // multiple terminals heartbeating and slow Windows renames, that lock could be
6
+ // held long enough to starve a port advertise for an hour+. Splitting each
7
+ // service's advert into its own file, written by its single owner via a plain
8
+ // atomic rename (NO .lock), removes the shared-lock contention entirely.
9
+ //
10
+ // Each advert is stamped with the owner pid and updatedAt. Readers prefer the
11
+ // discovery file (validating the owner pid is still alive) and callers may fall
12
+ // back to the legacy active-instance fields for cross-version compat.
13
+ import { readFileSync, unlinkSync } from 'node:fs'
14
+ import { tmpdir } from 'node:os'
15
+ import { join, resolve } from 'node:path'
16
+ import { writeJsonAtomicSync } from './atomic-file.mjs'
17
+
18
+ export function discoveryRoot() {
19
+ const root = process.env.MIXDOG_RUNTIME_ROOT
20
+ ? resolve(process.env.MIXDOG_RUNTIME_ROOT)
21
+ : join(tmpdir(), 'mixdog')
22
+ return join(root, 'discovery')
23
+ }
24
+
25
+ export function discoveryPath(service) {
26
+ return join(discoveryRoot(), `${service}.json`)
27
+ }
28
+
29
+ function parsePid(value) {
30
+ const pid = Number(value)
31
+ return Number.isInteger(pid) && pid > 0 ? pid : null
32
+ }
33
+
34
+ export function isPidAlive(value) {
35
+ const pid = parsePid(value)
36
+ if (!pid) return false
37
+ try {
38
+ process.kill(pid, 0)
39
+ return true
40
+ } catch (e) {
41
+ // EPERM: process exists but we lack permission → alive. ESRCH → dead.
42
+ return e?.code === 'EPERM'
43
+ }
44
+ }
45
+
46
+ // Per-process "unreachable port" distrust set: service → { port, updatedAt,
47
+ // pid, ts } a consumer WITHOUT its own health probe proved dead this process
48
+ // (connect-failure). pid-liveness alone cannot catch a recycled owner pid: the
49
+ // advert's pid can be alive as an UNRELATED process while its advertised port
50
+ // is dead, so a pid-validated advert would keep routing consumers to a corpse
51
+ // port and suppress the legacy fallback. Consumers call markServiceUnreachable()
52
+ // on a CONNECTION-level failure (see isConnRefuseError); readLiveServiceAdvert
53
+ // then skips THAT port so the caller falls back to legacy/buffer.
54
+ //
55
+ // The entry records the advert IDENTITY at mark time (updatedAt + pid). A
56
+ // daemon restarted on the SAME port re-stamps updatedAt / gets a new pid, so
57
+ // readLiveServiceAdvert detects the change and clears the distrust — the old
58
+ // port-only key otherwise stayed distrusted for the whole process lifetime. A
59
+ // short TTL is a second self-heal so any residual false positive expires.
60
+ const _DISTRUST_TTL_MS = 30_000
61
+ const _unreachablePorts = new Map()
62
+
63
+ // Connection-level failure classifier for distrust decisions: only "nothing is
64
+ // listening / socket died on connect" justifies distrusting an advert port.
65
+ // Timeouts (slow-but-alive daemon) and HTTP/handshake status errors must NOT —
66
+ // they would false-distrust a healthy service.
67
+ const _CONN_REFUSE_CODES = new Set([
68
+ 'ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EHOSTUNREACH', 'ENETUNREACH',
69
+ ])
70
+ export function isConnRefuseError(err) {
71
+ if (!err) return false
72
+ const code = String(err.code || err.cause?.code || '')
73
+ if (_CONN_REFUSE_CODES.has(code)) return true
74
+ const msg = String(err.message || err.cause?.message || '')
75
+ return /socket hang up|ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH|ENETUNREACH/i.test(msg)
76
+ }
77
+
78
+ export function markServiceUnreachable(service, port) {
79
+ const p = Number(port)
80
+ if (!Number.isInteger(p) || p <= 0) return
81
+ // Snapshot the CURRENT advert identity so a later restart on the same port is
82
+ // detected (newer updatedAt / different pid) and the distrust auto-clears.
83
+ const raw = readServiceAdvert(service)
84
+ const same = raw && Number(raw.port) === p
85
+ _unreachablePorts.set(service, {
86
+ port: p,
87
+ updatedAt: same ? (Number(raw.updatedAt) || 0) : 0,
88
+ pid: same && raw.pid != null ? Number(raw.pid) : null,
89
+ ts: Date.now(),
90
+ })
91
+ }
92
+
93
+ // Raw read of a service advert (no validation). Returns the parsed object or
94
+ // null when absent/unreadable/partial (mid-rename).
95
+ export function readServiceAdvert(service) {
96
+ try {
97
+ const raw = JSON.parse(readFileSync(discoveryPath(service), 'utf8'))
98
+ return raw && typeof raw === 'object' ? raw : null
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ // Validated read: returns the advert only when it carries a live port whose
105
+ // owner pid (field `pid`) is still alive. A dead owner → null (stale advert).
106
+ export function readLiveServiceAdvert(service, { requirePid = true } = {}) {
107
+ const raw = readServiceAdvert(service)
108
+ if (!raw) return null
109
+ const port = Number(raw.port)
110
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536) return null
111
+ // A port a consumer already proved unreachable this process is treated as an
112
+ // invalid advert (recycled-pid corpse guard) → fall back to legacy/buffer.
113
+ // Self-heal: a restart on the same port re-stamps updatedAt / changes pid,
114
+ // and a stale entry ages out — either clears the distrust and re-trusts.
115
+ const distrust = _unreachablePorts.get(service)
116
+ if (distrust && distrust.port === port) {
117
+ const curUpdatedAt = Number(raw.updatedAt) || 0
118
+ const curPid = raw.pid != null ? Number(raw.pid) : null
119
+ const restarted = curUpdatedAt > distrust.updatedAt || curPid !== distrust.pid
120
+ const expired = Date.now() - distrust.ts > _DISTRUST_TTL_MS
121
+ if (restarted || expired) _unreachablePorts.delete(service)
122
+ else return null
123
+ }
124
+ if (raw.pid != null) {
125
+ if (!isPidAlive(raw.pid)) return null
126
+ } else if (requirePid) {
127
+ return null
128
+ }
129
+ return raw
130
+ }
131
+
132
+ // Convenience: live port only (or null).
133
+ export function readServicePort(service, opts) {
134
+ const raw = readLiveServiceAdvert(service, opts)
135
+ return raw ? Number(raw.port) : null
136
+ }
137
+
138
+ // Single-writer full-replace advert. Plain atomic rename, NO .lock. Always
139
+ // stamps updatedAt; callers pass port/pid + any extra metadata fields.
140
+ export function writeServiceAdvert(service, fields = {}) {
141
+ const file = discoveryPath(service)
142
+ writeJsonAtomicSync(
143
+ file,
144
+ { ...fields, updatedAt: Date.now() },
145
+ { compact: true, fsyncDir: true, renameFallback: 'truncate' },
146
+ )
147
+ return file
148
+ }
149
+
150
+ // Single-writer merge patch: read current advert, apply fields (null/undefined
151
+ // value ⇒ delete key), re-stamp updatedAt. When the merged result no longer
152
+ // advertises a port, the file is removed (clean shutdown). Read-modify-write is
153
+ // race-free enough for a genuine single owner (only the service's supervisor
154
+ // writes its own file).
155
+ export function patchServiceAdvert(service, fields = {}) {
156
+ const cur = readServiceAdvert(service) ?? {}
157
+ const merged = { ...cur }
158
+ for (const [k, v] of Object.entries(fields)) {
159
+ if (v == null) delete merged[k]
160
+ else merged[k] = v
161
+ }
162
+ const port = Number(merged.port)
163
+ if (!Number.isInteger(port) || port <= 0) {
164
+ // No live port left → drop the advert entirely rather than leaving a husk.
165
+ try { unlinkSync(discoveryPath(service)) } catch {}
166
+ return null
167
+ }
168
+ return writeServiceAdvert(service, merged)
169
+ }
@@ -0,0 +1,27 @@
1
+ // Centralized child_process spawn/fork flag presets (win32 console-leak fix).
2
+ //
3
+ // win32 rule: DETACHED_PROCESS (Node `detached:true`) makes the OS IGNORE
4
+ // CREATE_NO_WINDOW (Node `windowsHide:true`), so every descendant allocates
5
+ // its own VISIBLE console window. On win32 we therefore NEVER set
6
+ // `detached:true` for hidden background spawns — `windowsHide` alone gives the
7
+ // child a hidden own console that descendants inherit; it survives the parent
8
+ // console closing, and Windows does not kill children on parent exit anyway.
9
+ //
10
+ // POSIX has no console concept, so `windowsHide` is a no-op there and
11
+ // `detached:true` stays REQUIRED: it makes the child a process-group leader so
12
+ // the whole tree can be signalled / tree-killed.
13
+ //
14
+ // Modeled on hermes-agent's _subprocess_compat two-mode split.
15
+ const isWin = process.platform === 'win32';
16
+
17
+ // Background daemon / process-tree spawns: hidden window everywhere, plus
18
+ // process-group detachment on POSIX only (dropped on win32 per the rule above).
19
+ export const detachedSpawnOpts = Object.freeze({
20
+ windowsHide: true,
21
+ ...(isWin ? {} : { detached: true }),
22
+ });
23
+
24
+ // Short-lived helper spawns that only need the console window hidden on win32.
25
+ export const hiddenSpawnOpts = Object.freeze({
26
+ windowsHide: true,
27
+ });
@@ -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
+ 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) });