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,297 @@
1
+ /**
2
+ * Context Builder Module
3
+ *
4
+ * Handles building context from Claude Code sessions for sidecar operations.
5
+ * Spec Reference: §5 Context Passing
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ const { resolveSession, getSessionDirectory } = require('../session');
13
+ const { formatContext, readJSONL } = require('../jsonl-parser');
14
+ const { logger } = require('../utils/logger');
15
+
16
+ /**
17
+ * Parse duration string (e.g., '2h', '30m', '1d')
18
+ * @param {string} str - Duration string
19
+ * @returns {number} Milliseconds
20
+ */
21
+ function parseDuration(str) {
22
+ if (!str || typeof str !== 'string') {
23
+ return 0;
24
+ }
25
+ const match = str.match(/^(\d+)(m|h|d)$/);
26
+ if (!match) {
27
+ return 0;
28
+ }
29
+ const multipliers = { m: 60000, h: 3600000, d: 86400000 };
30
+ return parseInt(match[1], 10) * multipliers[match[2]];
31
+ }
32
+
33
+ /**
34
+ * Resolve session file from session directory
35
+ * @param {string} sessionDir - Session directory path
36
+ * @param {string} session - Session ID or 'current'
37
+ * @returns {{path: string|null, method: string, warning?: string}}
38
+ */
39
+ function resolveSessionFile(sessionDir, session) {
40
+ // Use the existing resolveSession function
41
+ return resolveSession(sessionDir, session);
42
+ }
43
+
44
+ /**
45
+ * Apply context filters to messages array
46
+ * @param {Array} messages - Array of messages
47
+ * @param {object} options - Filter options
48
+ * @param {number} [options.contextTurns] - Max number of turns (user messages)
49
+ * @param {string} [options.contextSince] - Time filter (e.g., '2h')
50
+ * @param {number} [options._testCutoff] - Test-only: override time cutoff
51
+ * @returns {Array} Filtered messages
52
+ */
53
+ function applyContextFilters(messages, options) {
54
+ if (!messages || messages.length === 0) {
55
+ return [];
56
+ }
57
+
58
+ const { contextTurns, contextSince, _testCutoff } = options;
59
+ let filtered = [...messages];
60
+
61
+ // Apply time filter if specified (overrides turns)
62
+ if (contextSince) {
63
+ const cutoffMs = _testCutoff || (Date.now() - parseDuration(contextSince));
64
+ filtered = filtered.filter(m => {
65
+ const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0;
66
+ return ts >= cutoffMs;
67
+ });
68
+ } else if (contextTurns && contextTurns > 0) {
69
+ // Apply turn filter - count user messages as turns
70
+ const userIndices = filtered
71
+ .map((m, i) => m.type === 'user' ? i : -1)
72
+ .filter(i => i >= 0);
73
+
74
+ if (userIndices.length > contextTurns) {
75
+ const startIdx = userIndices[userIndices.length - contextTurns];
76
+ filtered = filtered.slice(startIdx);
77
+ }
78
+ }
79
+
80
+ return filtered;
81
+ }
82
+
83
+ /**
84
+ * Get the Cowork local-agent-mode-sessions root for the current platform.
85
+ * @param {string} [homeDir] - Home directory override (for testing)
86
+ * @returns {string} Path to local-agent-mode-sessions directory
87
+ */
88
+ function getCoworkSessionsRoot(homeDir = os.homedir()) {
89
+ // Cowork stores session data inside Claude Desktop's Application Support:
90
+ // ~/Library/Application Support/Claude/local-agent-mode-sessions/<org>/<user>/local_<id>/audit.jsonl
91
+ return path.join(homeDir, 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions');
92
+ }
93
+
94
+ /**
95
+ * Find a Cowork session's audit.jsonl by process name or fall back to most recent.
96
+ * Scans ~/Library/Application Support/Claude/local-agent-mode-sessions/
97
+ *
98
+ * When coworkProcess is provided, matches the session metadata JSON file
99
+ * whose processName matches. This is the reliable path for parallel sessions.
100
+ * Falls back to most recently modified audit.jsonl when no process name given.
101
+ *
102
+ * @param {string} [homeDir] - Home directory override (for testing)
103
+ * @param {string} [coworkProcess] - Cowork VM process name (e.g., 'modest-laughing-goodall')
104
+ * @returns {string|null} Path to the matching audit.jsonl, or null
105
+ */
106
+ function findCoworkSession(homeDir = os.homedir(), coworkProcess = null) {
107
+ const root = getCoworkSessionsRoot(homeDir);
108
+ if (!fs.existsSync(root)) {
109
+ return null;
110
+ }
111
+
112
+ let bestPath = null;
113
+ let bestMtime = 0;
114
+
115
+ // Structure: root/<org-id>/<user-id>/local_<session-id>/audit.jsonl
116
+ // Metadata: root/<org-id>/<user-id>/local_<session-id>.json (has processName)
117
+ try {
118
+ for (const org of fs.readdirSync(root)) {
119
+ const orgPath = path.join(root, org);
120
+ try { if (!fs.statSync(orgPath).isDirectory()) { continue; } } catch { continue; }
121
+
122
+ for (const user of fs.readdirSync(orgPath)) {
123
+ const userPath = path.join(orgPath, user);
124
+ try { if (!fs.statSync(userPath).isDirectory()) { continue; } } catch { continue; }
125
+
126
+ for (const session of fs.readdirSync(userPath)) {
127
+ if (!session.startsWith('local_')) { continue; }
128
+ const auditPath = path.join(userPath, session, 'audit.jsonl');
129
+
130
+ // If matching by process name, check the metadata JSON
131
+ if (coworkProcess) {
132
+ const metaPath = path.join(userPath, `${session}.json`);
133
+ try {
134
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
135
+ if (meta.processName === coworkProcess) {
136
+ logger.info('Matched Cowork session by processName', { coworkProcess, session });
137
+ return fs.existsSync(auditPath) ? auditPath : null;
138
+ }
139
+ } catch {
140
+ continue;
141
+ }
142
+ continue; // Skip mtime check when matching by process name
143
+ }
144
+
145
+ // Fallback: track most recent audit.jsonl
146
+ try {
147
+ const mtime = fs.statSync(auditPath).mtime.getTime();
148
+ if (mtime > bestMtime) {
149
+ bestMtime = mtime;
150
+ bestPath = auditPath;
151
+ }
152
+ } catch {
153
+ continue;
154
+ }
155
+ }
156
+ }
157
+ }
158
+ } catch {
159
+ return null;
160
+ }
161
+
162
+ return bestPath;
163
+ }
164
+
165
+ /**
166
+ * Normalize Cowork audit.jsonl messages to match Claude Code JSONL format.
167
+ * Maps _audit_timestamp → timestamp and filters to user/assistant messages.
168
+ *
169
+ * @param {Array} messages - Raw parsed audit.jsonl entries
170
+ * @returns {Array} Normalized messages compatible with formatContext/applyContextFilters
171
+ */
172
+ function normalizeCoworkMessages(messages) {
173
+ return messages
174
+ .filter(m => m.type === 'user' || m.type === 'assistant')
175
+ .map(m => ({
176
+ ...m,
177
+ timestamp: m.timestamp || m._audit_timestamp || null
178
+ }));
179
+ }
180
+
181
+ /**
182
+ * Build context from Claude Code session
183
+ * Spec Reference: §5 Context Passing
184
+ *
185
+ * @param {string} project - Project directory
186
+ * @param {string} session - Session ID or 'current'
187
+ * @param {object} options - Context options
188
+ * @param {number} [options.contextTurns=50] - Max conversation turns
189
+ * @param {string} [options.contextSince] - Time filter (e.g., '2h')
190
+ * @param {number} [options.contextMaxTokens=80000] - Max context tokens
191
+ * @param {string} [options.sessionDir] - Explicit session directory override (for code-web, cowork)
192
+ * @param {string} [options.client] - Client type (code-local, code-web, cowork)
193
+ * @param {string} [options._homeDir] - Home directory override (testing only)
194
+ * @returns {string} Formatted context string
195
+ */
196
+ function buildContext(project, session, options) {
197
+ const { contextTurns = 50, contextSince, contextMaxTokens = 80000, sessionDir: sessionDirOverride, client, coworkProcess, _homeDir } = options;
198
+ const homeDir = _homeDir || os.homedir();
199
+
200
+ // Determine session directory:
201
+ // 1. If sessionDir is explicitly provided, use it directly (code-web, cowork)
202
+ // 2. Otherwise, use the standard getSessionDirectory for code-local
203
+ const resolvedSessionDir = sessionDirOverride || getSessionDirectory(project, homeDir);
204
+
205
+ // For cowork clients: read directly from Cowork's local-agent-mode-sessions
206
+ // on the host Mac. The Cowork VM can't expose its session path to the MCP server,
207
+ // so we find the most recently active session's audit.jsonl on the host.
208
+ if (client === 'cowork' && !sessionDirOverride) {
209
+ const auditPath = findCoworkSession(homeDir, coworkProcess);
210
+ if (!auditPath) {
211
+ logger.warn('No Cowork session found in local-agent-mode-sessions', { project });
212
+ return '[No Claude Code conversation history found]';
213
+ }
214
+
215
+ logger.info('Using Cowork session', { auditPath });
216
+
217
+ let messages;
218
+ try {
219
+ messages = readJSONL(auditPath);
220
+ } catch (err) {
221
+ logger.error('Error reading Cowork session', { error: err.message });
222
+ return '[Error reading Claude Code session]';
223
+ }
224
+
225
+ messages = normalizeCoworkMessages(messages);
226
+ if (messages.length === 0) {
227
+ return '[Empty Claude Code session]';
228
+ }
229
+
230
+ messages = applyContextFilters(messages, { contextTurns, contextSince });
231
+ let context = formatContext(messages);
232
+ const maxChars = contextMaxTokens * 4;
233
+ if (context.length > maxChars) {
234
+ context = '[Earlier context truncated...]\n\n' + context.slice(-maxChars);
235
+ }
236
+ return context || '[No relevant context found]';
237
+ }
238
+
239
+ if (!fs.existsSync(resolvedSessionDir)) {
240
+ logger.warn('No Claude Code conversation history found', { project, sessionDir: resolvedSessionDir, client });
241
+ return '[No Claude Code conversation history found]';
242
+ }
243
+
244
+ // Resolve session file
245
+ const resolution = resolveSessionFile(resolvedSessionDir, session);
246
+
247
+ if (!resolution.path) {
248
+ logger.warn('No Claude Code session found', { project, session });
249
+ return '[No Claude Code conversation history found]';
250
+ }
251
+
252
+ if (resolution.warning) {
253
+ logger.warn('Session resolution warning', { warning: resolution.warning });
254
+ }
255
+
256
+ logger.info('Using session', {
257
+ session: path.basename(resolution.path),
258
+ method: resolution.method
259
+ });
260
+
261
+ // Read and parse the session file
262
+ let messages;
263
+ try {
264
+ messages = readJSONL(resolution.path);
265
+ } catch (err) {
266
+ logger.error('Error reading session', { error: err.message });
267
+ return '[Error reading Claude Code session]';
268
+ }
269
+
270
+ if (messages.length === 0) {
271
+ return '[Empty Claude Code session]';
272
+ }
273
+
274
+ // Apply filters
275
+ messages = applyContextFilters(messages, { contextTurns, contextSince });
276
+
277
+ // Format as context
278
+ let context = formatContext(messages);
279
+
280
+ // Truncate to token limit (~4 chars per token)
281
+ const maxChars = contextMaxTokens * 4;
282
+ if (context.length > maxChars) {
283
+ context = '[Earlier context truncated...]\n\n' + context.slice(-maxChars);
284
+ }
285
+
286
+ return context || '[No relevant context found]';
287
+ }
288
+
289
+ module.exports = {
290
+ buildContext,
291
+ parseDuration,
292
+ resolveSessionFile,
293
+ applyContextFilters,
294
+ findCoworkSession,
295
+ normalizeCoworkMessages,
296
+ getCoworkSessionsRoot
297
+ };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Sidecar Continue Operations - Handles continuing from previous sessions
3
+ * Spec Reference: §4.4, §8.5
4
+ */
5
+
6
+ const fs = require('fs');
7
+
8
+ const { generateTaskId, runInteractive, buildMcpConfig } = require('./start');
9
+ const {
10
+ SessionPaths,
11
+ saveInitialContext,
12
+ finalizeSession,
13
+ outputSummary,
14
+ createHeartbeat
15
+ } = require('./session-utils');
16
+ const { acquireLock, releaseLock } = require('../utils/session-lock');
17
+ const { runHeadless } = require('../headless');
18
+ const { buildPrompts } = require('../prompt-builder');
19
+ const { logger } = require('../utils/logger');
20
+
21
+ /** Load previous session data (metadata, summary, conversation) */
22
+ function loadPreviousSession(taskId, project) {
23
+ // Reads an EXISTING session — resolve dual-dir (amicus, then legacy).
24
+ const sessionDir = SessionPaths.resolveSessionDir(project, taskId);
25
+
26
+ if (!fs.existsSync(sessionDir)) {
27
+ throw new Error(`Session ${taskId} not found`);
28
+ }
29
+
30
+ // Load metadata
31
+ const metaPath = SessionPaths.metadataFile(sessionDir);
32
+ const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
33
+
34
+ // Load summary if available
35
+ const summaryPath = SessionPaths.summaryFile(sessionDir);
36
+ const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
37
+
38
+ // Load and format conversation if available
39
+ const convPath = SessionPaths.conversationFile(sessionDir);
40
+ let conversation = '';
41
+
42
+ if (fs.existsSync(convPath)) {
43
+ const lines = fs.readFileSync(convPath, 'utf-8').split('\n').filter(Boolean);
44
+ const messages = lines.map(line => {
45
+ try { return JSON.parse(line); } catch { return null; }
46
+ }).filter(Boolean);
47
+
48
+ conversation = messages.map(m => {
49
+ const time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString() : '';
50
+ return `[${m.role} @ ${time}] ${m.content}`;
51
+ }).join('\n\n');
52
+ }
53
+
54
+ return { metadata, summary, conversation };
55
+ }
56
+
57
+ /** Build continuation context from previous session data */
58
+ function buildContinuationContext(metadata, summary, conversation, contextMaxTokens = 80000) {
59
+ const maxChars = contextMaxTokens * 4;
60
+
61
+ const truncatedConversation = conversation.length > maxChars
62
+ ? conversation.slice(-maxChars)
63
+ : conversation;
64
+
65
+ return `
66
+ ## PREVIOUS SIDECAR SESSION
67
+
68
+ This sidecar continues from a previous session (${metadata.taskId}).
69
+
70
+ ### Previous Task
71
+ ${metadata.briefing || 'No briefing recorded'}
72
+
73
+ ### Previous Summary
74
+ ${summary || 'No summary available'}
75
+
76
+ ### Previous Conversation Excerpt
77
+ ${truncatedConversation || 'No conversation recorded'}
78
+
79
+ ---
80
+
81
+ ## NEW TASK
82
+
83
+ Build on the previous sidecar's findings. The user wants to continue or extend that work.
84
+ `;
85
+ }
86
+
87
+ /** Create session metadata for continuation */
88
+ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
89
+ const { model, briefing, headless, agent } = options;
90
+
91
+ const sessionDir = SessionPaths.sessionDir(project, taskId);
92
+ fs.mkdirSync(sessionDir, { recursive: true });
93
+
94
+ const metadata = {
95
+ taskId,
96
+ model,
97
+ project,
98
+ briefing,
99
+ mode: headless ? 'headless' : 'interactive',
100
+ agent: agent || (headless ? 'build' : 'chat'),
101
+ status: 'running',
102
+ createdAt: new Date().toISOString(),
103
+ continuesFrom: oldTaskId
104
+ };
105
+
106
+ fs.writeFileSync(SessionPaths.metadataFile(sessionDir), JSON.stringify(metadata, null, 2));
107
+
108
+ return sessionDir;
109
+ }
110
+
111
+ /** Continue from a previous sidecar session - Spec Reference: §4.4, §8.5 */
112
+ async function continueSidecar(options) {
113
+ const {
114
+ taskId: oldTaskId,
115
+ briefing,
116
+ project = process.cwd(),
117
+ contextMaxTokens = 80000,
118
+ headless = false,
119
+ timeout = 15,
120
+ agent,
121
+ mcp, mcpConfig, client, noMcp, excludeMcp
122
+ } = options;
123
+
124
+ // Load previous session data
125
+ const { metadata: oldMetadata, summary: previousSummary, conversation: previousConversation } =
126
+ loadPreviousSession(oldTaskId, project);
127
+
128
+ // Lock the previous (EXISTING) session directory to prevent concurrent
129
+ // continue operations — resolve dual-dir so a legacy session is locked too.
130
+ const prevSessionDir = SessionPaths.resolveSessionDir(project, oldTaskId);
131
+ acquireLock(prevSessionDir, headless ? 'headless' : 'interactive');
132
+
133
+ const model = options.model || oldMetadata.model;
134
+ if (!options.model) {
135
+ // Inherited model: advisory only (F5) — never block reopening a session.
136
+ const { warnIfNotInCatalog } = require('../utils/model-validator');
137
+ await warnIfNotInCatalog(model);
138
+ }
139
+ const mcpServers = buildMcpConfig({ mcp, mcpConfig, clientType: client, noMcp, excludeMcp });
140
+ logger.info('Continuing from session', { oldTaskId, model });
141
+
142
+ // Build continuation context
143
+ const previousContext = buildContinuationContext(
144
+ oldMetadata, previousSummary, previousConversation, contextMaxTokens
145
+ );
146
+ const fullContext = previousContext + '\n\n' + briefing;
147
+
148
+ // Inherit agent from previous session if not specified
149
+ const effectiveAgent = agent || oldMetadata.agent || 'Build';
150
+
151
+ // Build system prompt and user message
152
+ const { system: systemPrompt, userMessage } = buildPrompts(
153
+ briefing, fullContext, project, headless, effectiveAgent, 'normal', client
154
+ );
155
+
156
+ // Use provided task ID (from MCP server) or generate a new one
157
+ const newTaskId = options.newTaskId || generateTaskId();
158
+ logger.info('New continuation task', { newTaskId, oldTaskId });
159
+
160
+ const sessionDir = createContinueSessionMetadata(newTaskId, project, {
161
+ model, briefing, headless, agent: effectiveAgent
162
+ }, oldTaskId);
163
+
164
+ saveInitialContext(sessionDir, systemPrompt, userMessage);
165
+
166
+ // Start heartbeat
167
+ const heartbeat = createHeartbeat();
168
+
169
+ let summary;
170
+
171
+ try {
172
+ if (headless) {
173
+ const result = await runHeadless(
174
+ model, systemPrompt, userMessage, newTaskId, project,
175
+ timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
176
+ );
177
+ summary = result.summary ||
178
+ '## Sidecar Results: No Output\n\nContinued session completed without summary.';
179
+
180
+ if (result.timedOut) { logger.warn('Continuation task timed out', { taskId: newTaskId }); }
181
+ if (result.error) { logger.error('Continuation task error', { taskId: newTaskId, error: result.error }); }
182
+ } else {
183
+ logger.info('Launching interactive continue', { taskId: newTaskId, model });
184
+ const result = await runInteractive(
185
+ model, systemPrompt, userMessage, newTaskId, project,
186
+ { agent: effectiveAgent, mcp: mcpServers }
187
+ );
188
+ summary = result.summary || '';
189
+ if (result.error) { logger.error('Interactive continue error', { taskId: newTaskId, error: result.error }); }
190
+ }
191
+ } finally {
192
+ heartbeat.stop();
193
+ releaseLock(prevSessionDir);
194
+ }
195
+
196
+ // Output summary
197
+ outputSummary(summary);
198
+
199
+ // Load current metadata for finalization
200
+ const metaPath = SessionPaths.metadataFile(sessionDir);
201
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
202
+
203
+ // Finalize session
204
+ finalizeSession(sessionDir, summary, project, meta);
205
+ }
206
+
207
+ module.exports = {
208
+ loadPreviousSession,
209
+ buildContinuationContext,
210
+ createContinueSessionMetadata,
211
+ continueSidecar
212
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Crash Handler - Updates metadata to 'error' on uncaught exceptions
3
+ *
4
+ * Installed by bin/amicus.js for MCP-spawned processes that have a --task-id.
5
+ * When the process crashes, the handler marks the session as failed so the
6
+ * MCP client can detect the error instead of seeing a stuck 'running' status.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { SessionPaths } = require('./session-utils');
12
+
13
+ /**
14
+ * Create a crash handler that updates session metadata on error.
15
+ *
16
+ * @param {string} taskId - The sidecar task ID
17
+ * @param {string} project - The project root directory
18
+ * @returns {function(Error): void} Handler function to call with the error
19
+ */
20
+ function installCrashHandler(taskId, project) {
21
+ return function handleCrash(err) {
22
+ try {
23
+ // Operates on an EXISTING (possibly legacy) session — resolve dual-dir.
24
+ const sessionDir = SessionPaths.resolveSessionDir(project, taskId);
25
+ const metaPath = SessionPaths.metadataFile(sessionDir);
26
+
27
+ if (!fs.existsSync(metaPath)) {
28
+ return;
29
+ }
30
+
31
+ const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
32
+
33
+ if (metadata.status !== 'running') {
34
+ return;
35
+ }
36
+
37
+ metadata.status = 'error';
38
+ metadata.reason = err.message;
39
+ metadata.errorAt = new Date().toISOString();
40
+
41
+ fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
42
+
43
+ // Delete session lock if it exists
44
+ const lockPath = path.join(sessionDir, 'session.lock');
45
+ try {
46
+ fs.unlinkSync(lockPath);
47
+ } catch {
48
+ // Lock may not exist yet
49
+ }
50
+ } catch (_ignored) {
51
+ // Crash handler must never throw - swallow all errors
52
+ }
53
+ };
54
+ }
55
+
56
+ module.exports = { installCrashHandler };
@@ -0,0 +1,107 @@
1
+ // src/sidecar/fanout-leg.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module fanout-leg
6
+ * Per-leg helpers extracted from fanout.js to keep both files ≤300 lines.
7
+ * Exports: legStatusFromResult, writeLegPatch, runLeg
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { logger } = require('../utils/logger');
13
+
14
+ /** Map a runHeadless result to a leg metadata status. */
15
+ function legStatusFromResult(result) {
16
+ const { statusFromResult } = require('../utils/result-schema');
17
+ return statusFromResult(result);
18
+ }
19
+
20
+ /** Read-merge-write a leg's metadata.json. Returns the merged object. */
21
+ function writeLegPatch(legDir, patch) {
22
+ const metaPath = path.join(legDir, 'metadata.json');
23
+ let meta = {};
24
+ try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* fresh */ }
25
+ const defined = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
26
+ // A signal/abort marker is authoritative: never demote 'aborted' back to a
27
+ // softer terminal status (a leg finishing concurrently with Ctrl-C must not
28
+ // win the write race and report 'complete').
29
+ if (meta.status === 'aborted' && defined.status && defined.status !== 'aborted') {
30
+ delete defined.status;
31
+ }
32
+ const merged = { ...meta, ...defined };
33
+ fs.writeFileSync(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
34
+ return merged;
35
+ }
36
+
37
+ /**
38
+ * Run one leg end-to-end: session record → runHeadless (shared server) →
39
+ * leg finalize. Never throws — always resolves to a run document.
40
+ */
41
+ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet }) {
42
+ const { IdleWatchdog } = require('../utils/idle-watchdog');
43
+ const { markAborted } = require('../utils/session-abort');
44
+ const { runHeadless } = require('../headless');
45
+ const { SessionPaths, saveInitialContext } = require('./session-utils');
46
+ const { buildRunResult } = require('../utils/result-schema');
47
+ const { createSessionMetadata } = require('./start');
48
+
49
+ const legDir = createSessionMetadata(legId, project, {
50
+ model: leg.model, prompt: userMessage, noUi: true, agent: agent || 'build',
51
+ });
52
+ writeLegPatch(legDir, { parentWave: waveId, modelInput: leg.modelInput });
53
+ saveInitialContext(legDir, systemPrompt, userMessage);
54
+
55
+ // Per-leg watchdog: a BACKSTOP strictly behind runHeadless's own deadline
56
+ // (timeoutMs + 60s), so it only fires if the poll loop itself wedges. Its
57
+ // timeout aborts ONLY this leg, and only while the leg is still running.
58
+ // NEVER server.close()/process.exit() — shared server.
59
+ const watchdog = new IdleWatchdog({
60
+ mode: 'headless',
61
+ timeout: timeoutMs + 60000,
62
+ onTimeout: () => {
63
+ let current = {};
64
+ try { current = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8')); } catch { /* unreadable */ }
65
+ if (current.status === 'running') {
66
+ logger.warn('Leg watchdog backstop fired — aborting leg', { legId });
67
+ markAborted(legDir, 'leg watchdog backstop');
68
+ }
69
+ },
70
+ }).start();
71
+
72
+ let result;
73
+ try {
74
+ result = await runHeadless(
75
+ leg.model, systemPrompt, userMessage, legId, project,
76
+ timeoutMs, agent || 'build',
77
+ { client, server, watchdog, summaryLength, reasoning }
78
+ );
79
+ } catch (err) {
80
+ result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
81
+ } finally {
82
+ watchdog.cancel();
83
+ }
84
+
85
+ const status = legStatusFromResult(result);
86
+ const summary = result.summary || null;
87
+ if (summary) {
88
+ fs.writeFileSync(SessionPaths.summaryFile(legDir), summary, { mode: 0o600 });
89
+ }
90
+ const finalMeta = writeLegPatch(legDir, {
91
+ status,
92
+ reason: result.error || undefined,
93
+ completedAt: new Date().toISOString(),
94
+ });
95
+ const effectiveResult = finalMeta.status === 'aborted'
96
+ ? { ...result, aborted: true }
97
+ : result;
98
+ if (!quiet) {
99
+ process.stderr.write(`[fanout] leg ${legId} (${leg.modelInput}): ${finalMeta.status}\n`);
100
+ }
101
+ return buildRunResult({
102
+ taskId: legId, metadata: finalMeta, result: effectiveResult, summary,
103
+ modelInput: leg.modelInput, sessionDir: legDir, waveId,
104
+ });
105
+ }
106
+
107
+ module.exports = { legStatusFromResult, writeLegPatch, runLeg };