amicus 1.0.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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/bin/amicus.js +382 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +5 -0
- package/electron/fold.js +163 -0
- package/electron/ipc-setup.js +176 -0
- package/electron/load-failsafe.js +85 -0
- package/electron/main.js +468 -0
- package/electron/preload-setup.js +38 -0
- package/electron/preload.js +33 -0
- package/electron/setup-ui-alias-script.js +218 -0
- package/electron/setup-ui-aliases.js +85 -0
- package/electron/setup-ui-keys-script.js +115 -0
- package/electron/setup-ui-keys.js +97 -0
- package/electron/setup-ui-model.js +138 -0
- package/electron/setup-ui-styles.js +327 -0
- package/electron/setup-ui.js +465 -0
- package/electron/summary.js +118 -0
- package/electron/toolbar.js +229 -0
- package/electron/window-position.js +35 -0
- package/package.json +98 -0
- package/scripts/postinstall.js +193 -0
- package/scripts/setup-hooks.js +42 -0
- package/skill/SKILL.md +976 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
- package/skills/second-opinion/MODEL-NOTES.md +104 -0
- package/skills/second-opinion/SKILL.md +389 -0
- package/src/cli-handlers.js +188 -0
- package/src/cli.js +400 -0
- package/src/conflict.js +144 -0
- package/src/context-compression.js +102 -0
- package/src/context.js +199 -0
- package/src/drift.js +144 -0
- package/src/environment.js +157 -0
- package/src/headless.js +742 -0
- package/src/index.js +106 -0
- package/src/jsonl-parser.js +180 -0
- package/src/mcp-server.js +625 -0
- package/src/mcp-tools.js +407 -0
- package/src/opencode-client.js +615 -0
- package/src/prompt-builder.js +355 -0
- package/src/prompts/cowork-agent-prompt.js +118 -0
- package/src/session-manager.js +414 -0
- package/src/session.js +180 -0
- package/src/sidecar/context-builder.js +297 -0
- package/src/sidecar/continue.js +212 -0
- package/src/sidecar/crash-handler.js +56 -0
- package/src/sidecar/fanout-leg.js +107 -0
- package/src/sidecar/fanout-output.js +46 -0
- package/src/sidecar/fanout.js +236 -0
- package/src/sidecar/interactive.js +217 -0
- package/src/sidecar/models.js +135 -0
- package/src/sidecar/progress.js +218 -0
- package/src/sidecar/read.js +183 -0
- package/src/sidecar/resume.js +221 -0
- package/src/sidecar/session-utils.js +288 -0
- package/src/sidecar/setup-window.js +79 -0
- package/src/sidecar/setup.js +280 -0
- package/src/sidecar/start.js +251 -0
- package/src/utils/agent-mapping.js +138 -0
- package/src/utils/alias-audit.js +98 -0
- package/src/utils/alias-resolver.js +77 -0
- package/src/utils/api-key-store.js +259 -0
- package/src/utils/api-key-validation.js +97 -0
- package/src/utils/auth-json.js +109 -0
- package/src/utils/config.js +291 -0
- package/src/utils/curated-models.js +82 -0
- package/src/utils/env-compat.js +38 -0
- package/src/utils/env-loader.js +54 -0
- package/src/utils/idle-watchdog.js +225 -0
- package/src/utils/input-validators.js +127 -0
- package/src/utils/lifecycle.js +43 -0
- package/src/utils/logger.js +84 -0
- package/src/utils/mcp-discovery.js +194 -0
- package/src/utils/mcp-validators.js +78 -0
- package/src/utils/model-catalog.js +103 -0
- package/src/utils/model-fetcher.js +179 -0
- package/src/utils/model-validator.js +207 -0
- package/src/utils/path-setup.js +41 -0
- package/src/utils/port-pid.js +39 -0
- package/src/utils/prompt-source.js +53 -0
- package/src/utils/result-schema.js +261 -0
- package/src/utils/server-setup.js +93 -0
- package/src/utils/session-abort.js +53 -0
- package/src/utils/session-lock.js +95 -0
- package/src/utils/shared-server.js +216 -0
- package/src/utils/start-helpers.js +76 -0
- package/src/utils/thinking-validators.js +92 -0
- package/src/utils/update-notifier-loader.js +18 -0
- package/src/utils/updater.js +157 -0
- package/src/utils/validators.js +300 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar Start Operations - Handles starting new sidecar sessions
|
|
3
|
+
* Spec Reference: §4.1, §9
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
|
|
9
|
+
const { buildContext } = require('./context-builder');
|
|
10
|
+
const {
|
|
11
|
+
SessionPaths,
|
|
12
|
+
saveInitialContext,
|
|
13
|
+
finalizeSession,
|
|
14
|
+
outputSummary,
|
|
15
|
+
createHeartbeat,
|
|
16
|
+
HEARTBEAT_INTERVAL
|
|
17
|
+
} = require('./session-utils');
|
|
18
|
+
const { runInteractive, checkElectronAvailable } = require('./interactive');
|
|
19
|
+
const { buildPrompts } = require('../prompt-builder');
|
|
20
|
+
const { runHeadless } = require('../headless');
|
|
21
|
+
const { logger } = require('../utils/logger');
|
|
22
|
+
const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
23
|
+
const { loadMcpConfig, parseMcpSpec } = require('../opencode-client');
|
|
24
|
+
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
25
|
+
const { discoverParentMcps } = require('../utils/mcp-discovery');
|
|
26
|
+
|
|
27
|
+
/** Generate a unique 8-character hex task ID */
|
|
28
|
+
function generateTaskId() {
|
|
29
|
+
return crypto.randomBytes(4).toString('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Create session directory and save metadata */
|
|
33
|
+
function createSessionMetadata(taskId, project, options) {
|
|
34
|
+
const { model, prompt, briefing, noUi, headless, agent, thinking } = options;
|
|
35
|
+
|
|
36
|
+
const sessionDir = SessionPaths.sessionDir(project, taskId);
|
|
37
|
+
fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
38
|
+
|
|
39
|
+
const effectiveBriefing = prompt || briefing;
|
|
40
|
+
const isHeadless = noUi !== undefined ? noUi : headless;
|
|
41
|
+
|
|
42
|
+
// Preserve fields from existing metadata (e.g., pid written by MCP handler)
|
|
43
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
44
|
+
let existing = {};
|
|
45
|
+
if (fs.existsSync(metaPath)) {
|
|
46
|
+
try {
|
|
47
|
+
existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore corrupt metadata
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const metadata = {
|
|
54
|
+
...existing,
|
|
55
|
+
taskId,
|
|
56
|
+
model,
|
|
57
|
+
project,
|
|
58
|
+
briefing: effectiveBriefing,
|
|
59
|
+
mode: isHeadless ? 'headless' : 'interactive',
|
|
60
|
+
agent: agent || (isHeadless ? 'build' : 'chat'),
|
|
61
|
+
thinking: thinking || 'medium',
|
|
62
|
+
status: 'running',
|
|
63
|
+
pid: existing.pid || process.pid,
|
|
64
|
+
createdAt: existing.createdAt || new Date().toISOString()
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
68
|
+
|
|
69
|
+
return sessionDir;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build MCP configuration from options.
|
|
74
|
+
* Merge priority: CLI --mcp > --mcp-config > file config > discovered parent MCPs
|
|
75
|
+
*
|
|
76
|
+
* @param {object} options
|
|
77
|
+
* @param {string} [options.mcp] - CLI --mcp spec
|
|
78
|
+
* @param {string} [options.mcpConfig] - CLI --mcp-config path
|
|
79
|
+
* @param {string} [options.clientType] - Parent client type for discovery
|
|
80
|
+
* @param {boolean} [options.noMcp] - Skip MCP inheritance from parent
|
|
81
|
+
* @param {string[]} [options.excludeMcp] - Server names to exclude
|
|
82
|
+
* @returns {object|null} MCP server configs or null
|
|
83
|
+
*/
|
|
84
|
+
function buildMcpConfig(options) {
|
|
85
|
+
const { mcp, mcpConfig, clientType, noMcp, excludeMcp } = options;
|
|
86
|
+
let mcpServers = null;
|
|
87
|
+
|
|
88
|
+
// Layer 1: Discover parent MCPs (unless --no-mcp)
|
|
89
|
+
if (!noMcp) {
|
|
90
|
+
const discovered = discoverParentMcps(clientType);
|
|
91
|
+
if (discovered) {
|
|
92
|
+
mcpServers = { ...discovered };
|
|
93
|
+
logger.info('Discovered parent MCP servers', { serverCount: Object.keys(mcpServers).length });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Layer 2: File config (opencode.json) overrides discovered
|
|
98
|
+
const fileConfig = loadMcpConfig(mcpConfig);
|
|
99
|
+
if (fileConfig) {
|
|
100
|
+
mcpServers = mcpServers ? { ...mcpServers, ...fileConfig } : { ...fileConfig };
|
|
101
|
+
logger.debug('Loaded MCP config from file', { serverCount: Object.keys(fileConfig).length });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Layer 3: CLI --mcp (highest priority)
|
|
105
|
+
if (mcp) {
|
|
106
|
+
const parsed = parseMcpSpec(mcp);
|
|
107
|
+
if (parsed) {
|
|
108
|
+
mcpServers = mcpServers || {};
|
|
109
|
+
mcpServers[parsed.name] = parsed.config;
|
|
110
|
+
logger.debug('Added CLI MCP server', { name: parsed.name });
|
|
111
|
+
} else {
|
|
112
|
+
logger.warn('Invalid MCP server spec', { mcp });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Always exclude the sidecar itself to prevent recursive spawning.
|
|
117
|
+
// When launched from Cowork, the discovered MCP list includes "sidecar"
|
|
118
|
+
// which would cause an infinite spawn loop.
|
|
119
|
+
if (mcpServers && mcpServers.sidecar) {
|
|
120
|
+
delete mcpServers.sidecar;
|
|
121
|
+
logger.debug('Auto-excluded sidecar MCP (recursive spawn prevention)');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Apply explicit exclusions
|
|
125
|
+
if (excludeMcp && Array.isArray(excludeMcp) && mcpServers) {
|
|
126
|
+
for (const name of excludeMcp) {
|
|
127
|
+
if (mcpServers[name]) {
|
|
128
|
+
delete mcpServers[name];
|
|
129
|
+
logger.debug('Excluded MCP server', { name });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Return null if all servers were excluded
|
|
135
|
+
if (mcpServers && Object.keys(mcpServers).length === 0) {
|
|
136
|
+
mcpServers = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return mcpServers;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Start a new sidecar session - Spec Reference: §4.1, §9 */
|
|
143
|
+
async function startSidecar(options) {
|
|
144
|
+
const {
|
|
145
|
+
model, prompt, briefing, sessionId, session = 'current',
|
|
146
|
+
cwd, project = process.cwd(), contextTurns = 50, contextSince,
|
|
147
|
+
contextMaxTokens = 80000, noUi, headless = false, timeout = 15,
|
|
148
|
+
agent, mcp, mcpConfig, summaryLength = 'normal', thinking,
|
|
149
|
+
client, sessionDir, noMcp, excludeMcp, opencodePort, coworkProcess, includeContext = true,
|
|
150
|
+
position = 'right', json = false, modelInput = null
|
|
151
|
+
} = options;
|
|
152
|
+
|
|
153
|
+
const effectivePrompt = prompt || briefing;
|
|
154
|
+
const effectiveSession = sessionId || session;
|
|
155
|
+
const effectiveProject = cwd || project;
|
|
156
|
+
const effectiveHeadless = noUi !== undefined ? noUi : headless;
|
|
157
|
+
const mcpServers = buildMcpConfig({ mcp, mcpConfig, clientType: client, noMcp, excludeMcp });
|
|
158
|
+
const taskId = options.taskId || generateTaskId();
|
|
159
|
+
const reasoning = thinking ? { effort: thinking } : undefined;
|
|
160
|
+
|
|
161
|
+
logger.info('Starting task', { taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive' });
|
|
162
|
+
|
|
163
|
+
const context = includeContext !== false
|
|
164
|
+
? buildContext(effectiveProject, effectiveSession, { contextTurns, contextSince, contextMaxTokens, sessionDir, client, coworkProcess })
|
|
165
|
+
: '[Context excluded by caller - briefing is self-contained]';
|
|
166
|
+
const { system: systemPrompt, userMessage } = buildPrompts(
|
|
167
|
+
effectivePrompt, context, effectiveProject, effectiveHeadless, agent, summaryLength, client
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const sessDir = createSessionMetadata(taskId, effectiveProject, {
|
|
171
|
+
model, prompt: effectivePrompt, noUi: effectiveHeadless, agent, thinking
|
|
172
|
+
});
|
|
173
|
+
saveInitialContext(sessDir, systemPrompt, userMessage);
|
|
174
|
+
acquireLock(sessDir, effectiveHeadless ? 'headless' : 'interactive');
|
|
175
|
+
|
|
176
|
+
const heartbeat = createHeartbeat(HEARTBEAT_INTERVAL, sessDir);
|
|
177
|
+
let summary;
|
|
178
|
+
let result;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
if (effectiveHeadless) {
|
|
182
|
+
try {
|
|
183
|
+
result = await runHeadless(
|
|
184
|
+
model, systemPrompt, userMessage, taskId, effectiveProject,
|
|
185
|
+
timeout * 60 * 1000, agent || 'build', { mcp: mcpServers, summaryLength, reasoning, port: opencodePort }
|
|
186
|
+
);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (!json) { throw err; }
|
|
189
|
+
// --json contract: stdout must always carry a parseable run doc,
|
|
190
|
+
// even when the engine throws rather than returning {error}.
|
|
191
|
+
result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId };
|
|
192
|
+
}
|
|
193
|
+
summary = result.summary || '## Sidecar Results: No Output\n\nHeadless mode completed without summary.';
|
|
194
|
+
if (result.timedOut) { logger.warn('Task timed out', { taskId }); }
|
|
195
|
+
if (result.error) { logger.error('Task error', { taskId, error: result.error }); }
|
|
196
|
+
} else {
|
|
197
|
+
const effectiveAgent = mapAgentToOpenCode(agent).agent;
|
|
198
|
+
logger.info('Launching interactive sidecar', { taskId, model, agent: effectiveAgent });
|
|
199
|
+
result = await runInteractive(
|
|
200
|
+
model, systemPrompt, userMessage, taskId, effectiveProject,
|
|
201
|
+
{ agent, mcp: mcpServers, reasoning, client, windowPosition: position }
|
|
202
|
+
);
|
|
203
|
+
summary = result.summary || '';
|
|
204
|
+
if (result.error) { logger.error('Interactive task error', { taskId, error: result.error }); }
|
|
205
|
+
}
|
|
206
|
+
} finally {
|
|
207
|
+
heartbeat.stop();
|
|
208
|
+
releaseLock(sessDir);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!json) { outputSummary(summary); }
|
|
212
|
+
const metaPath = SessionPaths.metadataFile(sessDir);
|
|
213
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
214
|
+
|
|
215
|
+
// Persist OpenCode session ID for resume capability
|
|
216
|
+
if (result && result.opencodeSessionId) {
|
|
217
|
+
meta.opencodeSessionId = result.opencodeSessionId;
|
|
218
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Mark error results as 'error' instead of 'complete'
|
|
222
|
+
if (result && result.error) {
|
|
223
|
+
meta.status = 'error';
|
|
224
|
+
meta.reason = result.error;
|
|
225
|
+
meta.completedAt = new Date().toISOString();
|
|
226
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
227
|
+
logger.error('Session completed with error', { taskId, error: result.error });
|
|
228
|
+
} else {
|
|
229
|
+
finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (json) {
|
|
233
|
+
const { buildRunResult } = require('../utils/result-schema');
|
|
234
|
+
const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
235
|
+
const doc = buildRunResult({
|
|
236
|
+
taskId, metadata: finalMeta, result, summary,
|
|
237
|
+
modelInput, sessionDir: sessDir,
|
|
238
|
+
});
|
|
239
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
generateTaskId,
|
|
245
|
+
createSessionMetadata,
|
|
246
|
+
buildMcpConfig,
|
|
247
|
+
checkElectronAvailable,
|
|
248
|
+
runInteractive,
|
|
249
|
+
startSidecar,
|
|
250
|
+
HEARTBEAT_INTERVAL
|
|
251
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Mapping Module
|
|
3
|
+
*
|
|
4
|
+
* Maps agent names to OpenCode's native agent framework.
|
|
5
|
+
*
|
|
6
|
+
* OpenCode Native Agents (https://opencode.ai/docs/agents/):
|
|
7
|
+
* - Chat: Reads auto, writes/bash ask permission (interactive default)
|
|
8
|
+
* - Build: Default primary agent with full tool access
|
|
9
|
+
* - Plan: Read-only agent for analysis and planning
|
|
10
|
+
* - General: Full-access agent for research
|
|
11
|
+
* - Explore: Read-only agent for codebase exploration
|
|
12
|
+
*
|
|
13
|
+
* Custom agents defined in ~/.config/opencode/agents/ or .opencode/agents/
|
|
14
|
+
* are passed through directly to OpenCode.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* OpenCode's primary agent names (for main sessions)
|
|
19
|
+
* Note: OpenCode API expects lowercase agent names
|
|
20
|
+
* 'chat' is a custom sidecar agent: reads auto, writes/bash ask. When client=cowork, gets a general-purpose prompt.
|
|
21
|
+
*/
|
|
22
|
+
const PRIMARY_AGENTS = ['chat', 'build', 'plan'];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* All OpenCode native agent names (lowercase)
|
|
26
|
+
*/
|
|
27
|
+
const OPENCODE_AGENTS = [...PRIMARY_AGENTS, 'general', 'explore'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Agents safe for headless (--no-ui) mode.
|
|
31
|
+
* 'chat' requires user permission for writes/bash and stalls in headless.
|
|
32
|
+
*/
|
|
33
|
+
const HEADLESS_SAFE_AGENTS = ['build', 'plan', 'explore', 'general'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Map an agent name to OpenCode native agent configuration
|
|
37
|
+
*
|
|
38
|
+
* @param {string} agent - Agent name (OpenCode native or custom)
|
|
39
|
+
* @returns {{agent: string}} OpenCode agent configuration
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* mapAgentToOpenCode('Build') // { agent: 'build' }
|
|
43
|
+
* mapAgentToOpenCode('Plan') // { agent: 'plan' }
|
|
44
|
+
* mapAgentToOpenCode('custom') // { agent: 'custom' }
|
|
45
|
+
*/
|
|
46
|
+
function mapAgentToOpenCode(agent) {
|
|
47
|
+
// Handle undefined/null/empty - default to chat (reads auto, writes ask)
|
|
48
|
+
if (!agent || (typeof agent === 'string' && agent.trim() === '')) {
|
|
49
|
+
return { agent: 'chat' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Normalize for case-insensitive matching of native agents
|
|
53
|
+
const normalized = agent.toLowerCase();
|
|
54
|
+
|
|
55
|
+
// Check if it's an OpenCode native agent (case-insensitive match)
|
|
56
|
+
const nativeMatch = OPENCODE_AGENTS.find(
|
|
57
|
+
native => native === normalized
|
|
58
|
+
);
|
|
59
|
+
if (nativeMatch) {
|
|
60
|
+
return { agent: nativeMatch };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pass through custom agent names as lowercase (OpenCode API expects lowercase)
|
|
64
|
+
return { agent: normalized };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if an agent is safe for headless (--no-ui) mode
|
|
69
|
+
*
|
|
70
|
+
* @param {string} agent - Agent name to check
|
|
71
|
+
* @returns {boolean|null} true if safe, false if unsafe (chat), null if unknown/custom
|
|
72
|
+
*/
|
|
73
|
+
function isHeadlessSafe(agent) {
|
|
74
|
+
if (!agent || (typeof agent === 'string' && agent.trim() === '')) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const normalized = agent.toLowerCase();
|
|
79
|
+
|
|
80
|
+
if (HEADLESS_SAFE_AGENTS.includes(normalized)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (normalized === 'chat') {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Unknown/custom agents — we can't determine safety
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if an agent name is valid for primary sessions
|
|
94
|
+
*
|
|
95
|
+
* @param {string} agent - Agent name to validate
|
|
96
|
+
* @returns {boolean} True if valid primary agent or custom agent
|
|
97
|
+
*/
|
|
98
|
+
function isValidPrimaryAgent(agent) {
|
|
99
|
+
if (!isValidAgent(agent)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// All non-empty strings are valid (custom agents allowed)
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if an agent name is valid (non-empty string)
|
|
109
|
+
*
|
|
110
|
+
* All non-empty agent names are considered valid because:
|
|
111
|
+
* 1. OpenCode native agents (Build, Plan, General, Explore) are always valid
|
|
112
|
+
* 2. Custom agents defined in user's agent directory should be allowed
|
|
113
|
+
* (OpenCode will validate at runtime)
|
|
114
|
+
*
|
|
115
|
+
* @param {string} agent - Agent name to validate
|
|
116
|
+
* @returns {boolean} True if valid (non-empty string)
|
|
117
|
+
*/
|
|
118
|
+
function isValidAgent(agent) {
|
|
119
|
+
if (agent === null || agent === undefined) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof agent !== 'string') {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return agent.trim().length > 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
PRIMARY_AGENTS,
|
|
132
|
+
OPENCODE_AGENTS,
|
|
133
|
+
HEADLESS_SAFE_AGENTS,
|
|
134
|
+
mapAgentToOpenCode,
|
|
135
|
+
isValidAgent,
|
|
136
|
+
isHeadlessSafe,
|
|
137
|
+
isValidPrimaryAgent
|
|
138
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias Audit (F5) — report + suggest, never auto-repair.
|
|
3
|
+
*
|
|
4
|
+
* Finds aliases/routes pointing at models absent from the catalog and
|
|
5
|
+
* suggests current same-vendor replacements. Pure functions over inputs;
|
|
6
|
+
* collectAliasSources() does the gathering. Consumed by `amicus models
|
|
7
|
+
* --check` and the npm wrapper scripts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @returns {Array<{alias,model,source}>} every alias mapping we ship or the user set
|
|
14
|
+
* Identical (alias, model) pairs are deduped, first source wins — defaults derive from curated
|
|
15
|
+
* routes, so every default would otherwise double-report. A user --add-alias override creates a
|
|
16
|
+
* distinct user-config row; shipped defaults/curated routes stay audited until curated-models.js
|
|
17
|
+
* itself is updated.
|
|
18
|
+
*/
|
|
19
|
+
function collectAliasSources() {
|
|
20
|
+
const { getDefaultAliases, loadConfig } = require('./config');
|
|
21
|
+
const { listCuratedRoutes } = require('./curated-models');
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const [alias, model] of Object.entries(getDefaultAliases())) {
|
|
24
|
+
out.push({ alias, model, source: 'defaults' });
|
|
25
|
+
}
|
|
26
|
+
const cfg = loadConfig();
|
|
27
|
+
for (const [alias, model] of Object.entries((cfg && cfg.aliases) || {})) {
|
|
28
|
+
if (typeof model === 'string' && model) {
|
|
29
|
+
out.push({ alias, model, source: 'user-config' });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const r of listCuratedRoutes()) {
|
|
33
|
+
out.push({ alias: r.alias, model: r.model, source: `curated-route (${r.provider})` });
|
|
34
|
+
}
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
return out.filter(({ alias, model }) => {
|
|
37
|
+
const key = `${alias} ${model}`;
|
|
38
|
+
if (seen.has(key)) { return false; }
|
|
39
|
+
seen.add(key);
|
|
40
|
+
return true;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Catalog ids grouped by leading provider segment, e.g. 'openrouter', 'google'. */
|
|
45
|
+
function idsByProvider(catalog) {
|
|
46
|
+
const map = new Map();
|
|
47
|
+
for (const m of catalog) {
|
|
48
|
+
if (!(m && typeof m.id === 'string')) { continue; }
|
|
49
|
+
const provider = m.id.split('/')[0];
|
|
50
|
+
if (!map.has(provider)) { map.set(provider, new Set()); }
|
|
51
|
+
map.get(provider).add(m.id);
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Entries whose model is absent from the catalog. A model is only checkable
|
|
58
|
+
* when its provider has rows in the catalog (unkeyed providers never produce
|
|
59
|
+
* false stales). Empty catalog → [] (cannot check).
|
|
60
|
+
* @param {Array<{alias,model,source}>} sources
|
|
61
|
+
* @param {Array<{id:string}>} catalog
|
|
62
|
+
*/
|
|
63
|
+
function findStaleAliases(sources, catalog) {
|
|
64
|
+
if (!catalog || catalog.length === 0) { return []; }
|
|
65
|
+
const byProvider = idsByProvider(catalog);
|
|
66
|
+
return sources.filter(({ model }) => {
|
|
67
|
+
const provider = model.split('/')[0];
|
|
68
|
+
const ids = byProvider.get(provider);
|
|
69
|
+
if (!ids) { return false; } // provider unverifiable
|
|
70
|
+
return !ids.has(model);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Same-vendor replacement candidates for a stale model id, ranked by
|
|
76
|
+
* shared-prefix length with the stale id (desc), then id descending so
|
|
77
|
+
* higher version numbers sort first. Deterministic; max n.
|
|
78
|
+
* @param {string} staleModel - e.g. 'openrouter/x-ai/grok-4.1-fast'
|
|
79
|
+
* @param {Array<{id:string}>} catalog
|
|
80
|
+
* @param {number} [n=3]
|
|
81
|
+
* @returns {string[]} candidate ids
|
|
82
|
+
*/
|
|
83
|
+
function suggestReplacements(staleModel, catalog, n = 3) {
|
|
84
|
+
const vendorPrefix = staleModel.split('/').slice(0, -1).join('/') + '/';
|
|
85
|
+
const sharedLen = (a, b) => {
|
|
86
|
+
let i = 0;
|
|
87
|
+
while (i < a.length && i < b.length && a[i] === b[i]) { i++; }
|
|
88
|
+
return i;
|
|
89
|
+
};
|
|
90
|
+
return catalog
|
|
91
|
+
.filter(m => m && typeof m.id === 'string').map(m => m.id)
|
|
92
|
+
.filter(id => id.startsWith(vendorPrefix) && id !== staleModel)
|
|
93
|
+
.sort((a, b) =>
|
|
94
|
+
(sharedLen(b, staleModel) - sharedLen(a, staleModel)) || b.localeCompare(a, 'en', { numeric: true }))
|
|
95
|
+
.slice(0, n);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { collectAliasSources, findStaleAliases, suggestReplacements };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias Resolver Utilities
|
|
3
|
+
*
|
|
4
|
+
* Handles alias auto-repair and direct API fallback logic,
|
|
5
|
+
* extracted from config.js to keep it under the 300-line limit.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { PROVIDER_ENV_MAP, readApiKeyValues } = require('./api-key-store');
|
|
9
|
+
const { logger } = require('./logger');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Strip openrouter/ prefix when direct provider API key is available
|
|
13
|
+
* but OPENROUTER_API_KEY is not.
|
|
14
|
+
* @param {string} model - Full model identifier
|
|
15
|
+
* @returns {string} Model with or without openrouter/ prefix
|
|
16
|
+
*/
|
|
17
|
+
function applyDirectApiFallback(model) {
|
|
18
|
+
if (!model.startsWith('openrouter/')) {
|
|
19
|
+
return model;
|
|
20
|
+
}
|
|
21
|
+
const persistedKeys = readApiKeyValues();
|
|
22
|
+
if (process.env.OPENROUTER_API_KEY || persistedKeys.openrouter) {
|
|
23
|
+
return model;
|
|
24
|
+
}
|
|
25
|
+
const direct = model.slice('openrouter/'.length);
|
|
26
|
+
const provider = direct.split('/')[0];
|
|
27
|
+
const envVar = PROVIDER_ENV_MAP[provider];
|
|
28
|
+
if (envVar && (process.env[envVar] || persistedKeys[provider])) {
|
|
29
|
+
logger.warn({ msg: 'Using direct provider API (OPENROUTER_API_KEY not set)', original: model, resolved: direct });
|
|
30
|
+
process.stderr.write(
|
|
31
|
+
`Notice: Using direct ${provider} API (OPENROUTER_API_KEY not set). ` +
|
|
32
|
+
'Model availability is validated automatically; pass --no-validate-model to skip.\n'
|
|
33
|
+
);
|
|
34
|
+
return direct;
|
|
35
|
+
}
|
|
36
|
+
return model;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Auto-repair a null alias by falling back to DEFAULT_ALIASES.
|
|
41
|
+
* Updates config on disk and warns to stderr.
|
|
42
|
+
* @param {string} alias - The alias name with null value
|
|
43
|
+
* @param {object|null} config - Current config object
|
|
44
|
+
* @param {object} defaultAliases - DEFAULT_ALIASES map
|
|
45
|
+
* @param {Function} saveConfig - saveConfig function reference
|
|
46
|
+
* @returns {string} Repaired model string
|
|
47
|
+
* @throws {Error} If no default exists for this alias
|
|
48
|
+
*/
|
|
49
|
+
function autoRepairAlias(alias, config, defaultAliases, saveConfig) {
|
|
50
|
+
const defaultModel = defaultAliases[alias];
|
|
51
|
+
if (defaultModel) {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`Notice: Auto-repaired null alias '${alias}' -> '${defaultModel}'\n`
|
|
54
|
+
);
|
|
55
|
+
if (config && config.aliases) {
|
|
56
|
+
config.aliases[alias] = defaultModel;
|
|
57
|
+
try {
|
|
58
|
+
saveConfig(config);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`Notice: Could not persist repaired alias '${alias}' (${err.message}). ` +
|
|
62
|
+
'Using default for this session only.\n'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return applyDirectApiFallback(defaultModel);
|
|
67
|
+
}
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Alias '${alias}' is configured but has no model value. ` +
|
|
70
|
+
`Fix with: sidecar setup --add-alias ${alias}=provider/model`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
applyDirectApiFallback,
|
|
76
|
+
autoRepairAlias,
|
|
77
|
+
};
|