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
@@ -15,7 +15,7 @@ import { writeLastSessionCwd } from '../runtime/shared/user-cwd.mjs';
15
15
  import { cancelBackgroundTasks } from '../runtime/shared/background-tasks.mjs';
16
16
  import { createTranscriptWriter } from '../runtime/shared/transcript-writer.mjs';
17
17
  import { mixdogHome } from '../runtime/shared/plugin-paths.mjs';
18
- import { checkLatestVersion, runGlobalUpdate, localPackageVersion } from '../runtime/shared/update-checker.mjs';
18
+ import { checkLatestVersion, runGlobalUpdate, localPackageVersion, isDevInstall } from '../runtime/shared/update-checker.mjs';
19
19
  import {
20
20
  modelVisibleToolCompletionMessage,
21
21
  shouldPersistModelVisibleToolCompletion,
@@ -634,6 +634,12 @@ export async function createMixdogSessionRuntime({
634
634
  };
635
635
  // phase: 'idle' | 'checking' | 'installing' | 'installed' | 'failed'
636
636
  let updateProcessState = { phase: 'idle', version: null, error: null };
637
+ // Boot detects an available update but DEFERS the actual npm install to the
638
+ // graceful shutdown path (flushPendingUpdate, invoked from close()). Running
639
+ // `npm install -g` while the process is live overwrites the very .mjs files
640
+ // node has loaded → Windows TAR_ENTRY_ERROR file-locks. Installing on quit
641
+ // lets the parent release those handles first.
642
+ let pendingUpdateInstall = false;
637
643
 
638
644
  function autoUpdateEnabled() {
639
645
  return config?.update?.auto !== false;
@@ -662,6 +668,9 @@ export async function createMixdogSessionRuntime({
662
668
  if (updateProcessState.phase === 'installing') {
663
669
  return { ...updateProcessState, alreadyInstalling: true, error: 'update already in progress' };
664
670
  }
671
+ // A manual install consumes the boot-armed deferred install so quitting
672
+ // afterwards can't spawn a second redundant `npm install -g`.
673
+ pendingUpdateInstall = false;
665
674
  updateProcessState = { phase: 'installing', version: null, error: null };
666
675
  try {
667
676
  const result = await runGlobalUpdate();
@@ -678,36 +687,45 @@ export async function createMixdogSessionRuntime({
678
687
 
679
688
  // Non-blocking boot hook: fires after the runtime object below is fully
680
689
  // constructed (setTimeout(0) defers past the synchronous return), so a
681
- // slow/hanging registry request or npm install can never delay session
682
- // boot. Auto-update defaults to ON unless config.update.auto is explicitly
683
- // false; a failed check or install is
684
- // swallowed getUpdateStatus()/getUpdateSettings() are the only surfaces,
685
- // there is no push notice channel from runtime -> TUI today.
690
+ // slow/hanging registry request can never delay session boot. The check
691
+ // ALWAYS runs (populates updateCheckState for the maintenance picker), but
692
+ // the boot hook only DETECTS + arms a deferred install — the actual
693
+ // runGlobalUpdate now happens on graceful shutdown (flushPendingUpdate via
694
+ // close()) so npm never overwrites files the live process holds.
686
695
  // force:true — always hit the registry at boot (the 24h disk cache went
687
696
  // stale-visible: it kept reporting an older "latest" than the installed
688
697
  // version). checkLatestVersion() still falls back to the cache offline.
698
+ // isDevInstall() gate: a git checkout / clone (or non-node_modules install)
699
+ // must never self-update — `npm install -g` would fight the working tree.
689
700
  const updateBootTimer = setTimeout(() => {
690
701
  void (async () => {
691
702
  await checkForUpdateInternal({ force: true });
692
- if (autoUpdateEnabled() && updateCheckState.updateAvailable) {
693
- const result = await runUpdateNowInternal();
694
- // Surface the boot auto-update outcome as a UI-only notice (TUI maps
695
- // meta.kind 'update-notice' to a transcript notice, never a
696
- // model-visible message). Silent before: installed-but-needs-restart
697
- // and failed installs were invisible unless the user opened the
698
- // maintenance picker.
699
- if (result?.phase === 'installed') {
700
- const v = result.version && result.version !== 'unknown'
701
- ? `v${result.version}` : (updateCheckState.latestVersion ? `v${updateCheckState.latestVersion}` : 'latest');
702
- emitRuntimeNotification(`mixdog ${v} installed — restart to apply.`, { kind: 'update-notice', tone: 'info' });
703
- } else if (result?.phase === 'failed') {
704
- emitRuntimeNotification(`mixdog auto-update failed: ${result.error || 'unknown error'}`, { kind: 'update-notice', tone: 'warn' });
705
- }
703
+ if (autoUpdateEnabled() && !isDevInstall() && updateCheckState.updateAvailable) {
704
+ pendingUpdateInstall = true;
705
+ const v = updateCheckState.latestVersion ? `v${updateCheckState.latestVersion}` : 'latest';
706
+ // UI-only notice (TUI maps meta.kind 'update-notice' to a transcript
707
+ // notice, never a model-visible message). tone 'info': non-urgent,
708
+ // the install fires quietly on quit.
709
+ emitRuntimeNotification(`mixdog ${v} available — installs when you quit.`, { kind: 'update-notice', tone: 'info' });
706
710
  }
707
711
  })().catch(() => {});
708
712
  }, 0);
709
713
  updateBootTimer.unref?.();
710
714
 
715
+ // Invoked from the graceful shutdown path (lifecycle close() on exit/quit).
716
+ // Spawns the deferred npm install fire-and-forget: runGlobalUpdate() detaches
717
+ // (POSIX) / unrefs its child, so we neither await it nor block teardown —
718
+ // the parent exits, releasing file handles, while npm installs in the hidden
719
+ // background child. No-op unless an update was armed at boot.
720
+ function flushPendingUpdate() {
721
+ if (!pendingUpdateInstall) return;
722
+ pendingUpdateInstall = false;
723
+ // Re-check at flush time: the user may have turned update.auto off after
724
+ // the boot notice armed the install — respect the current setting.
725
+ if (!autoUpdateEnabled()) return;
726
+ try { void runGlobalUpdate(); } catch { /* best-effort: exit must not wedge */ }
727
+ }
728
+
711
729
  function emitRuntimeNotification(content, meta = {}) {
712
730
  const text = String(content || '').trim();
713
731
  if (!text) return false;
@@ -1985,6 +2003,7 @@ export async function createMixdogSessionRuntime({
1985
2003
  resolveRoute,
1986
2004
  applyDeferredToolSurface,
1987
2005
  standaloneTools,
2006
+ flushPendingUpdate,
1988
2007
  });
1989
2008
  const resourceApi = createResourceApi({
1990
2009
  getConfig: () => config,
@@ -1587,17 +1587,48 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1587
1587
  if (!fallbackTag || fallbackTag.startsWith('sess_') || !isDeadTarget) throw err;
1588
1588
  const prompt = clean(args.message || args.prompt);
1589
1589
  if (!prompt) throw err;
1590
- // Inherit the dead session's agent/cwd from the tag registry so the
1591
- // auto-respawn doesn't die on "agent spawn: agent is required" when
1592
- // Lead's send (correctly) omitted them. Read BEFORE forgetTag wipes
1593
- // the registry entries.
1594
- const inheritedAgent = clean(args.agent) || clean(tagAgents.get(fallbackTag));
1595
- const inheritedCwd = clean(args.cwd) || clean(tagCwds.get(fallbackTag));
1596
- if (!inheritedAgent) throw err;
1597
- // Clear the terminal trace so the fresh spawn isn't rejected by
1598
- // the lingering-trace guard, then run the normal deferred spawn.
1599
- try { forgetTag(fallbackTag); } catch {}
1600
- try { removeWorkerRow({ tag: fallbackTag }); } catch {}
1590
+ // Inherit the dead session's agent/cwd so the auto-respawn doesn't
1591
+ // die on "agent spawn: agent is required" when Lead's send (correctly)
1592
+ // omitted them. Read BEFORE forgetTag wipes the registry entries.
1593
+ //
1594
+ // Root cause of the surfaced "target not found" errors: when a tag is
1595
+ // FULLY reaped (session gone past the 5m grace window), the in-memory
1596
+ // tagAgents/tagCwds maps are cleared, so inheritedAgent came back empty
1597
+ // and this path re-threw instead of respawning. Recover the agent/cwd
1598
+ // from the persisted worker row, then fall back to the default `worker`
1599
+ // agent so an unknown-but-plausible tag still cold-respawns. The guards
1600
+ // above (empty tag / raw sess_ id / non-dead error) keep plainly invalid
1601
+ // targets erroring.
1602
+ // readWorkerRows is already scoped to THIS terminal (clientHostPid,
1603
+ // via rowMatchesContext), so inheritedRow — and every mutation below
1604
+ // keyed on its sessionId — never touches another terminal's same-tag
1605
+ // worker row.
1606
+ let inheritedRow = null;
1607
+ try {
1608
+ inheritedRow = readWorkerRows(scopedContext).find((row) => clean(row.tag) === fallbackTag) || null;
1609
+ } catch { inheritedRow = null; }
1610
+ // Gate the respawn on evidence the tag previously existed IN THIS
1611
+ // terminal: a persisted, clientHostPid-scoped worker row. The
1612
+ // in-memory tags/tagAgents/tagCwds maps are NOT reliable evidence —
1613
+ // closeAll() calls refreshTagsFromSessions() with NO context
1614
+ // (see ~L1501), which populates them from EVERY terminal's rows
1615
+ // (readWorkerRows({}) — rowMatchesContext returns true with no pid),
1616
+ // so they can hold a peer terminal's tag. A typo'd / never-existed
1617
+ // tag has no scoped row and must keep the original "not found" error.
1618
+ if (!inheritedRow) throw err;
1619
+ const inheritedSessionId = clean(inheritedRow.sessionId);
1620
+ const inheritedAgent = clean(args.agent) || clean(inheritedRow.agent) || 'worker';
1621
+ const inheritedCwd = clean(args.cwd) || clean(inheritedRow.cwd) || clean(callerCwd);
1622
+ // Drop this terminal's in-memory trace and remove ONLY the persisted
1623
+ // row matching inheritedRow.sessionId. Do NOT call forgetTag here: it
1624
+ // does a tag-wide removeWorkerRow({tag,sessionId}) (L556) whose OR
1625
+ // match (L395) would delete peer terminals' same-tag rows. The map
1626
+ // deletes are guarded on the tag pointing at OUR sessionId so a peer
1627
+ // cache entry (see above) is left intact (it rebuilds from rows).
1628
+ if (tags.get(fallbackTag) === inheritedSessionId) {
1629
+ try { tags.delete(fallbackTag); tagAgents.delete(fallbackTag); tagCwds.delete(fallbackTag); } catch {}
1630
+ }
1631
+ if (inheritedSessionId) { try { removeWorkerRow({ sessionId: inheritedSessionId }); } catch {} }
1601
1632
  const spawnArgs = {
1602
1633
  ...args,
1603
1634
  type: 'spawn',
@@ -24,6 +24,20 @@ import {
24
24
  } from '../runtime/shared/config.mjs';
25
25
  import { resolvePluginData } from '../runtime/shared/plugin-paths.mjs';
26
26
  import { readMarkdownDocument, serializeFrontmatterDoc } from '../runtime/shared/markdown-frontmatter.mjs';
27
+ import {
28
+ listSchedules as dbListSchedules,
29
+ getSchedule as dbGetSchedule,
30
+ upsertSchedule,
31
+ deleteSchedule as dbDeleteSchedule,
32
+ setEnabled as dbSetEnabled,
33
+ } from '../runtime/shared/schedules-db.mjs';
34
+ import {
35
+ listEndpoints as dbListEndpoints,
36
+ loadEndpointConfig as dbLoadEndpoint,
37
+ upsertEndpoint as dbUpsertEndpoint,
38
+ deleteEndpoint as dbDeleteEndpoint,
39
+ setEndpointEnabled as dbSetEndpointEnabled,
40
+ } from '../runtime/shared/webhooks-db.mjs';
27
41
 
28
42
  const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
29
43
  const DEFAULT_CHANNELS = Object.freeze({
@@ -38,10 +52,6 @@ function dataDir() {
38
52
  return resolvePluginData();
39
53
  }
40
54
 
41
- function schedulesDir() {
42
- return join(dataDir(), 'schedules');
43
- }
44
-
45
55
  function webhooksDir() {
46
56
  return join(dataDir(), 'webhooks');
47
57
  }
@@ -279,34 +289,86 @@ function normalizeCron(time) {
279
289
  return value;
280
290
  }
281
291
 
282
- export function listSchedules() {
283
- return listEntryDirs(schedulesDir()).map((name) => {
284
- const dir = join(schedulesDir(), name);
285
- const { frontmatter, body } = readMarkdownDocument(readText(join(dir, 'SCHEDULE.md'), ''));
286
- const config = { ...frontmatter };
287
- if (Object.prototype.hasOwnProperty.call(config, 'enabled')) {
288
- config.enabled = config.enabled !== 'false' && config.enabled !== false;
292
+ // Day-name / keyword -> cron day-of-week number (Sun=0 .. Sat=6).
293
+ const DAY_TOKEN_TO_DOW = {
294
+ sun: 0, sunday: 0,
295
+ mon: 1, monday: 1,
296
+ tue: 2, tues: 2, tuesday: 2,
297
+ wed: 3, weds: 3, wednesday: 3,
298
+ thu: 4, thur: 4, thurs: 4, thursday: 4,
299
+ fri: 5, friday: 5,
300
+ sat: 6, saturday: 6,
301
+ };
302
+
303
+ // Fold a legacy `days` selector into the day-of-week (last) field of a cron
304
+ // expression: daily -> '*', weekday -> '1-5', weekend -> '0,6', explicit day
305
+ // lists ("mon,wed,fri" | "1,3,5") -> comma-joined numbers. Throws on an
306
+ // unmappable token so bad combos surface instead of silently mis-scheduling.
307
+ export function foldDaysIntoCron(cron, days) {
308
+ const parts = String(cron).trim().split(/\s+/);
309
+ const dowIndex = parts.length - 1;
310
+ const raw = String(days || '').trim().toLowerCase();
311
+ // days absent -> keep the cron's own day-of-week field ('0 9 * * 1' stays
312
+ // Monday-only). Only an explicit selector rewrites the dow field.
313
+ if (!raw) return parts.join(' ');
314
+ let dow;
315
+ if (raw === 'daily' || raw === 'everyday' || raw === 'every day') dow = '*';
316
+ else if (raw === 'weekday' || raw === 'weekdays') dow = '1-5';
317
+ else if (raw === 'weekend' || raw === 'weekends') dow = '0,6';
318
+ else {
319
+ const nums = raw.split(/[\s,]+/).filter(Boolean).map((tok) => (
320
+ /^[0-6]$/.test(tok) ? Number(tok) : DAY_TOKEN_TO_DOW[tok]
321
+ ));
322
+ if (nums.some((n) => n === undefined)) {
323
+ throw new Error(`days "${days}" is not a recognizable day selector`);
289
324
  }
290
- // Dir name is canonical identity; frontmatter `name` is display-only and
291
- // must never override the slug used for routing/deletion. `description`
292
- // defaults to '' for old files with no such key (backward compat).
293
- const description = String(config.description || '');
294
- delete config.name;
295
- delete config.description;
296
- return {
297
- name,
298
- description,
299
- ...config,
300
- instructions: body,
301
- route: config.channel ? `channel:${config.channel}` : 'session',
302
- };
303
- });
325
+ dow = nums.join(',');
326
+ }
327
+ parts[dowIndex] = dow;
328
+ return parts.join(' ');
329
+ }
330
+
331
+ function parseAtDatetime(at) {
332
+ const raw = String(at || '').trim();
333
+ if (!raw) throw new Error('at must be a datetime');
334
+ const d = new Date(raw);
335
+ if (Number.isNaN(d.getTime())) throw new Error(`at "${at}" is not a valid datetime`);
336
+ return d;
337
+ }
338
+
339
+ // Map the store's def shape onto the flat display shape every schedule reader
340
+ // (channelSetup, renderChannelStatus, TUI pickers) consumes: `.time` renders
341
+ // the cron or the one-shot datetime, `.route` the channel/session target.
342
+ function scheduleToDisplay(s) {
343
+ return {
344
+ name: s.name,
345
+ description: s.description || '',
346
+ time: s.whenCron || (s.whenAt ? `at ${new Date(s.whenAt).toISOString()}` : ''),
347
+ whenAt: s.whenAt || undefined,
348
+ whenCron: s.whenCron || undefined,
349
+ timezone: s.timezone || undefined,
350
+ channel: s.channelId || undefined,
351
+ model: s.model || undefined,
352
+ enabled: s.enabled !== false,
353
+ instructions: s.prompt,
354
+ route: s.target === 'channel' ? `channel:${s.channelId}` : 'session',
355
+ };
356
+ }
357
+
358
+ export async function listSchedules() {
359
+ const rows = await dbListSchedules();
360
+ return rows.map(scheduleToDisplay);
304
361
  }
305
362
 
306
- export function saveSchedule({
363
+ // Register or update a schedule in the PG store. Recurring input maps `time`
364
+ // (+ optional `days`) to a cron; one-shot input maps an `at` datetime; the two
365
+ // are mutually exclusive (also enforced by the store's when_at/when_cron XOR).
366
+ // `channel` selects a channel target (model required); otherwise session.
367
+ export async function saveSchedule({
307
368
  name,
308
369
  description = '',
309
370
  time,
371
+ at,
310
372
  timezone,
311
373
  days,
312
374
  channel,
@@ -316,78 +378,69 @@ export function saveSchedule({
316
378
  overwrite = false,
317
379
  } = {}) {
318
380
  const id = assertName(name, 'schedule name');
319
- const cron = normalizeCron(time);
320
381
  const body = String(instructions || '').trim();
321
382
  if (!body) throw new Error('schedule instructions are required');
322
383
  if (channel && !model) throw new Error('model is required when channel is set');
323
- const dir = join(schedulesDir(), id);
324
- if (existsSync(dir) && overwrite !== true) throw new Error(`schedule "${id}" already exists`);
325
- mkdirSync(dir, { recursive: true });
326
- // name (== dir slug) and description lead the frontmatter per the SKILL.md
327
- // convention; operational keys follow.
328
- const config = { name: id, description: String(description || '').trim(), time: cron };
329
- if (timezone) config.timezone = String(timezone).trim();
330
- if (days && days !== 'daily') config.days = String(days).trim();
331
- if (channel) config.channel = String(channel).trim();
332
- if (model) config.model = String(model).trim();
333
- if (enabled === false) config.enabled = false;
334
- writeTextAtomic(join(dir, 'SCHEDULE.md'), serializeFrontmatterDoc(config, body));
335
- return { name: id, ...config, instructions: body };
336
- }
337
-
338
- export function deleteSchedule(name) {
384
+ const hasTime = time != null && String(time).trim() !== '';
385
+ const hasAt = at != null && String(at).trim() !== '';
386
+ if (hasTime && hasAt) throw new Error('provide either `time` (recurring) or `at` (one-shot), not both');
387
+ if (!hasTime && !hasAt) throw new Error('either `time` (recurring cron) or `at` (one-shot datetime) is required');
388
+ if (overwrite !== true && (await dbGetSchedule(id))) {
389
+ throw new Error(`schedule "${id}" already exists`);
390
+ }
391
+ const whenCron = hasTime ? foldDaysIntoCron(normalizeCron(time), days) : null;
392
+ const whenAt = hasAt ? parseAtDatetime(at) : null;
393
+ const saved = await upsertSchedule({
394
+ name: id,
395
+ description: String(description || '').trim(),
396
+ whenCron,
397
+ whenAt,
398
+ timezone: timezone ? String(timezone).trim() : null,
399
+ target: channel ? 'channel' : 'session',
400
+ channelId: channel ? String(channel).trim() : null,
401
+ model: model ? String(model).trim() : null,
402
+ prompt: body,
403
+ enabled: enabled !== false,
404
+ });
405
+ return scheduleToDisplay(saved);
406
+ }
407
+
408
+ export async function deleteSchedule(name) {
339
409
  const id = assertName(name, 'schedule name');
340
- rmSync(join(schedulesDir(), id), { recursive: true, force: true });
410
+ await dbDeleteSchedule(id);
341
411
  return { name: id, deleted: true };
342
412
  }
343
413
 
344
- export function setScheduleEnabled(name, enabled) {
414
+ export async function setScheduleEnabled(name, enabled) {
345
415
  const id = assertName(name, 'schedule name');
346
- const dir = join(schedulesDir(), id);
347
- const mdPath = join(dir, 'SCHEDULE.md');
348
- if (!existsSync(mdPath)) throw new Error(`schedule "${id}" does not exist`);
349
- const { frontmatter, body } = readMarkdownDocument(readText(mdPath, ''));
350
- // Re-spread with name/description first so the round-trip keeps SKILL.md
351
- // key order; name stays the dir slug regardless of stale frontmatter.
352
- const { name: _staleName, description = '', ...rest } = frontmatter;
353
- writeTextAtomic(mdPath, serializeFrontmatterDoc(
354
- { name: id, description, ...rest, enabled: enabled !== false },
355
- body,
356
- ));
416
+ const updated = await dbSetEnabled(id, enabled !== false);
417
+ if (!updated) throw new Error(`schedule "${id}" does not exist`);
357
418
  return { name: id, enabled: enabled !== false };
358
419
  }
359
420
 
360
- export function listWebhooks() {
361
- return listEntryDirs(webhooksDir()).map((name) => {
362
- const dir = join(webhooksDir(), name);
363
- const { frontmatter, body } = readMarkdownDocument(readText(join(dir, 'WEBHOOK.md'), ''));
364
- const config = { ...frontmatter };
365
- if (Object.prototype.hasOwnProperty.call(config, 'enabled')) {
366
- config.enabled = config.enabled !== 'false' && config.enabled !== false;
367
- }
368
- // Dir name is canonical identity/URL routing key; frontmatter `name` is
369
- // display-only and must not override the slug. `description` defaults to
370
- // '' for old files with no such key (backward compat).
371
- const description = String(config.description || '');
372
- delete config.name;
373
- delete config.description;
374
- // Secret lives in a side file (<name>/secret), never in frontmatter, so
375
- // an arbitrary user-supplied secret (quotes/colon/newline) round-trips
376
- // losslessly and setWebhookEnabled cannot corrupt it.
377
- const hasSecret = Boolean(readText(join(dir, 'secret'), '').trim());
378
- return {
379
- name,
380
- description,
381
- ...config,
382
- secretSet: hasSecret,
383
- secret: undefined,
384
- instructions: body,
385
- route: config.channel ? `channel:${config.channel}` : 'session',
386
- };
387
- });
421
+ // Webhook endpoints are stored in the PG table `webhooks.endpoints`
422
+ // (webhooks-db.mjs) the single source of truth. Legacy per-endpoint
423
+ // WEBHOOK.md + secret folders are imported once at boot and deleted by the
424
+ // store's migration hook.
425
+ export async function listWebhooks() {
426
+ const endpoints = await dbListEndpoints();
427
+ return endpoints.map((ep) => ({
428
+ name: ep.name,
429
+ description: ep.description || '',
430
+ parser: ep.parser || 'github',
431
+ ...(ep.channelId ? { channel: ep.channelId } : {}),
432
+ ...(ep.model ? { model: ep.model } : {}),
433
+ enabled: ep.enabled,
434
+ // The store never projects the plaintext secret through list paths; it
435
+ // exposes a presence flag (secretSet) instead.
436
+ secretSet: ep.secretSet === true,
437
+ secret: undefined,
438
+ instructions: ep.instructions,
439
+ route: ep.channelId ? `channel:${ep.channelId}` : 'session',
440
+ }));
388
441
  }
389
442
 
390
- export function saveWebhook({
443
+ export async function saveWebhook({
391
444
  name,
392
445
  description = '',
393
446
  parser = 'github',
@@ -406,47 +459,46 @@ export function saveWebhook({
406
459
  const body = String(instructions || '').trim();
407
460
  if (!body) throw new Error('webhook instructions are required');
408
461
  if (channel && !model) throw new Error('model is required when channel is set');
409
- const dir = join(webhooksDir(), id);
410
- if (existsSync(dir) && overwrite !== true) throw new Error(`webhook "${id}" already exists`);
411
- mkdirSync(dir, { recursive: true });
462
+ if (overwrite !== true && (await dbLoadEndpoint(id))) {
463
+ throw new Error(`webhook "${id}" already exists`);
464
+ }
412
465
  const secretValue = String(secret || randomBytes(24).toString('hex')).trim();
413
- // name (== dir slug) and description lead the frontmatter per the SKILL.md
414
- // convention; operational keys (parser/channel/model/enabled) follow.
415
- const config = { name: id, description: String(description || '').trim(), parser: nextParser };
416
- if (channel) config.channel = String(channel).trim();
417
- if (model) config.model = String(model).trim();
418
- if (enabled === false) config.enabled = false;
419
- // Secret to a side file (plaintext, same exposure level as the former
420
- // config.json), kept OUT of the frontmatter to avoid unquote round-trip
421
- // corruption. deleteWebhook rmSync's the whole dir, so this is removed too.
422
- writeTextAtomic(join(dir, 'secret'), secretValue + '\n');
423
- writeTextAtomic(join(dir, 'WEBHOOK.md'), serializeFrontmatterDoc(config, body));
424
- return { name: id, ...config, secret: secretValue, instructions: body };
425
- }
426
-
427
- export function deleteWebhook(name) {
466
+ const saved = await dbUpsertEndpoint({
467
+ name: id,
468
+ description: String(description || '').trim(),
469
+ parser: nextParser,
470
+ channelId: channel ? String(channel).trim() : null,
471
+ model: model ? String(model).trim() : null,
472
+ secret: secretValue,
473
+ instructions: body,
474
+ enabled: enabled !== false,
475
+ });
476
+ return {
477
+ name: id,
478
+ description: saved.description,
479
+ parser: saved.parser,
480
+ ...(saved.channelId ? { channel: saved.channelId } : {}),
481
+ ...(saved.model ? { model: saved.model } : {}),
482
+ ...(enabled === false ? { enabled: false } : {}),
483
+ secret: secretValue,
484
+ instructions: body,
485
+ };
486
+ }
487
+
488
+ export async function deleteWebhook(name) {
428
489
  const id = assertName(name, 'webhook name');
429
- rmSync(join(webhooksDir(), id), { recursive: true, force: true });
490
+ await dbDeleteEndpoint(id);
430
491
  return { name: id, deleted: true };
431
492
  }
432
493
 
433
- export function setWebhookEnabled(name, enabled) {
494
+ export async function setWebhookEnabled(name, enabled) {
434
495
  const id = assertName(name, 'webhook name');
435
- const dir = join(webhooksDir(), id);
436
- const mdPath = join(dir, 'WEBHOOK.md');
437
- if (!existsSync(mdPath)) throw new Error(`webhook "${id}" does not exist`);
438
- const { frontmatter, body } = readMarkdownDocument(readText(mdPath, ''));
439
- // Re-spread with name/description first so the round-trip keeps SKILL.md
440
- // key order; name stays the dir slug regardless of stale frontmatter.
441
- const { name: _staleName, description = '', ...rest } = frontmatter;
442
- writeTextAtomic(mdPath, serializeFrontmatterDoc(
443
- { name: id, description, ...rest, enabled: enabled !== false },
444
- body,
445
- ));
496
+ const updated = await dbSetEndpointEnabled(id, enabled !== false);
497
+ if (!updated) throw new Error(`webhook "${id}" does not exist`);
446
498
  return { name: id, enabled: enabled !== false };
447
499
  }
448
500
 
449
- export function channelSetup(config = null) {
501
+ export async function channelSetup(config = null) {
450
502
  const cfg = normalizeChannelsConfig(config || readSection('channels'));
451
503
  const discordToken = getDiscordToken();
452
504
  const discordProblem = diagnoseDiscordTokenValue(discordToken, cfg);
@@ -475,13 +527,13 @@ export function channelSetup(config = null) {
475
527
  status: webhookAuth ? 'Set' : 'Off',
476
528
  },
477
529
  channel: getChannel(cfg),
478
- schedules: listSchedules(),
479
- webhooks: listWebhooks(),
530
+ schedules: await listSchedules(),
531
+ webhooks: await listWebhooks(),
480
532
  };
481
533
  }
482
534
 
483
- export function renderChannelStatus(config = null) {
484
- const setup = channelSetup(config);
535
+ export async function renderChannelStatus(config = null) {
536
+ const setup = await channelSetup(config);
485
537
  const lines = [];
486
538
  lines.push(`discord ${setup.discord.status}${setup.discord.problem ? ` (${setup.discord.problem})` : ''}`);
487
539
  lines.push(`webhook ${setup.webhook.enabled === false ? 'disabled' : 'enabled'} · auth ${setup.webhook.status} · port ${setup.webhook.port || 3333}`);
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { startChildGuardian } from '../runtime/shared/child-guardian.mjs';
7
+ import { detachedSpawnOpts } from '../runtime/shared/spawn-flags.mjs';
7
8
  import { appendBuffered } from '../runtime/shared/buffered-appender.mjs';
8
9
  import { scrubLoaderVars } from '../runtime/agent/orchestrator/tools/env-scrub.mjs';
9
10
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
@@ -441,9 +442,8 @@ export function createStandaloneChannelWorker({
441
442
  cwd,
442
443
  execArgv: ['--require', WORKER_PRELOAD],
443
444
  stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
444
- detached: true,
445
445
  env: daemonEnv(),
446
- windowsHide: true,
446
+ ...detachedSpawnOpts,
447
447
  });
448
448
  } catch (err) {
449
449
  logLine(logPath, `daemon spawn failed: ${err?.message || err}`);
@@ -1,10 +1,12 @@
1
1
  import { fork } from 'node:child_process';
2
+ import { detachedSpawnOpts } from '../runtime/shared/spawn-flags.mjs';
2
3
  import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
3
4
  import http from 'node:http';
4
5
  import { dirname, join, resolve } from 'node:path';
5
6
  import { tmpdir } from 'node:os';
6
7
  import { pathToFileURL } from 'node:url';
7
8
  import { claimSingletonOwner, handoffSingletonOwner, readSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
9
+ import { readLiveServiceAdvert } from '../runtime/shared/service-discovery.mjs';
8
10
  import { scrubLoaderVars } from '../runtime/agent/orchestrator/tools/env-scrub.mjs';
9
11
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
10
12
 
@@ -253,10 +255,14 @@ export function createStandaloneMemoryRuntime({
253
255
  }
254
256
 
255
257
  async function findLivePort({ allowStarting = false } = {}) {
256
- const active = readActiveInstance();
257
- const port = parsePort(active?.memory_port);
258
+ // Prefer the single-writer discovery advert (discovery/memory.json); the
259
+ // legacy active-instance.json memory_port/memory_server_pid fields remain a
260
+ // cross-version fallback when no discovery advert is present.
261
+ const advert = readLiveServiceAdvert('memory', { requirePid: false });
262
+ const active = advert ? null : readActiveInstance();
263
+ const port = advert ? parsePort(advert.port) : parsePort(active?.memory_port);
258
264
  if (!port) return null;
259
- const ownerPid = parsePid(active?.memory_server_pid);
265
+ const ownerPid = advert ? parsePid(advert.pid) : parsePid(active?.memory_server_pid);
260
266
  // A dead server pid means the published memory_port is stale — the daemon
261
267
  // that owned it is gone. Clearing portCache here (and letting the caller
262
268
  // re-claim + respawn) prevents the stale port from wedging recovery.
@@ -384,7 +390,6 @@ export function createStandaloneMemoryRuntime({
384
390
  cwd,
385
391
  execArgv: [],
386
392
  stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
387
- detached: true,
388
393
  env: {
389
394
  ...daemonEnv,
390
395
  MIXDOG_DATA_DIR: dataDir,
@@ -407,7 +412,7 @@ export function createStandaloneMemoryRuntime({
407
412
  MIXDOG_MEMORY_DAEMON: '1',
408
413
  MIXDOG_MEMORY_IDLE_TTL_MS: String(idleTtlMs),
409
414
  },
410
- windowsHide: true,
415
+ ...detachedSpawnOpts,
411
416
  });
412
417
  const childPid = child.pid;
413
418
  if (singletonEnabled && childPid) {