amicus 1.9.1 → 2.1.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 +200 -0
- package/README.md +40 -170
- package/bin/amicus.js +19 -107
- package/commands/council.md +7 -3
- 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 +30 -28
- package/skills/sidecar/SKILL.md +20 -17
- package/src/cli-handlers-abort.js +244 -0
- package/src/cli-handlers-council.js +101 -1
- package/src/cli-handlers-doctor.js +20 -53
- package/src/cli-handlers-resume-continue.js +103 -0
- package/src/cli-handlers-run.js +9 -8
- package/src/cli-handlers-spend.js +198 -0
- package/src/cli-handlers.js +5 -120
- package/src/cli.js +55 -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 +140 -113
- package/src/mcp-tools.js +58 -24
- 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 +34 -12
- 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 +41 -11
- 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/abort-result.js +36 -0
- package/src/utils/api-key-store.js +2 -13
- package/src/utils/cli-preflight.js +43 -0
- package/src/utils/config.js +30 -43
- package/src/utils/council-presets.js +87 -0
- package/src/utils/doctor-mcp-checks.js +84 -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/input-validators.js +52 -1
- package/src/utils/lifecycle.js +1 -1
- package/src/utils/mcp-discovery.js +80 -19
- 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-version.js +14 -0
- package/src/utils/result-schema.js +18 -12
- 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/opencode-client.js
CHANGED
|
@@ -540,34 +540,57 @@ function buildServerOptions(options = {}) {
|
|
|
540
540
|
}
|
|
541
541
|
|
|
542
542
|
const { findListenerPid } = require('./utils/port-pid');
|
|
543
|
+
const { waitThenKill: defaultWaitThenKill } = require('./utils/abort-coordinator');
|
|
544
|
+
|
|
545
|
+
// Teardown-appropriate grace window for the SIGTERM->SIGKILL escalation on
|
|
546
|
+
// close() (B06). Constant, not env-overridable — this is process teardown,
|
|
547
|
+
// not the marker-honoring abort grace in abort-coordinator.js.
|
|
548
|
+
const CLOSE_KILL_GRACE_MS = 2000;
|
|
543
549
|
|
|
544
550
|
/**
|
|
545
551
|
* Build the { url, goPid, close } server handle around a raw SDK server.
|
|
546
552
|
* Extracted + dependency-injected so the goPid capture and cross-platform
|
|
547
553
|
* force-kill (F3 #15) can be unit-tested without the SDK's dynamic import.
|
|
554
|
+
*
|
|
555
|
+
* close() (B06 teardown-race fix): sdkServer.close() SIGTERMs the SDK's
|
|
556
|
+
* wrapper script, not the Go binary — the SDK exposes no pid for it. So
|
|
557
|
+
* close() ALSO SIGTERMs goPid directly (captured via the port scan / sdk
|
|
558
|
+
* pid fields above) and runs it through the REF'd SIGTERM->SIGKILL
|
|
559
|
+
* escalation primitive, bounded by CLOSE_KILL_GRACE_MS. The old unref'd
|
|
560
|
+
* SIGKILL-only timer is gone: on POSIX it could die with the parent before
|
|
561
|
+
* ever firing (orphaning the Go server), and it never sent a SIGTERM to the
|
|
562
|
+
* Go pid at all. On win32 this is a no-op beyond firing the signal — the
|
|
563
|
+
* job-object semantics that already reap the tree are untouched.
|
|
548
564
|
* @param {{url:string, close:Function, pid?:number, process?:{pid:number}}} sdkServer
|
|
549
|
-
* @param {{findListenerPid?:Function, kill?:Function, logger?:object}} [deps]
|
|
565
|
+
* @param {{findListenerPid?:Function, kill?:Function, logger?:object, waitThenKill?:Function}} [deps]
|
|
550
566
|
* @returns {{url:string, goPid:number|null, close:Function}}
|
|
551
567
|
*/
|
|
552
568
|
function buildServerHandle(sdkServer, deps = {}) {
|
|
553
569
|
const findPid = deps.findListenerPid || findListenerPid;
|
|
554
570
|
const kill = deps.kill || ((pid, sig) => process.kill(pid, sig));
|
|
555
571
|
const log = deps.logger || require('./utils/logger').logger;
|
|
572
|
+
const waitThenKill = deps.waitThenKill || defaultWaitThenKill;
|
|
556
573
|
const serverPort = parseInt(new URL(sdkServer.url).port, 10);
|
|
557
574
|
const goPid = sdkServer.pid || (sdkServer.process && sdkServer.process.pid) || findPid(serverPort);
|
|
558
575
|
const server = {
|
|
559
576
|
url: sdkServer.url,
|
|
560
577
|
goPid,
|
|
561
|
-
close() {
|
|
578
|
+
async close() {
|
|
562
579
|
sdkServer.close();
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
},
|
|
570
|
-
|
|
580
|
+
const pid = server.goPid;
|
|
581
|
+
if (!pid || pid === process.pid) { return; }
|
|
582
|
+
// graceMs: 0 => waitThenKill's own poll fires the direct SIGTERM to
|
|
583
|
+
// goPid immediately (no wait beforehand — the SDK's close() above never
|
|
584
|
+
// reaches the Go binary, so there is no reason to delay this one), then
|
|
585
|
+
// the escalation tier SIGKILLs any survivor after CLOSE_KILL_GRACE_MS.
|
|
586
|
+
const { escalated } = await waitThenKill(pid, {
|
|
587
|
+
graceMs: 0,
|
|
588
|
+
escalate: { killGraceMs: CLOSE_KILL_GRACE_MS },
|
|
589
|
+
deps: { kill },
|
|
590
|
+
});
|
|
591
|
+
if (escalated.includes(pid)) {
|
|
592
|
+
log.debug('Force-killed OpenCode server', { port: serverPort, pid });
|
|
593
|
+
}
|
|
571
594
|
}
|
|
572
595
|
};
|
|
573
596
|
return server;
|
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
|
}
|
|
@@ -121,7 +123,7 @@ async function continueSidecar(options) {
|
|
|
121
123
|
headless = false,
|
|
122
124
|
timeout = 15,
|
|
123
125
|
agent,
|
|
124
|
-
mcp, mcpConfig, client, noMcp, excludeMcp
|
|
126
|
+
mcp, mcpConfig, client, noMcp, excludeMcp, json = false
|
|
125
127
|
} = options;
|
|
126
128
|
|
|
127
129
|
// Load previous session data
|
|
@@ -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
|
|
@@ -178,10 +185,17 @@ async function continueSidecar(options) {
|
|
|
178
185
|
|
|
179
186
|
try {
|
|
180
187
|
if (headless) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
188
|
+
try {
|
|
189
|
+
result = await runHeadless(
|
|
190
|
+
model, systemPrompt, userMessage, newTaskId, project,
|
|
191
|
+
timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers, nonce: foldNonce }
|
|
192
|
+
);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (!json) { throw err; }
|
|
195
|
+
// --json contract: stdout must always carry a parseable run doc,
|
|
196
|
+
// even when the engine throws rather than returning {error}.
|
|
197
|
+
result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: newTaskId };
|
|
198
|
+
}
|
|
185
199
|
summary = result.summary ||
|
|
186
200
|
'## Sidecar Results: No Output\n\nContinued session completed without summary.';
|
|
187
201
|
|
|
@@ -191,7 +205,7 @@ async function continueSidecar(options) {
|
|
|
191
205
|
logger.info('Launching interactive continue', { taskId: newTaskId, model });
|
|
192
206
|
result = await runInteractive(
|
|
193
207
|
model, systemPrompt, userMessage, newTaskId, project,
|
|
194
|
-
{ agent: effectiveAgent, mcp: mcpServers }
|
|
208
|
+
{ agent: effectiveAgent, mcp: mcpServers, foldNonce }
|
|
195
209
|
);
|
|
196
210
|
summary = result.summary || '';
|
|
197
211
|
if (result.error) { logger.error('Interactive continue error', { taskId: newTaskId, error: result.error }); }
|
|
@@ -202,8 +216,8 @@ async function continueSidecar(options) {
|
|
|
202
216
|
releaseLock(prevSessionDir);
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
// Output summary
|
|
206
|
-
outputSummary(summary);
|
|
219
|
+
// Output summary (human mode only — json mode keeps stdout to the doc below)
|
|
220
|
+
if (!json) { outputSummary(summary); }
|
|
207
221
|
|
|
208
222
|
// Load current metadata for finalization
|
|
209
223
|
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
@@ -220,11 +234,19 @@ async function continueSidecar(options) {
|
|
|
220
234
|
meta.status = 'error';
|
|
221
235
|
meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
222
236
|
meta.completedAt = new Date().toISOString();
|
|
223
|
-
|
|
237
|
+
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
224
238
|
logger.error('Continuation completed with error', { taskId: newTaskId, error: meta.reason });
|
|
225
239
|
} else {
|
|
226
|
-
finalizeSession(sessionDir, summary, project, meta, { status: terminal.status });
|
|
240
|
+
finalizeSession(sessionDir, summary, project, meta, { quietStdout: json, status: terminal.status });
|
|
227
241
|
}
|
|
242
|
+
|
|
243
|
+
if (json) {
|
|
244
|
+
const { buildRunResult } = require('../utils/result-schema');
|
|
245
|
+
const finalMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
246
|
+
const doc = buildRunResult({ taskId: newTaskId, metadata: finalMeta, result, summary, sessionDir });
|
|
247
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
248
|
+
}
|
|
249
|
+
|
|
228
250
|
return terminal.exitCode;
|
|
229
251
|
}
|
|
230
252
|
|
|
@@ -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
|
}
|