mixdog 0.9.25 → 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 (68) hide show
  1. package/package.json +1 -1
  2. package/scripts/steering-fold-provenance-test.mjs +71 -0
  3. package/scripts/webhook-smoke.mjs +46 -53
  4. package/src/defaults/skills/setup/SKILL.md +6 -1
  5. package/src/rules/shared/01-tool.md +8 -4
  6. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  7. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  8. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  9. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  10. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  11. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  12. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  13. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  14. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
  15. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  16. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  17. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  19. package/src/runtime/channels/lib/config.mjs +13 -11
  20. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  21. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  22. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  23. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  24. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  25. package/src/runtime/channels/lib/webhook.mjs +98 -150
  26. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  27. package/src/runtime/memory/index.mjs +50 -25
  28. package/src/runtime/memory/lib/cycle-scheduler.mjs +3 -1
  29. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  30. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  31. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  32. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  33. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  34. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  35. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  36. package/src/runtime/memory/tool-defs.mjs +1 -1
  37. package/src/runtime/shared/child-guardian.mjs +2 -2
  38. package/src/runtime/shared/open-url.mjs +2 -1
  39. package/src/runtime/shared/schedules-db.mjs +350 -0
  40. package/src/runtime/shared/service-discovery.mjs +169 -0
  41. package/src/runtime/shared/spawn-flags.mjs +27 -0
  42. package/src/runtime/shared/tool-primitives.mjs +3 -1
  43. package/src/runtime/shared/tool-surface.mjs +19 -3
  44. package/src/runtime/shared/update-checker.mjs +54 -10
  45. package/src/runtime/shared/webhooks-db.mjs +405 -0
  46. package/src/session-runtime/channel-config-api.mjs +13 -13
  47. package/src/session-runtime/lifecycle-api.mjs +14 -0
  48. package/src/session-runtime/runtime-core.mjs +39 -20
  49. package/src/standalone/agent-tool.mjs +42 -11
  50. package/src/standalone/channel-admin.mjs +173 -121
  51. package/src/standalone/channel-worker.mjs +2 -2
  52. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  53. package/src/tui/App.jsx +32 -10
  54. package/src/tui/app/channel-pickers.mjs +9 -9
  55. package/src/tui/app/clipboard.mjs +14 -0
  56. package/src/tui/app/doctor.mjs +1 -1
  57. package/src/tui/app/maintenance-pickers.mjs +17 -7
  58. package/src/tui/app/settings-picker.mjs +2 -2
  59. package/src/tui/app/transcript-window.mjs +37 -1
  60. package/src/tui/app/use-mouse-input.mjs +19 -6
  61. package/src/tui/components/ToolExecution.jsx +12 -4
  62. package/src/tui/display-width.mjs +10 -4
  63. package/src/tui/dist/index.mjs +129 -55
  64. package/src/tui/engine/session-api-ext.mjs +12 -12
  65. package/src/tui/index.jsx +12 -2
  66. package/src/ui/statusline-segments.mjs +12 -1
  67. package/vendor/ink/build/display-width.js +5 -4
  68. package/src/runtime/shared/schedules-store.mjs +0 -82
@@ -0,0 +1,350 @@
1
+ /**
2
+ * PG-backed schedules store — the single source of truth for registered
3
+ * schedules (schema `scheduler`, table `scheduler.schedules`).
4
+ *
5
+ * All schedule readers/writers (scheduler.mjs, config.mjs, channel-admin.mjs)
6
+ * go through this module. Legacy `<dataDir>/schedules/<name>/SCHEDULE.md`
7
+ * files are imported once by the migration hook in getDb and the directory is
8
+ * renamed to `schedules.migrated`; the old file-based schedules-store.mjs has
9
+ * been removed.
10
+ *
11
+ * DDL is idempotent and runs once on the first call per process. All queries
12
+ * fully-qualify `scheduler.schedules` so they are correct regardless of the
13
+ * connection search_path.
14
+ */
15
+
16
+ import { ensurePgInstance, withSchemaBootstrapLock } from '../memory/lib/pg/adapter.mjs';
17
+ import { resolvePluginData } from './plugin-paths.mjs';
18
+ import { readdirSync, readFileSync, renameSync, existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { readMarkdownDocument } from './markdown-frontmatter.mjs';
21
+
22
+ const SCHEMA = 'scheduler';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Lazy connection + one-shot idempotent DDL (keyed per resolved dataDir).
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const _ready = new Map(); // dataDir → Promise<db>
29
+
30
+ const DDL = `
31
+ CREATE TABLE IF NOT EXISTS scheduler.schedules (
32
+ name text PRIMARY KEY,
33
+ description text NOT NULL DEFAULT '',
34
+ when_at timestamptz,
35
+ when_cron text,
36
+ timezone text,
37
+ target text NOT NULL CHECK (target IN ('channel','session')),
38
+ channel_id text,
39
+ model text,
40
+ prompt text NOT NULL,
41
+ enabled boolean NOT NULL DEFAULT true,
42
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','done')),
43
+ last_fired_at timestamptz,
44
+ next_fire_at timestamptz,
45
+ deferred_until timestamptz,
46
+ skipped_until timestamptz,
47
+ created_at timestamptz NOT NULL DEFAULT now(),
48
+ updated_at timestamptz NOT NULL DEFAULT now(),
49
+ CONSTRAINT schedules_when_xor CHECK ((when_at IS NOT NULL) <> (when_cron IS NOT NULL))
50
+ );
51
+ `;
52
+
53
+ async function getDb(dataDir = resolvePluginData()) {
54
+ if (_ready.has(dataDir)) return _ready.get(dataDir);
55
+ const p = (async () => {
56
+ const { db, pool } = await ensurePgInstance(dataDir, { schema: SCHEMA });
57
+ // Serialize the CREATE TABLE across concurrent first-boot processes on the
58
+ // same cluster-global advisory lock the adapter uses for schema bootstrap,
59
+ // so racing first calls can't run the DDL simultaneously.
60
+ await withSchemaBootstrapLock(pool, () => db.exec(DDL));
61
+ await migrateLegacySchedules(db, dataDir);
62
+ return db;
63
+ })();
64
+ _ready.set(dataDir, p);
65
+ try {
66
+ return await p;
67
+ } catch (err) {
68
+ _ready.delete(dataDir); // let the next call retry DDL after a transient failure
69
+ throw err;
70
+ }
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // One-time legacy SCHEDULE.md migration (additive; runs once per dataDir on
75
+ // first getDb, right after DDL). Imports every `<dataDir>/schedules/<name>/
76
+ // SCHEDULE.md` not already present in the table (by name), using the same
77
+ // days->cron folding as the admin write path, then renames the directory to
78
+ // `schedules.migrated` (never deletes user data). Per-entry failures are
79
+ // isolated and never block store readiness.
80
+ // ---------------------------------------------------------------------------
81
+
82
+ // Day-name / keyword -> cron day-of-week number (Sun=0 .. Sat=6).
83
+ const MIGRATE_DAY_TO_DOW = {
84
+ sun: 0, sunday: 0,
85
+ mon: 1, monday: 1,
86
+ tue: 2, tues: 2, tuesday: 2,
87
+ wed: 3, weds: 3, wednesday: 3,
88
+ thu: 4, thur: 4, thurs: 4, thursday: 4,
89
+ fri: 5, friday: 5,
90
+ sat: 6, saturday: 6,
91
+ };
92
+
93
+ function foldLegacyDaysIntoCron(cron, days) {
94
+ const parts = String(cron || '').trim().split(/\s+/).filter(Boolean);
95
+ if (parts.length !== 5 && parts.length !== 6) {
96
+ throw new Error(`invalid cron "${cron}"`);
97
+ }
98
+ const raw = String(days || '').trim().toLowerCase();
99
+ const dowIndex = parts.length - 1;
100
+ // days absent -> keep the cron's own day-of-week field ('0 9 * * 1' stays
101
+ // Monday-only). Only an explicit selector rewrites the dow field.
102
+ if (!raw) return parts.join(' ');
103
+ let dow;
104
+ if (raw === 'daily' || raw === 'everyday' || raw === 'every day') dow = '*';
105
+ else if (raw === 'weekday' || raw === 'weekdays') dow = '1-5';
106
+ else if (raw === 'weekend' || raw === 'weekends') dow = '0,6';
107
+ else {
108
+ const nums = raw.split(/[\s,]+/).filter(Boolean).map((t) => (
109
+ /^[0-6]$/.test(t) ? Number(t) : MIGRATE_DAY_TO_DOW[t]
110
+ ));
111
+ if (nums.some((n) => n === undefined)) {
112
+ throw new Error(`days "${days}" is not a recognizable day selector`);
113
+ }
114
+ dow = nums.join(',');
115
+ }
116
+ parts[dowIndex] = dow;
117
+ return parts.join(' ');
118
+ }
119
+
120
+ async function migrateLegacySchedules(db, dataDir) {
121
+ const dir = join(dataDir, 'schedules');
122
+ let entries;
123
+ try {
124
+ entries = readdirSync(dir, { withFileTypes: true });
125
+ } catch {
126
+ return; // no legacy schedules/ dir -> nothing to migrate
127
+ }
128
+ let imported = 0;
129
+ let skipped = 0;
130
+ const failed = [];
131
+ for (const ent of entries) {
132
+ if (!ent.isDirectory()) continue;
133
+ const name = ent.name;
134
+ try {
135
+ const { rows } = await db.query('SELECT 1 FROM scheduler.schedules WHERE name = $1', [name]);
136
+ if (rows.length) { skipped++; continue; }
137
+ let md;
138
+ try { md = readFileSync(join(dir, name, 'SCHEDULE.md'), 'utf8'); }
139
+ catch { skipped++; continue; }
140
+ const { frontmatter, body } = readMarkdownDocument(md);
141
+ const cron = foldLegacyDaysIntoCron(frontmatter.time, frontmatter.days);
142
+ const channel = String(frontmatter.channel || '').trim();
143
+ const enabled = frontmatter.enabled !== 'false' && frontmatter.enabled !== false;
144
+ await db.query(
145
+ `INSERT INTO scheduler.schedules
146
+ (name, description, when_cron, timezone, target, channel_id, model, prompt, enabled)
147
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
148
+ ON CONFLICT (name) DO NOTHING`,
149
+ [
150
+ name,
151
+ String(frontmatter.description || '').trim(),
152
+ cron,
153
+ frontmatter.timezone ? String(frontmatter.timezone).trim() : null,
154
+ channel ? 'channel' : 'session',
155
+ channel || null,
156
+ frontmatter.model ? String(frontmatter.model).trim() : null,
157
+ String(body || '').trim(),
158
+ enabled,
159
+ ],
160
+ );
161
+ imported++;
162
+ } catch (err) {
163
+ failed.push(name);
164
+ console.error(`[schedules] migration failed for "${name}": ${err?.message || err}`);
165
+ }
166
+ }
167
+ if (failed.length) {
168
+ // Some entries failed to import. Leave schedules/ in place (do NOT rename)
169
+ // so the next boot retries them; already-imported entries are skipped by
170
+ // the name check above, so retry is idempotent.
171
+ console.error(`[schedules] migrated ${imported} legacy schedule(s), ${failed.length} failed (${failed.join(', ')}); leaving schedules/ for retry`);
172
+ return;
173
+ }
174
+ try {
175
+ let target = `${dir}.migrated`;
176
+ if (existsSync(target)) target = `${dir}.migrated-${Date.now()}`;
177
+ renameSync(dir, target);
178
+ } catch (err) {
179
+ console.error(`[schedules] migrated ${imported} legacy schedule(s) but could not rename schedules/: ${err?.message || err}`);
180
+ return;
181
+ }
182
+ console.error(`[schedules] migrated ${imported} legacy schedule(s) (${skipped} skipped); renamed schedules/ -> schedules.migrated`);
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Row <-> def mapping
187
+ // ---------------------------------------------------------------------------
188
+
189
+ function rowToDef(row) {
190
+ if (!row) return null;
191
+ return {
192
+ name: row.name,
193
+ description: row.description,
194
+ whenAt: row.when_at,
195
+ whenCron: row.when_cron,
196
+ timezone: row.timezone,
197
+ target: row.target,
198
+ channelId: row.channel_id,
199
+ model: row.model,
200
+ prompt: row.prompt,
201
+ enabled: row.enabled,
202
+ status: row.status,
203
+ lastFiredAt: row.last_fired_at,
204
+ nextFireAt: row.next_fire_at,
205
+ deferredUntil: row.deferred_until,
206
+ skippedUntil: row.skipped_until,
207
+ createdAt: row.created_at,
208
+ updatedAt: row.updated_at,
209
+ };
210
+ }
211
+
212
+ const COLS = 'name, description, when_at, when_cron, timezone, target, channel_id, model, prompt, enabled, status, last_fired_at, next_fire_at, deferred_until, skipped_until, created_at, updated_at';
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Public API
216
+ // ---------------------------------------------------------------------------
217
+
218
+ export async function listSchedules({ dataDir } = {}) {
219
+ const db = await getDb(dataDir);
220
+ const { rows } = await db.query(`SELECT ${COLS} FROM scheduler.schedules ORDER BY name`);
221
+ return rows.map(rowToDef);
222
+ }
223
+
224
+ export async function getSchedule(name, { dataDir } = {}) {
225
+ const db = await getDb(dataDir);
226
+ const { rows } = await db.query(`SELECT ${COLS} FROM scheduler.schedules WHERE name = $1`, [name]);
227
+ return rowToDef(rows[0]);
228
+ }
229
+
230
+ /**
231
+ * Insert-or-replace a schedule by name. Exactly one of `whenAt`/`whenCron`
232
+ * must be provided (enforced by the table's XOR CHECK constraint).
233
+ */
234
+ export async function upsertSchedule(def, { dataDir } = {}) {
235
+ if (!def || !def.name) throw new Error('upsertSchedule: def.name is required');
236
+ if (!def.prompt) throw new Error('upsertSchedule: def.prompt is required');
237
+ const db = await getDb(dataDir);
238
+ const params = [
239
+ def.name,
240
+ def.description ?? '',
241
+ def.whenAt ?? null,
242
+ def.whenCron ?? null,
243
+ def.timezone ?? null,
244
+ def.target,
245
+ def.channelId ?? null,
246
+ def.model ?? null,
247
+ def.prompt,
248
+ def.enabled ?? true,
249
+ def.status ?? 'active',
250
+ def.nextFireAt ?? null,
251
+ ];
252
+ const { rows } = await db.query(
253
+ `INSERT INTO scheduler.schedules
254
+ (name, description, when_at, when_cron, timezone, target, channel_id, model, prompt, enabled, status, next_fire_at)
255
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
256
+ ON CONFLICT (name) DO UPDATE SET
257
+ description = EXCLUDED.description,
258
+ when_at = EXCLUDED.when_at,
259
+ when_cron = EXCLUDED.when_cron,
260
+ timezone = EXCLUDED.timezone,
261
+ target = EXCLUDED.target,
262
+ channel_id = EXCLUDED.channel_id,
263
+ model = EXCLUDED.model,
264
+ prompt = EXCLUDED.prompt,
265
+ enabled = EXCLUDED.enabled,
266
+ next_fire_at = EXCLUDED.next_fire_at,
267
+ -- Redefinition clears stale runtime state and reactivates so the
268
+ -- re-registered schedule is due again in listDue.
269
+ status = 'active',
270
+ deferred_until = NULL,
271
+ skipped_until = NULL,
272
+ last_fired_at = NULL,
273
+ updated_at = now()
274
+ RETURNING ${COLS}`,
275
+ params,
276
+ );
277
+ return rowToDef(rows[0]);
278
+ }
279
+
280
+ export async function deleteSchedule(name, { dataDir } = {}) {
281
+ const db = await getDb(dataDir);
282
+ const { rowCount } = await db.query(`DELETE FROM scheduler.schedules WHERE name = $1`, [name]);
283
+ return rowCount > 0;
284
+ }
285
+
286
+ export async function setEnabled(name, enabled, { dataDir } = {}) {
287
+ const db = await getDb(dataDir);
288
+ const { rows } = await db.query(
289
+ `UPDATE scheduler.schedules SET enabled = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
290
+ [name, !!enabled],
291
+ );
292
+ return rowToDef(rows[0]);
293
+ }
294
+
295
+ export async function markFired(name, ts = new Date(), { dataDir } = {}) {
296
+ const db = await getDb(dataDir);
297
+ const { rows } = await db.query(
298
+ `UPDATE scheduler.schedules SET last_fired_at = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
299
+ [name, ts],
300
+ );
301
+ return rowToDef(rows[0]);
302
+ }
303
+
304
+ export async function markDone(name, { dataDir } = {}) {
305
+ const db = await getDb(dataDir);
306
+ const { rows } = await db.query(
307
+ `UPDATE scheduler.schedules SET status = 'done', next_fire_at = NULL, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
308
+ [name],
309
+ );
310
+ return rowToDef(rows[0]);
311
+ }
312
+
313
+ export async function setDeferred(name, untilTs, { dataDir } = {}) {
314
+ const db = await getDb(dataDir);
315
+ const { rows } = await db.query(
316
+ `UPDATE scheduler.schedules SET deferred_until = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
317
+ [name, untilTs ?? null],
318
+ );
319
+ return rowToDef(rows[0]);
320
+ }
321
+
322
+ export async function setSkippedUntil(name, ts, { dataDir } = {}) {
323
+ const db = await getDb(dataDir);
324
+ const { rows } = await db.query(
325
+ `UPDATE scheduler.schedules SET skipped_until = $2, updated_at = now() WHERE name = $1 RETURNING ${COLS}`,
326
+ [name, ts ?? null],
327
+ );
328
+ return rowToDef(rows[0]);
329
+ }
330
+
331
+ /**
332
+ * Schedules that are eligible to fire at `now`: active, enabled, with a
333
+ * next_fire_at at/before `now`, and not currently deferred or skipped past
334
+ * `now`.
335
+ */
336
+ export async function listDue(now = new Date(), { dataDir } = {}) {
337
+ const db = await getDb(dataDir);
338
+ const { rows } = await db.query(
339
+ `SELECT ${COLS} FROM scheduler.schedules
340
+ WHERE status = 'active'
341
+ AND enabled = true
342
+ AND next_fire_at IS NOT NULL
343
+ AND next_fire_at <= $1
344
+ AND (deferred_until IS NULL OR deferred_until <= $1)
345
+ AND (skipped_until IS NULL OR skipped_until <= $1)
346
+ ORDER BY next_fire_at`,
347
+ [now],
348
+ );
349
+ return rows.map(rowToDef);
350
+ }
@@ -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':