amicus 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +477 -0
- package/bin/amicus.js +382 -0
- package/electron/assets/icon.png +0 -0
- package/electron/assets/icon.svg +5 -0
- package/electron/fold.js +163 -0
- package/electron/ipc-setup.js +176 -0
- package/electron/load-failsafe.js +85 -0
- package/electron/main.js +468 -0
- package/electron/preload-setup.js +38 -0
- package/electron/preload.js +33 -0
- package/electron/setup-ui-alias-script.js +218 -0
- package/electron/setup-ui-aliases.js +85 -0
- package/electron/setup-ui-keys-script.js +115 -0
- package/electron/setup-ui-keys.js +97 -0
- package/electron/setup-ui-model.js +138 -0
- package/electron/setup-ui-styles.js +327 -0
- package/electron/setup-ui.js +465 -0
- package/electron/summary.js +118 -0
- package/electron/toolbar.js +229 -0
- package/electron/window-position.js +35 -0
- package/package.json +98 -0
- package/scripts/postinstall.js +193 -0
- package/scripts/setup-hooks.js +42 -0
- package/skill/SKILL.md +976 -0
- package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
- package/skills/second-opinion/MODEL-NOTES.md +104 -0
- package/skills/second-opinion/SKILL.md +389 -0
- package/src/cli-handlers.js +188 -0
- package/src/cli.js +400 -0
- package/src/conflict.js +144 -0
- package/src/context-compression.js +102 -0
- package/src/context.js +199 -0
- package/src/drift.js +144 -0
- package/src/environment.js +157 -0
- package/src/headless.js +742 -0
- package/src/index.js +106 -0
- package/src/jsonl-parser.js +180 -0
- package/src/mcp-server.js +625 -0
- package/src/mcp-tools.js +407 -0
- package/src/opencode-client.js +615 -0
- package/src/prompt-builder.js +355 -0
- package/src/prompts/cowork-agent-prompt.js +118 -0
- package/src/session-manager.js +414 -0
- package/src/session.js +180 -0
- package/src/sidecar/context-builder.js +297 -0
- package/src/sidecar/continue.js +212 -0
- package/src/sidecar/crash-handler.js +56 -0
- package/src/sidecar/fanout-leg.js +107 -0
- package/src/sidecar/fanout-output.js +46 -0
- package/src/sidecar/fanout.js +236 -0
- package/src/sidecar/interactive.js +217 -0
- package/src/sidecar/models.js +135 -0
- package/src/sidecar/progress.js +218 -0
- package/src/sidecar/read.js +183 -0
- package/src/sidecar/resume.js +221 -0
- package/src/sidecar/session-utils.js +288 -0
- package/src/sidecar/setup-window.js +79 -0
- package/src/sidecar/setup.js +280 -0
- package/src/sidecar/start.js +251 -0
- package/src/utils/agent-mapping.js +138 -0
- package/src/utils/alias-audit.js +98 -0
- package/src/utils/alias-resolver.js +77 -0
- package/src/utils/api-key-store.js +259 -0
- package/src/utils/api-key-validation.js +97 -0
- package/src/utils/auth-json.js +109 -0
- package/src/utils/config.js +291 -0
- package/src/utils/curated-models.js +82 -0
- package/src/utils/env-compat.js +38 -0
- package/src/utils/env-loader.js +54 -0
- package/src/utils/idle-watchdog.js +225 -0
- package/src/utils/input-validators.js +127 -0
- package/src/utils/lifecycle.js +43 -0
- package/src/utils/logger.js +84 -0
- package/src/utils/mcp-discovery.js +194 -0
- package/src/utils/mcp-validators.js +78 -0
- package/src/utils/model-catalog.js +103 -0
- package/src/utils/model-fetcher.js +179 -0
- package/src/utils/model-validator.js +207 -0
- package/src/utils/path-setup.js +41 -0
- package/src/utils/port-pid.js +39 -0
- package/src/utils/prompt-source.js +53 -0
- package/src/utils/result-schema.js +261 -0
- package/src/utils/server-setup.js +93 -0
- package/src/utils/session-abort.js +53 -0
- package/src/utils/session-lock.js +95 -0
- package/src/utils/shared-server.js +216 -0
- package/src/utils/start-helpers.js +76 -0
- package/src/utils/thinking-validators.js +92 -0
- package/src/utils/update-notifier-loader.js +18 -0
- package/src/utils/updater.js +157 -0
- package/src/utils/validators.js +300 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Builder Module
|
|
3
|
+
*
|
|
4
|
+
* Handles building context from Claude Code sessions for sidecar operations.
|
|
5
|
+
* Spec Reference: §5 Context Passing
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
|
|
12
|
+
const { resolveSession, getSessionDirectory } = require('../session');
|
|
13
|
+
const { formatContext, readJSONL } = require('../jsonl-parser');
|
|
14
|
+
const { logger } = require('../utils/logger');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse duration string (e.g., '2h', '30m', '1d')
|
|
18
|
+
* @param {string} str - Duration string
|
|
19
|
+
* @returns {number} Milliseconds
|
|
20
|
+
*/
|
|
21
|
+
function parseDuration(str) {
|
|
22
|
+
if (!str || typeof str !== 'string') {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
const match = str.match(/^(\d+)(m|h|d)$/);
|
|
26
|
+
if (!match) {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
const multipliers = { m: 60000, h: 3600000, d: 86400000 };
|
|
30
|
+
return parseInt(match[1], 10) * multipliers[match[2]];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve session file from session directory
|
|
35
|
+
* @param {string} sessionDir - Session directory path
|
|
36
|
+
* @param {string} session - Session ID or 'current'
|
|
37
|
+
* @returns {{path: string|null, method: string, warning?: string}}
|
|
38
|
+
*/
|
|
39
|
+
function resolveSessionFile(sessionDir, session) {
|
|
40
|
+
// Use the existing resolveSession function
|
|
41
|
+
return resolveSession(sessionDir, session);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Apply context filters to messages array
|
|
46
|
+
* @param {Array} messages - Array of messages
|
|
47
|
+
* @param {object} options - Filter options
|
|
48
|
+
* @param {number} [options.contextTurns] - Max number of turns (user messages)
|
|
49
|
+
* @param {string} [options.contextSince] - Time filter (e.g., '2h')
|
|
50
|
+
* @param {number} [options._testCutoff] - Test-only: override time cutoff
|
|
51
|
+
* @returns {Array} Filtered messages
|
|
52
|
+
*/
|
|
53
|
+
function applyContextFilters(messages, options) {
|
|
54
|
+
if (!messages || messages.length === 0) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { contextTurns, contextSince, _testCutoff } = options;
|
|
59
|
+
let filtered = [...messages];
|
|
60
|
+
|
|
61
|
+
// Apply time filter if specified (overrides turns)
|
|
62
|
+
if (contextSince) {
|
|
63
|
+
const cutoffMs = _testCutoff || (Date.now() - parseDuration(contextSince));
|
|
64
|
+
filtered = filtered.filter(m => {
|
|
65
|
+
const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0;
|
|
66
|
+
return ts >= cutoffMs;
|
|
67
|
+
});
|
|
68
|
+
} else if (contextTurns && contextTurns > 0) {
|
|
69
|
+
// Apply turn filter - count user messages as turns
|
|
70
|
+
const userIndices = filtered
|
|
71
|
+
.map((m, i) => m.type === 'user' ? i : -1)
|
|
72
|
+
.filter(i => i >= 0);
|
|
73
|
+
|
|
74
|
+
if (userIndices.length > contextTurns) {
|
|
75
|
+
const startIdx = userIndices[userIndices.length - contextTurns];
|
|
76
|
+
filtered = filtered.slice(startIdx);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return filtered;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the Cowork local-agent-mode-sessions root for the current platform.
|
|
85
|
+
* @param {string} [homeDir] - Home directory override (for testing)
|
|
86
|
+
* @returns {string} Path to local-agent-mode-sessions directory
|
|
87
|
+
*/
|
|
88
|
+
function getCoworkSessionsRoot(homeDir = os.homedir()) {
|
|
89
|
+
// Cowork stores session data inside Claude Desktop's Application Support:
|
|
90
|
+
// ~/Library/Application Support/Claude/local-agent-mode-sessions/<org>/<user>/local_<id>/audit.jsonl
|
|
91
|
+
return path.join(homeDir, 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Find a Cowork session's audit.jsonl by process name or fall back to most recent.
|
|
96
|
+
* Scans ~/Library/Application Support/Claude/local-agent-mode-sessions/
|
|
97
|
+
*
|
|
98
|
+
* When coworkProcess is provided, matches the session metadata JSON file
|
|
99
|
+
* whose processName matches. This is the reliable path for parallel sessions.
|
|
100
|
+
* Falls back to most recently modified audit.jsonl when no process name given.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} [homeDir] - Home directory override (for testing)
|
|
103
|
+
* @param {string} [coworkProcess] - Cowork VM process name (e.g., 'modest-laughing-goodall')
|
|
104
|
+
* @returns {string|null} Path to the matching audit.jsonl, or null
|
|
105
|
+
*/
|
|
106
|
+
function findCoworkSession(homeDir = os.homedir(), coworkProcess = null) {
|
|
107
|
+
const root = getCoworkSessionsRoot(homeDir);
|
|
108
|
+
if (!fs.existsSync(root)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let bestPath = null;
|
|
113
|
+
let bestMtime = 0;
|
|
114
|
+
|
|
115
|
+
// Structure: root/<org-id>/<user-id>/local_<session-id>/audit.jsonl
|
|
116
|
+
// Metadata: root/<org-id>/<user-id>/local_<session-id>.json (has processName)
|
|
117
|
+
try {
|
|
118
|
+
for (const org of fs.readdirSync(root)) {
|
|
119
|
+
const orgPath = path.join(root, org);
|
|
120
|
+
try { if (!fs.statSync(orgPath).isDirectory()) { continue; } } catch { continue; }
|
|
121
|
+
|
|
122
|
+
for (const user of fs.readdirSync(orgPath)) {
|
|
123
|
+
const userPath = path.join(orgPath, user);
|
|
124
|
+
try { if (!fs.statSync(userPath).isDirectory()) { continue; } } catch { continue; }
|
|
125
|
+
|
|
126
|
+
for (const session of fs.readdirSync(userPath)) {
|
|
127
|
+
if (!session.startsWith('local_')) { continue; }
|
|
128
|
+
const auditPath = path.join(userPath, session, 'audit.jsonl');
|
|
129
|
+
|
|
130
|
+
// If matching by process name, check the metadata JSON
|
|
131
|
+
if (coworkProcess) {
|
|
132
|
+
const metaPath = path.join(userPath, `${session}.json`);
|
|
133
|
+
try {
|
|
134
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
135
|
+
if (meta.processName === coworkProcess) {
|
|
136
|
+
logger.info('Matched Cowork session by processName', { coworkProcess, session });
|
|
137
|
+
return fs.existsSync(auditPath) ? auditPath : null;
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
continue; // Skip mtime check when matching by process name
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fallback: track most recent audit.jsonl
|
|
146
|
+
try {
|
|
147
|
+
const mtime = fs.statSync(auditPath).mtime.getTime();
|
|
148
|
+
if (mtime > bestMtime) {
|
|
149
|
+
bestMtime = mtime;
|
|
150
|
+
bestPath = auditPath;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return bestPath;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Normalize Cowork audit.jsonl messages to match Claude Code JSONL format.
|
|
167
|
+
* Maps _audit_timestamp → timestamp and filters to user/assistant messages.
|
|
168
|
+
*
|
|
169
|
+
* @param {Array} messages - Raw parsed audit.jsonl entries
|
|
170
|
+
* @returns {Array} Normalized messages compatible with formatContext/applyContextFilters
|
|
171
|
+
*/
|
|
172
|
+
function normalizeCoworkMessages(messages) {
|
|
173
|
+
return messages
|
|
174
|
+
.filter(m => m.type === 'user' || m.type === 'assistant')
|
|
175
|
+
.map(m => ({
|
|
176
|
+
...m,
|
|
177
|
+
timestamp: m.timestamp || m._audit_timestamp || null
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build context from Claude Code session
|
|
183
|
+
* Spec Reference: §5 Context Passing
|
|
184
|
+
*
|
|
185
|
+
* @param {string} project - Project directory
|
|
186
|
+
* @param {string} session - Session ID or 'current'
|
|
187
|
+
* @param {object} options - Context options
|
|
188
|
+
* @param {number} [options.contextTurns=50] - Max conversation turns
|
|
189
|
+
* @param {string} [options.contextSince] - Time filter (e.g., '2h')
|
|
190
|
+
* @param {number} [options.contextMaxTokens=80000] - Max context tokens
|
|
191
|
+
* @param {string} [options.sessionDir] - Explicit session directory override (for code-web, cowork)
|
|
192
|
+
* @param {string} [options.client] - Client type (code-local, code-web, cowork)
|
|
193
|
+
* @param {string} [options._homeDir] - Home directory override (testing only)
|
|
194
|
+
* @returns {string} Formatted context string
|
|
195
|
+
*/
|
|
196
|
+
function buildContext(project, session, options) {
|
|
197
|
+
const { contextTurns = 50, contextSince, contextMaxTokens = 80000, sessionDir: sessionDirOverride, client, coworkProcess, _homeDir } = options;
|
|
198
|
+
const homeDir = _homeDir || os.homedir();
|
|
199
|
+
|
|
200
|
+
// Determine session directory:
|
|
201
|
+
// 1. If sessionDir is explicitly provided, use it directly (code-web, cowork)
|
|
202
|
+
// 2. Otherwise, use the standard getSessionDirectory for code-local
|
|
203
|
+
const resolvedSessionDir = sessionDirOverride || getSessionDirectory(project, homeDir);
|
|
204
|
+
|
|
205
|
+
// For cowork clients: read directly from Cowork's local-agent-mode-sessions
|
|
206
|
+
// on the host Mac. The Cowork VM can't expose its session path to the MCP server,
|
|
207
|
+
// so we find the most recently active session's audit.jsonl on the host.
|
|
208
|
+
if (client === 'cowork' && !sessionDirOverride) {
|
|
209
|
+
const auditPath = findCoworkSession(homeDir, coworkProcess);
|
|
210
|
+
if (!auditPath) {
|
|
211
|
+
logger.warn('No Cowork session found in local-agent-mode-sessions', { project });
|
|
212
|
+
return '[No Claude Code conversation history found]';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
logger.info('Using Cowork session', { auditPath });
|
|
216
|
+
|
|
217
|
+
let messages;
|
|
218
|
+
try {
|
|
219
|
+
messages = readJSONL(auditPath);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
logger.error('Error reading Cowork session', { error: err.message });
|
|
222
|
+
return '[Error reading Claude Code session]';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
messages = normalizeCoworkMessages(messages);
|
|
226
|
+
if (messages.length === 0) {
|
|
227
|
+
return '[Empty Claude Code session]';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
messages = applyContextFilters(messages, { contextTurns, contextSince });
|
|
231
|
+
let context = formatContext(messages);
|
|
232
|
+
const maxChars = contextMaxTokens * 4;
|
|
233
|
+
if (context.length > maxChars) {
|
|
234
|
+
context = '[Earlier context truncated...]\n\n' + context.slice(-maxChars);
|
|
235
|
+
}
|
|
236
|
+
return context || '[No relevant context found]';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!fs.existsSync(resolvedSessionDir)) {
|
|
240
|
+
logger.warn('No Claude Code conversation history found', { project, sessionDir: resolvedSessionDir, client });
|
|
241
|
+
return '[No Claude Code conversation history found]';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Resolve session file
|
|
245
|
+
const resolution = resolveSessionFile(resolvedSessionDir, session);
|
|
246
|
+
|
|
247
|
+
if (!resolution.path) {
|
|
248
|
+
logger.warn('No Claude Code session found', { project, session });
|
|
249
|
+
return '[No Claude Code conversation history found]';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (resolution.warning) {
|
|
253
|
+
logger.warn('Session resolution warning', { warning: resolution.warning });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
logger.info('Using session', {
|
|
257
|
+
session: path.basename(resolution.path),
|
|
258
|
+
method: resolution.method
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Read and parse the session file
|
|
262
|
+
let messages;
|
|
263
|
+
try {
|
|
264
|
+
messages = readJSONL(resolution.path);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
logger.error('Error reading session', { error: err.message });
|
|
267
|
+
return '[Error reading Claude Code session]';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (messages.length === 0) {
|
|
271
|
+
return '[Empty Claude Code session]';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Apply filters
|
|
275
|
+
messages = applyContextFilters(messages, { contextTurns, contextSince });
|
|
276
|
+
|
|
277
|
+
// Format as context
|
|
278
|
+
let context = formatContext(messages);
|
|
279
|
+
|
|
280
|
+
// Truncate to token limit (~4 chars per token)
|
|
281
|
+
const maxChars = contextMaxTokens * 4;
|
|
282
|
+
if (context.length > maxChars) {
|
|
283
|
+
context = '[Earlier context truncated...]\n\n' + context.slice(-maxChars);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return context || '[No relevant context found]';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
buildContext,
|
|
291
|
+
parseDuration,
|
|
292
|
+
resolveSessionFile,
|
|
293
|
+
applyContextFilters,
|
|
294
|
+
findCoworkSession,
|
|
295
|
+
normalizeCoworkMessages,
|
|
296
|
+
getCoworkSessionsRoot
|
|
297
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar Continue Operations - Handles continuing from previous sessions
|
|
3
|
+
* Spec Reference: §4.4, §8.5
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
const { generateTaskId, runInteractive, buildMcpConfig } = require('./start');
|
|
9
|
+
const {
|
|
10
|
+
SessionPaths,
|
|
11
|
+
saveInitialContext,
|
|
12
|
+
finalizeSession,
|
|
13
|
+
outputSummary,
|
|
14
|
+
createHeartbeat
|
|
15
|
+
} = require('./session-utils');
|
|
16
|
+
const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
17
|
+
const { runHeadless } = require('../headless');
|
|
18
|
+
const { buildPrompts } = require('../prompt-builder');
|
|
19
|
+
const { logger } = require('../utils/logger');
|
|
20
|
+
|
|
21
|
+
/** Load previous session data (metadata, summary, conversation) */
|
|
22
|
+
function loadPreviousSession(taskId, project) {
|
|
23
|
+
// Reads an EXISTING session — resolve dual-dir (amicus, then legacy).
|
|
24
|
+
const sessionDir = SessionPaths.resolveSessionDir(project, taskId);
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(sessionDir)) {
|
|
27
|
+
throw new Error(`Session ${taskId} not found`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Load metadata
|
|
31
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
32
|
+
const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
33
|
+
|
|
34
|
+
// Load summary if available
|
|
35
|
+
const summaryPath = SessionPaths.summaryFile(sessionDir);
|
|
36
|
+
const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : '';
|
|
37
|
+
|
|
38
|
+
// Load and format conversation if available
|
|
39
|
+
const convPath = SessionPaths.conversationFile(sessionDir);
|
|
40
|
+
let conversation = '';
|
|
41
|
+
|
|
42
|
+
if (fs.existsSync(convPath)) {
|
|
43
|
+
const lines = fs.readFileSync(convPath, 'utf-8').split('\n').filter(Boolean);
|
|
44
|
+
const messages = lines.map(line => {
|
|
45
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
46
|
+
}).filter(Boolean);
|
|
47
|
+
|
|
48
|
+
conversation = messages.map(m => {
|
|
49
|
+
const time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString() : '';
|
|
50
|
+
return `[${m.role} @ ${time}] ${m.content}`;
|
|
51
|
+
}).join('\n\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { metadata, summary, conversation };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build continuation context from previous session data */
|
|
58
|
+
function buildContinuationContext(metadata, summary, conversation, contextMaxTokens = 80000) {
|
|
59
|
+
const maxChars = contextMaxTokens * 4;
|
|
60
|
+
|
|
61
|
+
const truncatedConversation = conversation.length > maxChars
|
|
62
|
+
? conversation.slice(-maxChars)
|
|
63
|
+
: conversation;
|
|
64
|
+
|
|
65
|
+
return `
|
|
66
|
+
## PREVIOUS SIDECAR SESSION
|
|
67
|
+
|
|
68
|
+
This sidecar continues from a previous session (${metadata.taskId}).
|
|
69
|
+
|
|
70
|
+
### Previous Task
|
|
71
|
+
${metadata.briefing || 'No briefing recorded'}
|
|
72
|
+
|
|
73
|
+
### Previous Summary
|
|
74
|
+
${summary || 'No summary available'}
|
|
75
|
+
|
|
76
|
+
### Previous Conversation Excerpt
|
|
77
|
+
${truncatedConversation || 'No conversation recorded'}
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## NEW TASK
|
|
82
|
+
|
|
83
|
+
Build on the previous sidecar's findings. The user wants to continue or extend that work.
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Create session metadata for continuation */
|
|
88
|
+
function createContinueSessionMetadata(taskId, project, options, oldTaskId) {
|
|
89
|
+
const { model, briefing, headless, agent } = options;
|
|
90
|
+
|
|
91
|
+
const sessionDir = SessionPaths.sessionDir(project, taskId);
|
|
92
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const metadata = {
|
|
95
|
+
taskId,
|
|
96
|
+
model,
|
|
97
|
+
project,
|
|
98
|
+
briefing,
|
|
99
|
+
mode: headless ? 'headless' : 'interactive',
|
|
100
|
+
agent: agent || (headless ? 'build' : 'chat'),
|
|
101
|
+
status: 'running',
|
|
102
|
+
createdAt: new Date().toISOString(),
|
|
103
|
+
continuesFrom: oldTaskId
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
fs.writeFileSync(SessionPaths.metadataFile(sessionDir), JSON.stringify(metadata, null, 2));
|
|
107
|
+
|
|
108
|
+
return sessionDir;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Continue from a previous sidecar session - Spec Reference: §4.4, §8.5 */
|
|
112
|
+
async function continueSidecar(options) {
|
|
113
|
+
const {
|
|
114
|
+
taskId: oldTaskId,
|
|
115
|
+
briefing,
|
|
116
|
+
project = process.cwd(),
|
|
117
|
+
contextMaxTokens = 80000,
|
|
118
|
+
headless = false,
|
|
119
|
+
timeout = 15,
|
|
120
|
+
agent,
|
|
121
|
+
mcp, mcpConfig, client, noMcp, excludeMcp
|
|
122
|
+
} = options;
|
|
123
|
+
|
|
124
|
+
// Load previous session data
|
|
125
|
+
const { metadata: oldMetadata, summary: previousSummary, conversation: previousConversation } =
|
|
126
|
+
loadPreviousSession(oldTaskId, project);
|
|
127
|
+
|
|
128
|
+
// Lock the previous (EXISTING) session directory to prevent concurrent
|
|
129
|
+
// continue operations — resolve dual-dir so a legacy session is locked too.
|
|
130
|
+
const prevSessionDir = SessionPaths.resolveSessionDir(project, oldTaskId);
|
|
131
|
+
acquireLock(prevSessionDir, headless ? 'headless' : 'interactive');
|
|
132
|
+
|
|
133
|
+
const model = options.model || oldMetadata.model;
|
|
134
|
+
if (!options.model) {
|
|
135
|
+
// Inherited model: advisory only (F5) — never block reopening a session.
|
|
136
|
+
const { warnIfNotInCatalog } = require('../utils/model-validator');
|
|
137
|
+
await warnIfNotInCatalog(model);
|
|
138
|
+
}
|
|
139
|
+
const mcpServers = buildMcpConfig({ mcp, mcpConfig, clientType: client, noMcp, excludeMcp });
|
|
140
|
+
logger.info('Continuing from session', { oldTaskId, model });
|
|
141
|
+
|
|
142
|
+
// Build continuation context
|
|
143
|
+
const previousContext = buildContinuationContext(
|
|
144
|
+
oldMetadata, previousSummary, previousConversation, contextMaxTokens
|
|
145
|
+
);
|
|
146
|
+
const fullContext = previousContext + '\n\n' + briefing;
|
|
147
|
+
|
|
148
|
+
// Inherit agent from previous session if not specified
|
|
149
|
+
const effectiveAgent = agent || oldMetadata.agent || 'Build';
|
|
150
|
+
|
|
151
|
+
// Build system prompt and user message
|
|
152
|
+
const { system: systemPrompt, userMessage } = buildPrompts(
|
|
153
|
+
briefing, fullContext, project, headless, effectiveAgent, 'normal', client
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Use provided task ID (from MCP server) or generate a new one
|
|
157
|
+
const newTaskId = options.newTaskId || generateTaskId();
|
|
158
|
+
logger.info('New continuation task', { newTaskId, oldTaskId });
|
|
159
|
+
|
|
160
|
+
const sessionDir = createContinueSessionMetadata(newTaskId, project, {
|
|
161
|
+
model, briefing, headless, agent: effectiveAgent
|
|
162
|
+
}, oldTaskId);
|
|
163
|
+
|
|
164
|
+
saveInitialContext(sessionDir, systemPrompt, userMessage);
|
|
165
|
+
|
|
166
|
+
// Start heartbeat
|
|
167
|
+
const heartbeat = createHeartbeat();
|
|
168
|
+
|
|
169
|
+
let summary;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
if (headless) {
|
|
173
|
+
const result = await runHeadless(
|
|
174
|
+
model, systemPrompt, userMessage, newTaskId, project,
|
|
175
|
+
timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
|
|
176
|
+
);
|
|
177
|
+
summary = result.summary ||
|
|
178
|
+
'## Sidecar Results: No Output\n\nContinued session completed without summary.';
|
|
179
|
+
|
|
180
|
+
if (result.timedOut) { logger.warn('Continuation task timed out', { taskId: newTaskId }); }
|
|
181
|
+
if (result.error) { logger.error('Continuation task error', { taskId: newTaskId, error: result.error }); }
|
|
182
|
+
} else {
|
|
183
|
+
logger.info('Launching interactive continue', { taskId: newTaskId, model });
|
|
184
|
+
const result = await runInteractive(
|
|
185
|
+
model, systemPrompt, userMessage, newTaskId, project,
|
|
186
|
+
{ agent: effectiveAgent, mcp: mcpServers }
|
|
187
|
+
);
|
|
188
|
+
summary = result.summary || '';
|
|
189
|
+
if (result.error) { logger.error('Interactive continue error', { taskId: newTaskId, error: result.error }); }
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
heartbeat.stop();
|
|
193
|
+
releaseLock(prevSessionDir);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Output summary
|
|
197
|
+
outputSummary(summary);
|
|
198
|
+
|
|
199
|
+
// Load current metadata for finalization
|
|
200
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
201
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
202
|
+
|
|
203
|
+
// Finalize session
|
|
204
|
+
finalizeSession(sessionDir, summary, project, meta);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
loadPreviousSession,
|
|
209
|
+
buildContinuationContext,
|
|
210
|
+
createContinueSessionMetadata,
|
|
211
|
+
continueSidecar
|
|
212
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash Handler - Updates metadata to 'error' on uncaught exceptions
|
|
3
|
+
*
|
|
4
|
+
* Installed by bin/amicus.js for MCP-spawned processes that have a --task-id.
|
|
5
|
+
* When the process crashes, the handler marks the session as failed so the
|
|
6
|
+
* MCP client can detect the error instead of seeing a stuck 'running' status.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { SessionPaths } = require('./session-utils');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a crash handler that updates session metadata on error.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} taskId - The sidecar task ID
|
|
17
|
+
* @param {string} project - The project root directory
|
|
18
|
+
* @returns {function(Error): void} Handler function to call with the error
|
|
19
|
+
*/
|
|
20
|
+
function installCrashHandler(taskId, project) {
|
|
21
|
+
return function handleCrash(err) {
|
|
22
|
+
try {
|
|
23
|
+
// Operates on an EXISTING (possibly legacy) session — resolve dual-dir.
|
|
24
|
+
const sessionDir = SessionPaths.resolveSessionDir(project, taskId);
|
|
25
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(metaPath)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
32
|
+
|
|
33
|
+
if (metadata.status !== 'running') {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
metadata.status = 'error';
|
|
38
|
+
metadata.reason = err.message;
|
|
39
|
+
metadata.errorAt = new Date().toISOString();
|
|
40
|
+
|
|
41
|
+
fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
42
|
+
|
|
43
|
+
// Delete session lock if it exists
|
|
44
|
+
const lockPath = path.join(sessionDir, 'session.lock');
|
|
45
|
+
try {
|
|
46
|
+
fs.unlinkSync(lockPath);
|
|
47
|
+
} catch {
|
|
48
|
+
// Lock may not exist yet
|
|
49
|
+
}
|
|
50
|
+
} catch (_ignored) {
|
|
51
|
+
// Crash handler must never throw - swallow all errors
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { installCrashHandler };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/sidecar/fanout-leg.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module fanout-leg
|
|
6
|
+
* Per-leg helpers extracted from fanout.js to keep both files ≤300 lines.
|
|
7
|
+
* Exports: legStatusFromResult, writeLegPatch, runLeg
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { logger } = require('../utils/logger');
|
|
13
|
+
|
|
14
|
+
/** Map a runHeadless result to a leg metadata status. */
|
|
15
|
+
function legStatusFromResult(result) {
|
|
16
|
+
const { statusFromResult } = require('../utils/result-schema');
|
|
17
|
+
return statusFromResult(result);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read-merge-write a leg's metadata.json. Returns the merged object. */
|
|
21
|
+
function writeLegPatch(legDir, patch) {
|
|
22
|
+
const metaPath = path.join(legDir, 'metadata.json');
|
|
23
|
+
let meta = {};
|
|
24
|
+
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* fresh */ }
|
|
25
|
+
const defined = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
|
|
26
|
+
// A signal/abort marker is authoritative: never demote 'aborted' back to a
|
|
27
|
+
// softer terminal status (a leg finishing concurrently with Ctrl-C must not
|
|
28
|
+
// win the write race and report 'complete').
|
|
29
|
+
if (meta.status === 'aborted' && defined.status && defined.status !== 'aborted') {
|
|
30
|
+
delete defined.status;
|
|
31
|
+
}
|
|
32
|
+
const merged = { ...meta, ...defined };
|
|
33
|
+
fs.writeFileSync(metaPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
34
|
+
return merged;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run one leg end-to-end: session record → runHeadless (shared server) →
|
|
39
|
+
* leg finalize. Never throws — always resolves to a run document.
|
|
40
|
+
*/
|
|
41
|
+
async function runLeg({ leg, legId, waveId, project, systemPrompt, userMessage, timeoutMs, agent, client, server, summaryLength, reasoning, quiet }) {
|
|
42
|
+
const { IdleWatchdog } = require('../utils/idle-watchdog');
|
|
43
|
+
const { markAborted } = require('../utils/session-abort');
|
|
44
|
+
const { runHeadless } = require('../headless');
|
|
45
|
+
const { SessionPaths, saveInitialContext } = require('./session-utils');
|
|
46
|
+
const { buildRunResult } = require('../utils/result-schema');
|
|
47
|
+
const { createSessionMetadata } = require('./start');
|
|
48
|
+
|
|
49
|
+
const legDir = createSessionMetadata(legId, project, {
|
|
50
|
+
model: leg.model, prompt: userMessage, noUi: true, agent: agent || 'build',
|
|
51
|
+
});
|
|
52
|
+
writeLegPatch(legDir, { parentWave: waveId, modelInput: leg.modelInput });
|
|
53
|
+
saveInitialContext(legDir, systemPrompt, userMessage);
|
|
54
|
+
|
|
55
|
+
// Per-leg watchdog: a BACKSTOP strictly behind runHeadless's own deadline
|
|
56
|
+
// (timeoutMs + 60s), so it only fires if the poll loop itself wedges. Its
|
|
57
|
+
// timeout aborts ONLY this leg, and only while the leg is still running.
|
|
58
|
+
// NEVER server.close()/process.exit() — shared server.
|
|
59
|
+
const watchdog = new IdleWatchdog({
|
|
60
|
+
mode: 'headless',
|
|
61
|
+
timeout: timeoutMs + 60000,
|
|
62
|
+
onTimeout: () => {
|
|
63
|
+
let current = {};
|
|
64
|
+
try { current = JSON.parse(fs.readFileSync(path.join(legDir, 'metadata.json'), 'utf-8')); } catch { /* unreadable */ }
|
|
65
|
+
if (current.status === 'running') {
|
|
66
|
+
logger.warn('Leg watchdog backstop fired — aborting leg', { legId });
|
|
67
|
+
markAborted(legDir, 'leg watchdog backstop');
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}).start();
|
|
71
|
+
|
|
72
|
+
let result;
|
|
73
|
+
try {
|
|
74
|
+
result = await runHeadless(
|
|
75
|
+
leg.model, systemPrompt, userMessage, legId, project,
|
|
76
|
+
timeoutMs, agent || 'build',
|
|
77
|
+
{ client, server, watchdog, summaryLength, reasoning }
|
|
78
|
+
);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
result = { summary: '', completed: false, timedOut: false, aborted: false, error: err.message, taskId: legId };
|
|
81
|
+
} finally {
|
|
82
|
+
watchdog.cancel();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const status = legStatusFromResult(result);
|
|
86
|
+
const summary = result.summary || null;
|
|
87
|
+
if (summary) {
|
|
88
|
+
fs.writeFileSync(SessionPaths.summaryFile(legDir), summary, { mode: 0o600 });
|
|
89
|
+
}
|
|
90
|
+
const finalMeta = writeLegPatch(legDir, {
|
|
91
|
+
status,
|
|
92
|
+
reason: result.error || undefined,
|
|
93
|
+
completedAt: new Date().toISOString(),
|
|
94
|
+
});
|
|
95
|
+
const effectiveResult = finalMeta.status === 'aborted'
|
|
96
|
+
? { ...result, aborted: true }
|
|
97
|
+
: result;
|
|
98
|
+
if (!quiet) {
|
|
99
|
+
process.stderr.write(`[fanout] leg ${legId} (${leg.modelInput}): ${finalMeta.status}\n`);
|
|
100
|
+
}
|
|
101
|
+
return buildRunResult({
|
|
102
|
+
taskId: legId, metadata: finalMeta, result: effectiveResult, summary,
|
|
103
|
+
modelInput: leg.modelInput, sessionDir: legDir, waveId,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { legStatusFromResult, writeLegPatch, runLeg };
|