amicus 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/bin/amicus.js +382 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +5 -0
- package/electron/fold.js +163 -0
- package/electron/ipc-setup.js +176 -0
- package/electron/load-failsafe.js +85 -0
- package/electron/main.js +468 -0
- package/electron/preload-setup.js +38 -0
- package/electron/preload.js +33 -0
- package/electron/setup-ui-alias-script.js +218 -0
- package/electron/setup-ui-aliases.js +85 -0
- package/electron/setup-ui-keys-script.js +115 -0
- package/electron/setup-ui-keys.js +97 -0
- package/electron/setup-ui-model.js +138 -0
- package/electron/setup-ui-styles.js +327 -0
- package/electron/setup-ui.js +465 -0
- package/electron/summary.js +118 -0
- package/electron/toolbar.js +229 -0
- package/electron/window-position.js +35 -0
- package/package.json +98 -0
- package/scripts/postinstall.js +193 -0
- package/scripts/setup-hooks.js +42 -0
- package/skill/SKILL.md +976 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
- package/skills/second-opinion/MODEL-NOTES.md +104 -0
- package/skills/second-opinion/SKILL.md +389 -0
- package/src/cli-handlers.js +188 -0
- package/src/cli.js +400 -0
- package/src/conflict.js +144 -0
- package/src/context-compression.js +102 -0
- package/src/context.js +199 -0
- package/src/drift.js +144 -0
- package/src/environment.js +157 -0
- package/src/headless.js +742 -0
- package/src/index.js +106 -0
- package/src/jsonl-parser.js +180 -0
- package/src/mcp-server.js +625 -0
- package/src/mcp-tools.js +407 -0
- package/src/opencode-client.js +615 -0
- package/src/prompt-builder.js +355 -0
- package/src/prompts/cowork-agent-prompt.js +118 -0
- package/src/session-manager.js +414 -0
- package/src/session.js +180 -0
- package/src/sidecar/context-builder.js +297 -0
- package/src/sidecar/continue.js +212 -0
- package/src/sidecar/crash-handler.js +56 -0
- package/src/sidecar/fanout-leg.js +107 -0
- package/src/sidecar/fanout-output.js +46 -0
- package/src/sidecar/fanout.js +236 -0
- package/src/sidecar/interactive.js +217 -0
- package/src/sidecar/models.js +135 -0
- package/src/sidecar/progress.js +218 -0
- package/src/sidecar/read.js +183 -0
- package/src/sidecar/resume.js +221 -0
- package/src/sidecar/session-utils.js +288 -0
- package/src/sidecar/setup-window.js +79 -0
- package/src/sidecar/setup.js +280 -0
- package/src/sidecar/start.js +251 -0
- package/src/utils/agent-mapping.js +138 -0
- package/src/utils/alias-audit.js +98 -0
- package/src/utils/alias-resolver.js +77 -0
- package/src/utils/api-key-store.js +259 -0
- package/src/utils/api-key-validation.js +97 -0
- package/src/utils/auth-json.js +109 -0
- package/src/utils/config.js +291 -0
- package/src/utils/curated-models.js +82 -0
- package/src/utils/env-compat.js +38 -0
- package/src/utils/env-loader.js +54 -0
- package/src/utils/idle-watchdog.js +225 -0
- package/src/utils/input-validators.js +127 -0
- package/src/utils/lifecycle.js +43 -0
- package/src/utils/logger.js +84 -0
- package/src/utils/mcp-discovery.js +194 -0
- package/src/utils/mcp-validators.js +78 -0
- package/src/utils/model-catalog.js +103 -0
- package/src/utils/model-fetcher.js +179 -0
- package/src/utils/model-validator.js +207 -0
- package/src/utils/path-setup.js +41 -0
- package/src/utils/port-pid.js +39 -0
- package/src/utils/prompt-source.js +53 -0
- package/src/utils/result-schema.js +261 -0
- package/src/utils/server-setup.js +93 -0
- package/src/utils/session-abort.js +53 -0
- package/src/utils/session-lock.js +95 -0
- package/src/utils/shared-server.js +216 -0
- package/src/utils/start-helpers.js +76 -0
- package/src/utils/thinking-validators.js +92 -0
- package/src/utils/update-notifier-loader.js +18 -0
- package/src/utils/updater.js +157 -0
- package/src/utils/validators.js +300 -0
package/src/headless.js
ADDED
|
@@ -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
|
+
};
|