mixdog 0.9.67 → 0.9.69
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/package.json +6 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
- package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
- package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
- package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
- package/src/runtime/channels/lib/config.mjs +6 -5
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
- package/src/runtime/channels/lib/scheduler.mjs +7 -15
- package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
- package/src/runtime/channels/lib/webhook.mjs +36 -46
- package/src/runtime/channels/lib/worker-main.mjs +45 -92
- package/src/runtime/shared/automation-attachments.mjs +72 -0
- package/src/runtime/shared/automation-workflow.mjs +41 -0
- package/src/runtime/shared/schedule-session-run.mjs +10 -1
- package/src/runtime/shared/schedules-db.mjs +18 -3
- package/src/runtime/shared/webhook-session-run.mjs +57 -0
- package/src/runtime/shared/webhooks-db.mjs +19 -3
- package/src/session-runtime/channel-config-api.mjs +8 -1
- package/src/session-runtime/lifecycle-api.mjs +7 -0
- package/src/session-runtime/memory-daemon-probe.mjs +61 -0
- package/src/session-runtime/model-capabilities.mjs +6 -4
- package/src/session-runtime/notification-bus.mjs +82 -0
- package/src/session-runtime/provider-auth-api.mjs +16 -1
- package/src/session-runtime/provider-usage.mjs +3 -0
- package/src/session-runtime/remote-control.mjs +166 -0
- package/src/session-runtime/remote-transcript.mjs +126 -0
- package/src/session-runtime/remote-transition-queue.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +184 -660
- package/src/session-runtime/runtime-tunables.mjs +57 -0
- package/src/session-runtime/self-update.mjs +129 -0
- package/src/session-runtime/skills-api.mjs +77 -0
- package/src/session-runtime/tool-surface.mjs +83 -0
- package/src/session-runtime/workflow-agents-api.mjs +206 -3
- package/src/session-runtime/workflow.mjs +84 -17
- package/src/standalone/agent-tool/worker-index.mjs +287 -0
- package/src/standalone/agent-tool.mjs +22 -346
- package/src/standalone/agent-watchdog-registry.mjs +101 -0
- package/src/standalone/channel-admin.mjs +36 -1
- package/src/standalone/channel-daemon-client.mjs +5 -1
- package/src/standalone/channel-daemon-transport.mjs +112 -20
- package/src/standalone/channel-daemon.mjs +6 -4
- package/src/standalone/channel-worker.mjs +7 -13
- package/src/tui/App.jsx +2 -32
- package/src/tui/app/channel-pickers.mjs +2 -143
- package/src/tui/app/slash-commands.mjs +1 -3
- package/src/tui/app/slash-dispatch.mjs +3 -3
- package/src/tui/components/StatusLine.jsx +6 -5
- package/src/tui/dist/index.mjs +162 -259
- package/src/tui/engine/labels.mjs +0 -8
- package/src/tui/engine/session-api-ext.mjs +45 -10
- package/src/tui/engine/turn-watchdog.mjs +92 -0
- package/src/tui/engine/turn.mjs +18 -70
- package/src/tui/engine.mjs +14 -1
- package/src/workflows/default/WORKFLOW.md +1 -1
- package/src/workflows/solo/WORKFLOW.md +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Env-tunable boot timings and feature gates for the session runtime, lifted
|
|
2
|
+
// out of runtime-core so the facade reads as wiring instead of a wall of
|
|
3
|
+
// knobs. Values are read ONCE per runtime construction: a mid-session env
|
|
4
|
+
// change must not retune a schedule that is already armed.
|
|
5
|
+
import { envDelayMs, envFlag, envPresent } from './env.mjs';
|
|
6
|
+
|
|
7
|
+
export function readRuntimeTunables() {
|
|
8
|
+
const codeGraphPrewarmEnabled = !envFlag('MIXDOG_DISABLE_CODE_GRAPH_PREWARM');
|
|
9
|
+
return {
|
|
10
|
+
sessionPrewarmDelayMs: envDelayMs('MIXDOG_SESSION_PREWARM_DELAY_MS', 50, { min: 0, max: 10_000 }),
|
|
11
|
+
providerSetupWarmupDelayMs: envDelayMs('MIXDOG_PROVIDER_SETUP_WARMUP_DELAY_MS', 300, { min: 0, max: 60_000 }),
|
|
12
|
+
modelCatalogWarmupDelayMs: envDelayMs('MIXDOG_MODEL_CATALOG_WARMUP_DELAY_MS', 200, { min: 0, max: 60_000 }),
|
|
13
|
+
providerWarmupDelayMs: envDelayMs('MIXDOG_PROVIDER_WARMUP_DELAY_MS', 1_500, { min: 0, max: 60_000 }),
|
|
14
|
+
// Background model-catalog prefetch delay. Kept short so the first `/model`
|
|
15
|
+
// open finds a warm cache instead of paying a cold full network load. The
|
|
16
|
+
// work is async + unref'd, so short-lived detached runtimes still exit
|
|
17
|
+
// cleanly without waiting on it. Operators can raise it via env if a
|
|
18
|
+
// detached runtime must avoid the /models round-trip entirely.
|
|
19
|
+
providerModelWarmupDelayMs: envDelayMs('MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS', 2_000, { min: 0, max: 120_000 }),
|
|
20
|
+
codeGraphPrewarmDelayMs: envDelayMs('MIXDOG_CODE_GRAPH_PREWARM_DELAY_MS', 250, { min: 0, max: 60_000 }),
|
|
21
|
+
statuslineUsageWarmupDelayMs: envDelayMs('MIXDOG_STATUSLINE_USAGE_WARMUP_DELAY_MS', 800, { min: 0, max: 60_000 }),
|
|
22
|
+
// Idle keep-alive: re-fetch usage before the statusline's 10-min staleness
|
|
23
|
+
// cut (LIVE_USAGE_SNAPSHOT_MAX_AGE_MS) so the usage segment does not
|
|
24
|
+
// disappear while the session sits idle with no turns to trigger a refresh.
|
|
25
|
+
statuslineUsageRefreshDelayMs: envDelayMs('MIXDOG_STATUSLINE_USAGE_REFRESH_MS', 240_000, { min: 30_000, max: 540_000 }),
|
|
26
|
+
channelStartDelayMs: envDelayMs('MIXDOG_CHANNEL_START_DELAY_MS', 10_000, { min: 0, max: 120_000 }),
|
|
27
|
+
backgroundBusyRetryMs: envDelayMs('MIXDOG_BACKGROUND_BUSY_RETRY_MS', 1_000, { min: 50, max: 10_000 }),
|
|
28
|
+
// Boot-time remote start is deferred past the first frame; see the caller.
|
|
29
|
+
remoteAutoStartDelayMs: envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 }),
|
|
30
|
+
sessionPrewarmEnabled: !envFlag('MIXDOG_DISABLE_SESSION_PREWARM')
|
|
31
|
+
&& (envFlag('MIXDOG_ENABLE_SESSION_PREWARM') || envPresent('MIXDOG_SESSION_PREWARM_DELAY_MS')),
|
|
32
|
+
providerWarmupEnabled: !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
|
|
33
|
+
&& (
|
|
34
|
+
envFlag('MIXDOG_ENABLE_PROVIDER_WARMUP')
|
|
35
|
+
|| envFlag('MIXDOG_PROVIDER_WARMUP_BEFORE_FIRST_TURN')
|
|
36
|
+
|| envPresent('MIXDOG_PROVIDER_WARMUP_DELAY_MS')
|
|
37
|
+
|| envPresent('MIXDOG_PROVIDER_MODEL_WARMUP_DELAY_MS')
|
|
38
|
+
),
|
|
39
|
+
// Boot-time model-catalog prefetch is intentionally decoupled from the
|
|
40
|
+
// heavier providerWarmupEnabled gate (which stays opt-in for provider
|
|
41
|
+
// *init* side effects). Fetching the model list in the background after a
|
|
42
|
+
// short delay is cheap, fire-and-forget and unref'd, so it is ON by
|
|
43
|
+
// default — otherwise the FIRST `/model` open always paid a cold full
|
|
44
|
+
// network load. Operators can still disable it explicitly.
|
|
45
|
+
modelPrefetchEnabled: !envFlag('MIXDOG_DISABLE_PROVIDER_WARMUP')
|
|
46
|
+
&& !envFlag('MIXDOG_DISABLE_MODEL_PREFETCH'),
|
|
47
|
+
codeGraphPrewarmEnabled,
|
|
48
|
+
modelCatalogWarmupEnabled: !envFlag('MIXDOG_DISABLE_MODEL_CATALOG_WARMUP'),
|
|
49
|
+
// Lazy code-graph prewarm (default ON): do NOT prewarm at startup / on cwd
|
|
50
|
+
// change — that fired ~250ms after the first frame and, in a large tree,
|
|
51
|
+
// burned a worker (and felt like a freeze) before the user did anything.
|
|
52
|
+
// Instead prewarm ONCE on the first real turn, when a code lookup is
|
|
53
|
+
// actually imminent. MIXDOG_CODE_GRAPH_PREWARM_EAGER=1 restores the old
|
|
54
|
+
// eager behavior.
|
|
55
|
+
codeGraphPrewarmLazy: codeGraphPrewarmEnabled && !envFlag('MIXDOG_CODE_GRAPH_PREWARM_EAGER'),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Self-update controller: registry version check + background staging of the
|
|
2
|
+
// next version. Extracted from runtime-core so the runtime facade owns wiring
|
|
3
|
+
// only. State is per-runtime and in-memory; the 24h TTL lives in the shared
|
|
4
|
+
// update-checker cache.
|
|
5
|
+
import {
|
|
6
|
+
checkLatestVersion,
|
|
7
|
+
isDevInstall,
|
|
8
|
+
} from '../runtime/shared/update-checker.mjs';
|
|
9
|
+
import {
|
|
10
|
+
spawnStagedInstall,
|
|
11
|
+
runStagedInstall,
|
|
12
|
+
isStagedComplete,
|
|
13
|
+
} from '../runtime/shared/staged-update.mjs';
|
|
14
|
+
|
|
15
|
+
const STAGE_POLL_MS = 3_000;
|
|
16
|
+
const STAGE_POLL_MAX_MS = 10 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
export function createSelfUpdateController({ getConfig, getDataDir, emitNotification }) {
|
|
19
|
+
let checkState = {
|
|
20
|
+
currentVersion: null,
|
|
21
|
+
latestVersion: null,
|
|
22
|
+
updateAvailable: false,
|
|
23
|
+
lastCheckedAt: 0,
|
|
24
|
+
};
|
|
25
|
+
// phase: 'idle' | 'checking' | 'installing' | 'installed' | 'failed'
|
|
26
|
+
let processState = { phase: 'idle', version: null, error: null };
|
|
27
|
+
|
|
28
|
+
function autoUpdateEnabled() {
|
|
29
|
+
return getConfig()?.update?.auto !== false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function checkForUpdate({ force = false } = {}) {
|
|
33
|
+
if (processState.phase !== 'installing') processState.phase = 'checking';
|
|
34
|
+
try {
|
|
35
|
+
const result = await checkLatestVersion({ force, dataDir: getDataDir() });
|
|
36
|
+
checkState = {
|
|
37
|
+
currentVersion: result.currentVersion,
|
|
38
|
+
latestVersion: result.latestVersion,
|
|
39
|
+
updateAvailable: result.updateAvailable,
|
|
40
|
+
lastCheckedAt: result.lastCheckedAt,
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
// checkLatestVersion() is already silent-safe; this catch is belt-and-
|
|
44
|
+
// braces so a boot-time call can never crash the runtime.
|
|
45
|
+
} finally {
|
|
46
|
+
if (processState.phase === 'checking') processState.phase = 'idle';
|
|
47
|
+
}
|
|
48
|
+
return checkState;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runUpdateNow() {
|
|
52
|
+
if (processState.phase === 'installing') {
|
|
53
|
+
return { ...processState, alreadyInstalling: true, error: 'update already in progress' };
|
|
54
|
+
}
|
|
55
|
+
if (isDevInstall()) {
|
|
56
|
+
processState = { phase: 'failed', version: null, error: 'dev install — update skipped' };
|
|
57
|
+
return processState;
|
|
58
|
+
}
|
|
59
|
+
const ver = checkState.latestVersion;
|
|
60
|
+
if (!ver || !checkState.updateAvailable) {
|
|
61
|
+
processState = { phase: 'idle', version: null, error: null };
|
|
62
|
+
return { ...processState, error: 'no update available' };
|
|
63
|
+
}
|
|
64
|
+
// "Update now" stages the new version (verified, self-contained) rather than
|
|
65
|
+
// installing over the live global dir; the swap applies on the next launch.
|
|
66
|
+
// phase 'installed' here means "staged & ready — restart to apply".
|
|
67
|
+
processState = { phase: 'installing', version: ver, error: null };
|
|
68
|
+
try {
|
|
69
|
+
const result = await runStagedInstall(ver);
|
|
70
|
+
processState = result?.ok
|
|
71
|
+
? { phase: 'installed', version: result.version || ver, error: null }
|
|
72
|
+
: { phase: 'failed', version: null, error: result?.error || 'update failed' };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
processState = { phase: 'failed', version: null, error: err?.message || String(err) };
|
|
75
|
+
}
|
|
76
|
+
return processState;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Non-blocking boot hook: the caller defers it past the synchronous runtime
|
|
80
|
+
// construction, so a slow/hanging registry request can never delay boot. The
|
|
81
|
+
// check ALWAYS runs (it populates the maintenance picker), but an available
|
|
82
|
+
// update only kicks off a hidden BACKGROUND staging install; the swap into
|
|
83
|
+
// the global dir happens on the next clean launch (cli.mjs pre-import), so
|
|
84
|
+
// npm never overwrites files this live process holds. force:true — the 24h
|
|
85
|
+
// disk cache went stale-visible (it kept reporting an older "latest" than the
|
|
86
|
+
// installed version); checkLatestVersion still falls back to it offline.
|
|
87
|
+
// isDevInstall(): a git checkout must never self-update.
|
|
88
|
+
function startBootCheck() {
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
void (async () => {
|
|
91
|
+
await checkForUpdate({ force: true });
|
|
92
|
+
if (!(autoUpdateEnabled() && !isDevInstall() && checkState.updateAvailable)) return;
|
|
93
|
+
const ver = checkState.latestVersion;
|
|
94
|
+
if (!ver) return;
|
|
95
|
+
// The notice fires ONLY once staging completed (a ready-to-apply package
|
|
96
|
+
// sits on disk) — never upfront — so no "update available" nag runs
|
|
97
|
+
// while the background stage does.
|
|
98
|
+
const announceReady = () => {
|
|
99
|
+
emitNotification('update ready', { kind: 'update-notice', version: ver, tone: 'info' });
|
|
100
|
+
};
|
|
101
|
+
if (isStagedComplete(ver)) { announceReady(); return; }
|
|
102
|
+
try { spawnStagedInstall(ver); } catch { /* best-effort background stage */ }
|
|
103
|
+
// Poll for completion, then announce once. Unref'd so it never holds the
|
|
104
|
+
// process open; gives up silently after the cap (next launch retries).
|
|
105
|
+
const startedAt = Date.now();
|
|
106
|
+
const poll = setInterval(() => {
|
|
107
|
+
if (isStagedComplete(ver)) {
|
|
108
|
+
clearInterval(poll);
|
|
109
|
+
announceReady();
|
|
110
|
+
} else if (Date.now() - startedAt > STAGE_POLL_MAX_MS) {
|
|
111
|
+
clearInterval(poll);
|
|
112
|
+
}
|
|
113
|
+
}, STAGE_POLL_MS);
|
|
114
|
+
poll.unref?.();
|
|
115
|
+
})().catch(() => {});
|
|
116
|
+
}, 0);
|
|
117
|
+
timer.unref?.();
|
|
118
|
+
return timer;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
autoUpdateEnabled,
|
|
123
|
+
checkForUpdate,
|
|
124
|
+
runUpdateNow,
|
|
125
|
+
getCheckState: () => checkState,
|
|
126
|
+
getProcessState: () => processState,
|
|
127
|
+
startBootCheck,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Skill surface (status listing, resource load, tool envelope, project skill
|
|
2
|
+
// creation). Extracted from runtime-core so the facade only wires the mutable
|
|
3
|
+
// cwd and the context module into it.
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { clean } from './session-text.mjs';
|
|
7
|
+
|
|
8
|
+
const SKILL_TEMPLATE = (name, description) => [
|
|
9
|
+
'---',
|
|
10
|
+
`name: ${name}`,
|
|
11
|
+
`description: ${description}`,
|
|
12
|
+
'---',
|
|
13
|
+
'',
|
|
14
|
+
'# Instructions',
|
|
15
|
+
'',
|
|
16
|
+
'Describe when and how to use this skill.',
|
|
17
|
+
'',
|
|
18
|
+
].join('\n');
|
|
19
|
+
|
|
20
|
+
export function createSkillsApi({ contextMod, getCwd }) {
|
|
21
|
+
function skillsStatus() {
|
|
22
|
+
const cwd = getCwd();
|
|
23
|
+
const skills = typeof contextMod.collectSkillsCached === 'function'
|
|
24
|
+
? contextMod.collectSkillsCached(cwd)
|
|
25
|
+
: [];
|
|
26
|
+
const norm = (value) => String(value || '').replace(/\\/g, '/').toLowerCase();
|
|
27
|
+
const cwdNorm = norm(cwd);
|
|
28
|
+
// A skill under <cwd>/.mixdog/skills is project-scoped; everything else
|
|
29
|
+
// comes from the user/global tree.
|
|
30
|
+
const sourceForSkill = (filePath) => (
|
|
31
|
+
cwdNorm && norm(filePath).startsWith(`${cwdNorm}/.mixdog/skills/`) ? 'project' : 'skill'
|
|
32
|
+
);
|
|
33
|
+
return {
|
|
34
|
+
cwd,
|
|
35
|
+
count: skills.length,
|
|
36
|
+
skills: skills.map((skill) => ({
|
|
37
|
+
name: skill.name,
|
|
38
|
+
description: skill.description || '',
|
|
39
|
+
filePath: skill.filePath || null,
|
|
40
|
+
source: sourceForSkill(skill.filePath),
|
|
41
|
+
})),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function skillContent(name) {
|
|
46
|
+
const res = typeof contextMod.loadSkillResource === 'function'
|
|
47
|
+
? contextMod.loadSkillResource(name, getCwd())
|
|
48
|
+
: null;
|
|
49
|
+
if (!res) throw new Error(`skill not found: ${name}`);
|
|
50
|
+
return { name, content: res.content, dir: res.dir };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function skillToolContent(name) {
|
|
54
|
+
if (typeof contextMod.isSkillDisabled === 'function' && contextMod.isSkillDisabled(name)) {
|
|
55
|
+
const label = String(name || '').trim() || 'skill';
|
|
56
|
+
return `Error: skill "${label}" is disabled`;
|
|
57
|
+
}
|
|
58
|
+
const skill = skillContent(name);
|
|
59
|
+
// The general tool envelope keeps the main/Lead session identical to agent
|
|
60
|
+
// loops: the model-visible tool_result is the short stub and the SKILL.md
|
|
61
|
+
// body is delivered ONCE as a separate injected user message.
|
|
62
|
+
return contextMod.buildSkillToolEnvelope(skill.name, skill.content, skill.dir);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function addProjectSkill(input = {}) {
|
|
66
|
+
const name = clean(input.name).replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
67
|
+
if (!name) throw new Error('skill name is required');
|
|
68
|
+
const dir = join(getCwd(), '.mixdog', 'skills', name);
|
|
69
|
+
const filePath = join(dir, 'SKILL.md');
|
|
70
|
+
if (existsSync(filePath)) throw new Error(`skill already exists: ${name}`);
|
|
71
|
+
mkdirSync(dir, { recursive: true });
|
|
72
|
+
writeFileSync(filePath, SKILL_TEMPLATE(name, clean(input.description) || 'Project skill.'), 'utf8');
|
|
73
|
+
return { name, filePath };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { skillsStatus, skillContent, skillToolContent, addProjectSkill };
|
|
77
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Lead tool surface: which tools the model sees before a session exists, and
|
|
2
|
+
// how that pre-session selection is replayed once one does. Extracted from
|
|
3
|
+
// runtime-core, which keeps the mutable session/route/mode it injects here.
|
|
4
|
+
import {
|
|
5
|
+
applyDeferredToolSurface,
|
|
6
|
+
filterDisallowedTools,
|
|
7
|
+
selectDeferredTools,
|
|
8
|
+
} from './tool-catalog.mjs';
|
|
9
|
+
import { LEAD_DISALLOWED_TOOLS } from './tool-defs.mjs';
|
|
10
|
+
import { deferredSurfaceModeForLead, toolSpecForMode } from './effort.mjs';
|
|
11
|
+
|
|
12
|
+
export function createToolSurface({
|
|
13
|
+
mgr,
|
|
14
|
+
mode,
|
|
15
|
+
standaloneTools,
|
|
16
|
+
agentToolNames,
|
|
17
|
+
getSession,
|
|
18
|
+
getRoute,
|
|
19
|
+
getConfig,
|
|
20
|
+
cfgMod,
|
|
21
|
+
loadWorkflowPack,
|
|
22
|
+
activeWorkflowId,
|
|
23
|
+
dataDir,
|
|
24
|
+
}) {
|
|
25
|
+
let preSessionSurface = null;
|
|
26
|
+
|
|
27
|
+
// A workflow that delegates to NOBODY must not advertise the agent tool: a
|
|
28
|
+
// schema-visible tool policy always rejects is a guaranteed error turn.
|
|
29
|
+
function workflowAllowsAgents() {
|
|
30
|
+
try {
|
|
31
|
+
const pack = loadWorkflowPack(cfgMod.getPluginData?.() || dataDir, activeWorkflowId(getConfig()));
|
|
32
|
+
if (!pack || pack.agentsConfigured !== true) return true;
|
|
33
|
+
return Array.isArray(pack.agents) && pack.agents.length > 0;
|
|
34
|
+
} catch {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function modelStandaloneTools() {
|
|
40
|
+
return workflowAllowsAgents()
|
|
41
|
+
? standaloneTools
|
|
42
|
+
: standaloneTools.filter((tool) => !agentToolNames.has(String(tool?.name || '')));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildPreSessionSurface() {
|
|
46
|
+
const previewTools = typeof mgr.previewSessionTools === 'function'
|
|
47
|
+
? mgr.previewSessionTools(toolSpecForMode(mode), [])
|
|
48
|
+
: [];
|
|
49
|
+
const tools = filterDisallowedTools(previewTools, LEAD_DISALLOWED_TOOLS);
|
|
50
|
+
const surface = { tools: Array.isArray(tools) ? tools.slice() : [] };
|
|
51
|
+
applyDeferredToolSurface(surface, deferredSurfaceModeForLead(mode), modelStandaloneTools(), {
|
|
52
|
+
provider: getRoute().provider,
|
|
53
|
+
});
|
|
54
|
+
return surface;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
modelStandaloneTools,
|
|
59
|
+
invalidatePreSessionToolSurface() {
|
|
60
|
+
preSessionSurface = null;
|
|
61
|
+
},
|
|
62
|
+
/** The live session once it exists; the preview surface until then. */
|
|
63
|
+
activeToolSurface() {
|
|
64
|
+
const session = getSession();
|
|
65
|
+
if (session) return session;
|
|
66
|
+
preSessionSurface ??= buildPreSessionSurface();
|
|
67
|
+
return preSessionSurface;
|
|
68
|
+
},
|
|
69
|
+
/** Replay deferred tools the model loaded before the session existed. */
|
|
70
|
+
applyPreSessionToolSelection() {
|
|
71
|
+
const session = getSession();
|
|
72
|
+
if (!session || !preSessionSurface) return;
|
|
73
|
+
const selected = Array.isArray(preSessionSurface.deferredSelectedTools)
|
|
74
|
+
? preSessionSurface.deferredSelectedTools
|
|
75
|
+
: [];
|
|
76
|
+
const discovered = Array.isArray(preSessionSurface.deferredDiscoveredTools)
|
|
77
|
+
? preSessionSurface.deferredDiscoveredTools
|
|
78
|
+
: [];
|
|
79
|
+
const replay = [...new Set([...selected, ...discovered])];
|
|
80
|
+
if (replay.length) selectDeferredTools(session, replay, deferredSurfaceModeForLead(mode));
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
3
|
import { clean, hasOwn, sessionHasConversationMessages, tombstoneOnClose } from './session-text.mjs';
|
|
2
4
|
import { isKnownProvider } from '../standalone/provider-admin.mjs';
|
|
5
|
+
import { serializeFrontmatterDoc } from '../runtime/shared/markdown-frontmatter.mjs';
|
|
6
|
+
import { isHiddenAgent } from '../runtime/agent/orchestrator/internal-agents.mjs';
|
|
3
7
|
import {
|
|
4
8
|
normalizeWorkflowRoute,
|
|
5
9
|
upsertWorkflowPreset,
|
|
@@ -9,8 +13,12 @@ import {
|
|
|
9
13
|
agentPresetSlot,
|
|
10
14
|
normalizeAgentId,
|
|
11
15
|
normalizeWorkflowId,
|
|
16
|
+
workflowIdFromName,
|
|
17
|
+
availableWorkflowId,
|
|
18
|
+
availableAgentId,
|
|
12
19
|
DEFAULT_WORKFLOW_ID,
|
|
13
20
|
normalizeSearchRouteConfig,
|
|
21
|
+
clearAgentDefinitionCache,
|
|
14
22
|
} from './workflow.mjs';
|
|
15
23
|
import { ONBOARDING_VERSION } from './quick-search-models.mjs';
|
|
16
24
|
import { findOutputStyle } from './output-styles.mjs';
|
|
@@ -27,7 +35,7 @@ export function createWorkflowAgentsApi(deps) {
|
|
|
27
35
|
cfgMod, reg, mgr, STANDALONE_DATA_DIR,
|
|
28
36
|
resolveRoute, lookupModelMeta, adoptConfig, saveConfigAndAdopt, displayConfig, ensureProvidersReady,
|
|
29
37
|
agentRouteFromConfig, loadAgentDefinition, activeWorkflowId, listWorkflowPacks,
|
|
30
|
-
loadWorkflowPack, workflowSummary,
|
|
38
|
+
loadWorkflowPack, workflowSummary, listCustomAgentIds,
|
|
31
39
|
getOutputStyleStatusCached, seedOutputStyleStatusCache, scheduleOutputStyleSave,
|
|
32
40
|
recreateCurrentSessionIfReady, notifyFnForSession, invalidateContextStatusCache,
|
|
33
41
|
} = deps;
|
|
@@ -124,12 +132,28 @@ export function createWorkflowAgentsApi(deps) {
|
|
|
124
132
|
listAgents() {
|
|
125
133
|
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
126
134
|
const config = getConfig();
|
|
127
|
-
|
|
135
|
+
const fixed = FIXED_AGENT_SLOTS.map((agent) => ({
|
|
128
136
|
...agent,
|
|
129
137
|
locked: true,
|
|
138
|
+
userOverride: existsSync(join(dataDir, 'agents', agent.id, 'AGENT.md')),
|
|
130
139
|
route: agentRouteFromConfig(config, agent.id, dataDir),
|
|
131
140
|
definition: loadAgentDefinition(dataDir, agent.id),
|
|
132
141
|
}));
|
|
142
|
+
// Custom agents (user-authored roles): discovered from agents/<id>/
|
|
143
|
+
// AGENT.md directories beyond the fixed slots.
|
|
144
|
+
const custom = (listCustomAgentIds?.(dataDir) || []).map((id) => {
|
|
145
|
+
const definition = loadAgentDefinition(dataDir, id);
|
|
146
|
+
return {
|
|
147
|
+
id,
|
|
148
|
+
label: definition?.name || id,
|
|
149
|
+
description: definition?.description || '',
|
|
150
|
+
custom: true,
|
|
151
|
+
userOverride: existsSync(join(dataDir, 'agents', id, 'AGENT.md')),
|
|
152
|
+
route: agentRouteFromConfig(config, id, dataDir),
|
|
153
|
+
definition,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
return [...fixed, ...custom];
|
|
133
157
|
},
|
|
134
158
|
listWorkflows() {
|
|
135
159
|
const currentConfig = displayConfig();
|
|
@@ -200,9 +224,188 @@ export function createWorkflowAgentsApi(deps) {
|
|
|
200
224
|
saveConfigAndAdopt(nextConfig);
|
|
201
225
|
return workflowSummary(pack);
|
|
202
226
|
},
|
|
227
|
+
// Workflow editor surface (desktop Workflows page): full pack read/write.
|
|
228
|
+
// User packs live at <dataDir>/workflows/<id>/WORKFLOW.md; saving a
|
|
229
|
+
// built-in id writes a user override, deleting the override reverts it.
|
|
230
|
+
getWorkflowPack(workflowId) {
|
|
231
|
+
const id = normalizeWorkflowId(workflowId, '');
|
|
232
|
+
if (!id) throw new Error(`unknown workflow "${workflowId}"`);
|
|
233
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
234
|
+
const pack = loadWorkflowPack(dataDir, id);
|
|
235
|
+
if (!pack || pack.id !== id) throw new Error(`workflow "${workflowId}" not found`);
|
|
236
|
+
return {
|
|
237
|
+
id: pack.id,
|
|
238
|
+
name: pack.name,
|
|
239
|
+
description: pack.description,
|
|
240
|
+
source: pack.source,
|
|
241
|
+
agentsConfigured: pack.agentsConfigured === true,
|
|
242
|
+
agents: pack.agents,
|
|
243
|
+
body: pack.body,
|
|
244
|
+
userOverride: existsSync(join(dataDir, 'workflows', id, 'WORKFLOW.md')),
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
async saveWorkflowPack(payload = {}) {
|
|
248
|
+
const id = normalizeWorkflowId(payload.id, '');
|
|
249
|
+
if (!id) throw new Error('workflow id must contain letters/numbers (dashes and dots allowed)');
|
|
250
|
+
const body = String(payload.body || '').trim();
|
|
251
|
+
if (!body) throw new Error('WORKFLOW.md body must not be empty');
|
|
252
|
+
// Frontmatter values are single-line by format; collapse any newlines.
|
|
253
|
+
const oneLine = (value) => clean(value).replace(/\s+/g, ' ');
|
|
254
|
+
const name = oneLine(payload.name) || id;
|
|
255
|
+
const description = oneLine(payload.description);
|
|
256
|
+
// agents: null/absent keeps the pack on "all agents" (no frontmatter key).
|
|
257
|
+
const agents = Array.isArray(payload.agents)
|
|
258
|
+
? [...new Set(payload.agents
|
|
259
|
+
.map((agent) => normalizeAgentId(agent) || normalizeWorkflowId(agent))
|
|
260
|
+
.filter(Boolean))]
|
|
261
|
+
: null;
|
|
262
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
263
|
+
const dir = join(dataDir, 'workflows', id);
|
|
264
|
+
mkdirSync(dir, { recursive: true });
|
|
265
|
+
writeFileSync(join(dir, 'WORKFLOW.md'), serializeFrontmatterDoc({
|
|
266
|
+
id,
|
|
267
|
+
name,
|
|
268
|
+
...(description ? { description } : {}),
|
|
269
|
+
...(agents ? { agents: agents.join(', ') } : {}),
|
|
270
|
+
}, body));
|
|
271
|
+
return this.getWorkflowPack(id);
|
|
272
|
+
},
|
|
273
|
+
async createWorkflow(payload = {}) {
|
|
274
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
275
|
+
const name = clean(payload.name).replace(/\s+/g, ' ');
|
|
276
|
+
if (!name) throw new Error('workflow name must not be empty');
|
|
277
|
+
const requestedId = normalizeWorkflowId(payload.id, '');
|
|
278
|
+
if (requestedId) {
|
|
279
|
+
const existing = loadWorkflowPack(dataDir, requestedId);
|
|
280
|
+
if (existing && existing.id === requestedId) throw new Error(`workflow "${requestedId}" already exists`);
|
|
281
|
+
return this.saveWorkflowPack({ ...payload, id: requestedId, name });
|
|
282
|
+
}
|
|
283
|
+
const id = availableWorkflowId(workflowIdFromName(name), (candidate) => {
|
|
284
|
+
const existing = loadWorkflowPack(dataDir, candidate);
|
|
285
|
+
return Boolean(existing && existing.id === candidate);
|
|
286
|
+
});
|
|
287
|
+
return this.saveWorkflowPack({ ...payload, id, name });
|
|
288
|
+
},
|
|
289
|
+
async deleteWorkflow(workflowId) {
|
|
290
|
+
const id = normalizeWorkflowId(workflowId, '');
|
|
291
|
+
if (!id) throw new Error(`unknown workflow "${workflowId}"`);
|
|
292
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
293
|
+
const pack = loadWorkflowPack(dataDir, id);
|
|
294
|
+
if (!pack || pack.id !== id) throw new Error(`workflow "${id}" not found`);
|
|
295
|
+
const dir = join(dataDir, 'workflows', id);
|
|
296
|
+
if (!existsSync(join(dir, 'WORKFLOW.md'))) {
|
|
297
|
+
throw new Error(`workflow "${id}" is built-in and cannot be deleted`);
|
|
298
|
+
}
|
|
299
|
+
rmSync(dir, { recursive: true, force: true });
|
|
300
|
+
const remaining = loadWorkflowPack(dataDir, id);
|
|
301
|
+
const revertedToBuiltIn = Boolean(remaining && remaining.id === id);
|
|
302
|
+
// A fully-removed pack must not stay active; fall back to Default.
|
|
303
|
+
if (!revertedToBuiltIn && activeWorkflowId(getConfig()) === id) {
|
|
304
|
+
const nextConfig = { ...getConfig() };
|
|
305
|
+
nextConfig.workflow = { ...(nextConfig.workflow || {}), active: DEFAULT_WORKFLOW_ID };
|
|
306
|
+
saveConfigAndAdopt(nextConfig);
|
|
307
|
+
}
|
|
308
|
+
return { id, deleted: true, revertedToBuiltIn };
|
|
309
|
+
},
|
|
310
|
+
// Agent editor surface (desktop Workflows page): the fixed roles stay
|
|
311
|
+
// built-in; custom agents are user-authored under <dataDir>/agents/<id>/.
|
|
312
|
+
getAgentDefinition(agentId) {
|
|
313
|
+
const id = normalizeAgentId(agentId) || normalizeWorkflowId(agentId);
|
|
314
|
+
if (!id) throw new Error(`unknown agent "${agentId}"`);
|
|
315
|
+
// Internal hidden roles (scheduler-task, webhook-handler, …) are
|
|
316
|
+
// Mixdog-managed and never editable through the agent surface.
|
|
317
|
+
if (isHiddenAgent(id)) throw new Error(`agent "${id}" is internal and cannot be edited`);
|
|
318
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
319
|
+
const definition = loadAgentDefinition(dataDir, id);
|
|
320
|
+
if (!definition) throw new Error(`agent "${agentId}" not found`);
|
|
321
|
+
return {
|
|
322
|
+
id,
|
|
323
|
+
name: definition.name,
|
|
324
|
+
description: definition.description,
|
|
325
|
+
body: definition.body,
|
|
326
|
+
custom: !FIXED_AGENT_SLOTS.some((agent) => agent.id === id),
|
|
327
|
+
userOverride: existsSync(join(dataDir, 'agents', id, 'AGENT.md')),
|
|
328
|
+
route: agentRouteFromConfig(getConfig(), id, dataDir),
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
async saveAgentDefinition(payload = {}) {
|
|
332
|
+
const body = String(payload.body || '').trim();
|
|
333
|
+
if (!body) throw new Error('AGENT.md body must not be empty');
|
|
334
|
+
const oneLine = (value) => clean(value).replace(/\s+/g, ' ');
|
|
335
|
+
const requestedId = clean(payload.id);
|
|
336
|
+
let id = normalizeAgentId(requestedId) || normalizeWorkflowId(requestedId);
|
|
337
|
+
if (hasOwn(payload, 'id') && !id) {
|
|
338
|
+
throw new Error('agent id must contain letters/numbers (dashes and dots allowed)');
|
|
339
|
+
}
|
|
340
|
+
if (id && isHiddenAgent(id)) throw new Error(`agent "${id}" is internal and cannot be edited`);
|
|
341
|
+
const name = oneLine(payload.name);
|
|
342
|
+
if (!name && !id) throw new Error('agent name must not be empty');
|
|
343
|
+
const description = oneLine(payload.description);
|
|
344
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
345
|
+
if (!id) {
|
|
346
|
+
id = availableAgentId(name, (candidate) => Boolean(loadAgentDefinition(dataDir, candidate)));
|
|
347
|
+
}
|
|
348
|
+
const dir = join(dataDir, 'agents', id);
|
|
349
|
+
mkdirSync(dir, { recursive: true });
|
|
350
|
+
writeFileSync(join(dir, 'AGENT.md'), serializeFrontmatterDoc({
|
|
351
|
+
name: name || id,
|
|
352
|
+
...(description ? { description } : {}),
|
|
353
|
+
}, body));
|
|
354
|
+
// loadAgentDefinition prefers the manifest for name/description.
|
|
355
|
+
writeFileSync(join(dir, 'agent.json'), `${JSON.stringify({
|
|
356
|
+
name: name || id,
|
|
357
|
+
...(description ? { description } : {}),
|
|
358
|
+
}, null, 2)}\n`);
|
|
359
|
+
clearAgentDefinitionCache(id);
|
|
360
|
+
if (payload.route) {
|
|
361
|
+
await this.setAgentRoute(id, payload.route);
|
|
362
|
+
} else if (!FIXED_AGENT_SLOTS.some((agent) => agent.id === id)
|
|
363
|
+
&& !agentRouteFromConfig(getConfig(), id, dataDir)) {
|
|
364
|
+
// Custom roles have no built-in preset fallback, so a model-less save
|
|
365
|
+
// would spawn as `agent "<id>" has no model assignment`. Seed the
|
|
366
|
+
// current default route; an unconfigured install just stays routeless.
|
|
367
|
+
try { await this.setAgentRoute(id, {}); } catch { /* no default route yet */ }
|
|
368
|
+
}
|
|
369
|
+
return this.getAgentDefinition(id);
|
|
370
|
+
},
|
|
371
|
+
async deleteAgentDefinition(agentId) {
|
|
372
|
+
const id = normalizeAgentId(agentId) || normalizeWorkflowId(agentId);
|
|
373
|
+
if (!id) throw new Error(`unknown agent "${agentId}"`);
|
|
374
|
+
if (isHiddenAgent(id)) throw new Error(`agent "${id}" is internal and cannot be deleted`);
|
|
375
|
+
const builtIn = FIXED_AGENT_SLOTS.some((agent) => agent.id === id);
|
|
376
|
+
const dataDir = cfgMod.getPluginData?.() || STANDALONE_DATA_DIR;
|
|
377
|
+
const dir = join(dataDir, 'agents', id);
|
|
378
|
+
// Built-in roles are never removed: dropping their user override reverts
|
|
379
|
+
// the role to the shipped AGENT.md, mirroring workflow-pack deletes.
|
|
380
|
+
if (!existsSync(join(dir, 'AGENT.md'))) {
|
|
381
|
+
throw new Error(builtIn
|
|
382
|
+
? `agent "${id}" has no user override to reset`
|
|
383
|
+
: `agent "${id}" not found`);
|
|
384
|
+
}
|
|
385
|
+
rmSync(dir, { recursive: true, force: true });
|
|
386
|
+
clearAgentDefinitionCache(id);
|
|
387
|
+
const revertedToBuiltIn = Boolean(loadAgentDefinition(dataDir, id));
|
|
388
|
+
// A fully-removed custom agent must not leave a dangling route/preset.
|
|
389
|
+
if (!revertedToBuiltIn) {
|
|
390
|
+
const nextConfig = { ...getConfig() };
|
|
391
|
+
if (nextConfig.agents && id in nextConfig.agents) {
|
|
392
|
+
const agents = { ...nextConfig.agents };
|
|
393
|
+
delete agents[id];
|
|
394
|
+
nextConfig.agents = agents;
|
|
395
|
+
}
|
|
396
|
+
const presetId = workflowPresetId(agentPresetSlot(id));
|
|
397
|
+
nextConfig.presets = (Array.isArray(nextConfig.presets) ? nextConfig.presets : [])
|
|
398
|
+
.filter((preset) => clean(preset?.id) !== presetId);
|
|
399
|
+
saveConfigAndAdopt(nextConfig);
|
|
400
|
+
}
|
|
401
|
+
return { id, deleted: true, revertedToBuiltIn };
|
|
402
|
+
},
|
|
203
403
|
async setAgentRoute(agentId, next) {
|
|
204
|
-
|
|
404
|
+
// Custom agents keep their workflow-style id; routes persist in
|
|
405
|
+
// config.agents[<id>] exactly like the fixed roles.
|
|
406
|
+
const id = normalizeAgentId(agentId) || normalizeWorkflowId(agentId);
|
|
205
407
|
if (!id) throw new Error(`unknown agent "${agentId}"`);
|
|
408
|
+
if (isHiddenAgent(id)) throw new Error(`agent "${id}" is internal and has no configurable route`);
|
|
206
409
|
let selectedRoute = resolveRoute(getConfig(), { ...(next || {}) });
|
|
207
410
|
await ensureProvidersReady(ensureProviderEnabled(getConfig(), selectedRoute.provider));
|
|
208
411
|
const modelMeta = await lookupModelMeta(selectedRoute.provider, selectedRoute.model);
|