@yemi33/minions 0.1.2066 → 0.1.2067

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.
@@ -0,0 +1,316 @@
1
+ // engine/dispatch-store.js — SQL-backed implementation of the
2
+ // section-shaped dispatch object. Same external contract as the legacy
3
+ // dispatch.json reader:
4
+ //
5
+ // readDispatchSectioned() -> { pending, active, completed, review }
6
+ // applyDispatchMutation(fn) -> runs fn against the section object,
7
+ // computes a diff, applies INSERTs/UPDATEs/
8
+ // DELETEs in one transaction, returns the
9
+ // mutated object. Returns { wrote, result }
10
+ // so callers can suppress side effects
11
+ // (event emission, cache invalidation)
12
+ // when nothing actually changed.
13
+ //
14
+ // All callers go through engine/dispatch.js#mutateDispatch and
15
+ // engine/queries.js#getDispatch as before — those are thin wrappers
16
+ // over the helpers here. Zero changes at any call site.
17
+
18
+ const SECTIONS = ['pending', 'active', 'completed', 'review'];
19
+
20
+ function _emptySectioned() {
21
+ return { pending: [], active: [], completed: [], review: [] };
22
+ }
23
+
24
+ function _toMs(v) {
25
+ if (v == null) return null;
26
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
27
+ const parsed = Date.parse(v);
28
+ return Number.isFinite(parsed) ? parsed : null;
29
+ }
30
+
31
+ function _parseRow(row) {
32
+ if (!row || !row.data) return null;
33
+ try { return JSON.parse(row.data); }
34
+ catch { return null; }
35
+ }
36
+
37
+ function readDispatchSectioned() {
38
+ const { getDb } = require('./db');
39
+ let db;
40
+ try { db = getDb(); }
41
+ catch { return _readDispatchJsonFallback(); } // SQLite unavailable
42
+
43
+ // All rows, all statuses. Matches the legacy dispatch.json reader's
44
+ // semantics — returns the complete dispatch state regardless of how old
45
+ // completed entries are. Cross-reference consumers like state-integrity
46
+ // need the full completed list to match against work-items, and the
47
+ // dashboard's slim slice handles its own payload sizing downstream.
48
+ const rows = db.prepare(`
49
+ SELECT data, status FROM dispatches
50
+ ORDER BY status, created_at
51
+ `).all();
52
+
53
+ if (rows.length === 0) {
54
+ // SQL has no live dispatches. Two scenarios:
55
+ // (a) Production fresh install (or all pruned) — JSON file is empty/missing,
56
+ // fallback returns the same empty sectioned object. No harm.
57
+ // (b) Test that wrote dispatch.json directly (legacy test helpers in
58
+ // timeout-behavioral / orphan-* / etc. — they bypass the proper
59
+ // mutateDispatch API and seed via fs.writeFileSync). Picking up
60
+ // the JSON keeps those tests working through the transition without
61
+ // touching every helper. Phase 1.5 migrates them to the proper API.
62
+ const fallback = _readDispatchJsonFallback();
63
+ const hasContent = (
64
+ (fallback.pending && fallback.pending.length > 0) ||
65
+ (fallback.active && fallback.active.length > 0) ||
66
+ (fallback.completed && fallback.completed.length > 0) ||
67
+ (fallback.review && fallback.review.length > 0)
68
+ );
69
+ if (hasContent) return fallback;
70
+ }
71
+
72
+ const out = _emptySectioned();
73
+ for (const row of rows) {
74
+ const rec = _parseRow(row);
75
+ if (!rec) continue;
76
+ if (out[row.status]) out[row.status].push(rec);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function _readDispatchJsonFallback() {
82
+ const path = require('path');
83
+ const fs = require('fs');
84
+ const shared = require('./shared');
85
+ const dispatchPath = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
86
+ let raw;
87
+ try {
88
+ raw = fs.readFileSync(dispatchPath, 'utf8');
89
+ } catch {
90
+ // ENOENT / permission / etc. — file genuinely absent. Silently
91
+ // return an empty sectioned object; that's the legacy semantics.
92
+ return _emptySectioned();
93
+ }
94
+ try {
95
+ const parsed = JSON.parse(raw);
96
+ const out = _emptySectioned();
97
+ for (const s of SECTIONS) {
98
+ if (Array.isArray(parsed[s])) out[s] = parsed[s];
99
+ }
100
+ return out;
101
+ } catch (e) {
102
+ // File exists but parse failed — that's user-visible corruption.
103
+ // Surface to logs the same way the legacy queries.getDispatch path
104
+ // did via shared.readJsonNoRestore. (Phase 1 contract test
105
+ // "getDispatch warns on corrupt dispatch.json".)
106
+ try {
107
+ // eslint-disable-next-line no-console
108
+ console.warn(`[dispatch-store] corrupt JSON in ${dispatchPath}: ${e.message}`);
109
+ } catch { /* console may be wrapped in tests */ }
110
+ return _emptySectioned();
111
+ }
112
+ }
113
+
114
+ // Internal: read the full (unfiltered) dispatch state from SQL.
115
+ // Used by applyDispatchMutation so the diff sees ALL completed rows,
116
+ // not just the recent window. Mutators that prune old completed
117
+ // dispatches MUST see those rows or the prune is a no-op.
118
+ function _readDispatchSectionedFull() {
119
+ const { getDb } = require('./db');
120
+ let db;
121
+ try { db = getDb(); }
122
+ catch { return _emptySectioned(); }
123
+ const rows = db.prepare('SELECT data, status FROM dispatches').all();
124
+ const out = _emptySectioned();
125
+ for (const row of rows) {
126
+ const rec = _parseRow(row);
127
+ if (!rec) continue;
128
+ if (out[row.status]) out[row.status].push(rec);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ // Build a flat { id -> { status, record } } map from a sectioned object.
134
+ function _indexBySection(sectioned) {
135
+ const out = new Map();
136
+ for (const status of SECTIONS) {
137
+ const rows = sectioned[status];
138
+ if (!Array.isArray(rows)) continue;
139
+ for (const rec of rows) {
140
+ if (!rec || !rec.id) continue;
141
+ out.set(String(rec.id), { status, record: rec });
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ // Compute the diff between before and after sectioned states.
148
+ // Returns { toUpsert: [{id, status, record}], toDelete: [id] }.
149
+ // toUpsert covers brand-new rows, status flips, and field changes.
150
+ // We compare JSON-stringified records to detect content edits; any
151
+ // change to ANY field flips the comparison and emits an UPSERT.
152
+ function _computeDispatchDiff(before, after) {
153
+ const beforeMap = _indexBySection(before);
154
+ const afterMap = _indexBySection(after);
155
+ const toUpsert = [];
156
+ const toDelete = [];
157
+
158
+ for (const [id, { status: afterStatus, record: afterRec }] of afterMap) {
159
+ const beforeEntry = beforeMap.get(id);
160
+ if (!beforeEntry) {
161
+ toUpsert.push({ id, status: afterStatus, record: afterRec });
162
+ continue;
163
+ }
164
+ const sameStatus = beforeEntry.status === afterStatus;
165
+ const sameContent = JSON.stringify(beforeEntry.record) === JSON.stringify(afterRec);
166
+ if (!sameStatus || !sameContent) {
167
+ toUpsert.push({ id, status: afterStatus, record: afterRec });
168
+ }
169
+ }
170
+ for (const [id] of beforeMap) {
171
+ if (!afterMap.has(id)) toDelete.push(id);
172
+ }
173
+ return { toUpsert, toDelete };
174
+ }
175
+
176
+ function _applyDispatchDiff(db, diff) {
177
+ if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
178
+ const now = Date.now();
179
+ const upsertStmt = db.prepare(`
180
+ INSERT INTO dispatches (id, status, agent, type, created_at, started_at, completed_at, data, updated_at)
181
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
182
+ ON CONFLICT(id) DO UPDATE SET
183
+ status = excluded.status,
184
+ agent = excluded.agent,
185
+ type = excluded.type,
186
+ created_at = excluded.created_at,
187
+ started_at = excluded.started_at,
188
+ completed_at = excluded.completed_at,
189
+ data = excluded.data,
190
+ updated_at = excluded.updated_at
191
+ `);
192
+ const deleteStmt = db.prepare('DELETE FROM dispatches WHERE id = ?');
193
+
194
+ for (const { id, status, record } of diff.toUpsert) {
195
+ upsertStmt.run(
196
+ String(id),
197
+ status,
198
+ record.agent || null,
199
+ record.type || null,
200
+ _toMs(record.created_at),
201
+ _toMs(record.started_at),
202
+ _toMs(record.completed_at),
203
+ JSON.stringify(record),
204
+ now,
205
+ );
206
+ }
207
+ for (const id of diff.toDelete) {
208
+ deleteStmt.run(String(id));
209
+ }
210
+ return true;
211
+ }
212
+
213
+ // Apply a mutation to the dispatch state. The mutator receives the
214
+ // section-shaped object, mutates it in place (or returns a replacement),
215
+ // and we diff against the pre-mutation snapshot to compute the SQL
216
+ // operations. Wrapped in a single transaction so the diff and the apply
217
+ // can't race against a concurrent reader/writer.
218
+ //
219
+ // Returns { wrote, result }:
220
+ // wrote — true iff at least one INSERT/UPDATE/DELETE landed.
221
+ // Callers (mutateDispatch) gate event emission + cache
222
+ // invalidation on this flag so no-op mutators don't bump
223
+ // the cache version (mirrors the onWrote contract from
224
+ // mutateJsonFileLocked).
225
+ // result — the mutator's return value (or the original object if the
226
+ // mutator mutated in place + returned undefined). Same
227
+ // contract as the legacy mutateJsonFileLocked-based path.
228
+ function applyDispatchMutation(mutator) {
229
+ const { getDb, withTransaction } = require('./db');
230
+ let db;
231
+ try { db = getDb(); }
232
+ catch (e) {
233
+ throw new Error(`engine/dispatch-store: SQLite unavailable (${e.message}); cannot mutate dispatch state`);
234
+ }
235
+
236
+ // withTransaction is nestable: shared.mutateWorkItems → mutator → cli
237
+ // codepath that itself calls mutateDispatch composes into a single
238
+ // outer BEGIN/COMMIT instead of throwing "transaction within transaction".
239
+ return withTransaction(db, () => {
240
+ const before = _readDispatchSectionedFull();
241
+ // Deep-clone before handing to the mutator so in-place mutations don't
242
+ // contaminate our snapshot. JSON round-trip is fine here — dispatch
243
+ // records are JSON-serializable by construction.
244
+ const beforeSnapshot = JSON.parse(JSON.stringify(before));
245
+ const next = mutator(before);
246
+ // Mutator may return a replacement, mutate in place + return undefined,
247
+ // or return a falsy value (legacy: same as undefined → use `before`).
248
+ const after = (next === undefined || next === null) ? before : next;
249
+ // Normalize: callers may return objects missing some sections (e.g.
250
+ // older code only touched pending). Fill in defaults so the diff
251
+ // doesn't mis-classify a missing section as "everything deleted".
252
+ for (const s of SECTIONS) {
253
+ if (!Array.isArray(after[s])) after[s] = beforeSnapshot[s] || [];
254
+ }
255
+ const diff = _computeDispatchDiff(beforeSnapshot, after);
256
+ const wrote = _applyDispatchDiff(db, diff);
257
+ return { wrote, result: after };
258
+ });
259
+ }
260
+
261
+ // Mirror the canonical SQL state back to dispatch.json after a successful
262
+ // mutation. Phase 1 keeps the JSON file as a *read-only derivative* of SQL
263
+ // — never an independent source of truth — so legacy callers that
264
+ // readFileSync the file directly (a couple of internal paths + the broader
265
+ // test suite) keep seeing consistent data. There's no drift opportunity
266
+ // because:
267
+ //
268
+ // - The mirror is always regenerated from the SQL table, never from
269
+ // in-memory state, so a write that lands transactionally in SQL but
270
+ // then crashes before the mirror is also written produces a stale
271
+ // mirror — but the SQL state is correct, and the next successful
272
+ // mutation overwrites the mirror.
273
+ // - No code path writes to dispatch.json independently anymore;
274
+ // mutateDispatch is the only writer.
275
+ //
276
+ // Phase 1.5 will remove the mirror once we've migrated the remaining
277
+ // direct-readers (engine/queries.js fallback path, engine/routing.js,
278
+ // the test infrastructure) to use readDispatchSectioned() / getDispatch().
279
+ function _mirrorJsonFromSql() {
280
+ try {
281
+ const path = require('path');
282
+ const fs = require('fs');
283
+ const shared = require('./shared');
284
+ // The full sectioned view (not the 24h-windowed one) so the JSON
285
+ // mirror matches what a legacy reader would have seen before Phase 1.
286
+ const full = _readDispatchSectionedFull();
287
+ const dispatchPath = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
288
+ // safeWrite handles the atomic rename + .backup sidecar. Best-effort —
289
+ // failure here doesn't propagate (SQL is source of truth, mirror is
290
+ // optional refresh material).
291
+ shared.safeWrite(dispatchPath, full);
292
+ // Re-derive what the parent shared.safeWrite would have done so an
293
+ // operator copying the file out gets matched fs.statSync mtimes
294
+ // (the dashboard's slow-state tracker watches this file as part of
295
+ // the dispatch path list). No-op if safeWrite already advanced mtime.
296
+ try { fs.utimesSync(dispatchPath, new Date(), new Date()); } catch { /* best-effort */ }
297
+ } catch {
298
+ // Mirror failures are non-fatal: SQL has already committed. Tests +
299
+ // legacy readers will see the previous mirror until the next write.
300
+ }
301
+ }
302
+
303
+ // Test seam — clear the dispatches table. Production code never calls this.
304
+ function _resetDispatchTableForTest() {
305
+ const { getDb } = require('./db');
306
+ try { getDb().exec('DELETE FROM dispatches'); } catch { /* not initialized */ }
307
+ }
308
+
309
+ module.exports = {
310
+ readDispatchSectioned,
311
+ applyDispatchMutation,
312
+ _readDispatchSectionedFull,
313
+ _computeDispatchDiff,
314
+ _mirrorJsonFromSql,
315
+ _resetDispatchTableForTest,
316
+ };
@@ -58,7 +58,57 @@ function _sidecarChangedPrompts(dispatch, prevSnap) {
58
58
  }
59
59
  }
60
60
 
61
+ // SQL-backed mutator (Phase 1). Same external contract as the legacy
62
+ // JSON path: mutator receives a `{ pending, active, completed, review }`
63
+ // object, mutates in place (or returns a replacement), and the changes
64
+ // land transactionally in the dispatches table. The diff-then-apply trick
65
+ // in dispatch-store preserves every field via the `data` JSON column, so
66
+ // new dispatch fields the engine adds in future commits don't need any
67
+ // migration — they round-trip automatically.
68
+ //
69
+ // Fallback path: if SQLite is unavailable (Node < 22.5, the DB init failed,
70
+ // the table doesn't exist yet because the migration hasn't run) we fall
71
+ // back to the legacy mutateJsonFileLocked flow against dispatch.json.
72
+ // The migration leaves dispatch.json.pre-sql-<ts> on disk, so an operator
73
+ // pinning to an older release can rename it back.
61
74
  function mutateDispatch(mutator) {
75
+ // Try the SQL path first.
76
+ try {
77
+ const store = require('./dispatch-store');
78
+ const { wrote, result } = store.applyDispatchMutation((dispatch) => {
79
+ dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
80
+ dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
81
+ dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
82
+ dispatch.review = Array.isArray(dispatch.review) ? dispatch.review : [];
83
+ const prevSnap = _snapshotPrompts(dispatch);
84
+ const next = mutator(dispatch) ?? dispatch;
85
+ // Prompt-size guard: only scan items whose prompt changed (or new items),
86
+ // so a 100-item status-flip doesn't re-byte-count every prompt.
87
+ _sidecarChangedPrompts(next, prevSnap);
88
+ return next;
89
+ });
90
+ if (wrote) {
91
+ try { require('./queries').invalidateDispatchCache(); } catch {}
92
+ try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
93
+ // Phase 1 dual-write: mirror the canonical SQL state to dispatch.json
94
+ // so legacy direct-readers (engine/queries.js fallback, engine/routing.js,
95
+ // test infrastructure that fs.readFileSync's the file) stay in sync.
96
+ // SQL is source of truth; the JSON file is regenerated from SQL on
97
+ // every successful mutation, never independently mutated.
98
+ try { store._mirrorJsonFromSql(); } catch { /* best-effort */ }
99
+ }
100
+ return result;
101
+ } catch (e) {
102
+ // Only fall back if the failure looks like a "DB not available" case,
103
+ // not a programming error. Surface mutator exceptions to the caller.
104
+ if (e && /SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
105
+ // fall through to legacy JSON path below
106
+ } else {
107
+ throw e;
108
+ }
109
+ }
110
+
111
+ // Legacy JSON path (pre-Phase 1 fallback). Same code as before.
62
112
  const defaultDispatch = { pending: [], active: [], completed: [] };
63
113
  const result = mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
64
114
  dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
@@ -66,8 +116,6 @@ function mutateDispatch(mutator) {
66
116
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
67
117
  const prevSnap = _snapshotPrompts(dispatch);
68
118
  const next = mutator(dispatch) ?? dispatch;
69
- // Prompt-size guard: only scan items whose prompt changed (or new items),
70
- // so a 100-item status-flip doesn't re-byte-count every prompt.
71
119
  _sidecarChangedPrompts(next, prevSnap);
72
120
  return next;
73
121
  }, {
@@ -76,7 +124,6 @@ function mutateDispatch(mutator) {
76
124
  try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
77
125
  },
78
126
  });
79
- // Invalidate the read cache so next getDispatch() sees fresh data
80
127
  try { require('./queries').invalidateDispatchCache(); } catch {}
81
128
  return result;
82
129
  }
package/engine/llm.js CHANGED
@@ -224,6 +224,7 @@ function _missingRuntimeMessage(runtimeName, runtime, reason) {
224
224
  '',
225
225
  'Choose a supported runtime in Settings -> Engine -> defaultCli/ccCli, then install its CLI:',
226
226
  '- Claude Code: npm install -g @anthropic-ai/claude-code or download from https://claude.ai/download',
227
+ '- OpenAI Codex CLI: npm install -g @openai/codex or brew install --cask codex',
227
228
  '- GitHub Copilot CLI: install via GitHub CLI/gh-copilot or download from https://github.com/github/copilot-cli/releases',
228
229
  '',
229
230
  'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
@@ -238,10 +239,10 @@ function _missingRuntimeMessage(runtimeName, runtime, reason) {
238
239
  '',
239
240
  'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
240
241
  ];
241
- if (selected !== 'claude') {
242
- lines.push('Or switch Settings -> Engine -> defaultCli/ccCli back to "claude" after installing Claude Code.');
242
+ if (selected === 'claude') {
243
+ lines.push('Or switch Settings -> Engine -> defaultCli/ccCli to "copilot" or "codex" after installing another supported runtime.');
243
244
  } else {
244
- lines.push('Or switch Settings -> Engine -> defaultCli/ccCli to "copilot" after installing GitHub Copilot CLI.');
245
+ lines.push('Or switch Settings -> Engine -> defaultCli/ccCli back to "claude" after installing Claude Code.');
245
246
  }
246
247
  return lines.join('\n');
247
248
  }
package/engine/queries.js CHANGED
@@ -174,10 +174,24 @@ function getControl() {
174
174
  let _dispatchCache = null;
175
175
  let _dispatchCacheAt = 0;
176
176
  function getDispatch() {
177
- // Short-lived cache — dispatch.json is read 10+ times per tick but only changes on mutateDispatch
177
+ // Short-lived cache — getDispatch is called 10+ times per tick (per-agent
178
+ // status derivation, work-item PR enrichment, etc.) but only changes on
179
+ // mutateDispatch. SQL reads are sub-millisecond, so the cache is now
180
+ // mostly a per-request memo rather than the disk-read amortizer it used
181
+ // to be when dispatch.json could be 1+ MB.
178
182
  const now = Date.now();
179
183
  if (_dispatchCache && (now - _dispatchCacheAt) < 2000) return _dispatchCache;
180
- _dispatchCache = readJsonNoRestore(DISPATCH_PATH) || { pending: [], active: [], completed: [] };
184
+ // Try SQL first (post-Phase 1). If the table doesn't exist (greenfield
185
+ // install, Node < 22.5, or DB init failed) fall back to the legacy JSON
186
+ // reader. The migration leaves dispatch.json.pre-sql-<ts> on disk so
187
+ // operators rolling back to a pre-migration release can rename it back.
188
+ try {
189
+ const store = require('./dispatch-store');
190
+ const sectioned = store.readDispatchSectioned();
191
+ _dispatchCache = sectioned;
192
+ } catch {
193
+ _dispatchCache = readJsonNoRestore(DISPATCH_PATH) || { pending: [], active: [], completed: [] };
194
+ }
181
195
  _dispatchCacheAt = now;
182
196
  return _dispatchCache;
183
197
  }
@@ -1247,30 +1261,44 @@ function getWorkItems(config) {
1247
1261
  if (!_workItemsMtimesDiffer(_workItemsCacheMtimes, currMtimes)) return _workItemsCache;
1248
1262
  }
1249
1263
  const projects = getProjects(config);
1250
- const allItems = [];
1264
+ let allItems = [];
1251
1265
 
1252
- // Central work items
1253
- const centralData = safeRead(path.join(MINIONS_DIR, 'work-items.json'));
1254
- if (centralData) {
1255
- try {
1256
- for (const item of JSON.parse(centralData)) {
1257
- item._source = 'central';
1258
- allItems.push(item);
1259
- }
1260
- } catch {}
1261
- }
1266
+ // Phase 2: prefer the SQL store. Returns every scope's items tagged
1267
+ // with `_source = <scope>`, matching the legacy decoration. Empty SQL
1268
+ // table OR a SQLite failure falls through to the JSON path below so a
1269
+ // fresh install + a node:sqlite-broken environment both keep working.
1270
+ try {
1271
+ const store = require('./work-items-store');
1272
+ const sqlItems = store.readAllWorkItems();
1273
+ if (Array.isArray(sqlItems) && sqlItems.length > 0) {
1274
+ allItems = sqlItems;
1275
+ }
1276
+ } catch { /* fall through to JSON */ }
1262
1277
 
1263
- // Per-project work items
1264
- for (const project of projects) {
1265
- const data = safeRead(projectWorkItemsPath(project));
1266
- if (data) {
1278
+ if (allItems.length === 0) {
1279
+ // Central work items
1280
+ const centralData = safeRead(path.join(MINIONS_DIR, 'work-items.json'));
1281
+ if (centralData) {
1267
1282
  try {
1268
- for (const item of JSON.parse(data)) {
1269
- item._source = project.name || 'project';
1283
+ for (const item of JSON.parse(centralData)) {
1284
+ item._source = 'central';
1270
1285
  allItems.push(item);
1271
1286
  }
1272
1287
  } catch {}
1273
1288
  }
1289
+
1290
+ // Per-project work items
1291
+ for (const project of projects) {
1292
+ const data = safeRead(projectWorkItemsPath(project));
1293
+ if (data) {
1294
+ try {
1295
+ for (const item of JSON.parse(data)) {
1296
+ item._source = project.name || 'project';
1297
+ allItems.push(item);
1298
+ }
1299
+ } catch {}
1300
+ }
1301
+ }
1274
1302
  }
1275
1303
 
1276
1304
  // Cross-reference with dispatch (fill in agent from active dispatch if missing on work item)
@@ -55,10 +55,16 @@ function isPortListening(port) {
55
55
  const re6 = new RegExp(`\\s\\[::1?\\]:${n}\\s+\\S+\\s+LISTENING`, 'i');
56
56
  return re.test(out) || re6.test(out);
57
57
  }
58
- const out = execSync(`ss -ltn 'sport = :${n}' 2>/dev/null || netstat -ltn 2>/dev/null`, {
58
+ try {
59
+ const out = execSync(`lsof -nP -iTCP:${n} -sTCP:LISTEN`, {
60
+ encoding: 'utf8', timeout: 3000,
61
+ });
62
+ if (/\bLISTEN\b/i.test(out)) return true;
63
+ } catch {}
64
+ const out = execSync(`ss -ltn 'sport = :${n}' 2>/dev/null || netstat -ltn 2>/dev/null || netstat -an -p tcp 2>/dev/null`, {
59
65
  encoding: 'utf8', timeout: 3000, shell: true,
60
66
  });
61
- return new RegExp(`[:.]${n}\\b`).test(out);
67
+ return new RegExp(`(?:[:.])${n}\\b[^\\n]*\\b(?:LISTEN|LISTENING)\\b`, 'i').test(out);
62
68
  } catch { return false; }
63
69
  }
64
70