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.
Files changed (73) hide show
  1. package/package.json +6 -4
  2. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
  4. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
  6. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
  7. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
  8. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  11. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
  12. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  13. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
  14. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  15. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  16. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  19. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  20. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  21. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  23. package/src/runtime/channels/lib/config.mjs +6 -5
  24. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  25. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  26. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  27. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  28. package/src/runtime/channels/lib/webhook.mjs +36 -46
  29. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  30. package/src/runtime/shared/automation-attachments.mjs +72 -0
  31. package/src/runtime/shared/automation-workflow.mjs +41 -0
  32. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  33. package/src/runtime/shared/schedules-db.mjs +18 -3
  34. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  35. package/src/runtime/shared/webhooks-db.mjs +19 -3
  36. package/src/session-runtime/channel-config-api.mjs +8 -1
  37. package/src/session-runtime/lifecycle-api.mjs +7 -0
  38. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  39. package/src/session-runtime/model-capabilities.mjs +6 -4
  40. package/src/session-runtime/notification-bus.mjs +82 -0
  41. package/src/session-runtime/provider-auth-api.mjs +16 -1
  42. package/src/session-runtime/provider-usage.mjs +3 -0
  43. package/src/session-runtime/remote-control.mjs +166 -0
  44. package/src/session-runtime/remote-transcript.mjs +126 -0
  45. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  46. package/src/session-runtime/runtime-core.mjs +184 -660
  47. package/src/session-runtime/runtime-tunables.mjs +57 -0
  48. package/src/session-runtime/self-update.mjs +129 -0
  49. package/src/session-runtime/skills-api.mjs +77 -0
  50. package/src/session-runtime/tool-surface.mjs +83 -0
  51. package/src/session-runtime/workflow-agents-api.mjs +206 -3
  52. package/src/session-runtime/workflow.mjs +84 -17
  53. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  54. package/src/standalone/agent-tool.mjs +22 -346
  55. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  56. package/src/standalone/channel-admin.mjs +36 -1
  57. package/src/standalone/channel-daemon-client.mjs +5 -1
  58. package/src/standalone/channel-daemon-transport.mjs +112 -20
  59. package/src/standalone/channel-daemon.mjs +6 -4
  60. package/src/standalone/channel-worker.mjs +7 -13
  61. package/src/tui/App.jsx +2 -32
  62. package/src/tui/app/channel-pickers.mjs +2 -143
  63. package/src/tui/app/slash-commands.mjs +1 -3
  64. package/src/tui/app/slash-dispatch.mjs +3 -3
  65. package/src/tui/components/StatusLine.jsx +6 -5
  66. package/src/tui/dist/index.mjs +162 -259
  67. package/src/tui/engine/labels.mjs +0 -8
  68. package/src/tui/engine/session-api-ext.mjs +45 -10
  69. package/src/tui/engine/turn-watchdog.mjs +92 -0
  70. package/src/tui/engine/turn.mjs +18 -70
  71. package/src/tui/engine.mjs +14 -1
  72. package/src/workflows/default/WORKFLOW.md +1 -1
  73. 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
- return FIXED_AGENT_SLOTS.map((agent) => ({
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
- const id = normalizeAgentId(agentId);
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);