mixdog 1.0.6 → 1.0.8

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 +158 -105
  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
@@ -17,7 +17,11 @@ import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from './lifecycle-
17
17
  import { isAgentOnlySession, isRootLeadSession, sessionVisibility } from './store-summary-visibility.mjs';
18
18
  import { applySummaryLogText, readSummaryLogText } from './store-summary-log.mjs';
19
19
  import { createStoredTranscriptCache, nextProjectionStamp } from './store-transcript-cache.mjs';
20
- import { OFFLOAD_MIN_CHARS, projectStoredTranscriptOffThread } from './store-transcript-worker.mjs';
20
+ import {
21
+ OFFLOAD_MIN_CHARS,
22
+ projectStoredTranscriptOffThread,
23
+ storedTranscriptWorkerRunning,
24
+ } from './store-transcript-worker.mjs';
21
25
  import { projectStoredTranscript } from './store-transcript-projection.mjs';
22
26
  import { dataDir, sessionHeartbeatMtimes } from './store-summary-locations.mjs';
23
27
  import { desktopSession, isStoredSessionId, positiveNumber } from './store-summary-fields.mjs';
@@ -49,6 +53,12 @@ export function forgetStoredSessionTranscript(sessionId) {
49
53
  storedTranscriptCache.forget(`${String(sessionId || '').trim()}|`);
50
54
  }
51
55
 
56
+ /** Its last view left: keep the cold projections for a revisit, within the
57
+ * cache's idle budget. */
58
+ export function releaseStoredSessionTranscript(sessionId) {
59
+ storedTranscriptCache.release(`${String(sessionId || '').trim()}|`);
60
+ }
61
+
52
62
  /** Test/diagnostic seam: size of the retained cold projections. */
53
63
  export function storedTranscriptCacheStats() {
54
64
  return storedTranscriptCache.stats();
@@ -184,7 +194,6 @@ function normalizedRow(row, heartbeatAt = 0) {
184
194
  agent: row.agent || null,
185
195
  sourceType: row.sourceType || null,
186
196
  sourceName: row.sourceName || null,
187
- sourceDelivery: row.sourceDelivery || null,
188
197
  scopeKey: row.scopeKey || null,
189
198
  ownerSessionId: row.ownerSessionId || row.parentSessionId || null,
190
199
  visibility: sessionVisibility(row),
@@ -487,6 +496,8 @@ export async function readStoredSessionTranscript(id, options = {}) {
487
496
  // checkpoint skips recovery: an unreadable probe must not silently
488
497
  // downgrade an interrupted turn to a plain cold read.
489
498
  const startedAt = performance.now();
499
+ // Phase durations of the parse this call ran (absent on a cache hit).
500
+ const timing = {};
490
501
  const recordStat = probePath(recordPath);
491
502
  if (recordStat.state === PROBE_ABSENT) return readArchivedAgentResult(sessionId);
492
503
  if (recordStat.state !== PROBE_PRESENT) return null;
@@ -517,6 +528,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
517
528
  ].join(':'),
518
529
  fileStat: recordStat,
519
530
  loadText: async () => {
531
+ const readStartedAt = performance.now();
520
532
  const before = observeStamp(recordPath);
521
533
  let body = null;
522
534
  if (offloadable) {
@@ -529,6 +541,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
529
541
  }
530
542
  }
531
543
  body ||= readTextFile(recordPath);
544
+ timing.readMs = performance.now() - readStartedAt;
532
545
  readState = body.state;
533
546
  if (body.state !== PROBE_PRESENT) return null;
534
547
  readStamp = before && sameSessionStamp(before, observeStamp(recordPath)) ? before : null;
@@ -541,7 +554,11 @@ export async function readStoredSessionTranscript(id, options = {}) {
541
554
  // no parse to the load cache (a later resume reads the file itself).
542
555
  if (offloadable && text.length >= OFFLOAD_MIN_CHARS) {
543
556
  try {
557
+ timing.worker = storedTranscriptWorkerRunning() ? 'warm' : 'cold';
558
+ const workerStartedAt = performance.now();
544
559
  const answer = await projectStoredTranscriptOffThread({ sessionId, text, itemLimit });
560
+ timing.workerMs = performance.now() - workerStartedAt;
561
+ Object.assign(timing, answer.timing);
545
562
  if (answer.unreadable) return null;
546
563
  if (stamp) readCanonicalLifecycle.rememberStrictVerdict(recordPath, stamp, answer.lifecycle);
547
564
  // Stamps are process-unique per module realm; re-issue in this one.
@@ -551,7 +568,9 @@ export async function readStoredSessionTranscript(id, options = {}) {
551
568
  // Worker unavailable: fall through to the in-process pipeline.
552
569
  }
553
570
  }
571
+ const parseStartedAt = performance.now();
554
572
  const record = readTopLevelLifecycleRecord(text);
573
+ timing.parseMs = performance.now() - parseStartedAt;
555
574
  if (isLifecycleUnreadable(record) || record.id !== sessionId) return null;
556
575
  // The same strict verdict the lifecycle authority would reach for these
557
576
  // bytes (storedSessionExists runs next when the pane's runtime loads).
@@ -574,6 +593,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
574
593
  // a turn checkpoint the projection recovers through loadSession, the very
575
594
  // load the hand-off serves, and projects the load cache's session
576
595
  // exactly as before; only that projection is detached.
596
+ const projectStartedAt = performance.now();
577
597
  const value = await projectStoredTranscript(
578
598
  sessionId,
579
599
  handedOver && checkpointAbsent ? JSON.parse(text) : record.doc,
@@ -583,6 +603,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
583
603
  checkpointAbsent,
584
604
  }
585
605
  );
606
+ timing.projectMs = performance.now() - projectStartedAt;
586
607
  if (!handedOver || checkpointAbsent || !value || mode !== 'items') return value;
587
608
  try {
588
609
  return structuredClone(value);
@@ -601,6 +622,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
601
622
  ms: performance.now() - startedAt,
602
623
  chars: recordStat.size,
603
624
  items: Array.isArray(value?.items) ? value.items.length : 0,
625
+ timing,
604
626
  });
605
627
  }
606
628
  return value;
@@ -35,6 +35,10 @@ const SETTLED_FILE_AGE_MS = 2_500;
35
35
  // newcomer is served uncached.
36
36
  const ACTIVE_ENTRY_MS = 5_000;
37
37
  const GHOST_LIMIT = 256;
38
+ // Projections nobody is being served (a view that left, a hover/boot
39
+ // prefetch never opened) stay for a quick revisit only within this budget;
40
+ // the full budget above is for the views still refreshing.
41
+ const STORED_TRANSCRIPT_IDLE_MAX_BYTES = 4 * 1024 * 1024;
38
42
 
39
43
  function sameFileStat(left, right) {
40
44
  return (
@@ -60,6 +64,7 @@ export function createStoredTranscriptCache({
60
64
  // LRU reparsed every unchanged record when nine visible sessions refreshed.
61
65
  maxEntries = Number.POSITIVE_INFINITY,
62
66
  maxBytes = STORED_TRANSCRIPT_CACHE_MAX_BYTES,
67
+ maxIdleBytes = STORED_TRANSCRIPT_IDLE_MAX_BYTES,
63
68
  } = {}) {
64
69
  // Retain a content digest, not a second full transcript beside its projection.
65
70
  // The bound is the size of what is actually retained: the projection.
@@ -126,6 +131,17 @@ export function createStoredTranscriptCache({
126
131
  entries.delete(key);
127
132
  entries.set(key, entry);
128
133
  };
134
+ // Oldest idle entries go first until the idle ones fit their own budget.
135
+ const trimIdle = (now) => {
136
+ let idleBytes = 0;
137
+ for (const entry of entries.values()) if (now - entry.usedAt > ACTIVE_ENTRY_MS) idleBytes += entry.bytes;
138
+ for (const [key, entry] of [...entries]) {
139
+ if (idleBytes <= maxIdleBytes) break;
140
+ if (now - entry.usedAt <= ACTIVE_ENTRY_MS) continue;
141
+ idleBytes -= entry.bytes;
142
+ drop(key);
143
+ }
144
+ };
129
145
 
130
146
  return {
131
147
  /** The cached projection for this exact content, or a fresh one from
@@ -133,6 +149,7 @@ export function createStoredTranscriptCache({
133
149
  * the entry. Concurrent callers with the same content share one parse.
134
150
  * Entries naming the same `group` replace each other. */
135
151
  async read({ key, group = null, fingerprint, fileStat = null, loadText, produce, now = Date.now() }) {
152
+ trimIdle(now);
136
153
  const cached = entries.get(key);
137
154
  if (
138
155
  cached &&
@@ -181,6 +198,14 @@ export function createStoredTranscriptCache({
181
198
  if (inFlight.get(key) === record) inFlight.delete(key);
182
199
  }
183
200
  },
201
+ /** The last view of these projections left: they turn idle now and stay
202
+ * only within the idle budget, ready for a revisit. */
203
+ release(keyPrefix, now = Date.now()) {
204
+ for (const [key, entry] of entries) {
205
+ if (key.startsWith(keyPrefix)) entry.usedAt = Math.min(entry.usedAt, now - ACTIVE_ENTRY_MS - 1);
206
+ }
207
+ trimIdle(now);
208
+ },
184
209
  forget(keyPrefix) {
185
210
  for (const key of inFlight.keys()) {
186
211
  if (key.startsWith(keyPrefix)) inFlight.delete(key);
@@ -36,19 +36,32 @@ export function projectStoredTranscriptOffThread({ sessionId, text, itemLimit })
36
36
  return client({ sessionId, text, itemLimit });
37
37
  }
38
38
 
39
+ /** Whether a projection worker is attached (a request now skips its start). */
40
+ export function storedTranscriptWorkerRunning() {
41
+ return client?.running() === true;
42
+ }
43
+
39
44
  async function project({ sessionId, text, itemLimit }) {
45
+ const importStartedAt = performance.now();
40
46
  const [{ readTopLevelLifecycleRecord, isLifecycleUnreadable }, { projectStoredTranscript }] = await Promise.all([
41
47
  import('./lifecycle-scan.mjs'),
42
48
  import('./store-transcript-projection.mjs'),
43
49
  ]);
50
+ const parseStartedAt = performance.now();
44
51
  const record = readTopLevelLifecycleRecord(text);
45
52
  if (isLifecycleUnreadable(record) || record.id !== sessionId) return { unreadable: true };
53
+ const projectStartedAt = performance.now();
46
54
  const value = await projectStoredTranscript(sessionId, record.doc, {
47
55
  itemLimit,
48
56
  includeMessages: false,
49
57
  checkpointAbsent: true,
50
58
  });
51
- return { value, lifecycle: { id: record.id, closed: record.closed, generation: record.generation } };
59
+ const timing = {
60
+ importMs: parseStartedAt - importStartedAt,
61
+ parseMs: projectStartedAt - parseStartedAt,
62
+ projectMs: performance.now() - projectStartedAt,
63
+ };
64
+ return { value, lifecycle: { id: record.id, closed: record.closed, generation: record.generation }, timing };
52
65
  }
53
66
 
54
67
  if (!isMainThread && workerData?.kind === WORKER_KIND) serveWorkerRequests(project);
@@ -1,51 +1,9 @@
1
- import { accessSync, constants as fsConstants, readdirSync, readFileSync, statSync } from 'node:fs';
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { readdir as readdirAsync } from 'node:fs/promises';
4
4
  import { runGitOffThread } from '../../../../shared/git-runner.mjs';
5
- import {
6
- delimiter as pathDelimiter,
7
- dirname as pathDirname,
8
- join as pathJoin,
9
- resolve as pathResolveAbsolute,
10
- } from 'node:path';
11
-
12
- function _pathDirectory(value) {
13
- const text = String(value || '').trim();
14
- return text.length >= 2 && text.startsWith('"') && text.endsWith('"') ? text.slice(1, -1) : text;
15
- }
16
-
17
- function _executableNames(name, platform) {
18
- const text = String(name || '').trim();
19
- if (!text) return [];
20
- if (platform !== 'win32' || /\.(?:exe|cmd|bat|com)$/i.test(text)) return [text];
21
- return [text, `${text}.exe`, `${text}.cmd`, `${text}.bat`, `${text}.com`];
22
- }
23
-
24
- function findPathExecutable(
25
- name,
26
- { pathValue = process.env.PATH || '', platform = process.platform, maxDirectories = 64 } = {}
27
- ) {
28
- const names = _executableNames(name, platform);
29
- if (!names.length) return null;
30
- const seenDirectories = new Set();
31
- for (const rawDirectory of String(pathValue).split(pathDelimiter)) {
32
- const directory = _pathDirectory(rawDirectory);
33
- if (!directory) continue;
34
- const key = platform === 'win32' ? directory.toLowerCase() : directory;
35
- if (seenDirectories.has(key)) continue;
36
- seenDirectories.add(key);
37
- if (seenDirectories.size > maxDirectories) break;
38
- for (const executable of names) {
39
- const file = pathJoin(directory, executable);
40
- try {
41
- if (!statSync(file).isFile()) continue;
42
- accessSync(file, platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK);
43
- return `${executable} (${directory.replace(/\\/g, '/')})`;
44
- } catch {}
45
- }
46
- }
47
- return null;
48
- }
5
+ import { findPathExecutable } from '../../../../shared/path-executable.mjs';
6
+ import { dirname as pathDirname, join as pathJoin, resolve as pathResolveAbsolute } from 'node:path';
49
7
 
50
8
  // Startup shell-tool line. An earlier inventory was dropped because it was
51
9
  // measured in this process's PATH while commands run under the login-shell
@@ -53,7 +53,6 @@ async function loadConfig() {
53
53
  return applyDefaults({
54
54
  ...DEFAULT_CONFIG,
55
55
  ...raw,
56
- channelId: '',
57
56
  access: {
58
57
  ...DEFAULT_ACCESS,
59
58
  ...(raw.access || {}),
@@ -26,7 +26,7 @@ export function createConfigReloader({
26
26
  // startAutomationRuntime() is already past its automationRunning guard, so
27
27
  // nobody would call scheduler.start() again and every saved/edited schedule
28
28
  // stayed silently disarmed until the daemon restarted.
29
- scheduler.reloadConfig(getConfig().nonInteractive ?? [], getConfig().interactive ?? [], getConfig().channelId, {
29
+ scheduler.reloadConfig(getConfig().nonInteractive ?? [], getConfig().interactive ?? [], {
30
30
  restart: state.automationRunning || getBridgeRuntimeConnected(),
31
31
  });
32
32
  }
@@ -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
+ }