amicus 1.9.1 → 2.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +138 -0
- package/README.md +40 -170
- package/bin/amicus.js +14 -20
- package/commands/council.md +3 -1
- package/electron/fold.js +10 -1
- package/electron/ipc-setup.js +10 -15
- package/electron/main.js +21 -16
- package/electron/preload-setup.js +0 -1
- package/electron/setup-ui-council.js +64 -10
- package/electron/setup-ui-styles.js +34 -3
- package/electron/setup-ui.js +44 -12
- package/package.json +2 -5
- package/skills/second-opinion/MODEL-NOTES.md +2 -2
- package/skills/second-opinion/SKILL.md +24 -23
- package/skills/sidecar/SKILL.md +3 -3
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +7 -0
- package/src/cli-handlers-run.js +4 -4
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli.js +35 -0
- package/src/council/presets-cli.js +141 -0
- package/src/headless.js +146 -38
- package/src/index.js +1 -9
- package/src/mcp-server.js +132 -108
- package/src/mcp-tools.js +27 -3
- package/src/mcp-wait.js +8 -5
- package/src/opencode-client.js +33 -10
- package/src/prompt-builder.js +32 -11
- package/src/session-manager.js +7 -14
- package/src/sidecar/continue.js +12 -5
- package/src/sidecar/conversation-mirror.js +22 -1
- package/src/sidecar/crash-handler.js +2 -1
- package/src/sidecar/fanout-leg.js +12 -3
- package/src/sidecar/fanout.js +27 -10
- package/src/sidecar/interactive-process.js +6 -17
- package/src/sidecar/interactive.js +5 -6
- package/src/sidecar/models.js +33 -4
- package/src/sidecar/progress.js +2 -1
- package/src/sidecar/read.js +4 -6
- package/src/sidecar/resume.js +19 -4
- package/src/sidecar/session-finalize.js +2 -1
- package/src/sidecar/session-utils.js +13 -35
- package/src/sidecar/setup-window.js +2 -3
- package/src/sidecar/start.js +22 -7
- package/src/utils/abort-coordinator.js +57 -7
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/env-loader.js +1 -2
- package/src/utils/fold-marker.js +79 -0
- package/src/utils/idle-watchdog.js +9 -12
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +29 -5
- package/src/utils/mcp-self-identity.js +12 -5
- package/src/utils/model-catalog.js +54 -6
- package/src/utils/read-slice.js +73 -0
- package/src/utils/remediation-hints.js +9 -0
- package/src/utils/result-schema.js +8 -2
- package/src/utils/session-abort.js +1 -1
- package/src/utils/session-index-tmp-sweep.js +80 -0
- package/src/utils/session-index.js +4 -5
- package/src/utils/session-path.js +6 -10
- package/src/utils/shared-server.js +7 -5
- package/src/utils/spend-ledger.js +80 -0
- package/src/utils/updater.js +2 -3
- package/src/utils/env-compat.js +0 -38
package/src/prompt-builder.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Spec Reference: §6 Fold Mechanism, §9 Implementation
|
|
5
5
|
* Constructs system prompts for sidecar sessions in both interactive and headless modes.
|
|
6
6
|
*/
|
|
7
|
+
const { buildFoldMarker } = require('./utils/fold-marker');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Summary template for fold output per spec §6.1
|
|
@@ -77,6 +78,16 @@ function buildSystemPrompt(briefing, context, project, headless, mode, client) {
|
|
|
77
78
|
* @param {string} [mode='code'] - Agent mode ('code', 'ask', or 'plan')
|
|
78
79
|
* @param {string} [summaryLength='normal'] - Desired summary length for headless mode
|
|
79
80
|
* @param {string} [client='code-local'] - Client type for branding
|
|
81
|
+
* @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual). Only used in
|
|
82
|
+
* headless mode: the model is instructed to emit `[SIDECAR_FOLD:<nonce>]` instead of
|
|
83
|
+
* the legacy bare `[SIDECAR_FOLD]`, so runHeadless's detector (which must be given
|
|
84
|
+
* this SAME nonce) can't be forced into completing by a model that merely echoes the
|
|
85
|
+
* public, guessable bare marker. Callers that build a headless prompt and intend to
|
|
86
|
+
* run it should generate one nonce (utils/fold-marker.generateFoldNonce()) BEFORE
|
|
87
|
+
* calling buildPrompts, pass it here, and pass the SAME value to runHeadless's
|
|
88
|
+
* options.nonce. Omitted in interactive mode (GUI fold is exit-code driven, not
|
|
89
|
+
* marker-detected) and harmless to omit in headless mode too — buildHeadlessModeSection
|
|
90
|
+
* falls back to the legacy bare marker, matching runHeadless's own no-nonce fallback.
|
|
80
91
|
* @returns {{system: string, userMessage: string}} Separated prompts
|
|
81
92
|
*
|
|
82
93
|
* @example
|
|
@@ -89,16 +100,19 @@ function buildSystemPrompt(briefing, context, project, headless, mode, client) {
|
|
|
89
100
|
* );
|
|
90
101
|
* // Use: POST /session/:id/message { system, parts: [{ type: 'text', text: userMessage }] }
|
|
91
102
|
*/
|
|
92
|
-
function buildPrompts(briefing, context, project, headless, mode, summaryLength = 'normal', client) {
|
|
103
|
+
function buildPrompts(briefing, context, project, headless, mode, summaryLength = 'normal', client, nonce) {
|
|
93
104
|
const systemSections = [
|
|
94
105
|
buildHeader(client),
|
|
95
106
|
buildEnvironmentSection(project, mode),
|
|
96
|
-
headless ? buildHeadlessModeSection(summaryLength) : buildInteractiveModeSection()
|
|
107
|
+
headless ? buildHeadlessModeSection(summaryLength, nonce) : buildInteractiveModeSection()
|
|
97
108
|
];
|
|
98
109
|
|
|
99
|
-
// Strip
|
|
100
|
-
//
|
|
101
|
-
|
|
110
|
+
// Strip fold markers from context so the model doesn't mimic them from
|
|
111
|
+
// previous sidecar outputs in the conversation history. Matches BOTH the
|
|
112
|
+
// legacy bare `[SIDECAR_FOLD]` and any nonced `[SIDECAR_FOLD:<nonce>]` —
|
|
113
|
+
// a resumed/continued conversation's history can carry either shape
|
|
114
|
+
// depending on when the prior turn ran (15b.3).
|
|
115
|
+
const cleanContext = context ? context.replace(/\[SIDECAR_FOLD(:[^\]]*)?\]/g, '') : context;
|
|
102
116
|
|
|
103
117
|
let userMessage;
|
|
104
118
|
if (headless) {
|
|
@@ -237,9 +251,16 @@ Keep track of key findings as you work.`;
|
|
|
237
251
|
* Spec Reference: §6.2 Headless Mode
|
|
238
252
|
*
|
|
239
253
|
* @param {string} summaryLength - Desired summary length (brief, normal, verbose)
|
|
254
|
+
* @param {string} [nonce] - Per-run fold nonce (15b.3, #BL-7 residual). When provided,
|
|
255
|
+
* the model is instructed to emit `[SIDECAR_FOLD:<nonce>]` instead of the legacy bare
|
|
256
|
+
* `[SIDECAR_FOLD]` — see buildPrompts' @param doc for the full rationale. Falls back to
|
|
257
|
+
* the legacy bare marker when omitted (keeps this function usable standalone / by the
|
|
258
|
+
* deprecated buildSystemPrompt(), which has no orchestration-layer caller to source a
|
|
259
|
+
* nonce from).
|
|
240
260
|
* @returns {string}
|
|
241
261
|
*/
|
|
242
|
-
function buildHeadlessModeSection(summaryLength) {
|
|
262
|
+
function buildHeadlessModeSection(summaryLength, nonce) {
|
|
263
|
+
const marker = nonce ? buildFoldMarker(nonce) : '[SIDECAR_FOLD]';
|
|
243
264
|
let summaryFormat = `## Summary Format
|
|
244
265
|
|
|
245
266
|
When complete, output your findings in this format:
|
|
@@ -266,7 +287,7 @@ When complete, output your findings in this format:
|
|
|
266
287
|
|
|
267
288
|
**Open Questions:** (if any)
|
|
268
289
|
|
|
269
|
-
|
|
290
|
+
${marker}`;
|
|
270
291
|
|
|
271
292
|
if (summaryLength === 'brief') {
|
|
272
293
|
summaryFormat = `## Summary Format
|
|
@@ -281,7 +302,7 @@ When complete, output a BRIEF summary in this format:
|
|
|
281
302
|
**Recommendations:**
|
|
282
303
|
[Suggested actions]
|
|
283
304
|
|
|
284
|
-
|
|
305
|
+
${marker}`;
|
|
285
306
|
} else if (summaryLength === 'verbose') {
|
|
286
307
|
// Verbose could include more details or examples
|
|
287
308
|
summaryFormat = `## Summary Format (VERBOSE)
|
|
@@ -315,7 +336,7 @@ When complete, output a COMPREHENSIVE summary in this format, including all deta
|
|
|
315
336
|
**Open Questions:** (if any)
|
|
316
337
|
[List all remaining ambiguities, unresolved issues, or areas requiring further investigation.]
|
|
317
338
|
|
|
318
|
-
|
|
339
|
+
${marker}`;
|
|
319
340
|
}
|
|
320
341
|
|
|
321
342
|
return `## HEADLESS MODE INSTRUCTIONS
|
|
@@ -324,14 +345,14 @@ You are running autonomously without human interaction.
|
|
|
324
345
|
|
|
325
346
|
1. Execute the task completely
|
|
326
347
|
2. Make reasonable assumptions and document them
|
|
327
|
-
3. When done, output your summary followed by
|
|
348
|
+
3. When done, output your summary followed by ${marker}
|
|
328
349
|
|
|
329
350
|
Do NOT ask questions. Work independently.
|
|
330
351
|
|
|
331
352
|
If you encounter a blocker you cannot resolve:
|
|
332
353
|
1. Document what you tried
|
|
333
354
|
2. Output partial results
|
|
334
|
-
3. End with
|
|
355
|
+
3. End with ${marker}
|
|
335
356
|
|
|
336
357
|
${summaryFormat}`;
|
|
337
358
|
}
|
package/src/session-manager.js
CHANGED
|
@@ -21,9 +21,6 @@ const SESSION_STATUS = {
|
|
|
21
21
|
|
|
22
22
|
/** Canonical session dir name — new sessions are written here. */
|
|
23
23
|
const SESSIONS_DIR = 'amicus_sessions';
|
|
24
|
-
// DEPRECATED(amicus-shim): legacy session dir read for pre-rebrand sessions.
|
|
25
|
-
// Remove in a future revision — see docs/SHIMS.md.
|
|
26
|
-
const LEGACY_SESSIONS_DIR = 'sidecar_sessions';
|
|
27
24
|
|
|
28
25
|
/**
|
|
29
26
|
* Get the canonical session directory path for a task (used for WRITES).
|
|
@@ -50,19 +47,16 @@ function getSessionDir(projectDir, taskId) {
|
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
/**
|
|
53
|
-
* Resolve an EXISTING session dir for reads
|
|
54
|
-
*
|
|
50
|
+
* Resolve an EXISTING session dir for reads. Currently identical to
|
|
51
|
+
* getSessionDir (kept as a distinct name for call-site clarity/history —
|
|
52
|
+
* pre-#19 it also probed a legacy session-dir root).
|
|
55
53
|
*
|
|
56
54
|
* @param {string} projectDir - Project directory path
|
|
57
55
|
* @param {string} taskId - Sidecar task ID
|
|
58
|
-
* @returns {string} Path to the resolved session directory
|
|
56
|
+
* @returns {string} Path to the resolved session directory
|
|
59
57
|
*/
|
|
60
58
|
function resolveExistingSessionDir(projectDir, taskId) {
|
|
61
|
-
|
|
62
|
-
if (fs.existsSync(current)) { return current; }
|
|
63
|
-
const legacy = path.join(projectDir, '.claude', LEGACY_SESSIONS_DIR, taskId);
|
|
64
|
-
if (fs.existsSync(legacy)) { return legacy; }
|
|
65
|
-
return current; // default to the new path
|
|
59
|
+
return getSessionDir(projectDir, taskId);
|
|
66
60
|
}
|
|
67
61
|
|
|
68
62
|
/**
|
|
@@ -299,7 +293,7 @@ function createSubagentSession(projectDir, parentTaskId, subagentId, metadata) {
|
|
|
299
293
|
};
|
|
300
294
|
|
|
301
295
|
// Write metadata
|
|
302
|
-
|
|
296
|
+
writeFileAtomic(
|
|
303
297
|
path.join(subagentDir, 'metadata.json'),
|
|
304
298
|
JSON.stringify(subagentMetadata, null, 2),
|
|
305
299
|
{ mode: 0o600 }
|
|
@@ -329,7 +323,7 @@ function updateSubagentSession(projectDir, parentTaskId, subagentId, updates) {
|
|
|
329
323
|
|
|
330
324
|
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
|
331
325
|
const updated = { ...metadata, ...updates };
|
|
332
|
-
|
|
326
|
+
writeFileAtomic(metadataPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
|
333
327
|
}
|
|
334
328
|
|
|
335
329
|
/**
|
|
@@ -418,7 +412,6 @@ module.exports = {
|
|
|
418
412
|
getSessionDir,
|
|
419
413
|
resolveExistingSessionDir,
|
|
420
414
|
SESSIONS_DIR,
|
|
421
|
-
LEGACY_SESSIONS_DIR,
|
|
422
415
|
SESSION_STATUS,
|
|
423
416
|
// Sub-agent functions
|
|
424
417
|
getSubagentDir,
|
package/src/sidecar/continue.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
|
|
8
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
8
9
|
const { generateTaskId, runInteractive, buildMcpConfig } = require('./start');
|
|
9
10
|
const {
|
|
10
11
|
SessionPaths,
|
|
@@ -16,6 +17,7 @@ const {
|
|
|
16
17
|
const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
17
18
|
const { runHeadless } = require('../headless');
|
|
18
19
|
const { buildPrompts } = require('../prompt-builder');
|
|
20
|
+
const { generateFoldNonce } = require('../utils/fold-marker');
|
|
19
21
|
const { logger } = require('../utils/logger');
|
|
20
22
|
|
|
21
23
|
/** Load previous session data (metadata, summary, conversation) */
|
|
@@ -103,7 +105,7 @@ function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
|
103
105
|
continuesFrom: oldTaskId
|
|
104
106
|
};
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
writeFileAtomic(SessionPaths.metadataFile(sessionDir), JSON.stringify(metadata, null, 2));
|
|
107
109
|
|
|
108
110
|
return sessionDir;
|
|
109
111
|
}
|
|
@@ -151,9 +153,14 @@ async function continueSidecar(options) {
|
|
|
151
153
|
// Inherit agent from previous session if not specified
|
|
152
154
|
const effectiveAgent = agent || oldMetadata.agent || 'Build';
|
|
153
155
|
|
|
156
|
+
// 15b.3: a continuation builds a FRESH prompt (unlike resume, which re-sends
|
|
157
|
+
// the original), so it gets a fresh nonce too — the old session's nonce has
|
|
158
|
+
// no bearing on this new one.
|
|
159
|
+
const foldNonce = generateFoldNonce();
|
|
160
|
+
|
|
154
161
|
// Build system prompt and user message
|
|
155
162
|
const { system: systemPrompt, userMessage } = buildPrompts(
|
|
156
|
-
briefing, fullContext, project, headless, effectiveAgent, 'normal', client
|
|
163
|
+
briefing, fullContext, project, headless, effectiveAgent, 'normal', client, foldNonce
|
|
157
164
|
);
|
|
158
165
|
|
|
159
166
|
// Use provided task ID (from MCP server) or generate a new one
|
|
@@ -180,7 +187,7 @@ async function continueSidecar(options) {
|
|
|
180
187
|
if (headless) {
|
|
181
188
|
result = await runHeadless(
|
|
182
189
|
model, systemPrompt, userMessage, newTaskId, project,
|
|
183
|
-
timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
|
|
190
|
+
timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
|
|
184
191
|
);
|
|
185
192
|
summary = result.summary ||
|
|
186
193
|
'## Sidecar Results: No Output\n\nContinued session completed without summary.';
|
|
@@ -191,7 +198,7 @@ async function continueSidecar(options) {
|
|
|
191
198
|
logger.info('Launching interactive continue', { taskId: newTaskId, model });
|
|
192
199
|
result = await runInteractive(
|
|
193
200
|
model, systemPrompt, userMessage, newTaskId, project,
|
|
194
|
-
{ agent: effectiveAgent, mcp: mcpServers }
|
|
201
|
+
{ agent: effectiveAgent, mcp: mcpServers, foldNonce }
|
|
195
202
|
);
|
|
196
203
|
summary = result.summary || '';
|
|
197
204
|
if (result.error) { logger.error('Interactive continue error', { taskId: newTaskId, error: result.error }); }
|
|
@@ -220,7 +227,7 @@ async function continueSidecar(options) {
|
|
|
220
227
|
meta.status = 'error';
|
|
221
228
|
meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
222
229
|
meta.completedAt = new Date().toISOString();
|
|
223
|
-
|
|
230
|
+
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
224
231
|
logger.error('Continuation completed with error', { taskId: newTaskId, error: meta.reason });
|
|
225
232
|
} else {
|
|
226
233
|
finalizeSession(sessionDir, summary, project, meta, { status: terminal.status });
|
|
@@ -24,6 +24,7 @@ function createMirrorState() {
|
|
|
24
24
|
toolCalls: [], // [{id,name,input}] — capped at MAX_TOOL_CALLS (most-recent-N)
|
|
25
25
|
seenToolCallIds: new Set(), // stable dedup identity for tool calls (survives the cap)
|
|
26
26
|
seenToolResultIds: new Set(),
|
|
27
|
+
pendingToolCalls: new Map(), // id -> {id,name,firstSeenAt} — tool_use with no tool_result yet (B53)
|
|
27
28
|
receivingReported: false,
|
|
28
29
|
output: '', // accumulated assistant text
|
|
29
30
|
seenReasoningParts: new Map(), // partId -> last captured reasoning length
|
|
@@ -32,6 +33,19 @@ function createMirrorState() {
|
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Unresolved tool calls: tool_use ids seen with no matching tool_result yet
|
|
38
|
+
* (matched by `part.tool_use_id`). Used by the headless poll loop's stall
|
|
39
|
+
* detector (B53) to fail fast on a wedged tool call instead of burning the
|
|
40
|
+
* full timeout. Returns a fresh array each call; `state.pendingToolCalls`
|
|
41
|
+
* is the live source of truth.
|
|
42
|
+
* @param {object} state from createMirrorState()
|
|
43
|
+
* @returns {Array<{id:string,name:string,firstSeenAt:string}>}
|
|
44
|
+
*/
|
|
45
|
+
function getPendingToolCalls(state) {
|
|
46
|
+
return Array.from(state.pendingToolCalls.values());
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
/**
|
|
36
50
|
* @param {Array} messages getMessages() snapshot
|
|
37
51
|
* @param {object} state from createMirrorState() (mutated + returned)
|
|
@@ -94,6 +108,10 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
94
108
|
if (state.toolCalls.length > MAX_TOOL_CALLS) { state.toolCalls.shift(); }
|
|
95
109
|
appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
|
|
96
110
|
|
|
111
|
+
// Track as pending until a matching tool_result arrives (B53 stall detector).
|
|
112
|
+
// firstSeenAt is captured once here — never touched again for this id.
|
|
113
|
+
state.pendingToolCalls.set(part.id, { id: part.id, name: part.name, firstSeenAt: now() });
|
|
114
|
+
|
|
97
115
|
// Update progress on tool_use detection
|
|
98
116
|
progressUpdates.push({
|
|
99
117
|
stage: 'receiving',
|
|
@@ -117,6 +135,9 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
117
135
|
timestamp: now(),
|
|
118
136
|
});
|
|
119
137
|
}
|
|
138
|
+
// Resolved: clear the pending entry regardless of dedup state above (a
|
|
139
|
+
// result seen again for an id already cleared is a harmless no-op delete).
|
|
140
|
+
if (part.tool_use_id) { state.pendingToolCalls.delete(part.tool_use_id); }
|
|
120
141
|
} else if (part.type === 'reasoning' && part.text) {
|
|
121
142
|
// Some providers (e.g. Gemini 3.x on the direct Google path) return the
|
|
122
143
|
// answer as a reasoning part with no separate text part. Accumulate it in a
|
|
@@ -178,4 +199,4 @@ function logMessage(conversationPath, message) {
|
|
|
178
199
|
fs.appendFileSync(conversationPath, JSON.stringify(message) + '\n', { mode: 0o600 });
|
|
179
200
|
}
|
|
180
201
|
|
|
181
|
-
module.exports = { createMirrorState, mirrorMessages, logMessage };
|
|
202
|
+
module.exports = { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls };
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const { SessionPaths } = require('./session-utils');
|
|
12
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Create a crash handler that updates session metadata on error.
|
|
@@ -38,7 +39,7 @@ function installCrashHandler(taskId, project) {
|
|
|
38
39
|
metadata.reason = err.message;
|
|
39
40
|
metadata.errorAt = new Date().toISOString();
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
42
43
|
|
|
43
44
|
// Delete session lock if it exists
|
|
44
45
|
const lockPath = path.join(sessionDir, 'session.lock');
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { logger } = require('../utils/logger');
|
|
13
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
13
14
|
|
|
14
15
|
/** Map a runHeadless result to a leg metadata status. */
|
|
15
16
|
function legStatusFromResult(result) {
|
|
@@ -30,7 +31,7 @@ function writeLegPatch(legDir, patch) {
|
|
|
30
31
|
delete defined.status;
|
|
31
32
|
}
|
|
32
33
|
const merged = { ...meta, ...defined };
|
|
33
|
-
|
|
34
|
+
writeFileAtomic(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
34
35
|
return merged;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -38,7 +39,7 @@ function writeLegPatch(legDir, patch) {
|
|
|
38
39
|
* Run one leg end-to-end: session record → runHeadless (shared server) →
|
|
39
40
|
* leg finalize. Never throws — always resolves to a run document.
|
|
40
41
|
*/
|
|
41
|
-
async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet }) {
|
|
42
|
+
async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet, foldNonce }) {
|
|
42
43
|
const { IdleWatchdog } = require('../utils/idle-watchdog');
|
|
43
44
|
const { markAborted } = require('../utils/session-abort');
|
|
44
45
|
const { runHeadless } = require('../headless');
|
|
@@ -81,7 +82,7 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
|
|
|
81
82
|
result = await runHeadless(
|
|
82
83
|
leg.model, systemPrompt, userMessage, legId, project,
|
|
83
84
|
timeoutMs, agent || 'build',
|
|
84
|
-
{ client, server, watchdog, summaryLength, reasoning }
|
|
85
|
+
{ client, server, watchdog, summaryLength, reasoning, nonce: foldNonce }
|
|
85
86
|
);
|
|
86
87
|
} catch (err) {
|
|
87
88
|
result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
|
|
@@ -93,6 +94,14 @@ async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage,
|
|
|
93
94
|
const summary = result.summary || null;
|
|
94
95
|
const { resolveUsage } = require('../utils/pricing');
|
|
95
96
|
const usage = result && result.usage ? resolveUsage({ model: leg.model, usageTotals: result.usage }) : null;
|
|
97
|
+
// B24: cross-run spend ledger — one row per leg (mirrors start.js's single-run
|
|
98
|
+
// append). Best-effort; never let ledger bookkeeping affect the leg's own result.
|
|
99
|
+
if (usage) {
|
|
100
|
+
try {
|
|
101
|
+
const { appendSpend } = require('../utils/spend-ledger');
|
|
102
|
+
appendSpend({ taskId: legId, waveId, model: leg.model, mode: 'leg', usage });
|
|
103
|
+
} catch { /* best-effort */ }
|
|
104
|
+
}
|
|
96
105
|
// If setup threw before the session dir existed, there is nothing on disk to
|
|
97
106
|
// finalize — still resolve to an error run document so the wave aggregates.
|
|
98
107
|
const legPatch = {
|
package/src/sidecar/fanout.js
CHANGED
|
@@ -15,6 +15,7 @@ const path = require('path');
|
|
|
15
15
|
const { logger } = require('../utils/logger');
|
|
16
16
|
const { runLeg } = require('./fanout-leg');
|
|
17
17
|
const { ERROR_CODES } = require('../utils/error-doc');
|
|
18
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
18
19
|
|
|
19
20
|
/** Default max legs per wave (env-overridable). */
|
|
20
21
|
const DEFAULT_MAX_LEGS = 10;
|
|
@@ -86,15 +87,24 @@ async function validateFanoutModels(modelsArg, opts = {}) {
|
|
|
86
87
|
return { legs };
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* Write/merge wave metadata (preserves fields an MCP pre-spawn handler wrote).
|
|
92
|
+
* Abort-wins: once existing status is 'aborted', a patch cannot demote it back
|
|
93
|
+
* to a softer status (same precedence rule as writeLegPatch — a signal/abort
|
|
94
|
+
* marker must never lose a write race against an in-flight init/finalize).
|
|
95
|
+
*/
|
|
90
96
|
function writeWaveMetadata(waveDir, patch) {
|
|
91
97
|
const metaPath = path.join(waveDir, 'metadata.json');
|
|
92
98
|
let existing = {};
|
|
93
99
|
if (fs.existsSync(metaPath)) {
|
|
94
100
|
try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* corrupt → rewrite */ }
|
|
95
101
|
}
|
|
96
|
-
const
|
|
97
|
-
|
|
102
|
+
const safePatch = { ...patch };
|
|
103
|
+
if (existing.status === 'aborted' && safePatch.status && safePatch.status !== 'aborted') {
|
|
104
|
+
delete safePatch.status;
|
|
105
|
+
}
|
|
106
|
+
const merged = { ...existing, ...safePatch };
|
|
107
|
+
writeFileAtomic(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
98
108
|
return merged;
|
|
99
109
|
}
|
|
100
110
|
|
|
@@ -114,6 +124,7 @@ async function runFanout(options) {
|
|
|
114
124
|
const { createWaveHeartbeat } = require('./wave-progress');
|
|
115
125
|
const { buildContext } = require('./context-builder');
|
|
116
126
|
const { buildPrompts } = require('../prompt-builder');
|
|
127
|
+
const { generateFoldNonce } = require('../utils/fold-marker');
|
|
117
128
|
const { installSignalAbort, markAborted } = require('../utils/session-abort');
|
|
118
129
|
const { getSessionDir } = require('../session-manager');
|
|
119
130
|
|
|
@@ -188,8 +199,12 @@ async function runFanout(options) {
|
|
|
188
199
|
coworkProcess: options.coworkProcess,
|
|
189
200
|
})
|
|
190
201
|
: '[Context excluded by caller - briefing is self-contained]';
|
|
202
|
+
// 15b.3: ONE nonce for the whole wave — every leg shares the SAME prompt
|
|
203
|
+
// (built once, above), so every leg's model is instructed with the same
|
|
204
|
+
// nonce, and runLeg threads it to each leg's own runHeadless detector.
|
|
205
|
+
const foldNonce = generateFoldNonce();
|
|
191
206
|
const { system: systemPrompt, userMessage } = buildPrompts(
|
|
192
|
-
options.prompt, context, project, true, options.agent || 'build', options.summaryLength, options.client
|
|
207
|
+
options.prompt, context, project, true, options.agent || 'build', options.summaryLength, options.client, foldNonce
|
|
193
208
|
);
|
|
194
209
|
|
|
195
210
|
// 4. One shared OpenCode server
|
|
@@ -220,7 +235,10 @@ async function runFanout(options) {
|
|
|
220
235
|
logger.warn('Signal received — aborting wave', { waveId, signal });
|
|
221
236
|
markAborted(waveDir, signal);
|
|
222
237
|
for (const dir of legDirs) { markAborted(dir, signal); }
|
|
223
|
-
|
|
238
|
+
// close() is async (B06 escalation); this handler stays sync, so
|
|
239
|
+
// fire-and-forget with a rejection guard. The 10s exit watchdog below
|
|
240
|
+
// comfortably outlives the ~2s escalation grace inside close().
|
|
241
|
+
try { server.close().catch(() => {}); } catch { /* best-effort */ }
|
|
224
242
|
const { armExitWatchdog } = require('../utils/lifecycle');
|
|
225
243
|
armExitWatchdog(code, 10000, { log: (m, meta) => logger.debug(m, meta) });
|
|
226
244
|
},
|
|
@@ -241,11 +259,12 @@ async function runFanout(options) {
|
|
|
241
259
|
leg, legId: legIds[i], waveId, project, systemPrompt, userMessage,
|
|
242
260
|
timeoutMs, agent: options.agent, client, server,
|
|
243
261
|
summaryLength: options.summaryLength, reasoning, quiet: options.quiet,
|
|
262
|
+
foldNonce,
|
|
244
263
|
})));
|
|
245
264
|
} finally {
|
|
246
265
|
heartbeat.stop();
|
|
247
266
|
uninstallSignals();
|
|
248
|
-
try { server.close(); } catch { /* already closed on signal */ }
|
|
267
|
+
try { await server.close(); } catch { /* already closed on signal */ }
|
|
249
268
|
}
|
|
250
269
|
|
|
251
270
|
// 7. Aggregate, persist (atomic: tmp + rename), finalize, emit
|
|
@@ -255,9 +274,7 @@ async function runFanout(options) {
|
|
|
255
274
|
status: signalled ? 'aborted' : null,
|
|
256
275
|
});
|
|
257
276
|
const wavePath = path.join(waveDir, 'wave.json');
|
|
258
|
-
|
|
259
|
-
fs.writeFileSync(waveTmp, JSON.stringify(wave, null, 2), { mode: 0o600 });
|
|
260
|
-
fs.renameSync(waveTmp, wavePath);
|
|
277
|
+
writeFileAtomic(wavePath, JSON.stringify(wave, null, 2), { mode: 0o600 });
|
|
261
278
|
writeWaveMetadata(waveDir, { status: wave.status, completedAt });
|
|
262
279
|
emit(wave);
|
|
263
280
|
const exitCode = signalled
|
|
@@ -268,5 +285,5 @@ async function runFanout(options) {
|
|
|
268
285
|
|
|
269
286
|
module.exports = {
|
|
270
287
|
parseModelsList, deriveLegIds, validateFanoutModels, DEFAULT_MAX_LEGS,
|
|
271
|
-
runFanout,
|
|
288
|
+
runFanout, writeWaveMetadata,
|
|
272
289
|
};
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
|
|
8
8
|
const { logger } = require('../utils/logger');
|
|
9
|
-
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
10
9
|
|
|
11
10
|
/** Resolve the Electron binary path ONLY when the exe actually exists on disk.
|
|
12
11
|
* #54: path.txt surviving (require('electron') resolving) is NOT enough — a
|
|
@@ -31,13 +30,12 @@ function checkElectronAvailable() {
|
|
|
31
30
|
|
|
32
31
|
/** Build environment variables for Electron process */
|
|
33
32
|
function buildElectronEnv(taskId, model, project, nodeModulesBin, existingPath, options = {}) {
|
|
34
|
-
const {
|
|
33
|
+
const { client, windowPosition, sessionDirectory, foldNonce } = options;
|
|
35
34
|
const env = {
|
|
36
35
|
...process.env,
|
|
37
36
|
PATH: `${nodeModulesBin}${path.delimiter}${existingPath}`,
|
|
38
37
|
AMICUS_TASK_ID: taskId,
|
|
39
|
-
AMICUS_MODEL: model
|
|
40
|
-
SIDECAR_PROJECT: project
|
|
38
|
+
AMICUS_MODEL: model
|
|
41
39
|
};
|
|
42
40
|
|
|
43
41
|
if (client) { env.AMICUS_CLIENT = client; }
|
|
@@ -46,19 +44,10 @@ function buildElectronEnv(taskId, model, project, nodeModulesBin, existingPath,
|
|
|
46
44
|
// Web-UI route from THIS, not a fresh base64url(CWD) guess, so follow-up
|
|
47
45
|
// prompts resolve the session when process cwd != --cwd.
|
|
48
46
|
if (sessionDirectory) { env.AMICUS_SESSION_DIRECTORY = sessionDirectory; }
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (agentConfig.permissions) { env.SIDECAR_PERMISSIONS = agentConfig.permissions; }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (isResume) {
|
|
57
|
-
env.SIDECAR_RESUME = 'true';
|
|
58
|
-
if (conversation) { env.SIDECAR_CONVERSATION = conversation; }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (mcp) { env.SIDECAR_MCP_CONFIG = JSON.stringify(mcp); }
|
|
47
|
+
// 15b.3: per-run fold nonce (#BL-7 residual) — the same value baked into the
|
|
48
|
+
// system prompt's instruction (buildPrompts) so fold.js writes a marker the
|
|
49
|
+
// prompt actually asked for, not the guessable legacy bare `[SIDECAR_FOLD]`.
|
|
50
|
+
if (foldNonce) { env.AMICUS_FOLD_NONCE = foldNonce; }
|
|
62
51
|
|
|
63
52
|
return env;
|
|
64
53
|
}
|
|
@@ -11,7 +11,6 @@ const { startOpenCodeServer } = require('./session-utils');
|
|
|
11
11
|
const { createSession, sendPromptAsync, getMessages, abortSession } = require('../opencode-client');
|
|
12
12
|
const { mapAgentToOpenCode } = require('../utils/agent-mapping');
|
|
13
13
|
const { logger } = require('../utils/logger');
|
|
14
|
-
const { getCompatEnv } = require('../utils/env-compat');
|
|
15
14
|
const { startInteractiveMirror } = require('./interactive-mirror');
|
|
16
15
|
const { startAbortWatch, markResultAborted, readAbortedMarker } = require('./interactive-abort');
|
|
17
16
|
const { getSessionDir } = require('../session-manager');
|
|
@@ -34,7 +33,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
34
33
|
};
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client } = options;
|
|
36
|
+
const { agent, isResume, conversation, mcp, reasoning, opencodeSessionId, client, foldNonce } = options;
|
|
38
37
|
|
|
39
38
|
// F6c: mirror headless's lifecycle stages (best-effort — a write failure must
|
|
40
39
|
// never break the GUI) so the heartbeat/status never read "Starting up...".
|
|
@@ -101,7 +100,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
101
100
|
}
|
|
102
101
|
logger.debug('Interactive session ready', { sessionId, isResume: !!isResume });
|
|
103
102
|
} catch (error) {
|
|
104
|
-
server.close();
|
|
103
|
+
server.close().catch(() => {});
|
|
105
104
|
return {
|
|
106
105
|
summary: '', completed: false, timedOut: false, taskId,
|
|
107
106
|
error: `Session setup failed: ${error.message}`
|
|
@@ -157,12 +156,12 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
157
156
|
const existingPath = process.env.PATH || '';
|
|
158
157
|
const env = buildElectronEnv(
|
|
159
158
|
taskId, model, project, nodeModulesBin, existingPath,
|
|
160
|
-
{ agent, isResume, conversation, mcp, client, sessionDirectory }
|
|
159
|
+
{ agent, isResume, conversation, mcp, client, sessionDirectory, foldNonce }
|
|
161
160
|
);
|
|
162
161
|
env.AMICUS_OPENCODE_PORT = serverPort;
|
|
163
162
|
env.AMICUS_SESSION_ID = sessionId;
|
|
164
163
|
|
|
165
|
-
const debugPort =
|
|
164
|
+
const debugPort = process.env.AMICUS_DEBUG_PORT || '9222';
|
|
166
165
|
logger.debug('Launching Electron', { taskId, model, debugPort, serverPort, sessionId });
|
|
167
166
|
|
|
168
167
|
electronProcess = spawn(electronPath, [
|
|
@@ -200,7 +199,7 @@ async function runInteractive(model, systemPrompt, userMessage, taskId, project,
|
|
|
200
199
|
const { usage } = await mirror.stop();
|
|
201
200
|
if (usage) { result.usage = usage; }
|
|
202
201
|
} catch (err) { logger.debug('mirror stop failed', { error: err.message }); }
|
|
203
|
-
server.close();
|
|
202
|
+
try { await server.close(); } catch { /* best-effort */ }
|
|
204
203
|
logger.debug('OpenCode server closed after Electron exit');
|
|
205
204
|
result.opencodeSessionId = sessionId;
|
|
206
205
|
resolve(result);
|
package/src/sidecar/models.js
CHANGED
|
@@ -47,15 +47,29 @@ function aliasMarks() {
|
|
|
47
47
|
return map;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* #13: a one-line honest memo when the last refresh attempt on record failed
|
|
52
|
+
* AFTER the data currently being shown was fetched — i.e. the cache is stale
|
|
53
|
+
* because refreshing keeps failing, not just because nobody's refreshed lately.
|
|
54
|
+
* @returns {string|null}
|
|
55
|
+
*/
|
|
56
|
+
function staleMemo(fetchedAt, lastRefreshAttempt, lastRefreshError) {
|
|
57
|
+
if (!lastRefreshAttempt || !lastRefreshError) { return null; }
|
|
58
|
+
if (fetchedAt && lastRefreshAttempt <= fetchedAt) { return null; }
|
|
59
|
+
const attemptWhen = new Date(lastRefreshAttempt).toISOString();
|
|
60
|
+
const fetchedWhen = fetchedAt ? new Date(fetchedAt).toISOString() : 'never';
|
|
61
|
+
return `⚠ catalog may be stale: last refresh attempt failed ${attemptWhen} (${lastRefreshError}); showing data fetched ${fetchedWhen}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
async function runList(args) {
|
|
51
|
-
const { models, fetchedAt } = await getCatalogInfo();
|
|
65
|
+
const { models, fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo();
|
|
52
66
|
const q = typeof args.search === 'string' ? args.search.toLowerCase() : null;
|
|
53
67
|
const filtered = q
|
|
54
68
|
? models.filter(m => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
|
|
55
69
|
: models;
|
|
56
70
|
if (args.json) {
|
|
57
71
|
process.stdout.write(JSON.stringify(buildCatalogDoc({
|
|
58
|
-
models: filtered, fetchedAt, search: q
|
|
72
|
+
models: filtered, fetchedAt, search: q, lastRefreshAttempt, lastRefreshError
|
|
59
73
|
}), null, 2) + '\n');
|
|
60
74
|
return 0;
|
|
61
75
|
}
|
|
@@ -71,16 +85,31 @@ async function runList(args) {
|
|
|
71
85
|
if (filtered.length === 0 && models.length === 0) {
|
|
72
86
|
process.stdout.write('Catalog unavailable (offline or first run) — try: amicus models --refresh\n');
|
|
73
87
|
}
|
|
88
|
+
const memo = staleMemo(fetchedAt, lastRefreshAttempt, lastRefreshError);
|
|
89
|
+
if (memo) { process.stdout.write(memo + '\n'); }
|
|
74
90
|
return 0;
|
|
75
91
|
}
|
|
76
92
|
|
|
77
93
|
async function runRefresh(args) {
|
|
78
94
|
const models = await refreshCatalog();
|
|
95
|
+
const { fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
|
|
79
96
|
if (args.json) {
|
|
80
97
|
process.stdout.write(JSON.stringify(buildCatalogDoc({
|
|
81
|
-
models, fetchedAt
|
|
98
|
+
models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError
|
|
82
99
|
}), null, 2) + '\n');
|
|
83
|
-
return 0;
|
|
100
|
+
return models.length === 0 && !fetchedAt ? 1 : 0;
|
|
101
|
+
}
|
|
102
|
+
if (models.length === 0 && lastRefreshError) {
|
|
103
|
+
// Honest failure report — never claim "Refreshed catalog: 0 models" when
|
|
104
|
+
// the refresh actually failed and an old (or no) cache was retained.
|
|
105
|
+
if (fetchedAt) {
|
|
106
|
+
const when = new Date(fetchedAt).toISOString();
|
|
107
|
+
process.stdout.write(`refresh failed (${lastRefreshError}); keeping catalog from ${when}\n`);
|
|
108
|
+
process.stdout.write(`Cache: ${catalogPath()}\n`);
|
|
109
|
+
return 0; // stale-but-served: a warning, not a command failure
|
|
110
|
+
}
|
|
111
|
+
process.stdout.write(`refresh failed (${lastRefreshError}); no cache available\n`);
|
|
112
|
+
return 1; // no cache at all: a real failure
|
|
84
113
|
}
|
|
85
114
|
process.stdout.write(`Refreshed catalog: ${models.length} models.\n`);
|
|
86
115
|
process.stdout.write(`Cache: ${catalogPath()}\n`);
|
package/src/sidecar/progress.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { latestAssistantPreview } = require('./progress-fields');
|
|
13
|
+
const { writeFileAtomic } = require('../utils/atomic-write');
|
|
13
14
|
|
|
14
15
|
/** Lifecycle stage labels */
|
|
15
16
|
const STAGE_LABELS = {
|
|
@@ -117,7 +118,7 @@ function writeProgress(sessionDir, stage, extra = {}) {
|
|
|
117
118
|
updatedAt: new Date().toISOString(),
|
|
118
119
|
...extra
|
|
119
120
|
};
|
|
120
|
-
|
|
121
|
+
writeFileAtomic(progressPath, JSON.stringify(data), { mode: 0o600 });
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
/**
|