@dfsramos/foreman 0.0.0-stage → 0.3.0

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 (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +385 -2
  4. package/dist/agent/adapters/agy.js +96 -0
  5. package/dist/agent/adapters/claude.js +152 -0
  6. package/dist/agent/adapters/codex.js +107 -0
  7. package/dist/agent/adapters/opencode.js +91 -0
  8. package/dist/agent/config.js +169 -0
  9. package/dist/agent/process.js +230 -0
  10. package/dist/agent/prompt.js +184 -0
  11. package/dist/agent/registry.js +57 -0
  12. package/dist/agent/types.js +12 -0
  13. package/dist/cli-args.js +70 -0
  14. package/dist/cli.js +184 -0
  15. package/dist/config.js +102 -0
  16. package/dist/daemon.js +109 -0
  17. package/dist/db.js +467 -0
  18. package/dist/git/taskstore.js +224 -0
  19. package/dist/git/worktree.js +248 -0
  20. package/dist/imports/connections.js +57 -0
  21. package/dist/imports/destination.js +2 -0
  22. package/dist/imports/linear.js +40 -0
  23. package/dist/imports/registry.js +4 -0
  24. package/dist/imports/types.js +1 -0
  25. package/dist/kaneo/client.js +47 -0
  26. package/dist/kaneo/issues.js +138 -0
  27. package/dist/kaneo/workspace.js +24 -0
  28. package/dist/linear/browse.js +55 -0
  29. package/dist/linear/client.js +48 -0
  30. package/dist/linear/issues.js +99 -0
  31. package/dist/linear/workspace.js +30 -0
  32. package/dist/paths.js +12 -0
  33. package/dist/pidfile.js +35 -0
  34. package/dist/projects.js +211 -0
  35. package/dist/review/decide.js +186 -0
  36. package/dist/review/loop.js +114 -0
  37. package/dist/runner.js +1281 -0
  38. package/dist/server/app.js +344 -0
  39. package/dist/server/imports.js +182 -0
  40. package/dist/server/tasks.js +94 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +85 -0
  43. package/dist/sources/git.js +431 -0
  44. package/dist/sources/kaneo.js +156 -0
  45. package/dist/sources/lease.js +38 -0
  46. package/dist/sources/linear.js +186 -0
  47. package/dist/sources/options.js +26 -0
  48. package/dist/sources/registry.js +6 -0
  49. package/dist/sources/taskfile.js +210 -0
  50. package/dist/sources/types.js +1 -0
  51. package/package.json +63 -4
  52. package/public/agents.js +435 -0
  53. package/public/app.js +1200 -0
  54. package/public/imports.js +481 -0
  55. package/public/index.html +19 -0
  56. package/public/projects.js +481 -0
  57. package/public/style.css +1614 -0
  58. package/public/tasks.js +279 -0
  59. package/public/ui.js +165 -0
@@ -0,0 +1,152 @@
1
+ import { listSetting, textSetting, truncate, } from '../types.js';
2
+ function toolResultText(content) {
3
+ if (typeof content === 'string')
4
+ return content;
5
+ if (Array.isArray(content)) {
6
+ return content
7
+ .map((part) => (typeof part?.text === 'string' ? part.text : ''))
8
+ .filter(Boolean)
9
+ .join('\n');
10
+ }
11
+ return content === undefined ? '' : JSON.stringify(content);
12
+ }
13
+ /**
14
+ * Reads a `rate_limit_event`. This event is not documented; its shape was taken from the output of
15
+ * CLI 2.1.291, so anything unexpected is ignored rather than trusted.
16
+ */
17
+ function parseUsage(info) {
18
+ const windows = {};
19
+ for (const [name, window] of Object.entries(info?.unifiedWindows ?? {})) {
20
+ if (typeof window?.utilization !== 'number' || !Number.isFinite(window.utilization))
21
+ continue;
22
+ windows[name] = {
23
+ utilization: window.utilization,
24
+ resetsAt: typeof window.resetsAt === 'number' ? new Date(window.resetsAt * 1000).toISOString() : null,
25
+ };
26
+ }
27
+ if (Object.keys(windows).length === 0)
28
+ return null;
29
+ return { windows, usingOverage: info?.isUsingOverage === true, at: new Date().toISOString() };
30
+ }
31
+ const PERMISSION_MODES = ['acceptEdits', 'bypassPermissions', 'default'];
32
+ /** Claude Code: `claude -p` with stream-json output and an allow-list of tools. */
33
+ export const claudeAdapter = {
34
+ id: 'claude',
35
+ name: 'Claude Code',
36
+ defaultBin: 'claude',
37
+ versionArgs: ['--version'],
38
+ // `claude auth status` prints JSON with `loggedIn` and exits 1 when signed out (CLI 2.1.307).
39
+ signIn: {
40
+ args: ['auth', 'status'],
41
+ parse(stdout) {
42
+ try {
43
+ const status = JSON.parse(stdout);
44
+ return typeof status.loggedIn === 'boolean' ? status.loggedIn : null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ },
50
+ },
51
+ verified: true,
52
+ reportsUsage: true,
53
+ fields: [
54
+ {
55
+ key: 'permissionMode',
56
+ label: 'Permission mode',
57
+ type: 'select',
58
+ options: PERMISSION_MODES,
59
+ help: 'acceptEdits allows file edits; anything else needs a rule below. bypassPermissions allows everything.',
60
+ },
61
+ {
62
+ key: 'allowedTools',
63
+ label: 'Allowed tools',
64
+ type: 'list',
65
+ help: 'Passed as --allowedTools, e.g. Bash(git:*)',
66
+ },
67
+ ],
68
+ defaultSettings: {
69
+ permissionMode: 'acceptEdits',
70
+ allowedTools: [
71
+ 'Read',
72
+ 'Glob',
73
+ 'Grep',
74
+ 'Edit',
75
+ 'Write',
76
+ 'Skill',
77
+ 'Task',
78
+ 'TodoWrite',
79
+ 'Bash(git:*)',
80
+ 'Bash(gh pr:*)',
81
+ 'Bash(npm:*)',
82
+ 'Bash(npx:*)',
83
+ 'Bash(node:*)',
84
+ ],
85
+ },
86
+ requiresResult: true,
87
+ buildCommand({ prompt, settings }) {
88
+ const mode = textSetting(settings, 'permissionMode', 'acceptEdits');
89
+ const tools = listSetting(settings, 'allowedTools');
90
+ const args = ['-p', '--output-format', 'stream-json', '--verbose', '--permission-mode', mode];
91
+ if (tools.length > 0)
92
+ args.push('--allowedTools', ...tools);
93
+ return { args, stdin: prompt };
94
+ },
95
+ createParser() {
96
+ let parsed = null;
97
+ let lastAssistantText = null;
98
+ let usage = null;
99
+ return {
100
+ push(line) {
101
+ const events = [];
102
+ let message;
103
+ try {
104
+ message = JSON.parse(line);
105
+ }
106
+ catch {
107
+ return [{ kind: 'foreman', text: `unparsed output: ${truncate(line)}` }];
108
+ }
109
+ const content = Array.isArray(message.message?.content) ? message.message.content : [];
110
+ if (message.type === 'assistant') {
111
+ for (const block of content) {
112
+ if (block.type === 'text' && typeof block.text === 'string') {
113
+ lastAssistantText = block.text;
114
+ events.push({ kind: 'assistant', text: block.text });
115
+ }
116
+ else if (block.type === 'tool_use') {
117
+ events.push({ kind: 'tool_use', text: `${block.name ?? 'tool'} ${truncate(JSON.stringify(block.input ?? {}))}` });
118
+ }
119
+ }
120
+ }
121
+ else if (message.type === 'user') {
122
+ for (const block of content) {
123
+ if (block.type === 'tool_result') {
124
+ events.push({ kind: 'tool_result', text: truncate(toolResultText(block.content)) });
125
+ }
126
+ }
127
+ }
128
+ else if (message.type === 'rate_limit_event') {
129
+ usage = parseUsage(message.rate_limit_info) ?? usage;
130
+ }
131
+ else if (message.type === 'result') {
132
+ parsed = {
133
+ isError: message.is_error === true,
134
+ resultText: typeof message.result === 'string' ? message.result : null,
135
+ costUsd: typeof message.total_cost_usd === 'number' ? message.total_cost_usd : null,
136
+ numTurns: typeof message.num_turns === 'number' ? message.num_turns : null,
137
+ };
138
+ // The result normally repeats the agent's last message; logging it twice is noise.
139
+ if (parsed.resultText !== lastAssistantText || parsed.isError) {
140
+ events.push({
141
+ kind: 'result',
142
+ text: parsed.resultText ?? `Agent finished (${String(message.subtype ?? 'no result text')})`,
143
+ });
144
+ }
145
+ }
146
+ return events;
147
+ },
148
+ result: () => parsed,
149
+ usage: () => usage,
150
+ };
151
+ },
152
+ };
@@ -0,0 +1,107 @@
1
+ import { listSetting, textSetting, truncate } from '../types.js';
2
+ const SANDBOX_MODES = ['danger-full-access', 'workspace-write', 'read-only'];
3
+ /**
4
+ * OpenAI Codex CLI: `codex exec --json`, prompt on stdin.
5
+ *
6
+ * Written from the documentation and the event definitions in the CLI's source
7
+ * (codex-rs/exec/src/exec_events.rs); not yet run against the real CLI. The exact nesting of
8
+ * items inside events was not confirmed, so the parser accepts an item either under `item` or
9
+ * at the top level.
10
+ */
11
+ export const codexAdapter = {
12
+ id: 'codex',
13
+ name: 'Codex CLI',
14
+ defaultBin: 'codex',
15
+ versionArgs: ['--version'],
16
+ verified: false,
17
+ notes: [
18
+ 'The workspace-write sandbox keeps .git read-only and blocks the network, so commit, push and gh fail under it. danger-full-access removes the sandbox entirely.',
19
+ 'Reports token usage but not cost or turn count.',
20
+ ],
21
+ fields: [
22
+ {
23
+ key: 'sandbox',
24
+ label: 'Sandbox',
25
+ type: 'select',
26
+ options: SANDBOX_MODES,
27
+ help: 'Passed as --sandbox. Only danger-full-access can commit and push from a worktree.',
28
+ },
29
+ {
30
+ key: 'networkAccess',
31
+ label: 'Network in workspace-write',
32
+ type: 'select',
33
+ options: ['off', 'on'],
34
+ help: 'Sets sandbox_workspace_write.network_access; ignored in the other modes.',
35
+ },
36
+ { key: 'extraArgs', label: 'Extra arguments', type: 'list', help: 'Appended to codex exec, one argument per row.' },
37
+ ],
38
+ defaultSettings: { sandbox: 'danger-full-access', networkAccess: 'off', extraArgs: [] },
39
+ requiresResult: false,
40
+ buildCommand({ prompt, settings }) {
41
+ const sandbox = textSetting(settings, 'sandbox', 'danger-full-access');
42
+ const args = ['exec', '--json', '--sandbox', sandbox];
43
+ if (sandbox === 'workspace-write' && textSetting(settings, 'networkAccess', 'off') === 'on') {
44
+ args.push('-c', 'sandbox_workspace_write.network_access=true');
45
+ }
46
+ args.push(...listSetting(settings, 'extraArgs'));
47
+ // "-" makes codex read the whole prompt from stdin.
48
+ args.push('-');
49
+ return { args, stdin: prompt };
50
+ },
51
+ createParser() {
52
+ let parsed = null;
53
+ let lastMessage = null;
54
+ const fail = (message) => {
55
+ parsed = { isError: true, resultText: message, costUsd: null, numTurns: null };
56
+ };
57
+ return {
58
+ push(line) {
59
+ let event;
60
+ try {
61
+ event = JSON.parse(line);
62
+ }
63
+ catch {
64
+ return [{ kind: 'foreman', text: `unparsed output: ${truncate(line)}` }];
65
+ }
66
+ const events = [];
67
+ const type = event.type ?? '';
68
+ if (type.startsWith('item.')) {
69
+ const item = event.item ?? event;
70
+ if (item.type === 'agent_message' && type === 'item.completed' && typeof item.text === 'string') {
71
+ lastMessage = item.text;
72
+ events.push({ kind: 'assistant', text: item.text });
73
+ }
74
+ else if (item.type === 'command_execution') {
75
+ if (type === 'item.started') {
76
+ events.push({ kind: 'tool_use', text: `shell ${truncate(String(item.command ?? ''))}` });
77
+ }
78
+ else if (type === 'item.completed') {
79
+ const exit = item.exit_code === null || item.exit_code === undefined ? '' : `exit ${item.exit_code}\n`;
80
+ events.push({ kind: 'tool_result', text: truncate(`${exit}${item.aggregated_output ?? ''}`.trim() || (item.status ?? 'done')) });
81
+ }
82
+ }
83
+ else if (type === 'item.completed' && (item.type === 'file_change' || item.type === 'mcp_tool_call' || item.type === 'web_search')) {
84
+ const { type: itemType, ...rest } = item;
85
+ events.push({ kind: 'tool_use', text: `${itemType} ${truncate(JSON.stringify(rest))}` });
86
+ }
87
+ else if (type === 'item.completed' && item.type === 'error') {
88
+ events.push({ kind: 'foreman', text: `codex: ${truncate(String(item.message ?? 'error'))}` });
89
+ }
90
+ }
91
+ else if (type === 'turn.completed') {
92
+ parsed = { isError: false, resultText: lastMessage, costUsd: null, numTurns: null };
93
+ }
94
+ else if (type === 'turn.failed') {
95
+ fail(event.error?.message ?? 'Turn failed');
96
+ events.push({ kind: 'result', text: parsed.resultText ?? 'Turn failed' });
97
+ }
98
+ else if (type === 'error') {
99
+ fail(event.message ?? event.error?.message ?? 'Codex reported an error');
100
+ events.push({ kind: 'result', text: parsed.resultText ?? 'error' });
101
+ }
102
+ return events;
103
+ },
104
+ result: () => parsed,
105
+ };
106
+ },
107
+ };
@@ -0,0 +1,91 @@
1
+ import { listSetting, textSetting, truncate } from '../types.js';
2
+ /**
3
+ * opencode: `opencode run --format json`, prompt on stdin.
4
+ *
5
+ * Written from the documentation and the `run` command's source; not yet run against the real
6
+ * CLI. The event envelope (`type`, `timestamp`, `sessionID`) is confirmed from source, but where
7
+ * each event keeps its payload is not, so the parser looks under `part` and at the top level.
8
+ * There is no final result event: success is the exit code and the absence of an `error` event.
9
+ */
10
+ export const opencodeAdapter = {
11
+ id: 'opencode',
12
+ name: 'opencode',
13
+ defaultBin: 'opencode',
14
+ versionArgs: ['--version'],
15
+ verified: false,
16
+ notes: [
17
+ 'Has no sandbox. Its permission config decides what runs without asking; anything set to "ask" is rejected in a headless run unless auto-approve is on.',
18
+ 'Starts a local server for the duration of each run.',
19
+ ],
20
+ fields: [
21
+ {
22
+ key: 'autoApprove',
23
+ label: 'Auto-approve',
24
+ type: 'select',
25
+ options: ['off', 'on'],
26
+ help: 'Passes --auto, approving every permission that is not explicitly denied.',
27
+ },
28
+ {
29
+ key: 'permission',
30
+ label: 'Permission config (JSON)',
31
+ type: 'text',
32
+ help: 'Passed in OPENCODE_PERMISSION, e.g. {"bash":{"*":"deny","git *":"allow","gh pr *":"allow"}}. Empty uses your opencode config.',
33
+ },
34
+ { key: 'extraArgs', label: 'Extra arguments', type: 'list', help: 'Appended to opencode run, one argument per row.' },
35
+ ],
36
+ defaultSettings: { autoApprove: 'on', permission: '', extraArgs: [] },
37
+ requiresResult: false,
38
+ buildCommand({ prompt, settings }) {
39
+ const args = ['run', '--format', 'json'];
40
+ if (textSetting(settings, 'autoApprove', 'on') === 'on')
41
+ args.push('--auto');
42
+ args.push(...listSetting(settings, 'extraArgs'));
43
+ const permission = textSetting(settings, 'permission');
44
+ // opencode reads the message from stdin when it is not a terminal.
45
+ return { args, stdin: prompt, ...(permission ? { env: { OPENCODE_PERMISSION: permission } } : {}) };
46
+ },
47
+ createParser() {
48
+ let error = null;
49
+ let lastText = null;
50
+ let finished = false;
51
+ return {
52
+ push(line) {
53
+ let event;
54
+ try {
55
+ event = JSON.parse(line);
56
+ }
57
+ catch {
58
+ return [{ kind: 'foreman', text: `unparsed output: ${truncate(line)}` }];
59
+ }
60
+ const part = event.part ?? event;
61
+ const events = [];
62
+ if (event.type === 'text' && typeof part.text === 'string') {
63
+ lastText = part.text;
64
+ events.push({ kind: 'assistant', text: part.text });
65
+ }
66
+ else if (event.type === 'tool_use') {
67
+ // The call and its result arrive together, once the tool has finished.
68
+ events.push({ kind: 'tool_use', text: `${part.tool ?? 'tool'} ${truncate(JSON.stringify(part.state?.input ?? {}))}` });
69
+ const output = part.state?.error ?? part.state?.output;
70
+ if (output !== undefined) {
71
+ events.push({ kind: 'tool_result', text: truncate(typeof output === 'string' ? output : JSON.stringify(output)) });
72
+ }
73
+ }
74
+ else if (event.type === 'step_finish') {
75
+ finished = true;
76
+ }
77
+ else if (event.type === 'error') {
78
+ const detail = event.error ?? event.message ?? 'opencode reported an error';
79
+ error = typeof detail === 'string' ? detail : JSON.stringify(detail);
80
+ events.push({ kind: 'result', text: truncate(error) });
81
+ }
82
+ return events;
83
+ },
84
+ result() {
85
+ if (error !== null)
86
+ return { isError: true, resultText: error, costUsd: null, numTurns: null };
87
+ return finished ? { isError: false, resultText: lastText, costUsd: null, numTurns: null } : null;
88
+ },
89
+ };
90
+ },
91
+ };
@@ -0,0 +1,169 @@
1
+ import path from 'node:path';
2
+ import { ADAPTERS, probeAgent } from './registry.js';
3
+ export const MAX_CONCURRENT_LIMIT = 16;
4
+ const SETTINGS_KEY = 'agents';
5
+ /** Where the claude allow-list lived before agents had their own settings. */
6
+ const LEGACY_ALLOWED_TOOLS_KEY = 'agentAllowedTools';
7
+ export const DEFAULT_MAX_USAGE_PERCENT = 90;
8
+ const USAGE_DEFAULTS = { maxUsagePercent: DEFAULT_MAX_USAGE_PERCENT, allowOverage: false };
9
+ function defaultEntry(adapter, env, legacyTools) {
10
+ if (adapter.id !== 'claude') {
11
+ // Other CLIs are opt-in: they run with write and network access under adapters that may be unverified.
12
+ return { enabled: false, maxConcurrent: 1, ...USAGE_DEFAULTS, settings: structuredClone(adapter.defaultSettings) };
13
+ }
14
+ const tools = Array.isArray(legacyTools) && legacyTools.every((tool) => typeof tool === 'string') ? legacyTools : env.agentAllowedTools;
15
+ return {
16
+ enabled: true,
17
+ maxConcurrent: 1,
18
+ ...USAGE_DEFAULTS,
19
+ ...(env.claudeBin !== adapter.defaultBin ? { bin: env.claudeBin } : {}),
20
+ settings: { ...structuredClone(adapter.defaultSettings), allowedTools: [...tools] },
21
+ };
22
+ }
23
+ /** The stored agents configuration, completed with defaults for adapters it does not mention yet. */
24
+ export function loadAgentsConfig(db, env, adapters = ADAPTERS) {
25
+ const stored = db.getSetting(SETTINGS_KEY);
26
+ const legacyTools = stored ? undefined : db.getSetting(LEGACY_ALLOWED_TOOLS_KEY);
27
+ const known = new Set(adapters.map((adapter) => adapter.id));
28
+ const agents = {};
29
+ for (const adapter of adapters) {
30
+ const entry = stored?.agents?.[adapter.id];
31
+ const fallback = defaultEntry(adapter, env, legacyTools);
32
+ agents[adapter.id] = entry ? { ...fallback, ...entry, settings: { ...fallback.settings, ...entry.settings } } : fallback;
33
+ }
34
+ const order = [...new Set([...(stored?.order ?? []).filter((id) => known.has(id)), ...adapters.map((adapter) => adapter.id)])];
35
+ const config = { order, agents };
36
+ if (!stored && legacyTools !== undefined) {
37
+ // One-time migration of an allow-list saved through the old Config tab.
38
+ db.setSetting(SETTINGS_KEY, config);
39
+ db.deleteSetting(LEGACY_ALLOWED_TOOLS_KEY);
40
+ }
41
+ return config;
42
+ }
43
+ function validateSettings(adapter, value, errors) {
44
+ const settings = {};
45
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
46
+ errors[`${adapter.id}.settings`] = 'must be an object';
47
+ return settings;
48
+ }
49
+ const fields = new Map(adapter.fields.map((field) => [field.key, field]));
50
+ for (const [key, raw] of Object.entries(value)) {
51
+ const field = fields.get(key);
52
+ const where = `${adapter.id}.settings.${key}`;
53
+ if (!field) {
54
+ errors[where] = 'is not a setting of this agent';
55
+ }
56
+ else if (field.type === 'list') {
57
+ if (!Array.isArray(raw) || raw.some((item) => typeof item !== 'string')) {
58
+ errors[where] = 'must be a list of strings';
59
+ }
60
+ else {
61
+ const items = raw.map((item) => item.trim()).filter((item) => item.length > 0);
62
+ if (new Set(items).size !== items.length)
63
+ errors[where] = 'must not contain duplicates';
64
+ settings[key] = items;
65
+ }
66
+ }
67
+ else if (typeof raw !== 'string') {
68
+ errors[where] = 'must be text';
69
+ }
70
+ else if (field.type === 'select' && !field.options?.includes(raw)) {
71
+ errors[where] = `must be one of: ${field.options?.join(', ')}`;
72
+ }
73
+ else {
74
+ settings[key] = raw.trim();
75
+ }
76
+ }
77
+ return settings;
78
+ }
79
+ /** Validates and stores an agents configuration. Returns the stored value, or what is wrong with the input. */
80
+ export function saveAgentsConfig(db, input, adapters = ADAPTERS) {
81
+ const errors = {};
82
+ const body = input;
83
+ if (typeof body !== 'object' || body === null || typeof body.agents !== 'object' || body.agents === null || !Array.isArray(body.order)) {
84
+ return { errors: { _: 'Request body must be { order: [...], agents: {...} }' } };
85
+ }
86
+ const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
87
+ for (const id of Object.keys(body.agents)) {
88
+ if (!byId.has(id))
89
+ errors[id] = 'is not a known agent';
90
+ }
91
+ const order = body.order.filter((id) => typeof id === 'string');
92
+ if (order.length !== adapters.length || new Set(order).size !== order.length || order.some((id) => !byId.has(id))) {
93
+ errors.order = 'must list every known agent exactly once';
94
+ }
95
+ const agents = {};
96
+ for (const adapter of adapters) {
97
+ const raw = body.agents[adapter.id];
98
+ if (typeof raw !== 'object' || raw === null) {
99
+ errors[adapter.id] = 'is missing';
100
+ continue;
101
+ }
102
+ if (typeof raw.enabled !== 'boolean')
103
+ errors[`${adapter.id}.enabled`] = 'must be true or false';
104
+ const max = raw.maxConcurrent;
105
+ if (typeof max !== 'number' || !Number.isInteger(max) || max < 0 || max > MAX_CONCURRENT_LIMIT) {
106
+ errors[`${adapter.id}.maxConcurrent`] = `must be a whole number between 0 and ${MAX_CONCURRENT_LIMIT}`;
107
+ }
108
+ const maxUsage = raw.maxUsagePercent ?? null;
109
+ if (maxUsage !== null && (typeof maxUsage !== 'number' || !Number.isInteger(maxUsage) || maxUsage < 1 || maxUsage > 100)) {
110
+ errors[`${adapter.id}.maxUsagePercent`] = 'must be a whole number between 1 and 100, or empty';
111
+ }
112
+ if (raw.allowOverage !== undefined && typeof raw.allowOverage !== 'boolean') {
113
+ errors[`${adapter.id}.allowOverage`] = 'must be true or false';
114
+ }
115
+ if (raw.bin !== undefined && typeof raw.bin !== 'string')
116
+ errors[`${adapter.id}.bin`] = 'must be text';
117
+ const bin = typeof raw.bin === 'string' ? raw.bin.trim() : '';
118
+ if (bin.includes(path.sep) && !path.isAbsolute(bin))
119
+ errors[`${adapter.id}.bin`] = 'must be a command name or an absolute path';
120
+ agents[adapter.id] = {
121
+ enabled: raw.enabled === true,
122
+ maxConcurrent: typeof max === 'number' ? max : 0,
123
+ maxUsagePercent: typeof maxUsage === 'number' ? maxUsage : null,
124
+ allowOverage: raw.allowOverage === true,
125
+ ...(bin ? { bin } : {}),
126
+ settings: validateSettings(adapter, raw.settings ?? {}, errors),
127
+ };
128
+ }
129
+ if (Object.keys(errors).length > 0)
130
+ return { errors };
131
+ const config = { order, agents };
132
+ db.setSetting(SETTINGS_KEY, config);
133
+ return { config };
134
+ }
135
+ export function createAvailability(adapters = ADAPTERS, probe = probeAgent) {
136
+ const results = new Map();
137
+ return {
138
+ async refresh(config) {
139
+ const probed = await Promise.all(adapters.map(async (adapter) => [adapter.id, await probe(adapter, config.agents[adapter.id]?.bin)]));
140
+ for (const [id, result] of probed)
141
+ results.set(id, result);
142
+ },
143
+ get: (id) => results.get(id),
144
+ };
145
+ }
146
+ /** Agents in preference order, combining configuration with what was found installed. */
147
+ export function resolveAgents(config, availability, adapters = ADAPTERS) {
148
+ const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
149
+ return config.order.flatMap((id) => {
150
+ const adapter = byId.get(id);
151
+ const entry = config.agents[id];
152
+ if (!adapter || !entry)
153
+ return [];
154
+ const probe = availability.get(id);
155
+ return [
156
+ {
157
+ id,
158
+ adapter,
159
+ bin: probe?.available ? probe.bin : (entry.bin ?? adapter.defaultBin),
160
+ enabled: entry.enabled,
161
+ available: probe?.available ?? false,
162
+ maxConcurrent: entry.maxConcurrent,
163
+ maxUsagePercent: entry.maxUsagePercent ?? null,
164
+ allowOverage: entry.allowOverage ?? false,
165
+ settings: entry.settings,
166
+ },
167
+ ];
168
+ });
169
+ }