mixdog 1.0.5 → 1.0.7

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 (50) hide show
  1. package/README.md +206 -475
  2. package/package.json +11 -4
  3. package/src/defaults/skills/setup/references/actions.md +26 -6
  4. package/src/defaults/skills/setup/references/surfaces.md +8 -3
  5. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +16 -7
  6. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +6 -2
  7. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +0 -3
  8. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +0 -2
  9. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +24 -2
  10. package/src/runtime/agent/orchestrator/session/store-transcript-cache.mjs +25 -0
  11. package/src/runtime/agent/orchestrator/session/store-transcript-worker.mjs +14 -1
  12. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +3 -45
  13. package/src/runtime/channels/lib/config.mjs +0 -1
  14. package/src/runtime/channels/lib/owned-runtime/config-reload.mjs +1 -1
  15. package/src/runtime/channels/lib/scheduler.mjs +15 -56
  16. package/src/runtime/channels/lib/worker-main.mjs +2 -9
  17. package/src/runtime/shared/config.mjs +66 -1
  18. package/src/runtime/shared/llm/usage-ledger-quota.mjs +13 -2
  19. package/src/runtime/shared/path-executable.mjs +58 -0
  20. package/src/session-runtime/boot/apis.mjs +6 -2
  21. package/src/session-runtime/boot/begin.mjs +0 -2
  22. package/src/session-runtime/boot/providers.mjs +3 -0
  23. package/src/session-runtime/boot/tools.mjs +0 -1
  24. package/src/session-runtime/config-lifecycle.mjs +11 -0
  25. package/src/session-runtime/internal-tool-executor.mjs +0 -2
  26. package/src/session-runtime/runtime-feature-gates.mjs +2 -3
  27. package/src/session-runtime/schedule-session-run.mjs +0 -1
  28. package/src/session-runtime/services/channel-admin.mjs +8 -26
  29. package/src/session-runtime/settings-compaction-api.mjs +5 -1
  30. package/src/session-runtime/settings-system-api.mjs +2 -20
  31. package/src/session-runtime/setup-tool/executor.mjs +61 -3
  32. package/src/session-runtime/setup-tool/extended-actions.mjs +31 -6
  33. package/src/session-runtime/setup-tool/settings-contract.mjs +27 -3
  34. package/src/session-runtime/setup-tool/tool-defs.mjs +35 -5
  35. package/src/session-runtime/webhook-session-run.mjs +0 -2
  36. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +13 -3
  37. package/src/standalone/daemon-stored-session-views.mjs +4 -0
  38. package/src/standalone/session-protocol.mjs +0 -1
  39. package/src/standalone/session-service/session-calls/session-views.mjs +10 -4
  40. package/src/standalone/session-service/stored-reader.mjs +20 -2
  41. package/src/standalone/session-service/viewers.mjs +3 -3
  42. package/src/standalone/session-service.mjs +3 -1
  43. package/src/tui/App.jsx +6 -1
  44. package/src/tui/app/create-app-pickers.mjs +1 -0
  45. package/src/tui/app/doctor.mjs +6 -14
  46. package/src/tui/app/maintenance-pickers/auto-clear-picker.mjs +8 -3
  47. package/src/tui/app/settings-picker/settings-rows.mjs +5 -1
  48. package/src/tui/app/use-ui-open-request.mjs +13 -3
  49. package/src/tui/dist/index.mjs +41 -9
  50. package/src/tui/session/session-api/settings.mjs +0 -6
@@ -97,14 +97,12 @@ function releaseSchedulerRuntime(scheduler) {
97
97
  class Scheduler {
98
98
  nonInteractive;
99
99
  interactive;
100
- channelId;
101
100
  promptsDir;
102
101
  tickTimer = null;
103
102
  lastFired = /* @__PURE__ */ new Map();
104
103
  // name -> "YYYY-MM-DDTHH:MM"
105
104
  running = /* @__PURE__ */ new Set();
106
105
  injectFn = null;
107
- sendFn = null;
108
106
  injectReadyFn = null;
109
107
  pendingCheck = null;
110
108
  // Injected by the host: (schedule, { prompt }) => Promise<{ result }>.
@@ -118,13 +116,9 @@ class Scheduler {
118
116
  // name -> node-cron ScheduledTask for cron-expression entries
119
117
  oneShotTimers = /* @__PURE__ */ new Map();
120
118
  // name -> setTimeout handle for when_at one-shot entries
121
- //
122
- // `channelId` is the single resolved main-channel id used when a schedule's
123
- // `channel` flag is set (post-to-channel); absent flag → inject into session.
124
- constructor(nonInteractive, interactive, channelId) {
119
+ constructor(nonInteractive, interactive) {
125
120
  this.nonInteractive = nonInteractive.filter((s) => s.enabled !== false);
126
121
  this.interactive = interactive.filter((s) => s.enabled !== false);
127
- this.channelId = channelId ?? '';
128
122
  this.promptsDir = join(DATA_DIR, 'prompts');
129
123
  for (const s of [...this.nonInteractive, ...this.interactive]) {
130
124
  if (s.lastFiredAt) this.lastFired.set(s.name, new Date(s.lastFiredAt).toISOString());
@@ -134,9 +128,6 @@ class Scheduler {
134
128
  setInjectHandler(fn) {
135
129
  this.injectFn = fn;
136
130
  }
137
- setSendHandler(fn) {
138
- this.sendFn = fn;
139
- }
140
131
  /** Probe for a live Lead bridge seat. When set, the interactive
141
132
  * inject only fires while it reports true; otherwise the visible-session
142
133
  * fallback keeps the fire from being lost. Unset → inject when wired. */
@@ -561,10 +552,9 @@ ${prompt}`;
561
552
  releaseSchedulerRuntime(this);
562
553
  this.start();
563
554
  }
564
- reloadConfig(nonInteractive, interactive, channelId, options = {}) {
555
+ reloadConfig(nonInteractive, interactive, options = {}) {
565
556
  this.nonInteractive = nonInteractive.filter((s) => s.enabled !== false);
566
557
  this.interactive = interactive.filter((s) => s.enabled !== false);
567
- this.channelId = channelId ?? '';
568
558
  this.promptsDir = join(DATA_DIR, 'prompts');
569
559
  // Defer/skip state is persisted (deferred_until / skipped_until) and
570
560
  // re-read from the reloaded rows, so a reload no longer drops it.
@@ -629,14 +619,10 @@ ${prompt}`;
629
619
  this.notifyFailure(schedule, 'prompt not found — fire skipped');
630
620
  return false;
631
621
  }
632
- // target 'channel' → relay the run result to the schedule's channel_id
633
- // (falling back to the resolved main channel). target 'session' → no
634
- // channel relay; the visible session run IS the surface.
635
- const channelId = schedule.target === 'channel' ? this.resolveChannel(schedule.channelId) : '';
636
- return await this.fireTimedPrompt(schedule, type, prompt, channelId, opts);
622
+ return await this.fireTimedPrompt(schedule, type, prompt, opts);
637
623
  }
638
624
  /** Fire a timed schedule with the given prompt content */
639
- async fireTimedPrompt(schedule, type, prompt, channelId, { awaitDispatch = false } = {}) {
625
+ async fireTimedPrompt(schedule, type, prompt, { awaitDispatch = false } = {}) {
640
626
  logSchedule(`firing ${schedule.name} (${type})\n`);
641
627
  if (this.running.has(schedule.name)) {
642
628
  // Not silent anymore: overlap skips were invisible and read as the
@@ -644,7 +630,7 @@ ${prompt}`;
644
630
  logSchedule(`${schedule.name}: skipped — previous run still in progress\n`);
645
631
  return false;
646
632
  }
647
- if (type === 'interactive' && this.injectIntoLead(schedule, type, prompt, channelId)) return true;
633
+ if (type === 'interactive' && this.injectIntoLead(schedule, type, prompt)) return true;
648
634
  this.running.add(schedule.name);
649
635
  const presetId = schedule.model;
650
636
  if (!presetId) {
@@ -657,7 +643,7 @@ ${prompt}`;
657
643
  // and swallow async failures. A one-shot (awaitDispatch) instead awaits
658
644
  // the dispatch so a rejected run propagates and the caller leaves the
659
645
  // entry pending for retry instead of retiring it.
660
- const dispatch = this.dispatchScheduleRun(schedule, prompt, channelId, awaitDispatch);
646
+ const dispatch = this.dispatchScheduleRun(schedule, prompt, awaitDispatch);
661
647
  if (awaitDispatch) return await dispatch;
662
648
  return true;
663
649
  }
@@ -666,11 +652,11 @@ ${prompt}`;
666
652
  * exactly like inbound channel messages — enqueue counts as the fire.
667
653
  * False when no live Lead seat is attached or the inject fails, so the
668
654
  * visible-session run remains the fallback and a fire is never lost. */
669
- injectIntoLead(schedule, type, prompt, channelId) {
655
+ injectIntoLead(schedule, type, prompt) {
670
656
  if (!this.injectFn || !this.injectReady()) return false;
671
657
  try {
672
658
  const wrapped = this.wrapPrompt(schedule.name, prompt, type);
673
- this.injectFn(channelId, `schedule:${schedule.name}`, ' ', { type: 'schedule', instruction: wrapped });
659
+ this.injectFn('', `schedule:${schedule.name}`, ' ', { type: 'schedule', instruction: wrapped });
674
660
  logSchedule(`${schedule.name}: injected into Lead session queue (interactive fire)\n`);
675
661
  return true;
676
662
  } catch (err) {
@@ -682,21 +668,16 @@ ${prompt}`;
682
668
  }
683
669
  /** Fires run as VISIBLE schedule sessions (desktop Recent / TUI resume)
684
670
  * via runScheduleSession; the wrapped prompt keeps the schedule context
685
- * header, and the result is relayed to the channel. Resolves true on
686
- * success; a failure rejects for awaitDispatch callers and resolves false
687
- * (after a channel notice) for fire-and-forget cron fires. */
688
- dispatchScheduleRun(schedule, prompt, channelId, awaitDispatch) {
671
+ * header. Resolves true on success; a failure rejects for awaitDispatch
672
+ * callers and resolves false (after a logged notice) for fire-and-forget
673
+ * cron fires. */
674
+ dispatchScheduleRun(schedule, prompt, awaitDispatch) {
689
675
  const run = this.scheduleRunner
690
676
  ? this.scheduleRunner(schedule, { prompt })
691
677
  : Promise.reject(new Error('schedule runner not configured'));
692
678
  return run
693
679
  .then(({ result }) => {
694
680
  this.running.delete(schedule.name);
695
- if (result && channelId && this.sendFn) {
696
- this.sendFn(channelId, result).catch((err) =>
697
- process.stderr.write(`mixdog scheduler: ${schedule.name} relay failed: ${err}\n`)
698
- );
699
- }
700
681
  logSchedule(`${schedule.name} done\n`);
701
682
  return true;
702
683
  })
@@ -704,7 +685,7 @@ ${prompt}`;
704
685
  this.running.delete(schedule.name);
705
686
  logSchedule(`${schedule.name} LLM error: ${err.message}\n`);
706
687
  // The cron fire-and-forget contract swallowed failures entirely;
707
- // surface them on the channel so a failed scheduled run is never
688
+ // log them so a failed scheduled run is never
708
689
  // silent (one-shots propagate via the awaitDispatch throw instead).
709
690
  if (awaitDispatch) throw err;
710
691
  this.notifyFailure(schedule, `run failed: ${err.message}`);
@@ -712,18 +693,9 @@ ${prompt}`;
712
693
  });
713
694
  }
714
695
  // ── Helpers ─────────────────────────────────────────────────────────
715
- /** Best-effort failure notice to the schedule's channel (or the main
716
- * channel) so scheduled fires never fail silently. Respects an explicit
717
- * channel opt-out ("false" → resolveChannel yields "" → no notice). */
696
+ /** Failure notice: logged so scheduled fires never fail silently. */
718
697
  notifyFailure(schedule, reason) {
719
- try {
720
- if (!this.sendFn) return;
721
- const target = this.resolveChannel(schedule?.channelId);
722
- if (!target) return;
723
- Promise.resolve(this.sendFn(target, `schedule "${schedule?.name}": ${reason}`)).catch(() => {});
724
- } catch {
725
- /* best-effort */
726
- }
698
+ logSchedule(`schedule "${schedule?.name}": ${reason}\n`);
727
699
  }
728
700
  /** Resolve prompt: inline text from the row, with a prompts-dir file
729
701
  * fallback for legacy `<name>.md` references. */
@@ -736,18 +708,5 @@ ${prompt}`;
736
708
  const full = isAbsolute(nameOrPath) ? nameOrPath : join(this.promptsDir, nameOrPath);
737
709
  return tryRead(full);
738
710
  }
739
- /** Resolve a schedule's channel flag to a channel id (pre-refactor
740
- * semantics): absent/empty/"false" → "" (inject into session); a
741
- * pure-digit / snowflake value is honored verbatim as an explicit id
742
- * override; any other value — including legacy labels like "main" —
743
- * resolves to the configured main channel. */
744
- resolveChannel(flag) {
745
- if (flag == null) return this.channelId ?? '';
746
- const v = String(flag).trim();
747
- if (v.toLowerCase() === 'false') return '';
748
- if (/^-?\d+$/.test(v)) return v;
749
- // empty or a legacy label ("main") → configured main channel
750
- return this.channelId ?? '';
751
- }
752
711
  }
753
712
  export { Scheduler };
@@ -131,9 +131,7 @@ const statusState = new JsonStateFile(STATUS_FILE, {});
131
131
  statusState.ensure();
132
132
  const scheduler = new Scheduler(
133
133
  config.nonInteractive ?? [],
134
- config.interactive ?? [],
135
- // Single resolved main-channel id used for the schedule `channel` flag.
136
- config.channelId
134
+ config.interactive ?? []
137
135
  );
138
136
  // Schedules fire as visible sessions; the session runner belongs to the
139
137
  // session-runtime layer, so this worker entry injects it (lazy, like webhooks).
@@ -220,16 +218,12 @@ scheduler.setInjectHandler((channelId, name, content, options) => {
220
218
  // actually holds the live bridge seat; otherwise the scheduler falls back to
221
219
  // the visible-session run so the fire is never lost.
222
220
  scheduler.setInjectReadyCheck(() => bridgeRuntimeConnected && currentOwnerState().owned);
223
- scheduler.setSendHandler(async () => {
224
- // Channel delivery is retired: schedule results surface in the app session
225
- // (parent notify / Automations row) only.
226
- });
227
221
  function wireWebhookHandlers() {
228
222
  if (!webhookServer) return;
229
223
  webhookServer.setEventPipeline(eventPipeline);
230
224
  // Webhook fires run as sessions (schedules parity); the Automations
231
225
  // session row is the only surface (channel relay retired).
232
- webhookServer.setBridgeDispatch(async ({ prompt, model, cwd, workflow, attachments, delivery, context, signal }) => {
226
+ webhookServer.setBridgeDispatch(async ({ prompt, model, cwd, workflow, attachments, context, signal }) => {
233
227
  const { runWebhookSession } = await import('../../../session-runtime/webhook-session-run.mjs');
234
228
  const run = await runWebhookSession({
235
229
  name: context?.endpoint || 'webhook',
@@ -237,7 +231,6 @@ function wireWebhookHandlers() {
237
231
  cwd: cwd || null,
238
232
  workflow: workflow || null,
239
233
  attachments: attachments || null,
240
- delivery: delivery || null,
241
234
  // Dispatch-timeout cancellation only works if the signal reaches the run.
242
235
  signal: signal || null,
243
236
  prompt,
@@ -2,7 +2,7 @@
2
2
  * Unified config reader/writer.
3
3
  * Single file: mixdog-config.json with sections such as channels, agent, and memory.
4
4
  */
5
- import { readFileSync, statSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { readFileSync, statSync, mkdirSync, existsSync, watch as fsWatch } from 'node:fs';
6
6
  import { join, dirname } from 'node:path';
7
7
  import { createRequire } from 'node:module';
8
8
  import { resolvePluginData } from './plugin-paths.mjs';
@@ -315,8 +315,72 @@ function readAllForRmW() {
315
315
  }
316
316
  }
317
317
 
318
+ // Config-change notification. In-process writes notify directly after the file
319
+ // is persisted; writes from other processes arrive through one lazily started
320
+ // directory watch (the atomic rename replaces the file, which would orphan a
321
+ // file-level watch on POSIX). Bursts coalesce into one listener pass.
322
+ const CONFIG_CHANGE_DEBOUNCE_MS = 100;
323
+ const _configChangeListeners = new Set();
324
+ let _configChangeTimer = null;
325
+ let _configWatcher = null;
326
+ let _configWatchDir = '';
327
+
328
+ function stopConfigWatcher() {
329
+ try {
330
+ _configWatcher?.close();
331
+ } catch {}
332
+ _configWatcher = null;
333
+ _configWatchDir = '';
334
+ }
335
+
336
+ function notifyConfigChanged() {
337
+ if (!_configChangeListeners.size) return;
338
+ if (_configChangeTimer) clearTimeout(_configChangeTimer);
339
+ _configChangeTimer = setTimeout(() => {
340
+ _configChangeTimer = null;
341
+ invalidateConfigReadCache();
342
+ for (const listener of [..._configChangeListeners]) {
343
+ try {
344
+ listener();
345
+ } catch {}
346
+ }
347
+ }, CONFIG_CHANGE_DEBOUNCE_MS);
348
+ _configChangeTimer.unref?.();
349
+ }
350
+
351
+ function ensureConfigWatcher() {
352
+ const dir = dirname(configPath());
353
+ if (_configWatcher && _configWatchDir === dir) return;
354
+ stopConfigWatcher();
355
+ try {
356
+ _configWatcher = fsWatch(dir, (_event, filename) => {
357
+ if (!filename || String(filename) === 'mixdog-config.json') notifyConfigChanged();
358
+ });
359
+ _configWatcher.unref?.();
360
+ _configWatcher.on?.('error', stopConfigWatcher);
361
+ _configWatchDir = dir;
362
+ } catch {
363
+ _configWatcher = null;
364
+ }
365
+ }
366
+
367
+ /** Subscribe to persisted config changes (this process or another). Returns unsubscribe. */
368
+ export function subscribeConfigChange(listener) {
369
+ _configChangeListeners.add(listener);
370
+ ensureConfigWatcher();
371
+ return () => {
372
+ _configChangeListeners.delete(listener);
373
+ if (!_configChangeListeners.size) {
374
+ if (_configChangeTimer) clearTimeout(_configChangeTimer);
375
+ _configChangeTimer = null;
376
+ stopConfigWatcher();
377
+ }
378
+ };
379
+ }
380
+
318
381
  function writeAll(data) {
319
382
  writeConfigFile(canonicalizeUnifiedConfig(data));
383
+ notifyConfigChanged();
320
384
  }
321
385
 
322
386
  // Serialize a read-modify-write under the same file lock. Concurrent
@@ -390,6 +454,7 @@ async function writeConfigFileAsync(data) {
390
454
 
391
455
  async function writeAllAsync(data) {
392
456
  await writeConfigFileAsync(canonicalizeUnifiedConfig(data));
457
+ notifyConfigChanged();
393
458
  }
394
459
 
395
460
  function withConfigLockAsync(fn) {
@@ -209,11 +209,22 @@ function quotaInstances(rows, label, now) {
209
209
  paced: false,
210
210
  };
211
211
  });
212
+ // After a reset, early or on time, the meter reads zero until the next
213
+ // request: that last run is the window open now, not a gap between windows.
214
+ const latest = instances.at(-1);
215
+ if (
216
+ latest?.idle &&
217
+ instances.some((instance) => !instance.idle) &&
218
+ (latest.resetAt == null || latest.resetAt > now)
219
+ ) {
220
+ latest.idle = false;
221
+ }
212
222
  const active = instances.filter((instance) => !instance.idle);
213
223
  let previousEnd = -Infinity;
214
- active.forEach((instance, index) => {
224
+ active.forEach((instance) => {
215
225
  const first = instance.rows[0];
216
- const next = active[index + 1];
226
+ // Any later reading, even an idle one after an early reset, closes it.
227
+ const next = instances[instances.indexOf(instance) + 1];
217
228
  // A window's clock started at its opening: never after its first reading,
218
229
  // never inside the window before it.
219
230
  const opened = quotaWindowOpened(label, instance.resetAt);
@@ -0,0 +1,58 @@
1
+ // PATH lookups with no other runtime dependency, so a bundle that only needs
2
+ // "is this executable installed?" (the desktop service's feature gates) does
3
+ // not pull in the git worker or the rest of runtime-capabilities.
4
+ import { accessSync, constants as fsConstants, statSync } from 'node:fs';
5
+ import { delimiter as pathDelimiter, join as pathJoin } from 'node:path';
6
+
7
+ function _pathDirectory(value) {
8
+ const text = String(value || '').trim();
9
+ return text.length >= 2 && text.startsWith('"') && text.endsWith('"') ? text.slice(1, -1) : text;
10
+ }
11
+
12
+ function _executableNames(name, platform) {
13
+ const text = String(name || '').trim();
14
+ if (!text) return [];
15
+ if (platform !== 'win32' || /\.(?:exe|cmd|bat|com)$/i.test(text)) return [text];
16
+ return [text, `${text}.exe`, `${text}.cmd`, `${text}.bat`, `${text}.com`];
17
+ }
18
+
19
+ export function findPathExecutable(
20
+ name,
21
+ { pathValue = process.env.PATH || '', platform = process.platform, maxDirectories = 64 } = {}
22
+ ) {
23
+ const names = _executableNames(name, platform);
24
+ if (!names.length) return null;
25
+ const seenDirectories = new Set();
26
+ for (const rawDirectory of String(pathValue).split(pathDelimiter)) {
27
+ const directory = _pathDirectory(rawDirectory);
28
+ if (!directory) continue;
29
+ const key = platform === 'win32' ? directory.toLowerCase() : directory;
30
+ if (seenDirectories.has(key)) continue;
31
+ seenDirectories.add(key);
32
+ if (seenDirectories.size > maxDirectories) break;
33
+ for (const executable of names) {
34
+ const file = pathJoin(directory, executable);
35
+ try {
36
+ if (!statSync(file).isFile()) continue;
37
+ accessSync(file, platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK);
38
+ return `${executable} (${directory.replace(/\\/g, '/')})`;
39
+ } catch {}
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ // Whether a `git` executable is on PATH. The tool surface asks on every
46
+ // policy refresh, so the PATH walk is memoized briefly; the short window still
47
+ // lets a session opened after a Git install pick the tool up.
48
+ const GIT_PRESENCE_TTL_MS = 30_000;
49
+ let _gitPresence = null;
50
+ export function gitExecutablePresent({ pathValue = process.env.PATH || '', platform = process.platform } = {}) {
51
+ const now = Date.now();
52
+ if (_gitPresence && _gitPresence.pathValue === pathValue && now - _gitPresence.at < GIT_PRESENCE_TTL_MS) {
53
+ return _gitPresence.present;
54
+ }
55
+ const present = Boolean(findPathExecutable('git', { pathValue, platform }));
56
+ _gitPresence = { pathValue, at: now, present };
57
+ return present;
58
+ }
@@ -20,6 +20,7 @@ import {
20
20
  normalizeSystemShellCommand,
21
21
  normalizeAutoClearConfig,
22
22
  autoClearIdleMsForProvider,
23
+ resolveAutoClearIdleMs,
23
24
  autoClearProviderDefaults,
24
25
  normalizeCompactionConfig,
25
26
  setModuleEnabledInConfig,
@@ -75,7 +76,10 @@ export function wireApis(boot) {
75
76
  });
76
77
  boot.lifecycleApi = lifecycleApiFor(boot);
77
78
  boot.resourceApi = resourceApiFor(boot);
78
- boot.disposeGlobalExtensionSubscription = () => boot.resourceApi.disposeGlobalExtensionSubscription?.();
79
+ boot.disposeGlobalExtensionSubscription = () => {
80
+ boot.disposeConfigSync?.();
81
+ boot.resourceApi.disposeGlobalExtensionSubscription?.();
82
+ };
79
83
  boot.modelRouteApi = modelRouteApiFor(boot);
80
84
  boot.workflowAgentsApi = workflowAgentsApiFor(boot);
81
85
  boot.sessionTurnApi = sessionTurnApiFor(boot);
@@ -207,6 +211,7 @@ function settingsApiFor(boot) {
207
211
  hasOwn,
208
212
  normalizeAutoClearConfig,
209
213
  autoClearIdleMsForProvider,
214
+ resolveAutoClearIdleMs,
210
215
  autoClearProviderDefaults,
211
216
  normalizeCompactionConfig,
212
217
  normalizeSystemShellConfig,
@@ -229,7 +234,6 @@ function settingsApiFor(boot) {
229
234
  tidyToolEnabledFn: boot.tidyToolEnabledFn,
230
235
  localProviderEnabledFn: boot.localProviderEnabledFn,
231
236
  webSearchEnabled: boot.webSearchEnabled,
232
- channelsEnabled: boot.channelsEnabled,
233
237
  autoUpdateEnabled: selfUpdate.autoUpdateEnabled,
234
238
  getUpdateCheckState: () => selfUpdate.getCheckState(),
235
239
  getUpdateProcessState: () => selfUpdate.getProcessState(),
@@ -86,7 +86,6 @@ export async function loadModules(boot) {
86
86
  localProviderEnabledFn,
87
87
  mediaToolEnabledFn,
88
88
  tidyToolEnabledFn,
89
- channelsEnabled,
90
89
  featureDisallowedTools,
91
90
  } = createRuntimeFeatureGates({
92
91
  getConfig: () => rt.config,
@@ -114,7 +113,6 @@ export async function loadModules(boot) {
114
113
  localProviderEnabledFn,
115
114
  mediaToolEnabledFn,
116
115
  tidyToolEnabledFn,
117
- channelsEnabled,
118
116
  featureDisallowedTools,
119
117
  getMemoryModule,
120
118
  getWebSearchModule,
@@ -71,9 +71,11 @@ function wireConfigLifecycle(boot) {
71
71
  LAZY_SECRET_PROVIDERS,
72
72
  clean,
73
73
  resolve,
74
+ onConfigReloaded: () => boot.invalidateContextStatusCache?.(),
74
75
  STANDALONE_DATA_DIR,
75
76
  });
76
77
  const {
78
+ disposeConfigSync,
77
79
  getOutputStyleStatusCached,
78
80
  invalidateOutputStyleStatusCache,
79
81
  seedOutputStyleStatusCache,
@@ -89,6 +91,7 @@ function wireConfigLifecycle(boot) {
89
91
  ensureConfigForRouteProvider,
90
92
  } = lifecycle;
91
93
  Object.assign(boot, {
94
+ disposeConfigSync,
92
95
  getOutputStyleStatusCached,
93
96
  invalidateOutputStyleStatusCache,
94
97
  seedOutputStyleStatusCache,
@@ -232,7 +232,6 @@ function wireInternalTools(boot) {
232
232
  officeToolsEnabled: boot.officeToolsEnabledFn,
233
233
  mediaToolEnabled: boot.mediaToolEnabledFn,
234
234
  tidyToolEnabled: boot.tidyToolEnabledFn,
235
- channelsEnabled: boot.channelsEnabled,
236
235
  getWebSearchModule: boot.getWebSearchModule,
237
236
  getMemoryModule: boot.getMemoryModule,
238
237
  getCodeGraphModule: boot.getCodeGraphModule,
@@ -66,6 +66,7 @@ export function createConfigLifecycle({
66
66
  clean,
67
67
  resolve,
68
68
  performanceNow = () => performance.now(),
69
+ onConfigReloaded,
69
70
  STANDALONE_DATA_DIR,
70
71
  }) {
71
72
  const outputStyleCache = createOutputStyleStatusCache({
@@ -160,7 +161,17 @@ export function createConfigLifecycle({
160
161
  return ensureFullConfig();
161
162
  }
162
163
 
164
+ // Settings persisted by any other runtime (or process) reach this runtime's
165
+ // in-memory config. reloadFullConfig keeps our own pending edits, and the
166
+ // shared layer coalesces bursts into one pass per subscriber.
167
+ const unsubscribeConfigChange = sharedCfgMod?.subscribeConfigChange?.(() => {
168
+ reloadFullConfig();
169
+ outputStyleCache.invalidateOutputStyleStatusCache();
170
+ onConfigReloaded?.();
171
+ });
172
+
163
173
  return {
174
+ disposeConfigSync: () => unsubscribeConfigChange?.(),
164
175
  // output-style cache
165
176
  ...outputStyleCache,
166
177
  // adopt / save
@@ -22,7 +22,6 @@ export function createInternalToolExecutor({
22
22
  officeToolsEnabled,
23
23
  mediaToolEnabled,
24
24
  tidyToolEnabled = () => true,
25
- channelsEnabled,
26
25
  getWebSearchModule,
27
26
  getMemoryModule,
28
27
  getCodeGraphModule,
@@ -68,7 +67,6 @@ export function createInternalToolExecutor({
68
67
  if (callerCtx?.invocationSource === 'model-tool') guardModelCall(name);
69
68
  if (Object.hasOwn(handlers, name)) return handlers[name](args, { name, callerCtx, callerCwd });
70
69
  if (channels.isChannelTool(name)) {
71
- if (!channelsEnabled()) throw new Error('channels are disabled in settings');
72
70
  return await channels.execute(name, args || {});
73
71
  }
74
72
  throw new Error(`unknown standalone internal tool: ${name}`);
@@ -5,7 +5,7 @@
5
5
  // Key names mirror the dependency names of settings-api.mjs / the session-build
6
6
  // deps, so the runtime can hand them straight through.
7
7
  import { builtinFeatureActive, featureDisallowedToolsFor, localGitToolsActive } from '../runtime/agent/orchestrator/runtime-core/builtin-features.mjs';
8
- import { moduleEnabled, recapEnabled } from '../runtime/agent/orchestrator/runtime-core/config-helpers.mjs';
8
+ import { recapEnabled } from '../runtime/agent/orchestrator/runtime-core/config-helpers.mjs';
9
9
  import { browserBridgeAvailableSync } from '../runtime/browser-bridge/client.mjs';
10
10
  import { computerBridgeAvailableSync } from '../runtime/computer-bridge/client.mjs';
11
11
 
@@ -17,12 +17,11 @@ export function createRuntimeFeatureGates({ getConfig, getToolProfile }) {
17
17
  recapEnabledFn: () => recapEnabled(getConfig(), true),
18
18
  memoryToolsEnabledFn: () => builtinFeatureActive(getConfig(), 'memory'),
19
19
  webSearchEnabled: () => builtinFeatureActive(getConfig(), 'webSearch'),
20
- gitToolsEnabledFn: () => localGitToolsActive(getConfig(), getToolProfile()),
20
+ gitToolsEnabledFn: () => localGitToolsActive(getConfig()),
21
21
  officeToolsEnabledFn: () => builtinFeatureActive(getConfig(), 'office'),
22
22
  localProviderEnabledFn: () => builtinFeatureActive(getConfig(), 'localProvider'),
23
23
  mediaToolEnabledFn: () => builtinFeatureActive(getConfig(), 'media'),
24
24
  tidyToolEnabledFn: () => builtinFeatureActive(getConfig(), 'tidy'),
25
- channelsEnabled: () => moduleEnabled(getConfig(), 'channels', true),
26
25
  // Browser/Computer activate on live desktop-bridge presence, so the probe
27
26
  // runs per call instead of being captured at boot.
28
27
  featureDisallowedTools: () =>
@@ -48,7 +48,6 @@ export async function runScheduleSession(schedule, { config = null, prompt: prom
48
48
  owner: 'user',
49
49
  sourceType: 'schedule',
50
50
  sourceName: schedule.name,
51
- sourceDelivery: schedule.delivery || null,
52
51
  ...(cwd ? { cwd } : {}),
53
52
  desktopSession: cwd
54
53
  ? { classification: 'project', projectPath: cwd }