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,742 @@
1
+ /**
2
+ * Headless Mode Runner
3
+ *
4
+ * Spec Reference: §6.2 Headless Mode, §9 Implementation
5
+ * Uses OpenCode SDK for headless execution (no CLI spawning required).
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { logger } = require('./utils/logger');
11
+ const { ensureNodeModulesBinInPath } = require('./utils/path-setup');
12
+ const { ensurePortAvailable } = require('./utils/server-setup');
13
+ const { mapAgentToOpenCode } = require('./utils/agent-mapping');
14
+ const { writeProgress } = require('./sidecar/progress');
15
+
16
+ /**
17
+ * Fold marker that the agent outputs when done
18
+ * Spec Reference: §6.2
19
+ */
20
+ const FOLD_MARKER = '[SIDECAR_FOLD]';
21
+ const COMPLETE_MARKER = FOLD_MARKER; // backward compat
22
+
23
+ /**
24
+ * Default timeout: 15 minutes per spec §6.2
25
+ */
26
+ const DEFAULT_TIMEOUT = 15 * 60 * 1000;
27
+
28
+ /** Poll cadence + completion thresholds (env-overridable; injectable via options for tests). */
29
+ const POLL_INTERVAL_MS = Number(process.env.AMICUS_POLL_INTERVAL_MS) || 2000;
30
+ const STABLE_FINISHED_POLLS = Number(process.env.AMICUS_STABLE_FINISHED_POLLS) || 2; // when time.completed is set
31
+ const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30; // ~60s at 2s — no completion signal
32
+ const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
33
+ const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
34
+
35
+ /**
36
+ * Race a promise against a timeout. Returns the promise's result, or rejects with
37
+ * a timeout error after `ms`. A non-positive `ms` means "no extra timer" (return as-is).
38
+ */
39
+ function withTimeout(promise, ms, label) {
40
+ if (!(ms > 0)) { return promise; }
41
+ return Promise.race([
42
+ promise,
43
+ new Promise((_, reject) => {
44
+ const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
45
+ if (t.unref) { t.unref(); }
46
+ }),
47
+ ]);
48
+ }
49
+
50
+ /**
51
+ * Wait for the OpenCode server to be ready using SDK health check
52
+ */
53
+ async function waitForServer(client, checkHealthFn, maxAttempts = 30) {
54
+ for (let i = 0; i < maxAttempts; i++) {
55
+ try {
56
+ const isHealthy = await checkHealthFn(client);
57
+ if (isHealthy) {
58
+ return true;
59
+ }
60
+ } catch (e) {
61
+ // Server not ready yet
62
+ }
63
+ await new Promise(resolve => setTimeout(resolve, 500));
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Run a headless sidecar session
70
+ * Spec Reference: §6.2, §9.1 runHeadless function
71
+ *
72
+ * @param {string} model - Model to use (e.g., 'openrouter/google/gemini-2.5-flash')
73
+ * @param {string} systemPrompt - The system prompt for the agent (instruction-level context)
74
+ * @param {string} userMessage - The user message (task briefing)
75
+ * @param {string} taskId - Unique task identifier
76
+ * @param {string} project - Project directory path
77
+ * @param {number} [timeoutMs=DEFAULT_TIMEOUT] - Timeout in milliseconds
78
+ * @param {string} [agent] - Agent mode: build (default), plan, explore, general
79
+ * @param {object} [options] - Additional options
80
+ * @param {object} [options.mcp] - MCP server configurations
81
+ * @param {string} [options.summaryLength='normal'] - Desired summary length
82
+ * @param {object} [options.reasoning] - Reasoning/thinking configuration
83
+ * @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
84
+ * @returns {Promise<object>} Result object with summary, completed, timedOut flags
85
+ */
86
+ async function runHeadless(model, systemPrompt, userMessage, taskId, project, timeoutMs = DEFAULT_TIMEOUT, agent, options = {}) {
87
+ const {
88
+ createSession,
89
+ sendPromptAsync,
90
+ getMessages,
91
+ checkHealth,
92
+ startServer,
93
+ getSessionStatus
94
+ } = require('./opencode-client');
95
+
96
+ const { reasoning } = options;
97
+ const { getSessionDir } = require('./session-manager');
98
+ const sessionDir = getSessionDir(project, taskId);
99
+ const conversationPath = path.join(sessionDir, 'conversation.jsonl');
100
+
101
+ // Ensure session directory exists
102
+ if (!fs.existsSync(sessionDir)) {
103
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
104
+ }
105
+
106
+ // Log system prompt as first message in conversation
107
+ logMessage(conversationPath, {
108
+ role: 'system',
109
+ content: systemPrompt,
110
+ timestamp: new Date().toISOString()
111
+ });
112
+
113
+ // Write initial progress
114
+ writeProgress(sessionDir, 'initializing');
115
+
116
+ // Ensure node_modules/.bin is in PATH so SDK can find opencode wrapper
117
+ ensureNodeModulesBinInPath();
118
+
119
+ // Use specified port, or 0 to let the OS auto-assign (enables parallel sessions)
120
+ const port = options.port || 0;
121
+ if (port > 0) {
122
+ ensurePortAvailable(port);
123
+ }
124
+
125
+ // Detect shared-server mode: caller provides client + server directly
126
+ const externalServer = !!(options.client && options.server);
127
+ let client, server;
128
+
129
+ if (externalServer) {
130
+ client = options.client;
131
+ server = options.server;
132
+ logger.debug('Using external server (shared server mode)', { url: server.url });
133
+ } else {
134
+ // Start OpenCode server using SDK (no CLI spawning required)
135
+ logger.debug('Starting OpenCode server via SDK', { model, hasMcp: !!options.mcp, port });
136
+ try {
137
+ // Pass MCP config and port to server
138
+ const serverOptions = { port };
139
+ if (options.mcp) {
140
+ serverOptions.mcp = options.mcp;
141
+ }
142
+ const result = await startServer(serverOptions);
143
+ client = result.client;
144
+ server = result.server;
145
+ logger.debug('Server started', { url: server.url });
146
+ } catch (error) {
147
+ logger.error('Failed to start OpenCode server', { error: error.message });
148
+ return {
149
+ summary: '',
150
+ completed: false,
151
+ timedOut: false,
152
+ taskId,
153
+ error: `Failed to start server: ${error.message}`
154
+ };
155
+ }
156
+ }
157
+
158
+ let sessionId;
159
+ const { IdleWatchdog } = require('./utils/idle-watchdog');
160
+ let watchdog;
161
+ let uninstallSignals;
162
+
163
+ try {
164
+ if (!externalServer) {
165
+ // Wait for server to be ready
166
+ logger.debug('Waiting for OpenCode server to be ready');
167
+ const serverReady = await waitForServer(client, checkHealth);
168
+ logger.debug('Server ready', { serverReady });
169
+ writeProgress(sessionDir, 'server_ready');
170
+
171
+ if (!serverReady) {
172
+ server.close();
173
+ return {
174
+ summary: '',
175
+ completed: false,
176
+ timedOut: false,
177
+ taskId,
178
+ error: 'OpenCode server failed to start'
179
+ };
180
+ }
181
+ } else {
182
+ writeProgress(sessionDir, 'server_ready');
183
+ }
184
+
185
+ // Start idle watchdog to enforce the headless timeout
186
+ if (options.watchdog) {
187
+ watchdog = options.watchdog;
188
+ } else {
189
+ watchdog = new IdleWatchdog({
190
+ mode: 'headless',
191
+ onTimeout: () => {
192
+ logger.info('Headless idle timeout - shutting down', { taskId });
193
+ server.close();
194
+ process.exit(0);
195
+ },
196
+ }).start();
197
+ }
198
+
199
+ // Create a new session using SDK
200
+ logger.debug('Creating OpenCode session');
201
+ if (options.sessionId) {
202
+ sessionId = options.sessionId;
203
+ logger.debug('Using existing session', { sessionId });
204
+ } else {
205
+ try {
206
+ sessionId = await createSession(client);
207
+ } catch (error) {
208
+ if (watchdog) { watchdog.cancel(); }
209
+ if (!externalServer) { server.close(); }
210
+ return {
211
+ summary: '',
212
+ completed: false,
213
+ timedOut: false,
214
+ taskId,
215
+ error: error.message
216
+ };
217
+ }
218
+ }
219
+ logger.debug('Session ID', { sessionId });
220
+ writeProgress(sessionDir, 'session_created');
221
+
222
+ // F3 #20: abort this session if the parent process is signalled. Record the
223
+ // Go server PID so `amicus list` liveness checks can see it. Only for the
224
+ // owned (non-shared) server — shared servers are torn down by their owner.
225
+ if (!externalServer) {
226
+ if (server && server.goPid) {
227
+ try {
228
+ const metaPath = path.join(sessionDir, 'metadata.json');
229
+ const m = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
230
+ m.goPid = server.goPid;
231
+ fs.writeFileSync(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
232
+ } catch { /* metadata optional */ }
233
+ }
234
+ const { installSignalAbort, markAborted } = require('./utils/session-abort');
235
+ let aborting = false;
236
+ uninstallSignals = installSignalAbort({
237
+ onAbort: (signal) => {
238
+ if (aborting) { return; }
239
+ aborting = true;
240
+ logger.warn('Signal received — aborting headless session', { taskId, signal });
241
+ markAborted(sessionDir, signal);
242
+ try {
243
+ const { abortSession } = require('./opencode-client');
244
+ abortSession(client, sessionId).catch(() => {});
245
+ } catch { /* best-effort */ }
246
+ try { server.close(); } catch { /* best-effort */ }
247
+ const code = signal === 'SIGINT' ? 130 : 143;
248
+ const t = setTimeout(() => process.exit(code), 300);
249
+ if (t.unref) { t.unref(); }
250
+ },
251
+ });
252
+ }
253
+
254
+ // Log user message to conversation before sending
255
+ logMessage(conversationPath, {
256
+ role: 'user',
257
+ content: userMessage,
258
+ timestamp: new Date().toISOString()
259
+ });
260
+
261
+ // Send system prompt and user message using SDK
262
+ logger.debug('Sending message to OpenCode', {
263
+ sessionId,
264
+ systemLength: systemPrompt.length,
265
+ userMessageLength: userMessage.length
266
+ });
267
+
268
+ const promptOptions = {
269
+ model: model,
270
+ system: systemPrompt,
271
+ parts: [{ type: 'text', text: userMessage }]
272
+ };
273
+
274
+ // Default to 'build' in headless mode — 'chat' stalls without user interaction
275
+ const agentConfig = mapAgentToOpenCode(agent || 'build');
276
+ promptOptions.agent = agentConfig.agent;
277
+
278
+ // Add reasoning/thinking configuration if provided
279
+ if (reasoning) {
280
+ promptOptions.reasoning = reasoning;
281
+ }
282
+
283
+ // Send prompt asynchronously (returns immediately, we poll for results)
284
+ logger.info('Sending prompt to OpenCode', {
285
+ sessionId,
286
+ model,
287
+ agent: promptOptions.agent,
288
+ userMessageLength: userMessage.length
289
+ });
290
+ await sendPromptAsync(client, sessionId, promptOptions);
291
+ writeProgress(sessionDir, 'prompt_sent');
292
+ logger.info('Prompt sent successfully, entering polling loop', {
293
+ sessionId,
294
+ timeoutMs
295
+ });
296
+
297
+ let output = '';
298
+ let completed = false;
299
+ let timedOut = false;
300
+ let aborted = false;
301
+ let sessionError = null; // Captures model/SDK errors from assistant messages
302
+ const toolCalls = [];
303
+
304
+ // Poll for completion by checking messages
305
+ const startTime = Date.now();
306
+ const deadline = startTime + timeoutMs;
307
+ let pollCount = 0;
308
+ const pollIntervalMs = options.pollIntervalMs || POLL_INTERVAL_MS;
309
+ const stableFinishedPolls = options.stableFinishedPolls || STABLE_FINISHED_POLLS;
310
+ const stableIdlePolls = options.stableIdlePolls || STABLE_IDLE_POLLS;
311
+ const pollCallTimeoutMs = options.pollCallTimeoutMs || POLL_CALL_TIMEOUT_MS;
312
+ const maxConsecutivePollFailures = options.maxConsecutivePollFailures || MAX_CONSECUTIVE_POLL_FAILURES;
313
+ let consecutivePollFailures = 0;
314
+ let pollFailureBail = false;
315
+ let lastAssistantMsgId = null;
316
+ let lastOutputLength = 0; // Track output growth to detect streaming
317
+ let stablePolls = 0; // Count polls where nothing has changed
318
+ let lastToolCallCount = 0;
319
+ let lastToolResultCount = 0;
320
+ const seenToolResultIds = new Set(); // deduplicate tool_result parts across polls
321
+ let lastMessageCount = 0;
322
+ let receivingReported = false; // Track whether 'receiving' stage was reported
323
+ const seenTextParts = new Map(); // partId -> last captured text length
324
+ // seenPartIds reserved for future use (tracking processed non-text parts)
325
+
326
+ while (!completed && (Date.now() - startTime) < timeoutMs) {
327
+ watchdog.touch();
328
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
329
+
330
+ // Check for external abort signal (MCP tool or CLI command)
331
+ try {
332
+ const metaCheck = path.join(sessionDir, 'metadata.json');
333
+ if (fs.existsSync(metaCheck)) {
334
+ const metaContent = fs.readFileSync(metaCheck, 'utf-8');
335
+ const meta = JSON.parse(metaContent);
336
+ if (meta.status === 'aborted') {
337
+ logger.info('External abort signal received', { taskId });
338
+ try {
339
+ const { abortSession } = require('./opencode-client');
340
+ await abortSession(client, sessionId);
341
+ } catch (abortErr) {
342
+ logger.warn('Failed to abort OpenCode session', { error: abortErr.message });
343
+ }
344
+ aborted = true;
345
+ break;
346
+ }
347
+ }
348
+ } catch {
349
+ // Ignore metadata read errors during polling
350
+ }
351
+ pollCount++;
352
+
353
+ try {
354
+ const remaining = deadline - Date.now();
355
+ const messages = await withTimeout(
356
+ getMessages(client, sessionId),
357
+ Math.min(pollCallTimeoutMs, remaining),
358
+ 'getMessages'
359
+ );
360
+ consecutivePollFailures = 0;
361
+ const messageCount = messages?.length || 0;
362
+
363
+ // Find the last assistant message to check if it's complete
364
+ let currentAssistantMsgId = null;
365
+ let assistantFinished = false;
366
+
367
+ if (messages && Array.isArray(messages)) {
368
+ for (const msg of messages) {
369
+ const role = msg.info?.role;
370
+
371
+ // Track assistant message state
372
+ if (role === 'assistant') {
373
+ currentAssistantMsgId = msg.info.id;
374
+ // Check for errors — capture for result propagation
375
+ if (msg.info.error) {
376
+ sessionError = msg.info.error.data?.message
377
+ || msg.info.error.name
378
+ || 'Unknown model error';
379
+ logger.error('Session error detected in assistant message', {
380
+ sessionId,
381
+ error: msg.info.error.name,
382
+ message: msg.info.error.data?.message
383
+ });
384
+ }
385
+ }
386
+
387
+ // Only process parts from assistant messages (skip user messages)
388
+ if (role !== 'assistant' || !msg.parts) {
389
+ continue;
390
+ }
391
+
392
+ for (const part of msg.parts) {
393
+ const partId = part.id || `${msg.info.id}:${part.type}:${msg.parts.indexOf(part)}`;
394
+
395
+ if (part.type === 'text' && part.text) {
396
+ const prevLen = seenTextParts.get(partId) || 0;
397
+ if (part.text.length > prevLen) {
398
+ // Append only the new portion (handles streaming growth)
399
+ const newText = part.text.slice(prevLen);
400
+ output += newText;
401
+ seenTextParts.set(partId, part.text.length);
402
+ logMessage(conversationPath, {
403
+ role: 'assistant',
404
+ content: newText,
405
+ timestamp: new Date().toISOString()
406
+ });
407
+
408
+ // Report 'receiving' stage on first text detection
409
+ if (!receivingReported) {
410
+ receivingReported = true;
411
+ writeProgress(sessionDir, 'receiving', { messagesReceived: 1 });
412
+ }
413
+ }
414
+ } else if ((part.type === 'tool_use' || part.type === 'tool') && !toolCalls.find(t => t.id === part.id)) {
415
+ const toolCall = {
416
+ id: part.id,
417
+ name: part.name,
418
+ input: part.input
419
+ };
420
+ toolCalls.push(toolCall);
421
+ logger.debug('Tool call detected (polling)', {
422
+ toolName: part.name,
423
+ toolId: part.id,
424
+ subagentType: part.input?.subagent_type,
425
+ model: part.input?.model
426
+ });
427
+ logMessage(conversationPath, {
428
+ role: 'assistant',
429
+ type: 'tool_use',
430
+ toolCall,
431
+ timestamp: new Date().toISOString()
432
+ });
433
+
434
+ // Update progress on tool_use detection
435
+ const toolLabel = part.name
436
+ ? `Calling tool: ${part.name}`
437
+ : 'Executing tool call...';
438
+ writeProgress(sessionDir, 'receiving', {
439
+ messagesReceived: toolCalls.length,
440
+ latestTool: part.name || undefined,
441
+ stageLabel: toolLabel
442
+ });
443
+ receivingReported = true;
444
+ } else if (part.type === 'tool_result') {
445
+ if (!seenToolResultIds.has(partId)) {
446
+ seenToolResultIds.add(partId);
447
+ }
448
+ logger.debug('Tool result received (polling)', {
449
+ toolUseId: part.tool_use_id,
450
+ isError: part.is_error || false
451
+ });
452
+ logMessage(conversationPath, {
453
+ role: 'tool',
454
+ type: 'tool_result',
455
+ toolUseId: part.tool_use_id,
456
+ isError: part.is_error || false,
457
+ content: part.content,
458
+ timestamp: new Date().toISOString()
459
+ });
460
+ }
461
+ }
462
+ }
463
+
464
+ // assistantFinished = true only when the LAST assistant message is complete
465
+ // (earlier messages may finish while the model continues in new messages)
466
+ const lastAssistant = messages
467
+ .filter(m => m.info?.role === 'assistant')
468
+ .pop();
469
+ assistantFinished = !!(lastAssistant?.info?.time?.completed);
470
+ }
471
+
472
+ logger.debug('Poll status', {
473
+ pollCount,
474
+ messageCount,
475
+ assistantFinished,
476
+ outputLength: output.length,
477
+ elapsed: Date.now() - startTime
478
+ });
479
+
480
+ // Check for completion marker on its own line (not inline in prose).
481
+ // Models may mention [SIDECAR_FOLD] when describing code — only treat
482
+ // it as a signal when it appears as a standalone line.
483
+ if (/^\s*\[SIDECAR_FOLD\]\s*$/m.test(output)) {
484
+ completed = true;
485
+ break;
486
+ }
487
+
488
+ // If the model returned an error with no output, exit immediately
489
+ // (don't wait for timeout — the model won't produce anything)
490
+ if (sessionError && !output && assistantFinished) {
491
+ logger.error('Model returned error with no output, exiting', {
492
+ sessionError, pollCount
493
+ });
494
+ break;
495
+ }
496
+
497
+ // Authoritative idle signal from the OpenCode SDK (preferred over the heuristic).
498
+ // Gate on real output so a pre-processing 'idle' cannot end the run early.
499
+ // Best-effort: on any error, fall back to the activity heuristic below.
500
+ if (output.length > 0) {
501
+ try {
502
+ const remainingForStatus = deadline - Date.now();
503
+ const statusData = await withTimeout(
504
+ getSessionStatus(client, sessionId),
505
+ Math.min(pollCallTimeoutMs, remainingForStatus),
506
+ 'getSessionStatus'
507
+ );
508
+ const s = (statusData && statusData.type) ? statusData : (statusData && statusData[sessionId]);
509
+ if (s && s.type === 'idle') {
510
+ logger.debug('Session reported idle by SDK — completing', { sessionId });
511
+ break;
512
+ }
513
+ } catch (statusErr) {
514
+ logger.debug('session.status unavailable; using activity heuristic', { error: statusErr.message });
515
+ }
516
+ }
517
+
518
+ // Activity-aware idle detection: ANY of text growth, a new tool call, a new
519
+ // tool result, a new message, or a new assistant message id counts as progress.
520
+ // Only count toward completion when NOTHING changed (genuine idle).
521
+ const outputGrew = output.length > lastOutputLength;
522
+ lastOutputLength = output.length;
523
+ const toolActivity = toolCalls.length > lastToolCallCount;
524
+ lastToolCallCount = toolCalls.length;
525
+ const resultActivity = seenToolResultIds.size > lastToolResultCount;
526
+ lastToolResultCount = seenToolResultIds.size;
527
+ const messageActivity = messageCount > lastMessageCount;
528
+ lastMessageCount = messageCount;
529
+ const newAssistant = currentAssistantMsgId !== lastAssistantMsgId;
530
+
531
+ const progressed = outputGrew || toolActivity || resultActivity || messageActivity || newAssistant;
532
+
533
+ if (!progressed) {
534
+ // Require real output before counting toward completion — the SDK creates an
535
+ // empty assistant-message placeholder on promptAsync that is NOT a finished response.
536
+ if (currentAssistantMsgId !== null && output.length > 0) {
537
+ stablePolls++;
538
+ const threshold = assistantFinished ? stableFinishedPolls : stableIdlePolls;
539
+ if (stablePolls >= threshold) {
540
+ logger.debug('Session appears complete (idle)', { stablePolls, assistantFinished });
541
+ break;
542
+ }
543
+ } else {
544
+ logger.debug('Waiting for model to produce output', {
545
+ pollCount, hasAssistantMsg: currentAssistantMsgId !== null, outputLength: output.length
546
+ });
547
+ }
548
+ } else {
549
+ stablePolls = 0;
550
+ }
551
+ lastAssistantMsgId = currentAssistantMsgId;
552
+
553
+ } catch (pollError) {
554
+ consecutivePollFailures++;
555
+ logger.debug('Polling error', {
556
+ error: pollError.message, consecutivePollFailures
557
+ });
558
+ if (consecutivePollFailures >= maxConsecutivePollFailures) {
559
+ // F4: a dead server otherwise burns the full timeout in futile polls.
560
+ sessionError = sessionError
561
+ || `Polling failed ${consecutivePollFailures} consecutive times: ${pollError.message}`;
562
+ logger.error('Exiting poll loop after consecutive failures', {
563
+ consecutivePollFailures, taskId
564
+ });
565
+ pollFailureBail = true;
566
+ break;
567
+ }
568
+ }
569
+ }
570
+
571
+ // Log why the polling loop exited
572
+ const logLevel = (completed && !sessionError) ? 'info' : 'error';
573
+ logger[logLevel]('Polling loop exited', {
574
+ taskId,
575
+ completed,
576
+ aborted,
577
+ pollCount,
578
+ stablePolls,
579
+ outputLength: output.length,
580
+ elapsed: Date.now() - startTime,
581
+ hasAssistantMsg: lastAssistantMsgId !== null,
582
+ sessionError: sessionError || null
583
+ });
584
+
585
+ // Handle timeout
586
+ if (!completed && !aborted && (Date.now() - startTime) >= timeoutMs) {
587
+ timedOut = true;
588
+ logger.warn('Task timed out', { taskId, elapsed: Date.now() - startTime });
589
+
590
+ // Abort the OpenCode session on timeout (agent keeps running otherwise)
591
+ try {
592
+ const { abortSession } = require('./opencode-client');
593
+ await abortSession(client, sessionId);
594
+ logger.info('Session aborted after timeout', { taskId, sessionId });
595
+ } catch (abortErr) {
596
+ logger.warn('Failed to abort session after timeout', { error: abortErr.message });
597
+ }
598
+ }
599
+
600
+ watchdog.cancel();
601
+ if (uninstallSignals) { uninstallSignals(); }
602
+ if (!externalServer) { server.close(); }
603
+
604
+ // Log summary of tool calls for debugging
605
+ if (toolCalls.length > 0) {
606
+ logger.info('Tool calls summary', {
607
+ totalToolCalls: toolCalls.length,
608
+ taskToolCalls: toolCalls.filter(t => t.name === 'Task').length,
609
+ subagentTypes: toolCalls
610
+ .filter(t => t.name === 'Task' && t.input?.subagent_type)
611
+ .map(t => ({ type: t.input.subagent_type, model: t.input.model || 'inherited' }))
612
+ });
613
+ }
614
+
615
+ // Propagate the error when the model errored with no output (F1 semantics:
616
+ // a model error alongside streamed output still yields a usable summary),
617
+ // and ALWAYS when the poll loop bailed on consecutive failures (F4: a dead
618
+ // server must never classify as a complete leg, even with partial output).
619
+ if (sessionError && (!output || pollFailureBail)) {
620
+ return {
621
+ summary: output ? extractSummary(output) : '',
622
+ completed: false,
623
+ timedOut,
624
+ aborted,
625
+ taskId,
626
+ toolCalls,
627
+ error: sessionError
628
+ };
629
+ }
630
+
631
+ return {
632
+ summary: extractSummary(output),
633
+ completed,
634
+ timedOut,
635
+ aborted,
636
+ taskId,
637
+ toolCalls, // Include tool calls in result for verification
638
+ exitCode: 0
639
+ };
640
+
641
+ } catch (error) {
642
+ logger.error('runHeadless caught exception', {
643
+ taskId,
644
+ error: error.message,
645
+ stack: error.stack?.split('\n').slice(0, 3).join(' | ')
646
+ });
647
+ // Abort session on error (agent may keep running)
648
+ if (sessionId) {
649
+ try {
650
+ const { abortSession } = require('./opencode-client');
651
+ await abortSession(client, sessionId);
652
+ } catch {
653
+ // Ignore abort errors during error handling
654
+ }
655
+ }
656
+ if (watchdog) { watchdog.cancel(); }
657
+ if (uninstallSignals) { uninstallSignals(); }
658
+ if (!externalServer) { server.close(); }
659
+ return {
660
+ summary: '',
661
+ completed: false,
662
+ timedOut: false,
663
+ aborted: false,
664
+ taskId,
665
+ error: error.message
666
+ };
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Extract summary from output (everything before [SIDECAR_FOLD])
672
+ * Spec Reference: §6.2 - Return summary (everything before [SIDECAR_FOLD])
673
+ *
674
+ * @param {string} output - Raw output from OpenCode
675
+ * @returns {string} Extracted summary
676
+ */
677
+ function extractSummary(output) {
678
+ if (!output) {
679
+ return '';
680
+ }
681
+
682
+ // Split on the fold marker only when it appears on its own line.
683
+ // Models may mention [SIDECAR_FOLD] inline when describing code —
684
+ // only treat it as a delimiter when standalone.
685
+ const markerRegex = /^\s*\[SIDECAR_FOLD\]\s*$/m;
686
+ const match = output.match(markerRegex);
687
+ if (match) {
688
+ return output.slice(0, match.index).trim();
689
+ }
690
+ return output.trim();
691
+ }
692
+
693
+ /**
694
+ * Format a structured fold output with metadata
695
+ * @param {Object} options - Fold output options
696
+ * @param {string} options.model - Model identifier
697
+ * @param {string} options.sessionId - Session identifier
698
+ * @param {string} [options.client='code-local'] - Client identifier
699
+ * @param {string} [options.cwd] - Working directory (defaults to process.cwd())
700
+ * @param {string} [options.mode='headless'] - Execution mode
701
+ * @param {string} options.summary - Summary text
702
+ * @returns {string} Formatted fold output
703
+ */
704
+ function formatFoldOutput({ model, sessionId, client, cwd, mode, summary }) {
705
+ return [
706
+ '[SIDECAR_FOLD]',
707
+ `Model: ${model}`,
708
+ `Session: ${sessionId}`,
709
+ `Client: ${client || 'code-local'}`,
710
+ `CWD: ${cwd || process.cwd()}`,
711
+ `Mode: ${mode || 'headless'}`,
712
+ '---',
713
+ summary
714
+ ].join('\n');
715
+ }
716
+
717
+ /**
718
+ * Log a message to the conversation JSONL file
719
+ * Spec Reference: §8.2 - Capture conversation to JSONL in real-time
720
+ *
721
+ * @param {string} conversationPath - Path to conversation.jsonl
722
+ * @param {object} message - Message object with role, content, timestamp
723
+ */
724
+ function logMessage(conversationPath, message) {
725
+ fs.appendFileSync(conversationPath, JSON.stringify(message) + '\n', { mode: 0o600 });
726
+ }
727
+
728
+ module.exports = {
729
+ runHeadless,
730
+ waitForServer,
731
+ withTimeout,
732
+ extractSummary,
733
+ formatFoldOutput,
734
+ DEFAULT_TIMEOUT,
735
+ FOLD_MARKER,
736
+ COMPLETE_MARKER,
737
+ POLL_INTERVAL_MS,
738
+ STABLE_FINISHED_POLLS,
739
+ STABLE_IDLE_POLLS,
740
+ POLL_CALL_TIMEOUT_MS,
741
+ MAX_CONSECUTIVE_POLL_FAILURES,
742
+ };