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,179 @@
1
+ /**
2
+ * Model Fetcher
3
+ *
4
+ * Fetches available model lists from provider APIs for the dropdown selector.
5
+ * Uses the same HTTPS pattern as api-key-store.js validateApiKey().
6
+ */
7
+
8
+ const https = require('https');
9
+
10
+ /** Hardcoded Anthropic models (no public listing endpoint) */
11
+ const ANTHROPIC_MODELS = [
12
+ { id: 'anthropic/claude-opus-4-6', name: 'Claude Opus 4.6', contextLength: null, pricing: null },
13
+ { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextLength: null, pricing: null },
14
+ { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', contextLength: null, pricing: null },
15
+ { id: 'anthropic/claude-sonnet-4-5', name: 'Claude Sonnet 4.5', contextLength: null, pricing: null },
16
+ { id: 'anthropic/claude-3-5-haiku', name: 'Claude 3.5 Haiku', contextLength: null, pricing: null }
17
+ ];
18
+
19
+ const PROVIDER_FAMILY_NAMES = {
20
+ openrouter: 'OpenRouter',
21
+ google: 'Google',
22
+ openai: 'OpenAI',
23
+ anthropic: 'Anthropic'
24
+ };
25
+
26
+ /** Provider API configs for fetching model lists */
27
+ const PROVIDER_FETCH_CONFIG = {
28
+ openrouter: {
29
+ url: 'https://openrouter.ai/api/v1/models',
30
+ // Public endpoint: works keyless; attach auth only when a key exists (F5).
31
+ authHeader: (key) => (key ? { 'Authorization': `Bearer ${key}` } : {}),
32
+ normalize: (body) => {
33
+ const data = JSON.parse(body);
34
+ return (data.data || []).map(m => ({
35
+ id: `openrouter/${m.id}`,
36
+ name: m.name || m.id,
37
+ contextLength: m.context_length ?? null,
38
+ pricing: m.pricing
39
+ ? { prompt: m.pricing.prompt ?? null,
40
+ completion: m.pricing.completion ?? null }
41
+ : null
42
+ }));
43
+ }
44
+ },
45
+ google: {
46
+ url: null, // built dynamically with key
47
+ authHeader: () => ({}),
48
+ buildUrl: (key) => `https://generativelanguage.googleapis.com/v1beta/models?key=${key}`,
49
+ normalize: (body) => {
50
+ const data = JSON.parse(body);
51
+ return (data.models || []).map(m => ({
52
+ id: `google/${m.name.replace('models/', '')}`,
53
+ name: m.displayName || m.name.replace('models/', ''),
54
+ contextLength: m.inputTokenLimit ?? null,
55
+ pricing: null
56
+ }));
57
+ }
58
+ },
59
+ openai: {
60
+ url: 'https://api.openai.com/v1/models',
61
+ authHeader: (key) => ({ 'Authorization': `Bearer ${key}` }),
62
+ normalize: (body) => {
63
+ const data = JSON.parse(body);
64
+ return (data.data || []).map(m => ({
65
+ id: `openai/${m.id}`,
66
+ name: m.id,
67
+ contextLength: null,
68
+ pricing: null
69
+ }));
70
+ }
71
+ }
72
+ };
73
+
74
+ const FETCH_TIMEOUT_MS = 5000;
75
+
76
+ /**
77
+ * Fetch models from a single provider API
78
+ * @param {string} provider - Provider name (openrouter, google, openai, anthropic)
79
+ * @param {string} key - API key
80
+ * @returns {Promise<Array<{id: string, name: string, contextLength: number|null, pricing: {prompt: string|null, completion: string|null}|null}>>} Normalized model list
81
+ */
82
+ function fetchModelsFromProvider(provider, key) {
83
+ if (provider === 'anthropic') {
84
+ return Promise.resolve(ANTHROPIC_MODELS);
85
+ }
86
+
87
+ const config = PROVIDER_FETCH_CONFIG[provider];
88
+ if (!config) {
89
+ return Promise.resolve([]);
90
+ }
91
+
92
+ const url = config.buildUrl ? config.buildUrl(key) : config.url;
93
+ const headers = config.authHeader(key);
94
+
95
+ return new Promise((resolve) => {
96
+ let chunks = '';
97
+ const timer = setTimeout(() => {
98
+ req.destroy();
99
+ resolve([]);
100
+ }, FETCH_TIMEOUT_MS);
101
+
102
+ const req = https.get(url, { headers }, (res) => {
103
+ if (res.statusCode !== 200) {
104
+ clearTimeout(timer);
105
+ res.on('data', () => {});
106
+ res.on('end', () => resolve([]));
107
+ return;
108
+ }
109
+ res.on('data', (chunk) => { chunks += chunk; });
110
+ res.on('end', () => {
111
+ clearTimeout(timer);
112
+ try {
113
+ resolve(config.normalize(chunks));
114
+ } catch (_err) {
115
+ resolve([]);
116
+ }
117
+ });
118
+ });
119
+ req.on('error', () => {
120
+ clearTimeout(timer);
121
+ resolve([]);
122
+ });
123
+ });
124
+ }
125
+
126
+ /** Providers to fetch: every keyed provider + openrouter (keyless-capable) + anthropic. */
127
+ function providersToFetch(keys) {
128
+ const set = new Set(Object.keys(keys).filter(p => keys[p]));
129
+ set.add('openrouter');
130
+ set.add('anthropic');
131
+ return Array.from(set);
132
+ }
133
+
134
+ /**
135
+ * Fetch models from all providers that have keys configured; openrouter is
136
+ * always included (keyless public endpoint) as is anthropic (hardcoded list).
137
+ * @param {Object<string, string>} keys - Map of provider → API key string
138
+ * @returns {Promise<Array<{id: string, name: string, contextLength: number|null, pricing: object|null}>>} Combined model list
139
+ */
140
+ async function fetchAllModels(keys) {
141
+ const providers = providersToFetch(keys);
142
+ const results = await Promise.all(providers.map(p => fetchModelsFromProvider(p, keys[p] || '')));
143
+ return results.flat();
144
+ }
145
+
146
+ /**
147
+ * Group models by provider family for <optgroup> rendering
148
+ * @param {Array<{id: string, name: string, contextLength: number|null, pricing: {prompt: string|null, completion: string|null}|null}>} models
149
+ * @returns {Array<{family: string, models: Array<{id: string, name: string}>}>}
150
+ */
151
+ function groupModelsByFamily(models) {
152
+ if (models.length === 0) { return []; }
153
+
154
+ const groups = new Map();
155
+
156
+ for (const model of models) {
157
+ const prefix = model.id.split('/')[0];
158
+ const family = PROVIDER_FAMILY_NAMES[prefix] || prefix;
159
+ if (!groups.has(family)) {
160
+ groups.set(family, []);
161
+ }
162
+ groups.get(family).push(model);
163
+ }
164
+
165
+ return Array.from(groups.entries()).map(([family, familyModels]) => ({
166
+ family,
167
+ models: familyModels
168
+ }));
169
+ }
170
+
171
+ module.exports = {
172
+ fetchModelsFromProvider,
173
+ fetchAllModels,
174
+ providersToFetch,
175
+ groupModelsByFamily,
176
+ ANTHROPIC_MODELS,
177
+ PROVIDER_FETCH_CONFIG,
178
+ PROVIDER_FAMILY_NAMES
179
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Model Validator
3
+ *
4
+ * Validates that a direct-API fallback model exists on the provider.
5
+ * When a model is not found, prompts the user to pick an alternative
6
+ * (interactive) or fails with available models (headless).
7
+ */
8
+
9
+ const readline = require('readline');
10
+ const { fetchModelsFromProvider } = require('./model-fetcher');
11
+ const { readApiKeyValues } = require('./api-key-store');
12
+ const { loadConfig, saveConfig, getConfigPath } = require('./config');
13
+ const { logger } = require('./logger');
14
+
15
+ /**
16
+ * Normalize a model ID to include the provider prefix.
17
+ * @param {string} provider - e.g. 'google', 'openai'
18
+ * @param {string} id - Model ID, may or may not have provider prefix
19
+ * @returns {string} Normalized ID with provider prefix
20
+ */
21
+ function normalizeModelId(provider, id) {
22
+ if (id.startsWith(provider + '/')) {
23
+ return id;
24
+ }
25
+ return `${provider}/${id}`;
26
+ }
27
+
28
+ /** Alias-to-search-term mapping for filtering provider model lists */
29
+ const ALIAS_SEARCH_TERMS = {
30
+ 'gemini': 'gemini', 'gemini-pro': 'gemini',
31
+ 'gpt': 'gpt', 'gpt-pro': 'gpt', 'codex': 'gpt',
32
+ 'claude': 'claude', 'sonnet': 'claude', 'opus': 'claude', 'haiku': 'claude',
33
+ 'deepseek': 'deepseek',
34
+ };
35
+
36
+ /**
37
+ * Validate a direct-API fallback model exists on the provider.
38
+ * Returns silently if valid. On failure: prompts (interactive) or throws (headless).
39
+ *
40
+ * @param {string} resolvedModel - e.g. 'google/gemini-3.1-flash-lite-preview'
41
+ * @param {string} alias - Original alias name (e.g. 'gemini')
42
+ * @param {object} [options]
43
+ * @param {boolean} [options.headless] - If true, throw instead of prompting
44
+ * @returns {Promise<string>} Confirmed model string
45
+ */
46
+ async function validateDirectModel(resolvedModel, alias, options = {}) {
47
+ const parts = resolvedModel.split('/');
48
+ if (parts.length < 2) { return resolvedModel; }
49
+
50
+ const provider = parts[0];
51
+ const modelId = parts.slice(1).join('/');
52
+
53
+ const keys = readApiKeyValues();
54
+ const providerKey = keys[provider];
55
+ if (!providerKey) { return resolvedModel; }
56
+
57
+ let models;
58
+ try {
59
+ models = await fetchModelsFromProvider(provider, providerKey);
60
+ } catch (err) {
61
+ logger.debug({ msg: 'Model fetch failed, skipping validation', error: err.message });
62
+ return resolvedModel;
63
+ }
64
+
65
+ if (!models || models.length === 0) { return resolvedModel; }
66
+
67
+ const found = models.some(m => m.id === resolvedModel || m.id === modelId);
68
+ if (found) { return resolvedModel; }
69
+
70
+ const relevant = filterRelevantModels(models, alias);
71
+
72
+ if (options.headless || !process.stdin.isTTY) {
73
+ const list = relevant.slice(0, 10).map(m => ` ${normalizeModelId(provider, m.id)}`).join('\n');
74
+ throw new Error(
75
+ `Model '${modelId}' not found on ${provider} API.\n` +
76
+ `Available models:\n${list}\n` +
77
+ `Fix with: amicus setup --add-alias ${alias}=${relevant[0] ? normalizeModelId(provider, relevant[0].id) : 'provider/model'}`
78
+ );
79
+ }
80
+
81
+ return promptModelSelection(relevant, alias, provider, modelId);
82
+ }
83
+
84
+ /**
85
+ * Filter models to those relevant to the alias
86
+ * @param {Array<{id: string, name: string}>} models
87
+ * @param {string} alias - e.g. 'gemini', 'gpt', 'opus'
88
+ * @returns {Array<{id: string, name: string}>} Filtered, sorted, max 15
89
+ */
90
+ function filterRelevantModels(models, alias) {
91
+ const term = (ALIAS_SEARCH_TERMS[alias] || alias).toLowerCase();
92
+
93
+ let filtered = models.filter(m =>
94
+ m.id.toLowerCase().includes(term) ||
95
+ m.name.toLowerCase().includes(term)
96
+ );
97
+
98
+ if (filtered.length === 0) { filtered = models; }
99
+
100
+ filtered.sort((a, b) => a.name.localeCompare(b.name));
101
+ return filtered.slice(0, 15);
102
+ }
103
+
104
+ /** Interactive prompt — ask user to pick from available models */
105
+ async function promptModelSelection(models, alias, provider, failedModelId) {
106
+ process.stderr.write(`\n Model '${failedModelId}' not found on ${provider} API.\n`);
107
+ process.stderr.write(' Available models:\n');
108
+ models.forEach((m, i) => {
109
+ const label = (m.name && m.name !== m.id) ? `${m.name} (${m.id})` : m.id;
110
+ process.stderr.write(` ${i + 1}. ${label}\n`);
111
+ });
112
+ process.stderr.write('\n');
113
+
114
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
115
+
116
+ const answer = await new Promise(resolve => {
117
+ rl.question(` Select a model (1-${models.length}) or press Enter to cancel: `, resolve);
118
+ });
119
+ rl.close();
120
+
121
+ const idx = parseInt(answer, 10) - 1;
122
+ if (isNaN(idx) || idx < 0 || idx >= models.length) {
123
+ throw new Error('Model selection cancelled.');
124
+ }
125
+
126
+ const selected = models[idx];
127
+ const newModel = normalizeModelId(provider, selected.id);
128
+
129
+ let config = loadConfig();
130
+ if (!config) {
131
+ const fs = require('fs');
132
+ const configPath = getConfigPath();
133
+ if (fs.existsSync(configPath)) {
134
+ throw new Error(
135
+ `Cannot save model selection: config file at ${configPath} is malformed. ` +
136
+ 'Fix it manually or run \'amicus setup\'.'
137
+ );
138
+ }
139
+ config = {};
140
+ }
141
+ if (!config.aliases) { config.aliases = {}; }
142
+ config.aliases[alias] = newModel;
143
+ try {
144
+ saveConfig(config);
145
+ process.stderr.write(` Saved: ${alias} → ${newModel}\n`);
146
+ } catch (err) {
147
+ process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
148
+ }
149
+ process.stderr.write(` (To change later: amicus setup --add-alias ${alias}=...)\n\n`);
150
+
151
+ return newModel;
152
+ }
153
+
154
+ /**
155
+ * Validate an OpenRouter-resolved model against the cached catalog (F3 #18).
156
+ * Only enforces for `openrouter/`-prefixed models (the catalog is authoritative
157
+ * there). Graceful when the catalog is empty/unavailable. Fails fast with
158
+ * suggestions when the model is genuinely absent.
159
+ *
160
+ * @param {string} resolvedModel
161
+ * @param {string} [alias]
162
+ * @returns {Promise<string>} the model (unchanged) when valid/unverifiable
163
+ */
164
+ async function validateAgainstCatalog(resolvedModel, alias) {
165
+ if (!resolvedModel.startsWith('openrouter/')) { return resolvedModel; }
166
+
167
+ const { getCatalog } = require('./model-catalog');
168
+ let catalog;
169
+ try { catalog = await getCatalog(); } catch { return resolvedModel; }
170
+ if (!catalog || catalog.length === 0) { return resolvedModel; }
171
+
172
+ // Only enforce when the OpenRouter catalog is actually present. If the fetch
173
+ // was unavailable (e.g. no key reached the fetcher) the catalog won't contain
174
+ // any openrouter/* ids — degrade gracefully instead of false-rejecting.
175
+ if (!catalog.some(m => m.id.startsWith('openrouter/'))) { return resolvedModel; }
176
+
177
+ if (catalog.some(m => m.id === resolvedModel)) { return resolvedModel; }
178
+
179
+ const relevant = filterRelevantModels(catalog, alias || resolvedModel.split('/').pop());
180
+ const list = relevant.slice(0, 10).map(m => ` ${m.id}`).join('\n');
181
+ throw new Error(
182
+ `Model '${resolvedModel}' not found in the OpenRouter catalog.\n` +
183
+ (list ? `Did you mean:\n${list}\n` : '') +
184
+ `Fix: amicus setup --add-alias ${alias || '<alias>'}=${relevant[0] ? relevant[0].id : 'openrouter/provider/model'}\n` +
185
+ 'Run \'amicus models --refresh\' to update the catalog, or pass --no-validate-model to skip.'
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Non-blocking advisory check for inherited models (F5: continue/resume).
191
+ * Same catalog logic as validateAgainstCatalog, but warns on stderr instead
192
+ * of throwing — a session that already ran with this model must stay openable.
193
+ * @param {string} model
194
+ */
195
+ async function warnIfNotInCatalog(model) {
196
+ if (typeof model !== 'string' || !model) { return; }
197
+ try {
198
+ await validateAgainstCatalog(model);
199
+ } catch (err) {
200
+ process.stderr.write(
201
+ `Warning: ${String(err.message).split('\n')[0]} ` +
202
+ '(continuing anyway — run \'amicus models --check\' to review aliases)\n'
203
+ );
204
+ }
205
+ }
206
+
207
+ module.exports = { validateDirectModel, filterRelevantModels, normalizeModelId, validateAgainstCatalog, warnIfNotInCatalog };
@@ -0,0 +1,41 @@
1
+ const path = require('path');
2
+ const os = require('os');
3
+
4
+ /**
5
+ * Ensures that the project's node_modules/.bin directory is included in the PATH,
6
+ * and on Windows also adds the platform-specific native opencode binary directory
7
+ * so that `spawn('opencode', ...)` without shell:true can resolve the .exe.
8
+ * The OpenCode SDK spawns the 'opencode' command, and this ensures it can be found.
9
+ */
10
+ function ensureNodeModulesBinInPath() {
11
+ const nodeModulesRoot = path.join(__dirname, '..', '..', 'node_modules');
12
+ const nodeModulesBin = path.join(nodeModulesRoot, '.bin');
13
+
14
+ if (!process.env.PATH.includes(nodeModulesBin)) {
15
+ process.env.PATH = `${nodeModulesBin}${path.delimiter}${process.env.PATH}`;
16
+ }
17
+
18
+ // On Windows, Node's spawn() does not execute .cmd shims without shell:true.
19
+ // Add the platform-specific native binary directory so `opencode` resolves
20
+ // to opencode.exe directly (Windows searches PATHEXT-aware when .exe is present).
21
+ if (os.platform() === 'win32') {
22
+ const archMap = { x64: 'x64', arm64: 'arm64' };
23
+ const arch = archMap[os.arch()] || os.arch();
24
+ const nativeBin = path.join(nodeModulesRoot, `opencode-windows-${arch}`, 'bin');
25
+ if (!process.env.PATH.includes(nativeBin)) {
26
+ process.env.PATH = `${nativeBin}${path.delimiter}${process.env.PATH}`;
27
+ }
28
+ // Baseline variant: the default build needs AVX2; opencode ships a
29
+ // -baseline (pre-AVX2) build for older CPUs. Windows resolves the first
30
+ // PATH entry containing a real opencode.exe, so default-before-baseline
31
+ // order matters — do not delete this block as "dead code".
32
+ const nativeBinBaseline = path.join(nodeModulesRoot, `opencode-windows-${arch}-baseline`, 'bin');
33
+ if (!process.env.PATH.includes(nativeBinBaseline)) {
34
+ process.env.PATH = `${nativeBinBaseline}${path.delimiter}${process.env.PATH}`;
35
+ }
36
+ }
37
+ }
38
+
39
+ module.exports = {
40
+ ensureNodeModulesBinInPath,
41
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cross-platform listener-PID lookup.
3
+ *
4
+ * Finds the PID of the process LISTENING on a local TCP port. Replaces the
5
+ * Unix-only `lsof` call so the OpenCode Go server can be force-killed on
6
+ * Windows too (F3 #15).
7
+ */
8
+
9
+ const { execFileSync } = require('child_process');
10
+
11
+ /**
12
+ * @param {number} port - TCP port to inspect
13
+ * @returns {number|null} PID of the LISTENING process, or null if none/none found
14
+ */
15
+ function findListenerPid(port) {
16
+ try {
17
+ if (process.platform === 'win32') {
18
+ const out = execFileSync('netstat', ['-ano', '-p', 'TCP'], {
19
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
20
+ });
21
+ for (const line of out.split(/\r?\n/)) {
22
+ // Columns: Proto Local Foreign State PID
23
+ const m = line.trim().match(/^TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)$/i);
24
+ if (m && Number(m[1]) === port) { return Number(m[2]); }
25
+ }
26
+ return null;
27
+ }
28
+ const out = execFileSync('lsof', ['-ti', `:${port}`, '-sTCP:LISTEN'], {
29
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
30
+ }).trim();
31
+ // take only the first PID if multiple processes share the port
32
+ const pid = parseInt(out.split(/\s+/)[0], 10);
33
+ return Number.isInteger(pid) ? pid : null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ module.exports = { findListenerPid };
@@ -0,0 +1,53 @@
1
+ // src/utils/prompt-source.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module prompt-source
6
+ * Resolve the prompt for start/fanout from --prompt XOR --prompt-file (F4).
7
+ * --prompt-file exists because Windows caps a CLI argument at ~32 KB, which
8
+ * forced fragile `--prompt "$(cat briefing)"` launches.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ /**
15
+ * @param {object} args - Parsed CLI args (kebab-case keys, as from parseArgs)
16
+ * @returns {{prompt: string, promptMeta: {source: 'inline'|'file', file: string|null, chars: number}} | {error: string}}
17
+ */
18
+ function resolvePromptSource(args) {
19
+ const inline = args.prompt;
20
+ const file = args['prompt-file'];
21
+
22
+ if (inline !== undefined && file !== undefined) {
23
+ return { error: 'Error: --prompt and --prompt-file are mutually exclusive' };
24
+ }
25
+ if (inline === undefined && file === undefined) {
26
+ return { error: 'Error: --prompt or --prompt-file is required' };
27
+ }
28
+ if (inline === true || file === true) {
29
+ return { error: 'Error: --prompt/--prompt-file requires a value' };
30
+ }
31
+
32
+ if (file !== undefined) {
33
+ let text;
34
+ try {
35
+ text = fs.readFileSync(file, 'utf-8');
36
+ } catch (err) {
37
+ return { error: `Error: cannot read --prompt-file ${file}: ${err.message}` };
38
+ }
39
+ if (text.charCodeAt(0) === 0xFEFF) { text = text.slice(1); }
40
+ if (!text.trim()) {
41
+ return { error: `Error: --prompt-file ${file} is empty` };
42
+ }
43
+ return {
44
+ prompt: text,
45
+ promptMeta: { source: 'file', file: path.resolve(file), chars: text.length },
46
+ };
47
+ }
48
+
49
+ const text = String(inline);
50
+ return { prompt: text, promptMeta: { source: 'inline', file: null, chars: text.length } };
51
+ }
52
+
53
+ module.exports = { resolvePromptSource };