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.
Files changed (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +477 -0
  4. package/bin/amicus.js +382 -0
  5. package/electron/assets/icon.png +0 -0
  6. package/electron/assets/icon.svg +5 -0
  7. package/electron/fold.js +163 -0
  8. package/electron/ipc-setup.js +176 -0
  9. package/electron/load-failsafe.js +85 -0
  10. package/electron/main.js +468 -0
  11. package/electron/preload-setup.js +38 -0
  12. package/electron/preload.js +33 -0
  13. package/electron/setup-ui-alias-script.js +218 -0
  14. package/electron/setup-ui-aliases.js +85 -0
  15. package/electron/setup-ui-keys-script.js +115 -0
  16. package/electron/setup-ui-keys.js +97 -0
  17. package/electron/setup-ui-model.js +138 -0
  18. package/electron/setup-ui-styles.js +327 -0
  19. package/electron/setup-ui.js +465 -0
  20. package/electron/summary.js +118 -0
  21. package/electron/toolbar.js +229 -0
  22. package/electron/window-position.js +35 -0
  23. package/package.json +98 -0
  24. package/scripts/postinstall.js +193 -0
  25. package/scripts/setup-hooks.js +42 -0
  26. package/skill/SKILL.md +976 -0
  27. package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +104 -0
  29. package/skills/second-opinion/SKILL.md +389 -0
  30. package/src/cli-handlers.js +188 -0
  31. package/src/cli.js +400 -0
  32. package/src/conflict.js +144 -0
  33. package/src/context-compression.js +102 -0
  34. package/src/context.js +199 -0
  35. package/src/drift.js +144 -0
  36. package/src/environment.js +157 -0
  37. package/src/headless.js +742 -0
  38. package/src/index.js +106 -0
  39. package/src/jsonl-parser.js +180 -0
  40. package/src/mcp-server.js +625 -0
  41. package/src/mcp-tools.js +407 -0
  42. package/src/opencode-client.js +615 -0
  43. package/src/prompt-builder.js +355 -0
  44. package/src/prompts/cowork-agent-prompt.js +118 -0
  45. package/src/session-manager.js +414 -0
  46. package/src/session.js +180 -0
  47. package/src/sidecar/context-builder.js +297 -0
  48. package/src/sidecar/continue.js +212 -0
  49. package/src/sidecar/crash-handler.js +56 -0
  50. package/src/sidecar/fanout-leg.js +107 -0
  51. package/src/sidecar/fanout-output.js +46 -0
  52. package/src/sidecar/fanout.js +236 -0
  53. package/src/sidecar/interactive.js +217 -0
  54. package/src/sidecar/models.js +135 -0
  55. package/src/sidecar/progress.js +218 -0
  56. package/src/sidecar/read.js +183 -0
  57. package/src/sidecar/resume.js +221 -0
  58. package/src/sidecar/session-utils.js +288 -0
  59. package/src/sidecar/setup-window.js +79 -0
  60. package/src/sidecar/setup.js +280 -0
  61. package/src/sidecar/start.js +251 -0
  62. package/src/utils/agent-mapping.js +138 -0
  63. package/src/utils/alias-audit.js +98 -0
  64. package/src/utils/alias-resolver.js +77 -0
  65. package/src/utils/api-key-store.js +259 -0
  66. package/src/utils/api-key-validation.js +97 -0
  67. package/src/utils/auth-json.js +109 -0
  68. package/src/utils/config.js +291 -0
  69. package/src/utils/curated-models.js +82 -0
  70. package/src/utils/env-compat.js +38 -0
  71. package/src/utils/env-loader.js +54 -0
  72. package/src/utils/idle-watchdog.js +225 -0
  73. package/src/utils/input-validators.js +127 -0
  74. package/src/utils/lifecycle.js +43 -0
  75. package/src/utils/logger.js +84 -0
  76. package/src/utils/mcp-discovery.js +194 -0
  77. package/src/utils/mcp-validators.js +78 -0
  78. package/src/utils/model-catalog.js +103 -0
  79. package/src/utils/model-fetcher.js +179 -0
  80. package/src/utils/model-validator.js +207 -0
  81. package/src/utils/path-setup.js +41 -0
  82. package/src/utils/port-pid.js +39 -0
  83. package/src/utils/prompt-source.js +53 -0
  84. package/src/utils/result-schema.js +261 -0
  85. package/src/utils/server-setup.js +93 -0
  86. package/src/utils/session-abort.js +53 -0
  87. package/src/utils/session-lock.js +95 -0
  88. package/src/utils/shared-server.js +216 -0
  89. package/src/utils/start-helpers.js +76 -0
  90. package/src/utils/thinking-validators.js +92 -0
  91. package/src/utils/update-notifier-loader.js +18 -0
  92. package/src/utils/updater.js +157 -0
  93. package/src/utils/validators.js +300 -0
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Amicus Config Module
3
+ *
4
+ * Config directory resolution, file I/O, model alias resolution,
5
+ * config hashing, and alias table formatting.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const { applyDirectApiFallback, autoRepairAlias } = require('./alias-resolver');
12
+ const { getCompatEnv } = require('./env-compat');
13
+
14
+ /** Default model alias map — derived from the curated-models single source (F5) */
15
+ const { toDefaultAliases } = require('./curated-models');
16
+ const DEFAULT_ALIASES = toDefaultAliases();
17
+
18
+ /** @returns {string} Config directory path */
19
+ function getConfigDir() {
20
+ const override = getCompatEnv('CONFIG_DIR');
21
+ if (override) {
22
+ const resolved = path.resolve(override);
23
+ if (resolved.includes('\0')) {
24
+ throw new Error('Invalid AMICUS_CONFIG_DIR: null bytes not allowed');
25
+ }
26
+ return resolved;
27
+ }
28
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
29
+ const amicusDir = path.join(homeDir, '.config', 'amicus');
30
+ // DEPRECATED(amicus-shim): fall back to the legacy ~/.config/sidecar dir if it
31
+ // exists and the new one does not, so pre-rebrand credentials keep working.
32
+ // Remove in a future revision — see docs/SHIMS.md.
33
+ if (!fs.existsSync(amicusDir)) {
34
+ const legacyDir = path.join(homeDir, '.config', 'sidecar');
35
+ if (fs.existsSync(legacyDir)) {
36
+ return legacyDir;
37
+ }
38
+ }
39
+ return amicusDir;
40
+ }
41
+
42
+ /** @returns {string} Full path to config.json */
43
+ function getConfigPath() {
44
+ return path.join(getConfigDir(), 'config.json');
45
+ }
46
+
47
+ /** @returns {object|null} Parsed config data, or null if missing/invalid */
48
+ function loadConfig() {
49
+ const configPath = getConfigPath();
50
+ try {
51
+ if (!fs.existsSync(configPath)) {
52
+ return null;
53
+ }
54
+ const content = fs.readFileSync(configPath, 'utf-8');
55
+ if (!content || content.trim().length === 0) {
56
+ return null;
57
+ }
58
+ return JSON.parse(content);
59
+ } catch (_err) {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Save config data to disk, creating the directory if needed. Strips invalid aliases. */
65
+ function saveConfig(configData) {
66
+ if (configData && configData.aliases) {
67
+ const cleaned = {};
68
+ for (const [key, value] of Object.entries(configData.aliases)) {
69
+ if (key === 'null' || !value || typeof value !== 'string' || value === 'null') {
70
+ process.stderr.write(
71
+ `Notice: Removing invalid alias '${key}' (value: ${JSON.stringify(value)}) from config.\n`
72
+ );
73
+ continue;
74
+ }
75
+ cleaned[key] = value;
76
+ }
77
+ configData.aliases = cleaned;
78
+ }
79
+ const configDir = getConfigDir();
80
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
81
+ const configPath = getConfigPath();
82
+ fs.writeFileSync(configPath, JSON.stringify(configData, null, 2), { mode: 0o600 });
83
+ }
84
+
85
+ /** @returns {object} Copy of the default alias map */
86
+ function getDefaultAliases() {
87
+ return { ...DEFAULT_ALIASES };
88
+ }
89
+
90
+ /**
91
+ * Resolve a model argument to a full model identifier
92
+ *
93
+ * Resolution order:
94
+ * 1. If modelArg contains '/' -> return as-is (full model string)
95
+ * 2. If modelArg is a key in config.aliases -> return resolved string
96
+ * 3. If modelArg is unknown alias -> throw Error mentioning 'sidecar setup'
97
+ * 4. If modelArg is undefined and config.default exists -> resolve that alias
98
+ * 5. If no default -> throw Error
99
+ *
100
+ * @param {string|undefined} modelArg - Model argument from CLI or undefined
101
+ * @returns {string} Resolved full model identifier
102
+ * @throws {Error} When alias is unknown or no default configured
103
+ */
104
+ function resolveModel(modelArg) {
105
+ const config = loadConfig();
106
+
107
+ const effectiveAliases = getEffectiveAliases();
108
+
109
+ // If modelArg is provided
110
+ if (modelArg !== undefined && modelArg !== null) {
111
+ // Full model string with slash - return as-is
112
+ if (modelArg.includes('/')) {
113
+ return modelArg;
114
+ }
115
+
116
+ // Try to resolve as alias (user config + defaults)
117
+ if (effectiveAliases[modelArg] !== undefined) {
118
+ const resolved = effectiveAliases[modelArg];
119
+ if (!resolved || resolved === 'null') {
120
+ return autoRepairAlias(modelArg, config, DEFAULT_ALIASES, saveConfig);
121
+ }
122
+ return applyDirectApiFallback(resolved);
123
+ }
124
+
125
+ // Unknown alias
126
+ throw new Error(
127
+ `Unknown model alias '${modelArg}'. Run 'sidecar setup' to configure aliases.`
128
+ );
129
+ }
130
+
131
+ // modelArg is undefined - use default
132
+ if (!config || !config.default) {
133
+ throw new Error(
134
+ 'No model specified and no default configured. Run \'sidecar setup\' to set a default model.'
135
+ );
136
+ }
137
+
138
+ const defaultValue = config.default;
139
+
140
+ // Default is a full model string
141
+ if (defaultValue.includes('/')) {
142
+ return defaultValue;
143
+ }
144
+
145
+ // Default is an alias - resolve via user config + defaults
146
+ if (effectiveAliases[defaultValue] !== undefined) {
147
+ const resolved = effectiveAliases[defaultValue];
148
+ if (!resolved || resolved === 'null') {
149
+ return autoRepairAlias(defaultValue, config, DEFAULT_ALIASES, saveConfig);
150
+ }
151
+ return applyDirectApiFallback(resolved);
152
+ }
153
+
154
+ // Default alias not found anywhere
155
+ throw new Error(
156
+ `Default alias '${defaultValue}' not found in aliases. Run 'sidecar setup' to fix configuration.`
157
+ );
158
+ }
159
+
160
+ /** @returns {string|null} 8-char hex hash of config file, or null if missing */
161
+ function computeConfigHash() {
162
+ const configPath = getConfigPath();
163
+ try {
164
+ if (!fs.existsSync(configPath)) {
165
+ return null;
166
+ }
167
+ const content = fs.readFileSync(configPath, 'utf-8');
168
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 8);
169
+ } catch (_err) {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /** @returns {string} Markdown alias table with (default) marker, or empty string */
175
+ function buildAliasTable() {
176
+ const config = loadConfig();
177
+ if (!config || !config.aliases || Object.keys(config.aliases).length === 0) {
178
+ return '';
179
+ }
180
+
181
+ const defaultAlias = config.default || null;
182
+ const lines = [];
183
+
184
+ lines.push('| Alias | Model |');
185
+ lines.push('|-------|-------|');
186
+
187
+ for (const [alias, model] of Object.entries(config.aliases)) {
188
+ const marker = (alias === defaultAlias) ? ' (default)' : '';
189
+ lines.push(`| ${alias}${marker} | ${model} |`);
190
+ }
191
+
192
+ return lines.join('\n');
193
+ }
194
+
195
+ /** Check whether the config file has changed compared to a known hash */
196
+ function checkConfigChanged(currentHash) {
197
+ const newHash = computeConfigHash();
198
+
199
+ if (currentHash === newHash) {
200
+ return { changed: false, newHash };
201
+ }
202
+
203
+ // Config has changed (or was created/removed)
204
+ const aliasTable = buildAliasTable();
205
+ const hashComment = newHash ? `<!-- amicus-config-hash: ${newHash} -->` : '';
206
+ const updateData = [hashComment, aliasTable].filter(Boolean).join('\n');
207
+
208
+ return {
209
+ changed: true,
210
+ newHash,
211
+ updateData: updateData || undefined,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Get effective aliases: defaults merged with user config (user wins)
217
+ * @returns {object} Merged alias map
218
+ */
219
+ function getEffectiveAliases() {
220
+ const config = loadConfig();
221
+ const userAliases = (config && config.aliases) || {};
222
+ return { ...DEFAULT_ALIASES, ...userAliases };
223
+ }
224
+
225
+ /**
226
+ * Format alias names as a comma-separated string for tool descriptions
227
+ * @returns {string} e.g. "gemini, opus, gpt, deepseek, ..."
228
+ */
229
+ function formatAliasNames() {
230
+ return Object.keys(getEffectiveAliases()).join(', ');
231
+ }
232
+
233
+ /**
234
+ * Non-throwing wrapper around resolveModel
235
+ * @param {string|undefined} modelArg - Model argument
236
+ * @returns {{model?: string, error?: string}} Resolved model or error message
237
+ */
238
+ function tryResolveModel(modelArg) {
239
+ try {
240
+ return { model: resolveModel(modelArg) };
241
+ } catch (err) {
242
+ return { error: err.message };
243
+ }
244
+ }
245
+
246
+ /** Build OpenCode provider.models config from sidecar aliases.
247
+ * @returns {object} e.g. { openrouter: { models: { "x-ai/grok-4.3": {}, ... } } } */
248
+ function buildProviderModels() {
249
+ const aliases = getEffectiveAliases();
250
+ const providers = {};
251
+
252
+ for (const fullModel of Object.values(aliases)) {
253
+ if (!fullModel || typeof fullModel !== 'string') { continue; }
254
+ const parts = fullModel.split('/');
255
+ if (parts.length < 2) { continue; }
256
+
257
+ const providerID = parts[0];
258
+ const modelID = parts.slice(1).join('/');
259
+
260
+ if (!providers[providerID]) {
261
+ providers[providerID] = { models: {} };
262
+ }
263
+ providers[providerID].models[modelID] = {};
264
+ }
265
+
266
+ return providers;
267
+ }
268
+
269
+ /** Detect if direct API fallback was applied during alias resolution */
270
+ function detectFallback(alias, resolvedModel) {
271
+ if (!alias || alias.includes('/')) { return false; }
272
+ const val = getEffectiveAliases()[alias];
273
+ return !!(val && val.startsWith('openrouter/') && !resolvedModel.startsWith('openrouter/'));
274
+ }
275
+
276
+ module.exports = {
277
+ getConfigDir,
278
+ getConfigPath,
279
+ loadConfig,
280
+ saveConfig,
281
+ getDefaultAliases,
282
+ resolveModel,
283
+ detectFallback,
284
+ computeConfigHash,
285
+ buildAliasTable,
286
+ checkConfigChanged,
287
+ getEffectiveAliases,
288
+ formatAliasNames,
289
+ tryResolveModel,
290
+ buildProviderModels,
291
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Curated Models — THE single source of truth for default model lists.
3
+ *
4
+ * Three consumers derive from this module (F5 anti-drift):
5
+ * - src/utils/config.js DEFAULT_ALIASES (toDefaultAliases)
6
+ * - electron/setup-ui-model.js MODEL_CHOICES (getCuratedModels)
7
+ * - src/sidecar/setup.js MODEL_CHOICES (getCuratedModels)
8
+ * Never hand-edit a model id anywhere else. `amicus models --check`
9
+ * audits every route here against the live catalog.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ /**
15
+ * Card entries (shown as wizard quick picks). `routes` maps provider →
16
+ * full model id; the openrouter route doubles as the default alias target.
17
+ * Direct (non-openrouter) route ids MUST be verified against the provider
18
+ * whenever they change.
19
+ */
20
+ const CARDS = [
21
+ { alias: 'gemini', label: 'Gemini 3.1 Flash Lite', blurb: 'fast, large context',
22
+ routes: { openrouter: 'openrouter/google/gemini-3.1-flash-lite-preview',
23
+ google: 'google/gemini-3.1-flash-lite-preview' } },
24
+ { alias: 'gemini-pro', label: 'Gemini 3.1 Pro', blurb: 'advanced reasoning',
25
+ routes: { openrouter: 'openrouter/google/gemini-3.1-pro-preview',
26
+ google: 'google/gemini-3.1-pro-preview' } },
27
+ { alias: 'gpt', label: 'GPT-5.4', blurb: 'strong coding',
28
+ routes: { openrouter: 'openrouter/openai/gpt-5.4',
29
+ openai: 'openai/gpt-5.4' } },
30
+ { alias: 'opus', label: 'Claude Opus 4.6', blurb: 'deep analysis',
31
+ routes: { openrouter: 'openrouter/anthropic/claude-opus-4.6',
32
+ anthropic: 'anthropic/claude-opus-4-6' } },
33
+ { alias: 'deepseek', label: 'DeepSeek v3.2', blurb: 'open-source',
34
+ routes: { openrouter: 'openrouter/deepseek/deepseek-v3.2' } },
35
+ ];
36
+
37
+ /** Alias-only entries (no wizard card); openrouter route only. */
38
+ const CARDLESS = [
39
+ { alias: 'gpt-pro', routes: { openrouter: 'openrouter/openai/gpt-5.4-pro' } },
40
+ // codex: newest codex-specific model on OpenRouter (verified 2026-06-09).
41
+ { alias: 'codex', routes: { openrouter: 'openrouter/openai/gpt-5.3-codex' } },
42
+ { alias: 'claude', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
43
+ { alias: 'sonnet', routes: { openrouter: 'openrouter/anthropic/claude-sonnet-4.6' } },
44
+ { alias: 'haiku', routes: { openrouter: 'openrouter/anthropic/claude-haiku-4.5' } },
45
+ { alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.5-397b-a17b' } },
46
+ { alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
47
+ { alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.5-flash-02-23' } },
48
+ { alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-large-2512' } },
49
+ { alias: 'devstral', routes: { openrouter: 'openrouter/mistralai/devstral-2512' } },
50
+ { alias: 'glm', routes: { openrouter: 'openrouter/z-ai/glm-5' } },
51
+ { alias: 'minimax', routes: { openrouter: 'openrouter/minimax/minimax-m2.5' } },
52
+ { alias: 'grok', routes: { openrouter: 'openrouter/x-ai/grok-4.3' } },
53
+ { alias: 'kimi', routes: { openrouter: 'openrouter/moonshotai/kimi-k2.5' } },
54
+ { alias: 'seed', routes: { openrouter: 'openrouter/bytedance-seed/seed-2.0-mini' } },
55
+ ];
56
+
57
+ /** @returns {Array<{alias,label,blurb,routes}>} card entries (wizard quick picks) */
58
+ function getCuratedModels() {
59
+ return CARDS.map(c => ({ ...c, routes: { ...c.routes } }));
60
+ }
61
+
62
+ /** @returns {Object<string,string>} alias → preferred route (openrouter first) */
63
+ function toDefaultAliases() {
64
+ const out = {};
65
+ for (const e of [...CARDS, ...CARDLESS]) {
66
+ out[e.alias] = e.routes.openrouter || Object.values(e.routes)[0];
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** @returns {Array<{alias,provider,model}>} every route of every entry, flattened */
72
+ function listCuratedRoutes() {
73
+ const out = [];
74
+ for (const e of [...CARDS, ...CARDLESS]) {
75
+ for (const [provider, model] of Object.entries(e.routes)) {
76
+ out.push({ alias: e.alias, provider, model });
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ module.exports = { getCuratedModels, toDefaultAliases, listCuratedRoutes };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Environment-variable compatibility shim (Amicus rebrand).
3
+ *
4
+ * DEPRECATED(amicus-shim): the SIDECAR_* fallbacks exist only for backward
5
+ * compatibility with pre-rebrand setups. Remove in a future revision once users
6
+ * have migrated to the AMICUS_* names. See docs/SHIMS.md.
7
+ */
8
+ const { logger } = require('./logger');
9
+
10
+ const warned = new Set();
11
+
12
+ /**
13
+ * Read an env var by its canonical AMICUS_<suffix> name, falling back to the
14
+ * legacy SIDECAR_<suffix> name (with a one-time deprecation warning) if unset.
15
+ *
16
+ * @param {string} suffix - e.g. 'CONFIG_DIR' (no AMICUS_/SIDECAR_ prefix)
17
+ * @returns {string|undefined}
18
+ */
19
+ function getCompatEnv(suffix) {
20
+ const amicusName = `AMICUS_${suffix}`;
21
+ if (process.env[amicusName] !== undefined) {
22
+ return process.env[amicusName];
23
+ }
24
+ const legacyName = `SIDECAR_${suffix}`;
25
+ if (process.env[legacyName] !== undefined) {
26
+ if (!warned.has(legacyName)) {
27
+ warned.add(legacyName);
28
+ logger.warn(
29
+ `${legacyName} is deprecated; use ${amicusName} instead. ` +
30
+ 'Support will be removed in a future Amicus release.'
31
+ );
32
+ }
33
+ return process.env[legacyName];
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ module.exports = { getCompatEnv };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Credential Loader
3
+ *
4
+ * Loads API keys from multiple sources into process.env at CLI bootstrap.
5
+ * Priority: process.env (already set) > amicus .env > auth.json
6
+ * Never overwrites existing process.env values.
7
+ */
8
+ const { logger } = require('./logger');
9
+ const { loadEnvEntries, PROVIDER_ENV_MAP, LEGACY_KEY_NAMES } = require('./api-key-store');
10
+ const { readAuthJsonKeys } = require('./auth-json');
11
+
12
+ /**
13
+ * Load credentials from all sources into process.env.
14
+ * Call once at CLI startup, before any validation.
15
+ *
16
+ * Sources (in priority order):
17
+ * 1. process.env - already set, never overwritten
18
+ * 2. ~/.config/amicus/.env - user-configured via `amicus setup`
19
+ * (DEPRECATED(amicus-shim): falls back to ~/.config/sidecar/.env if the amicus .env absent)
20
+ * 3. ~/.local/share/opencode/auth.json - OpenCode SDK fallback
21
+ */
22
+ function loadCredentials() {
23
+ // Step 1: Load from sidecar .env file
24
+ const fileEntries = loadEnvEntries();
25
+ for (const [, envVar] of Object.entries(PROVIDER_ENV_MAP)) {
26
+ if (!process.env[envVar]) {
27
+ const fromFile = fileEntries.get(envVar);
28
+ if (fromFile && fromFile.length > 0) {
29
+ process.env[envVar] = fromFile;
30
+ logger.info(`Loaded ${envVar} from amicus .env`);
31
+ }
32
+ }
33
+ }
34
+
35
+ // Step 1b: Handle legacy key names in process.env
36
+ for (const [oldName, newName] of Object.entries(LEGACY_KEY_NAMES)) {
37
+ if (process.env[oldName] && !process.env[newName]) {
38
+ process.env[newName] = process.env[oldName];
39
+ logger.info(`Migrated ${oldName} to ${newName}`);
40
+ }
41
+ }
42
+
43
+ // Step 2: Import from auth.json (lowest priority)
44
+ const authKeys = readAuthJsonKeys();
45
+ for (const [provider, key] of Object.entries(authKeys)) {
46
+ const envVar = PROVIDER_ENV_MAP[provider];
47
+ if (envVar && !process.env[envVar]) {
48
+ process.env[envVar] = key;
49
+ logger.info(`Loaded ${envVar} from auth.json`);
50
+ }
51
+ }
52
+ }
53
+
54
+ module.exports = { loadCredentials };
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @module idle-watchdog
3
+ * IdleWatchdog - BUSY/IDLE state machine with self-terminating timer.
4
+ *
5
+ * Tracks whether a sidecar session is actively processing (BUSY) or
6
+ * waiting for work (IDLE). Fires an onTimeout callback after the
7
+ * configured idle period elapses. Supports stuck-stream protection to
8
+ * force-transition out of BUSY if a response stream never completes.
9
+ *
10
+ * Timeout priority (highest to lowest):
11
+ * 1. Per-mode env var (AMICUS_IDLE_TIMEOUT_HEADLESS, etc.) in minutes
12
+ * (legacy SIDECAR_IDLE_TIMEOUT_* still honored via env-compat shim)
13
+ * 2. Blanket env var AMICUS_IDLE_TIMEOUT / SIDECAR_IDLE_TIMEOUT in minutes
14
+ * 3. Constructor option `timeout` in milliseconds
15
+ * 4. Mode default (headless=15m, interactive=60m, server=30m)
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { getCompatEnv } = require('./env-compat');
21
+
22
+ /** @type {Object.<string, number>} Default timeouts per mode in milliseconds */
23
+ const MODE_TIMEOUTS = {
24
+ headless: 15 * 60 * 1000,
25
+ interactive: 60 * 60 * 1000,
26
+ server: 30 * 60 * 1000,
27
+ };
28
+
29
+ /** @type {Object.<string, string>} Per-mode env-compat suffixes */
30
+ const MODE_ENV_MAP = {
31
+ headless: 'IDLE_TIMEOUT_HEADLESS',
32
+ interactive: 'IDLE_TIMEOUT_INTERACTIVE',
33
+ server: 'IDLE_TIMEOUT_SERVER',
34
+ };
35
+
36
+ /**
37
+ * Resolve the effective timeout in milliseconds using the priority chain.
38
+ *
39
+ * @param {string} mode - The operating mode ('headless', 'interactive', 'server')
40
+ * @param {number|undefined} optionTimeout - Caller-supplied timeout in ms (or undefined)
41
+ * @returns {number} Effective timeout in ms, or Infinity if disabled
42
+ */
43
+ function resolveTimeout(mode, optionTimeout) {
44
+ const modeSuffix = MODE_ENV_MAP[mode];
45
+ if (modeSuffix !== undefined) {
46
+ const modeEnv = getCompatEnv(modeSuffix);
47
+ if (modeEnv !== undefined) {
48
+ const mins = Number(modeEnv);
49
+ return mins === 0 ? Infinity : mins * 60 * 1000;
50
+ }
51
+ }
52
+
53
+ const blanket = getCompatEnv('IDLE_TIMEOUT');
54
+ if (blanket !== undefined) {
55
+ const mins = Number(blanket);
56
+ return mins === 0 ? Infinity : mins * 60 * 1000;
57
+ }
58
+
59
+ if (optionTimeout !== undefined) {
60
+ return optionTimeout === 0 ? Infinity : optionTimeout;
61
+ }
62
+
63
+ return MODE_TIMEOUTS[mode] || MODE_TIMEOUTS.headless;
64
+ }
65
+
66
+ /**
67
+ * IdleWatchdog - BUSY/IDLE state machine with configurable idle timeout.
68
+ *
69
+ * @example
70
+ * const wd = new IdleWatchdog({ mode: 'headless', timeout: 60000, onTimeout: () => process.exit(0) });
71
+ * wd.start();
72
+ * wd.markBusy(); // called when a stream starts
73
+ * wd.markIdle(); // called when a stream completes
74
+ * wd.touch(); // called during polling to reset the idle clock
75
+ * wd.cancel(); // stop all timers (e.g. on clean shutdown)
76
+ */
77
+ class IdleWatchdog {
78
+ /**
79
+ * @param {object} options
80
+ * @param {'headless'|'interactive'|'server'} [options.mode='headless'] - Operating mode
81
+ * @param {number} [options.timeout] - Idle timeout in ms (0 = Infinity/disabled)
82
+ * @param {number} [options.stuckStreamTimeout] - Max ms to remain in BUSY before force-idle
83
+ * @param {Function} [options.onTimeout] - Called when idle timeout fires
84
+ * @param {object} [options.logger] - Logger with .warn() and .info() (defaults to console)
85
+ */
86
+ constructor(options = {}) {
87
+ const { mode = 'headless', timeout, stuckStreamTimeout, onTimeout, logger: log } = options;
88
+
89
+ /** @type {string} Current operating mode */
90
+ this.mode = mode;
91
+
92
+ /** @type {number} Effective idle timeout in ms */
93
+ this.timeout = resolveTimeout(mode, timeout);
94
+
95
+ /** @type {number} Max ms to stay BUSY before force-transitioning to IDLE */
96
+ this.stuckStreamTimeout = stuckStreamTimeout || 5 * 60 * 1000;
97
+
98
+ /** @type {Function} Callback fired on idle timeout */
99
+ this.onTimeout = onTimeout || (() => {});
100
+
101
+ /** @type {object} Logger instance */
102
+ this.logger = log || console;
103
+
104
+ /** @type {'IDLE'|'BUSY'} Current state */
105
+ this.state = 'IDLE';
106
+
107
+ /** @type {ReturnType<typeof setTimeout>|null} Active idle countdown timer */
108
+ this._timer = null;
109
+
110
+ /** @type {ReturnType<typeof setTimeout>|null} Stuck-stream detection timer */
111
+ this._stuckTimer = null;
112
+
113
+ /** @type {number} Epoch ms when this watchdog was created */
114
+ this._startedAt = Date.now();
115
+ }
116
+
117
+ /**
118
+ * Activate the watchdog and begin the idle countdown.
119
+ *
120
+ * @returns {IdleWatchdog} Returns `this` for chaining
121
+ */
122
+ start() {
123
+ this._resetTimer();
124
+ return this;
125
+ }
126
+
127
+ /**
128
+ * Transition to BUSY state, suspending the idle timer.
129
+ * Starts the stuck-stream protection timer.
130
+ * Idempotent: calling while already BUSY is a no-op for the state,
131
+ * but does refresh the stuck timer.
132
+ */
133
+ markBusy() {
134
+ this.state = 'BUSY';
135
+ clearTimeout(this._timer);
136
+ this._timer = null;
137
+ this._startStuckTimer();
138
+ }
139
+
140
+ /**
141
+ * Transition to IDLE state and restart the idle countdown.
142
+ * Cancels the stuck-stream protection timer.
143
+ */
144
+ markIdle() {
145
+ this.state = 'IDLE';
146
+ this._clearStuckTimer();
147
+ this._resetTimer();
148
+ }
149
+
150
+ /**
151
+ * Reset the idle countdown without changing state.
152
+ * Has no effect when in BUSY state.
153
+ */
154
+ touch() {
155
+ if (this.state === 'IDLE') {
156
+ this._resetTimer();
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Cancel all active timers. The watchdog becomes inert.
162
+ * Call this on clean shutdown to prevent stray callbacks.
163
+ */
164
+ cancel() {
165
+ clearTimeout(this._timer);
166
+ this._timer = null;
167
+ this._clearStuckTimer();
168
+ }
169
+
170
+ /**
171
+ * Clear and restart the idle countdown timer.
172
+ *
173
+ * @private
174
+ */
175
+ _resetTimer() {
176
+ clearTimeout(this._timer);
177
+ if (this.timeout === Infinity) {
178
+ this._timer = null;
179
+ return;
180
+ }
181
+ this._timer = setTimeout(() => {
182
+ this.logger.info?.('Idle timeout reached', {
183
+ mode: this.mode,
184
+ uptimeMs: Date.now() - this._startedAt,
185
+ });
186
+ this.onTimeout();
187
+ }, this.timeout);
188
+ // Allow Node process to exit naturally if only this timer remains.
189
+ if (this._timer.unref) {
190
+ this._timer.unref();
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Start the stuck-stream protection timer.
196
+ * If it fires, the watchdog force-transitions to IDLE.
197
+ *
198
+ * @private
199
+ */
200
+ _startStuckTimer() {
201
+ this._clearStuckTimer();
202
+ if (this.stuckStreamTimeout === Infinity) { return; }
203
+ this._stuckTimer = setTimeout(() => {
204
+ this.logger.warn?.('Stuck stream detected, force-transitioning to IDLE', {
205
+ stuckMs: this.stuckStreamTimeout,
206
+ });
207
+ this.markIdle();
208
+ }, this.stuckStreamTimeout);
209
+ if (this._stuckTimer.unref) {
210
+ this._stuckTimer.unref();
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Clear the stuck-stream protection timer.
216
+ *
217
+ * @private
218
+ */
219
+ _clearStuckTimer() {
220
+ clearTimeout(this._stuckTimer);
221
+ this._stuckTimer = null;
222
+ }
223
+ }
224
+
225
+ module.exports = { IdleWatchdog, resolveTimeout };