mixdog 1.0.6 → 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.
- package/README.md +158 -105
- package/package.json +11 -4
- package/src/defaults/skills/setup/references/actions.md +26 -6
- package/src/defaults/skills/setup/references/surfaces.md +8 -3
- package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +16 -7
- package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +0 -3
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +0 -2
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +24 -2
- package/src/runtime/agent/orchestrator/session/store-transcript-cache.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/store-transcript-worker.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +3 -45
- package/src/runtime/channels/lib/config.mjs +0 -1
- package/src/runtime/channels/lib/owned-runtime/config-reload.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +15 -56
- package/src/runtime/channels/lib/worker-main.mjs +2 -9
- package/src/runtime/shared/config.mjs +66 -1
- package/src/runtime/shared/llm/usage-ledger-quota.mjs +13 -2
- package/src/runtime/shared/path-executable.mjs +58 -0
- package/src/session-runtime/boot/apis.mjs +6 -2
- package/src/session-runtime/boot/begin.mjs +0 -2
- package/src/session-runtime/boot/providers.mjs +3 -0
- package/src/session-runtime/boot/tools.mjs +0 -1
- package/src/session-runtime/config-lifecycle.mjs +11 -0
- package/src/session-runtime/internal-tool-executor.mjs +0 -2
- package/src/session-runtime/runtime-feature-gates.mjs +2 -3
- package/src/session-runtime/schedule-session-run.mjs +0 -1
- package/src/session-runtime/services/channel-admin.mjs +8 -26
- package/src/session-runtime/settings-compaction-api.mjs +5 -1
- package/src/session-runtime/settings-system-api.mjs +2 -20
- package/src/session-runtime/setup-tool/executor.mjs +61 -3
- package/src/session-runtime/setup-tool/extended-actions.mjs +31 -6
- package/src/session-runtime/setup-tool/settings-contract.mjs +27 -3
- package/src/session-runtime/setup-tool/tool-defs.mjs +35 -5
- package/src/session-runtime/webhook-session-run.mjs +0 -2
- package/src/session-runtime/workflow-agents-api/agent-editor.mjs +13 -3
- package/src/standalone/daemon-stored-session-views.mjs +4 -0
- package/src/standalone/session-protocol.mjs +0 -1
- package/src/standalone/session-service/session-calls/session-views.mjs +10 -4
- package/src/standalone/session-service/stored-reader.mjs +20 -2
- package/src/standalone/session-service/viewers.mjs +3 -3
- package/src/standalone/session-service.mjs +3 -1
- package/src/tui/App.jsx +6 -1
- package/src/tui/app/create-app-pickers.mjs +1 -0
- package/src/tui/app/doctor.mjs +6 -14
- package/src/tui/app/maintenance-pickers/auto-clear-picker.mjs +8 -3
- package/src/tui/app/settings-picker/settings-rows.mjs +5 -1
- package/src/tui/app/use-ui-open-request.mjs +13 -3
- package/src/tui/dist/index.mjs +41 -9
- 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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
@@ -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 ?? [],
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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(
|
|
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
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
dispatchScheduleRun(schedule, prompt,
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
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,
|
|
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
|
|
224
|
+
active.forEach((instance) => {
|
|
215
225
|
const first = instance.rows[0];
|
|
216
|
-
|
|
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
|
+
}
|