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.
- package/package.json +2 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench-tmp.mjs +1 -1
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/shared/01-tool.md +8 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +39 -20
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +37 -1
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/ToolExecution.jsx +12 -4
- package/src/tui/display-width.mjs +10 -4
- package/src/tui/dist/index.mjs +166 -58
- package/src/tui/engine/agent-job-feed.mjs +42 -3
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/index.jsx +12 -2
- package/src/ui/statusline-segments.mjs +12 -1
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/vendor/ink/build/display-width.js +5 -4
- 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) =>
|
|
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
|
|
181
|
-
*
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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) });
|