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/headless.js
CHANGED
|
@@ -12,33 +12,51 @@ const { ensureNodeModulesBinInPath } = require('./utils/path-setup');
|
|
|
12
12
|
const { ensurePortAvailable } = require('./utils/server-setup');
|
|
13
13
|
const { mapAgentToOpenCode } = require('./utils/agent-mapping');
|
|
14
14
|
const { writeProgress } = require('./sidecar/progress');
|
|
15
|
-
const {
|
|
15
|
+
const { writeFileAtomic } = require('./utils/atomic-write');
|
|
16
|
+
const { createMirrorState, mirrorMessages, logMessage, getPendingToolCalls } = require('./sidecar/conversation-mirror');
|
|
17
|
+
const { buildFoldMarker, trailingFoldMarkerRegex, generateFoldNonce } = require('./utils/fold-marker');
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
|
-
* Fold marker that the agent outputs when done
|
|
20
|
+
* Fold marker that the agent outputs when done.
|
|
19
21
|
* Spec Reference: §6.2
|
|
22
|
+
*
|
|
23
|
+
* #BL-7 residual (15b.3): the bare `[SIDECAR_FOLD]` string is now a LEGACY
|
|
24
|
+
* literal only, kept exported for external consumers with no nonce context
|
|
25
|
+
* (see extractSummary/formatFoldOutput's no-nonce fallback paths below).
|
|
26
|
+
* NOTE for anyone `.toContain('[SIDECAR_FOLD]')`-checking real run output:
|
|
27
|
+
* that substring check does NOT match the real nonced marker — a nonced
|
|
28
|
+
* marker is `[SIDECAR_FOLD:<nonce>]`, which lacks the literal closing
|
|
29
|
+
* bracket immediately after FOLD that `[SIDECAR_FOLD]` requires. Real runs
|
|
30
|
+
* always call buildFoldMarker(nonce), never this bare constant.
|
|
20
31
|
*/
|
|
21
32
|
const FOLD_MARKER = '[SIDECAR_FOLD]';
|
|
22
33
|
const COMPLETE_MARKER = FOLD_MARKER; // backward compat
|
|
23
34
|
|
|
24
35
|
/**
|
|
25
|
-
* #BL-7: the fold marker
|
|
26
|
-
* legitimately emit
|
|
27
|
-
* reproducing these instructions, or from
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
36
|
+
* #BL-7: the fold marker used to be the fixed public string [SIDECAR_FOLD]. A
|
|
37
|
+
* model can legitimately emit that bare string on its own line mid-output —
|
|
38
|
+
* summarizing a prior sidecar, reproducing these instructions, or from
|
|
39
|
+
* scraped content — which forced a PREMATURE fold even after pinning the
|
|
40
|
+
* marker to the final non-empty line (the marker being fixed and public means
|
|
41
|
+
* ANY echo of it, if it happened to land last, still completed the run).
|
|
42
|
+
*
|
|
43
|
+
* 15b.3 closes the residual gap: every run now carries a per-run random
|
|
44
|
+
* nonce, and the model is instructed to emit `[SIDECAR_FOLD:<nonce>]` — a
|
|
45
|
+
* string the model can only produce by actually finishing (it isn't public,
|
|
46
|
+
* isn't in training data, and isn't guessable). A bare `[SIDECAR_FOLD]` or a
|
|
47
|
+
* marker carrying a DIFFERENT run's nonce no longer completes.
|
|
31
48
|
*
|
|
32
49
|
* @param {string} output - Accumulated assistant output
|
|
50
|
+
* @param {string} nonce - This run's fold nonce (required — see runHeadless)
|
|
33
51
|
* @returns {number} char index where the trailing marker line begins, or -1
|
|
34
52
|
*/
|
|
35
|
-
function findTrailingFoldMarker(output) {
|
|
36
|
-
if (!output) { return -1; }
|
|
53
|
+
function findTrailingFoldMarker(output, nonce) {
|
|
54
|
+
if (!output || !nonce) { return -1; }
|
|
37
55
|
// The marker must be the last non-empty line: it sits alone on its line
|
|
38
56
|
// (only intra-line whitespace around it) and NOTHING but whitespace follows
|
|
39
57
|
// to the end of the string. The `(?![\s\S]*\S)` lookahead pins it to the true
|
|
40
|
-
// end — a
|
|
41
|
-
const m =
|
|
58
|
+
// end — a marker followed by more prose is echoed content, not a signal.
|
|
59
|
+
const m = trailingFoldMarkerRegex(nonce).exec(output);
|
|
42
60
|
return m ? m.index : -1;
|
|
43
61
|
}
|
|
44
62
|
|
|
@@ -53,6 +71,7 @@ const STABLE_FINISHED_POLLS = Number(process.env.AMICUS_STABLE_FINISHED_POLLS) |
|
|
|
53
71
|
const STABLE_IDLE_POLLS = Number(process.env.AMICUS_STABLE_IDLE_POLLS) || 30; // ~60s at 2s — no completion signal
|
|
54
72
|
const POLL_CALL_TIMEOUT_MS = Number(process.env.AMICUS_POLL_CALL_TIMEOUT_MS) || 30000; // per getMessages call (used by a later task)
|
|
55
73
|
const MAX_CONSECUTIVE_POLL_FAILURES = Number(process.env.AMICUS_MAX_CONSECUTIVE_POLL_FAILURES) || 15; // ≈30s at 2s polls
|
|
74
|
+
const TOOL_CALL_STALL_MS = Number(process.env.AMICUS_TOOL_CALL_STALL_MS) || 180000; // B53: wedged tool call w/ no progress
|
|
56
75
|
|
|
57
76
|
/**
|
|
58
77
|
* Race a promise against a timeout. Returns the promise's result, or rejects with
|
|
@@ -103,6 +122,15 @@ async function waitForServer(client, checkHealthFn, maxAttempts = 30) {
|
|
|
103
122
|
* @param {string} [options.summaryLength='normal'] - Desired summary length
|
|
104
123
|
* @param {object} [options.reasoning] - Reasoning/thinking configuration
|
|
105
124
|
* @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
|
|
125
|
+
* @param {string} [options.nonce] - Per-run fold nonce (15b.3, #BL-7 residual). The
|
|
126
|
+
* PROMPT the caller built (prompt-builder.js buildPrompts) must have instructed the
|
|
127
|
+
* model with this SAME nonce — runHeadless only DETECTS, it never re-derives one from
|
|
128
|
+
* the prompt text, so caller and detector agreeing on the nonce is the caller's
|
|
129
|
+
* responsibility. Falls back to a freshly generated nonce if omitted (keeps this
|
|
130
|
+
* function usable standalone / in tests that don't care about the fold-nonce
|
|
131
|
+
* property) — but a fallback nonce the prompt never advertised means the model can
|
|
132
|
+
* never legitimately produce it, so such a run can only ever complete via one of the
|
|
133
|
+
* non-fold-marker paths (idle/timeout/etc.), never a premature bare-marker fold.
|
|
106
134
|
* @returns {Promise<object>} Result object with summary, completed, timedOut flags
|
|
107
135
|
*/
|
|
108
136
|
async function runHeadless(model, systemPrompt, userMessage, taskId, project, timeoutMs = DEFAULT_TIMEOUT, agent, options = {}) {
|
|
@@ -116,6 +144,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
116
144
|
} = require('./opencode-client');
|
|
117
145
|
|
|
118
146
|
const { reasoning } = options;
|
|
147
|
+
// 15b.3: never fall back to bare-marker detection — an omitted nonce still
|
|
148
|
+
// gets ONE generated here so findTrailingFoldMarker always has something to
|
|
149
|
+
// match, but since the prompt (built by the caller) never advertised THIS
|
|
150
|
+
// fallback value, the model cannot legitimately produce it. No silent
|
|
151
|
+
// bare-`[SIDECAR_FOLD]` acceptance path exists anywhere below.
|
|
152
|
+
const foldNonce = options.nonce || generateFoldNonce();
|
|
119
153
|
const { getSessionDir } = require('./session-manager');
|
|
120
154
|
const sessionDir = getSessionDir(project, taskId);
|
|
121
155
|
const conversationPath = path.join(sessionDir, 'conversation.jsonl');
|
|
@@ -202,7 +236,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
202
236
|
writeProgress(sessionDir, 'server_ready');
|
|
203
237
|
|
|
204
238
|
if (!serverReady) {
|
|
205
|
-
server.close();
|
|
239
|
+
await server.close();
|
|
206
240
|
return {
|
|
207
241
|
summary: '',
|
|
208
242
|
completed: false,
|
|
@@ -239,7 +273,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
239
273
|
sessionId = await createSession(client, ...dirArgs);
|
|
240
274
|
} catch (error) {
|
|
241
275
|
if (watchdog) { watchdog.cancel(); }
|
|
242
|
-
if (!externalServer) { server.close(); }
|
|
276
|
+
if (!externalServer) { await server.close(); }
|
|
243
277
|
return {
|
|
244
278
|
summary: '',
|
|
245
279
|
completed: false,
|
|
@@ -261,7 +295,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
261
295
|
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
262
296
|
const m = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
263
297
|
m.goPid = server.goPid;
|
|
264
|
-
|
|
298
|
+
writeFileAtomic(metaPath, JSON.stringify(m, null, 2), { mode: 0o600 });
|
|
265
299
|
} catch { /* metadata optional */ }
|
|
266
300
|
}
|
|
267
301
|
const { installSignalAbort, markAborted } = require('./utils/session-abort');
|
|
@@ -276,7 +310,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
276
310
|
const { abortSession } = require('./opencode-client');
|
|
277
311
|
abortSession(client, sessionId, ...dirArgs).catch(() => {});
|
|
278
312
|
} catch { /* best-effort */ }
|
|
279
|
-
|
|
313
|
+
// close() is now async (B06 escalation) — this handler stays sync
|
|
314
|
+
// (do not restructure signal handlers), so fire-and-forget with a
|
|
315
|
+
// rejection guard. The REF'd escalation poll inside close() still
|
|
316
|
+
// does its work; the pre-existing 300ms exit timer below may cut
|
|
317
|
+
// that grace short — see task 15b.1 report for that known gap.
|
|
318
|
+
try { server.close().catch(() => {}); } catch { /* best-effort */ }
|
|
280
319
|
const { resolveTerminalState } = require('./sidecar/session-finalize');
|
|
281
320
|
const code = resolveTerminalState({ aborted: true }, signal).exitCode;
|
|
282
321
|
const t = setTimeout(() => process.exit(code), 300);
|
|
@@ -356,6 +395,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
356
395
|
const stableIdlePolls = options.stableIdlePolls || STABLE_IDLE_POLLS;
|
|
357
396
|
const pollCallTimeoutMs = options.pollCallTimeoutMs || POLL_CALL_TIMEOUT_MS;
|
|
358
397
|
const maxConsecutivePollFailures = options.maxConsecutivePollFailures || MAX_CONSECUTIVE_POLL_FAILURES;
|
|
398
|
+
const toolCallStallMs = options.toolCallStallMs || TOOL_CALL_STALL_MS;
|
|
359
399
|
let consecutivePollFailures = 0;
|
|
360
400
|
let pollFailureBail = false;
|
|
361
401
|
let lastAssistantMsgId = null;
|
|
@@ -364,6 +404,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
364
404
|
let lastToolCallCount = 0;
|
|
365
405
|
let lastToolResultCount = 0;
|
|
366
406
|
let lastMessageCount = 0;
|
|
407
|
+
let lastReasoningLength = 0; // B53: track reasoning-output growth to detect thinking
|
|
408
|
+
let lastProgressAt = Date.now(); // B53: last poll where `progressed` was true
|
|
409
|
+
let toolStalled = false; // B53: distinct from completed/timedOut/aborted — see resolveTerminalState
|
|
367
410
|
|
|
368
411
|
while (!completed && (Date.now() - startTime) < timeoutMs) {
|
|
369
412
|
watchdog.touch();
|
|
@@ -420,11 +463,12 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
420
463
|
elapsed: Date.now() - startTime
|
|
421
464
|
});
|
|
422
465
|
|
|
423
|
-
// Check for the completion marker as the FINAL non-empty line
|
|
424
|
-
// Models may emit
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
466
|
+
// Check for the completion marker as the FINAL non-empty line, carrying
|
|
467
|
+
// THIS run's nonce (#BL-7 + 15b.3). Models may emit a bare or wrong-nonce
|
|
468
|
+
// marker on its own line mid-output (echoing a prior sidecar, these
|
|
469
|
+
// instructions, or scraped content) — only the exact nonced marker,
|
|
470
|
+
// with nothing but blank lines after it, is a completion signal.
|
|
471
|
+
if (findTrailingFoldMarker(mirror.output, foldNonce) !== -1) {
|
|
428
472
|
completed = true;
|
|
429
473
|
break;
|
|
430
474
|
}
|
|
@@ -472,8 +516,9 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
472
516
|
}
|
|
473
517
|
|
|
474
518
|
// Activity-aware idle detection: ANY of text growth, a new tool call, a new
|
|
475
|
-
// tool result, a new message,
|
|
476
|
-
// Only count toward completion when NOTHING changed
|
|
519
|
+
// tool result, a new message, a new assistant message id, or reasoning-output
|
|
520
|
+
// growth counts as progress. Only count toward completion when NOTHING changed
|
|
521
|
+
// (genuine idle).
|
|
477
522
|
const outputGrew = mirror.output.length > lastOutputLength;
|
|
478
523
|
lastOutputLength = mirror.output.length;
|
|
479
524
|
const toolActivity = mirror.toolCalls.length > lastToolCallCount;
|
|
@@ -483,8 +528,42 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
483
528
|
const messageActivity = messageCount > lastMessageCount;
|
|
484
529
|
lastMessageCount = messageCount;
|
|
485
530
|
const newAssistant = currentAssistantMsgId !== lastAssistantMsgId;
|
|
486
|
-
|
|
487
|
-
|
|
531
|
+
// B53: an interleaved-thinking model with a pending tool call can stream ONLY
|
|
532
|
+
// reasoning deltas for minutes with no text/tool/result/message growth — mirror
|
|
533
|
+
// the F6d treatment in conversation-mirror.js (reasoning growth = activity) so
|
|
534
|
+
// the stall clock resets instead of falsely firing "Tool call stalled".
|
|
535
|
+
const reasoningActivity = mirror.reasoningOutput.length > lastReasoningLength;
|
|
536
|
+
lastReasoningLength = mirror.reasoningOutput.length;
|
|
537
|
+
|
|
538
|
+
const progressed = outputGrew || toolActivity || resultActivity || messageActivity
|
|
539
|
+
|| newAssistant || reasoningActivity;
|
|
540
|
+
if (progressed) { lastProgressAt = Date.now(); }
|
|
541
|
+
|
|
542
|
+
// B53: a wedged tool call (tool_use emitted, result never arrives) otherwise
|
|
543
|
+
// burns the full --timeout with zero output — the stable-poll idle gate above
|
|
544
|
+
// requires mirror.output.length > 0, which a pre-text wedge never satisfies.
|
|
545
|
+
// Fire ONLY when a tool call is genuinely pending AND no progress of any kind
|
|
546
|
+
// (text/tool/result/message/new-assistant) has been observed for the stall
|
|
547
|
+
// window — this cannot false-positive during active streaming (progress
|
|
548
|
+
// resets the clock every poll) and cannot fire without a wedged tool.
|
|
549
|
+
const pendingToolCalls = getPendingToolCalls(mirror);
|
|
550
|
+
if (pendingToolCalls.length > 0 && (Date.now() - lastProgressAt) > toolCallStallMs) {
|
|
551
|
+
const stalled = pendingToolCalls[0];
|
|
552
|
+
const pendingSeconds = Math.round((Date.now() - Date.parse(stalled.firstSeenAt)) / 1000);
|
|
553
|
+
sessionError = `Tool call stalled: ${stalled.name} pending ${pendingSeconds}s with no result or output`;
|
|
554
|
+
logger.error('Tool call stalled — no progress within threshold', {
|
|
555
|
+
taskId, toolName: stalled.name, toolId: stalled.id, pendingSeconds, toolCallStallMs
|
|
556
|
+
});
|
|
557
|
+
toolStalled = true;
|
|
558
|
+
try {
|
|
559
|
+
const { abortSession } = require('./opencode-client');
|
|
560
|
+
await abortSession(client, sessionId, ...dirArgs);
|
|
561
|
+
logger.info('Session aborted after tool-call stall', { taskId, sessionId });
|
|
562
|
+
} catch (abortErr) {
|
|
563
|
+
logger.warn('Failed to abort session after tool-call stall', { error: abortErr.message });
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
488
567
|
|
|
489
568
|
if (!progressed) {
|
|
490
569
|
// Require real output before counting toward completion — the SDK creates an
|
|
@@ -556,7 +635,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
556
635
|
|
|
557
636
|
watchdog.cancel();
|
|
558
637
|
if (uninstallSignals) { uninstallSignals(); }
|
|
559
|
-
if (!externalServer) { server.close(); }
|
|
638
|
+
if (!externalServer) { await server.close(); }
|
|
560
639
|
|
|
561
640
|
// Log summary of tool calls for debugging
|
|
562
641
|
if (mirror.toolCalls.length > 0) {
|
|
@@ -572,13 +651,15 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
572
651
|
// Propagate the error when the model errored with no output (F1 semantics:
|
|
573
652
|
// a model error alongside streamed output still yields a usable summary),
|
|
574
653
|
// and ALWAYS when the poll loop bailed on consecutive failures (F4: a dead
|
|
575
|
-
// server must never classify as a complete leg, even with partial output)
|
|
654
|
+
// server must never classify as a complete leg, even with partial output)
|
|
655
|
+
// or on a tool-call stall (B53: same — a wedged tool must never classify
|
|
656
|
+
// as complete, even if some text streamed alongside it before the wedge).
|
|
576
657
|
const { sumPerMessageUsage } = require('./utils/pricing');
|
|
577
658
|
const usage = sumPerMessageUsage(mirror.usageByMsg);
|
|
578
659
|
|
|
579
|
-
if (sessionError && (!mirror.output || pollFailureBail)) {
|
|
660
|
+
if (sessionError && (!mirror.output || pollFailureBail || toolStalled)) {
|
|
580
661
|
return {
|
|
581
|
-
summary: mirror.output ? extractSummary(mirror.output) : '',
|
|
662
|
+
summary: mirror.output ? extractSummary(mirror.output, foldNonce) : '',
|
|
582
663
|
completed: false,
|
|
583
664
|
timedOut,
|
|
584
665
|
aborted,
|
|
@@ -590,7 +671,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
590
671
|
}
|
|
591
672
|
|
|
592
673
|
return {
|
|
593
|
-
summary: extractSummary(mirror.output),
|
|
674
|
+
summary: extractSummary(mirror.output, foldNonce),
|
|
594
675
|
completed,
|
|
595
676
|
timedOut,
|
|
596
677
|
aborted,
|
|
@@ -617,7 +698,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
617
698
|
}
|
|
618
699
|
if (watchdog) { watchdog.cancel(); }
|
|
619
700
|
if (uninstallSignals) { uninstallSignals(); }
|
|
620
|
-
if (!externalServer) { server.close(); }
|
|
701
|
+
if (!externalServer) { await server.close(); }
|
|
621
702
|
const { emptyUsageTotals } = require('./utils/pricing');
|
|
622
703
|
return {
|
|
623
704
|
summary: '',
|
|
@@ -632,28 +713,47 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
|
|
|
632
713
|
}
|
|
633
714
|
|
|
634
715
|
/**
|
|
635
|
-
* Extract summary from output (everything before the trailing
|
|
636
|
-
* Spec Reference: §6.2 - Return summary (everything before
|
|
716
|
+
* Extract summary from output (everything before the trailing fold marker)
|
|
717
|
+
* Spec Reference: §6.2 - Return summary (everything before the fold marker)
|
|
637
718
|
*
|
|
638
719
|
* @param {string} output - Raw output from OpenCode
|
|
720
|
+
* @param {string} [nonce] - This run's fold nonce (15b.3). When omitted, falls
|
|
721
|
+
* back to matching the LEGACY bare `[SIDECAR_FOLD]` marker — this keeps
|
|
722
|
+
* extractSummary usable as a standalone string utility (e.g. re-processing
|
|
723
|
+
* output captured before the nonce scheme, or a caller that genuinely has
|
|
724
|
+
* no nonce context) without ever accepting a WRONG nonce as a match.
|
|
639
725
|
* @returns {string} Extracted summary
|
|
640
726
|
*/
|
|
641
|
-
function extractSummary(output) {
|
|
727
|
+
function extractSummary(output, nonce) {
|
|
642
728
|
if (!output) {
|
|
643
729
|
return '';
|
|
644
730
|
}
|
|
645
731
|
|
|
646
732
|
// Split on the fold marker only when it is the FINAL non-empty line (#BL-7).
|
|
647
|
-
// A
|
|
733
|
+
// A marker echoed mid-output (describing code, reproducing these
|
|
648
734
|
// instructions, or from scraped content) is NOT a delimiter — keep it as
|
|
649
735
|
// content. Only the true trailing marker is stripped.
|
|
650
|
-
const idx = findTrailingFoldMarker(output);
|
|
736
|
+
const idx = nonce ? findTrailingFoldMarker(output, nonce) : findLegacyBareTrailingMarker(output);
|
|
651
737
|
if (idx !== -1) {
|
|
652
738
|
return output.slice(0, idx).trim();
|
|
653
739
|
}
|
|
654
740
|
return output.trim();
|
|
655
741
|
}
|
|
656
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Legacy bare-marker trailing match (`[SIDECAR_FOLD]`, no nonce) — the
|
|
745
|
+
* pre-15b.3 behavior, kept only for extractSummary's no-nonce fallback path.
|
|
746
|
+
* NEVER used by runHeadless's own detection (that always carries a nonce —
|
|
747
|
+
* see findTrailingFoldMarker), so no live completion path can be forced by a
|
|
748
|
+
* bare marker.
|
|
749
|
+
* @param {string} output
|
|
750
|
+
* @returns {number}
|
|
751
|
+
*/
|
|
752
|
+
function findLegacyBareTrailingMarker(output) {
|
|
753
|
+
const m = /^[^\S\r\n]*\[SIDECAR_FOLD\][^\S\r\n]*$(?![\s\S]*\S)/m.exec(output);
|
|
754
|
+
return m ? m.index : -1;
|
|
755
|
+
}
|
|
756
|
+
|
|
657
757
|
/**
|
|
658
758
|
* Format a structured fold output with metadata
|
|
659
759
|
* @param {Object} options - Fold output options
|
|
@@ -663,11 +763,14 @@ function extractSummary(output) {
|
|
|
663
763
|
* @param {string} [options.cwd] - Working directory (defaults to process.cwd())
|
|
664
764
|
* @param {string} [options.mode='headless'] - Execution mode
|
|
665
765
|
* @param {string} options.summary - Summary text
|
|
766
|
+
* @param {string} [options.nonce] - This run's fold nonce (15b.3). When omitted,
|
|
767
|
+
* falls back to the legacy bare `[SIDECAR_FOLD]` marker for back-compat with
|
|
768
|
+
* external callers of this exported utility that predate the nonce scheme.
|
|
666
769
|
* @returns {string} Formatted fold output
|
|
667
770
|
*/
|
|
668
|
-
function formatFoldOutput({ model, sessionId, client, cwd, mode, summary }) {
|
|
771
|
+
function formatFoldOutput({ model, sessionId, client, cwd, mode, summary, nonce }) {
|
|
669
772
|
return [
|
|
670
|
-
|
|
773
|
+
nonce ? buildFoldMarker(nonce) : FOLD_MARKER,
|
|
671
774
|
`Model: ${model}`,
|
|
672
775
|
`Session: ${sessionId}`,
|
|
673
776
|
`Client: ${client || 'code-local'}`,
|
|
@@ -688,9 +791,14 @@ module.exports = {
|
|
|
688
791
|
DEFAULT_TIMEOUT,
|
|
689
792
|
FOLD_MARKER,
|
|
690
793
|
COMPLETE_MARKER,
|
|
794
|
+
// 15b.3: re-exported so callers that already `require('./headless')` don't
|
|
795
|
+
// also need `require('./utils/fold-marker')` for the common case.
|
|
796
|
+
buildFoldMarker,
|
|
797
|
+
generateFoldNonce,
|
|
691
798
|
POLL_INTERVAL_MS,
|
|
692
799
|
STABLE_FINISHED_POLLS,
|
|
693
800
|
STABLE_IDLE_POLLS,
|
|
694
801
|
POLL_CALL_TIMEOUT_MS,
|
|
695
802
|
MAX_CONSECUTIVE_POLL_FAILURES,
|
|
803
|
+
TOOL_CALL_STALL_MS,
|
|
696
804
|
};
|
package/src/index.js
CHANGED
|
@@ -30,21 +30,13 @@ const { detectEnvironment, inferClient, getSessionRoot } = require('./environmen
|
|
|
30
30
|
const { compressContext, estimateTokenCount, buildPreamble } = require('./context-compression');
|
|
31
31
|
|
|
32
32
|
module.exports = {
|
|
33
|
-
// Canonical Amicus public API
|
|
33
|
+
// Canonical Amicus public API
|
|
34
34
|
startAmicus: startSidecar,
|
|
35
35
|
listAmicus: listSidecars,
|
|
36
36
|
resumeAmicus: resumeSidecar,
|
|
37
37
|
continueAmicus: continueSidecar,
|
|
38
38
|
readAmicus: readSidecar,
|
|
39
39
|
runFanout,
|
|
40
|
-
|
|
41
|
-
// DEPRECATED(amicus-shim): remove *Sidecar exports in a future revision — see docs/SHIMS.md
|
|
42
|
-
// Primary sidecar APIs
|
|
43
|
-
startSidecar,
|
|
44
|
-
listSidecars,
|
|
45
|
-
resumeSidecar,
|
|
46
|
-
continueSidecar,
|
|
47
|
-
readSidecar,
|
|
48
40
|
generateTaskId,
|
|
49
41
|
|
|
50
42
|
// Context building
|