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,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar Progress Reader
|
|
3
|
+
*
|
|
4
|
+
* Reads conversation.jsonl and progress.json from a session directory
|
|
5
|
+
* and returns progress info: message count, last activity time, latest action,
|
|
6
|
+
* and lifecycle stage.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
/** Lifecycle stage labels */
|
|
13
|
+
const STAGE_LABELS = {
|
|
14
|
+
initializing: 'Starting OpenCode server...',
|
|
15
|
+
server_ready: 'Server ready, creating session...',
|
|
16
|
+
session_created: 'Session created',
|
|
17
|
+
prompt_sent: 'Briefing delivered, waiting for response...',
|
|
18
|
+
receiving: 'Generating response...',
|
|
19
|
+
complete: 'Complete'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract the latest action description from parsed JSONL entries.
|
|
24
|
+
*
|
|
25
|
+
* @param {object[]} entries - Parsed JSONL entries
|
|
26
|
+
* @returns {string} Short description of last assistant action
|
|
27
|
+
*/
|
|
28
|
+
function extractLatest(entries) {
|
|
29
|
+
const assistantEntries = entries.filter(e => e.role === 'assistant');
|
|
30
|
+
|
|
31
|
+
if (assistantEntries.length === 0) {
|
|
32
|
+
return 'Starting up...';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const last = assistantEntries[assistantEntries.length - 1];
|
|
36
|
+
|
|
37
|
+
// Tool use entry with name
|
|
38
|
+
if (last.toolCall && last.toolCall.name) {
|
|
39
|
+
return `Using ${last.toolCall.name}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Text content: take first line, truncate to 80 chars
|
|
43
|
+
if (last.content) {
|
|
44
|
+
const firstLine = String(last.content).split('\n')[0];
|
|
45
|
+
if (!firstLine) {
|
|
46
|
+
return 'Working...';
|
|
47
|
+
}
|
|
48
|
+
if (firstLine.length > 80) {
|
|
49
|
+
return firstLine.slice(0, 80) + '...';
|
|
50
|
+
}
|
|
51
|
+
return firstLine;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Tool use entry without name (SDK may not populate part.name)
|
|
55
|
+
if (last.type === 'tool_use' || last.toolCall) {
|
|
56
|
+
return 'Executing tool call...';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Assistant entry exists but has no recognizable content
|
|
60
|
+
return 'Working...';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compute a relative time string from a file mtime.
|
|
65
|
+
*
|
|
66
|
+
* @param {Date|null|undefined} mtime - File modification time
|
|
67
|
+
* @returns {string} Relative time (e.g., "12s ago", "3m ago", "2h ago", "never")
|
|
68
|
+
*/
|
|
69
|
+
function computeLastActivity(mtime) {
|
|
70
|
+
if (!mtime) {
|
|
71
|
+
return 'never';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const diffMs = Date.now() - mtime.getTime();
|
|
75
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
76
|
+
|
|
77
|
+
if (diffSec < 60) {
|
|
78
|
+
return `${diffSec}s ago`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
82
|
+
if (diffMin < 60) {
|
|
83
|
+
return `${diffMin}m ago`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
87
|
+
return `${diffHr}h ago`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Write a progress update to progress.json.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} sessionDir - Path to the session directory
|
|
94
|
+
* @param {string} stage - Lifecycle stage name
|
|
95
|
+
* @param {object} [extra={}] - Additional fields (e.g., messagesReceived)
|
|
96
|
+
*/
|
|
97
|
+
function writeProgress(sessionDir, stage, extra = {}) {
|
|
98
|
+
const progressPath = path.join(sessionDir, 'progress.json');
|
|
99
|
+
const data = {
|
|
100
|
+
stage,
|
|
101
|
+
stageLabel: STAGE_LABELS[stage] || stage,
|
|
102
|
+
updatedAt: new Date().toISOString(),
|
|
103
|
+
...extra
|
|
104
|
+
};
|
|
105
|
+
fs.writeFileSync(progressPath, JSON.stringify(data), { mode: 0o600 });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read progress from a session's conversation.jsonl and progress.json files.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} sessionDir - Path to the session directory
|
|
112
|
+
* @returns {{ messages: number, lastActivity: string, latest: string, stage?: string }}
|
|
113
|
+
*/
|
|
114
|
+
function readProgress(sessionDir) {
|
|
115
|
+
const convPath = path.join(sessionDir, 'conversation.jsonl');
|
|
116
|
+
const progressPath = path.join(sessionDir, 'progress.json');
|
|
117
|
+
|
|
118
|
+
let convStat = null;
|
|
119
|
+
const entries = [];
|
|
120
|
+
|
|
121
|
+
// Read conversation.jsonl if it exists
|
|
122
|
+
if (fs.existsSync(convPath)) {
|
|
123
|
+
convStat = fs.statSync(convPath);
|
|
124
|
+
const content = fs.readFileSync(convPath, 'utf-8');
|
|
125
|
+
|
|
126
|
+
for (const line of content.split('\n')) {
|
|
127
|
+
if (!line.trim()) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
entries.push(JSON.parse(line));
|
|
132
|
+
} catch {
|
|
133
|
+
// Skip malformed lines
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Count assistant messages from conversation.jsonl
|
|
139
|
+
let messages = entries.filter(e => e.role === 'assistant').length;
|
|
140
|
+
|
|
141
|
+
// Extract latest action from conversation.jsonl
|
|
142
|
+
let latest = extractLatest(entries);
|
|
143
|
+
|
|
144
|
+
// Determine lastActivity from conversation.jsonl mtime
|
|
145
|
+
let lastActivity = convStat
|
|
146
|
+
? computeLastActivity(convStat.mtime)
|
|
147
|
+
: 'never';
|
|
148
|
+
|
|
149
|
+
// Read progress.json for lifecycle stage info
|
|
150
|
+
let stage;
|
|
151
|
+
|
|
152
|
+
if (fs.existsSync(progressPath)) {
|
|
153
|
+
try {
|
|
154
|
+
const progress = JSON.parse(fs.readFileSync(progressPath, 'utf-8'));
|
|
155
|
+
stage = progress.stage;
|
|
156
|
+
|
|
157
|
+
// Use progress stage label when no assistant entries exist yet
|
|
158
|
+
if (messages === 0 && progress.stageLabel) {
|
|
159
|
+
latest = progress.stageLabel;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Use progress.json latestTool for better latest when extractLatest
|
|
163
|
+
// returns a generic fallback (tool_use entries without name)
|
|
164
|
+
if (messages > 0 && progress.latestTool && (latest === 'Working...' || latest === 'Executing tool call...')) {
|
|
165
|
+
latest = `Calling tool: ${progress.latestTool}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Use messagesReceived from progress when conversation has no assistant entries
|
|
169
|
+
if (messages === 0 && progress.messagesReceived !== undefined) {
|
|
170
|
+
messages = progress.messagesReceived;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Use progress updatedAt for lastActivity if more recent
|
|
174
|
+
if (progress.updatedAt) {
|
|
175
|
+
const progressTime = new Date(progress.updatedAt);
|
|
176
|
+
if (!convStat || progressTime > convStat.mtime) {
|
|
177
|
+
lastActivity = computeLastActivity(progressTime);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Ignore malformed progress file
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Compute raw lastActivityMs for stall detection
|
|
186
|
+
let lastActivityMs = null;
|
|
187
|
+
if (convStat) {
|
|
188
|
+
lastActivityMs = Date.now() - convStat.mtime.getTime();
|
|
189
|
+
}
|
|
190
|
+
// Use progress.json updatedAt if more recent
|
|
191
|
+
if (fs.existsSync(progressPath)) {
|
|
192
|
+
try {
|
|
193
|
+
const progress = JSON.parse(fs.readFileSync(progressPath, 'utf-8'));
|
|
194
|
+
if (progress.updatedAt) {
|
|
195
|
+
const progressMs = Date.now() - new Date(progress.updatedAt).getTime();
|
|
196
|
+
if (lastActivityMs === null || progressMs < lastActivityMs) {
|
|
197
|
+
lastActivityMs = progressMs;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// Ignore — already handled above
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const result = { messages, lastActivity, latest, lastActivityMs };
|
|
206
|
+
if (stage !== undefined) {
|
|
207
|
+
result.stage = stage;
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = {
|
|
213
|
+
readProgress,
|
|
214
|
+
writeProgress,
|
|
215
|
+
extractLatest,
|
|
216
|
+
computeLastActivity,
|
|
217
|
+
STAGE_LABELS
|
|
218
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar Read Operations Module
|
|
3
|
+
*
|
|
4
|
+
* Handles reading and listing sidecar sessions.
|
|
5
|
+
* Spec Reference: §4.2, §4.5
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { safeSessionDir, TASK_ID_PATTERN } = require('../utils/validators');
|
|
11
|
+
const { SESSIONS_DIR, LEGACY_SESSIONS_DIR } = require('../session-manager');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Format a timestamp as relative age
|
|
15
|
+
* @param {string} dateStr - ISO date string
|
|
16
|
+
* @returns {string} Relative age (e.g., "30m ago", "5h ago", "3d ago")
|
|
17
|
+
*/
|
|
18
|
+
function formatAge(dateStr) {
|
|
19
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
20
|
+
const mins = Math.floor(diff / 60000);
|
|
21
|
+
if (mins < 60) {
|
|
22
|
+
return `${mins}m ago`;
|
|
23
|
+
}
|
|
24
|
+
const hours = Math.floor(mins / 60);
|
|
25
|
+
if (hours < 24) {
|
|
26
|
+
return `${hours}h ago`;
|
|
27
|
+
}
|
|
28
|
+
const days = Math.floor(hours / 24);
|
|
29
|
+
return `${days}d ago`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Enumerate sessions across canonical + legacy roots (dedup, amicus wins).
|
|
34
|
+
* @param {string} project
|
|
35
|
+
* @param {{status?: string}} [opts] - status filter ('running', etc.); omit/'all' for all
|
|
36
|
+
* @returns {Array<{id, model, status, agent, briefing, createdAt}>}
|
|
37
|
+
*/
|
|
38
|
+
function enumerateSessions(project, opts = {}) {
|
|
39
|
+
const roots = [SESSIONS_DIR, LEGACY_SESSIONS_DIR]
|
|
40
|
+
.map(d => path.join(project, '.claude', d))
|
|
41
|
+
.filter(fs.existsSync);
|
|
42
|
+
|
|
43
|
+
const byId = new Map();
|
|
44
|
+
for (const root of roots) {
|
|
45
|
+
for (const d of fs.readdirSync(root)) {
|
|
46
|
+
if (!TASK_ID_PATTERN.test(d)) { continue; }
|
|
47
|
+
if (byId.has(d)) { continue; }
|
|
48
|
+
const metaPath = path.join(root, d, 'metadata.json');
|
|
49
|
+
if (!fs.existsSync(metaPath)) { continue; }
|
|
50
|
+
try {
|
|
51
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
52
|
+
byId.set(d, {
|
|
53
|
+
id: d, model: meta.model, status: meta.status, agent: meta.agent,
|
|
54
|
+
briefing: meta.briefing, createdAt: meta.createdAt,
|
|
55
|
+
type: meta.type || 'run',
|
|
56
|
+
parentWave: meta.parentWave || null,
|
|
57
|
+
legCount: Array.isArray(meta.legs) ? meta.legs.length : null,
|
|
58
|
+
});
|
|
59
|
+
} catch { /* skip unreadable */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let sessions = Array.from(byId.values())
|
|
64
|
+
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
65
|
+
if (opts.status && opts.status !== 'all') {
|
|
66
|
+
sessions = sessions.filter(s => s.status === opts.status);
|
|
67
|
+
}
|
|
68
|
+
return sessions;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* List previous sidecar sessions
|
|
73
|
+
* Spec Reference: §4.2
|
|
74
|
+
*
|
|
75
|
+
* @param {object} options
|
|
76
|
+
* @param {string} [options.status] - Filter by status (all, running, complete)
|
|
77
|
+
* @param {boolean} [options.json] - Output as JSON
|
|
78
|
+
* @param {string} [options.project] - Project directory
|
|
79
|
+
*/
|
|
80
|
+
async function listSidecars(options) {
|
|
81
|
+
const { status, json, project = process.cwd() } = options;
|
|
82
|
+
|
|
83
|
+
const sessions = enumerateSessions(project, { status });
|
|
84
|
+
if (sessions.length === 0) {
|
|
85
|
+
console.log('No amicus sessions found.');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (json) {
|
|
90
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
91
|
+
} else {
|
|
92
|
+
console.log('ID MODEL STATUS AGE BRIEFING');
|
|
93
|
+
console.log('─'.repeat(80));
|
|
94
|
+
sessions.forEach(s => {
|
|
95
|
+
const age = formatAge(s.createdAt);
|
|
96
|
+
const briefingShort = (s.briefing || '').slice(0, 30) +
|
|
97
|
+
((s.briefing?.length > 30) ? '...' : '');
|
|
98
|
+
console.log(
|
|
99
|
+
`${(s.id || '').padEnd(10)}` +
|
|
100
|
+
`${(s.type === 'wave' ? `wave(${s.legCount ?? 0} legs)` : (s.model || '')).padEnd(23)}` +
|
|
101
|
+
`${(s.status || 'unknown').padEnd(11)}` +
|
|
102
|
+
`${age.padEnd(12)}` +
|
|
103
|
+
`${briefingShort}`
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read sidecar session data
|
|
111
|
+
* Spec Reference: §4.5
|
|
112
|
+
*
|
|
113
|
+
* @param {object} options
|
|
114
|
+
* @param {string} options.taskId - Task ID to read
|
|
115
|
+
* @param {boolean} [options.conversation] - Read conversation
|
|
116
|
+
* @param {boolean} [options.metadata] - Read metadata
|
|
117
|
+
* @param {string} [options.project] - Project directory
|
|
118
|
+
*/
|
|
119
|
+
async function readSidecar(options) {
|
|
120
|
+
const { taskId, conversation, metadata, json, project = process.cwd() } = options;
|
|
121
|
+
|
|
122
|
+
const sessionDir = safeSessionDir(project, taskId);
|
|
123
|
+
|
|
124
|
+
if (!fs.existsSync(sessionDir)) {
|
|
125
|
+
throw new Error(`Session ${taskId} not found`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
129
|
+
let meta = {};
|
|
130
|
+
try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch { /* legacy/partial */ }
|
|
131
|
+
|
|
132
|
+
if (json) {
|
|
133
|
+
const { buildRunResultFromSession, buildWaveResultFromSession } = require('../utils/result-schema');
|
|
134
|
+
const doc = meta.type === 'wave'
|
|
135
|
+
? buildWaveResultFromSession(project, taskId)
|
|
136
|
+
: buildRunResultFromSession(project, taskId);
|
|
137
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (meta.type === 'wave' && !conversation && !metadata) {
|
|
142
|
+
const { buildWaveResultFromSession } = require('../utils/result-schema');
|
|
143
|
+
const { formatWaveHuman } = require('./fanout-output');
|
|
144
|
+
console.log(formatWaveHuman(buildWaveResultFromSession(project, taskId)));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (conversation) {
|
|
149
|
+
const convPath = path.join(sessionDir, 'conversation.jsonl');
|
|
150
|
+
if (fs.existsSync(convPath)) {
|
|
151
|
+
const lines = fs.readFileSync(convPath, 'utf-8').split('\n').filter(Boolean);
|
|
152
|
+
lines.forEach(line => {
|
|
153
|
+
try {
|
|
154
|
+
const msg = JSON.parse(line);
|
|
155
|
+
const time = new Date(msg.timestamp).toLocaleTimeString();
|
|
156
|
+
console.log(`[${msg.role} @ ${time}] ${msg.content}\n`);
|
|
157
|
+
} catch {
|
|
158
|
+
// Skip malformed lines
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
} else {
|
|
162
|
+
console.log('No conversation recorded.');
|
|
163
|
+
}
|
|
164
|
+
} else if (metadata) {
|
|
165
|
+
const metaPath = path.join(sessionDir, 'metadata.json');
|
|
166
|
+
console.log(fs.readFileSync(metaPath, 'utf-8'));
|
|
167
|
+
} else {
|
|
168
|
+
// Default: show summary
|
|
169
|
+
const summaryPath = path.join(sessionDir, 'summary.md');
|
|
170
|
+
if (fs.existsSync(summaryPath)) {
|
|
171
|
+
console.log(fs.readFileSync(summaryPath, 'utf-8'));
|
|
172
|
+
} else {
|
|
173
|
+
console.log('No summary available (session may not have been folded).');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
formatAge,
|
|
180
|
+
enumerateSessions,
|
|
181
|
+
listSidecars,
|
|
182
|
+
readSidecar
|
|
183
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar Resume Operations - Handles resuming previous sidecar sessions
|
|
3
|
+
* Spec Reference: §4.3, §8.3
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const { runInteractive, buildMcpConfig } = require('./start');
|
|
10
|
+
const {
|
|
11
|
+
SessionPaths,
|
|
12
|
+
finalizeSession,
|
|
13
|
+
outputSummary,
|
|
14
|
+
createHeartbeat,
|
|
15
|
+
checkSessionLiveness
|
|
16
|
+
} = require('./session-utils');
|
|
17
|
+
const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
18
|
+
const { runHeadless } = require('../headless');
|
|
19
|
+
const { logger } = require('../utils/logger');
|
|
20
|
+
|
|
21
|
+
/** Load session metadata from session directory */
|
|
22
|
+
function loadSessionMetadata(sessionDir) {
|
|
23
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
24
|
+
if (!fs.existsSync(metaPath)) {
|
|
25
|
+
throw new Error(`Session metadata not found: ${metaPath}`);
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Load initial context (system prompt) from session */
|
|
31
|
+
function loadInitialContext(sessionDir) {
|
|
32
|
+
const contextPath = SessionPaths.contextFile(sessionDir);
|
|
33
|
+
if (fs.existsSync(contextPath)) {
|
|
34
|
+
return fs.readFileSync(contextPath, 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Check for file drift - files that were read may have changed */
|
|
40
|
+
function checkFileDrift(metadata, project) {
|
|
41
|
+
const filesRead = metadata.filesRead || [];
|
|
42
|
+
const lastActivity = metadata.completedAt || metadata.createdAt;
|
|
43
|
+
const lastActivityTime = new Date(lastActivity).getTime();
|
|
44
|
+
const changedFiles = [];
|
|
45
|
+
|
|
46
|
+
for (const file of filesRead) {
|
|
47
|
+
const filePath = path.join(project, file);
|
|
48
|
+
if (fs.existsSync(filePath)) {
|
|
49
|
+
const stat = fs.statSync(filePath);
|
|
50
|
+
if (stat.mtimeMs > lastActivityTime) {
|
|
51
|
+
changedFiles.push(file);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { hasChanges: changedFiles.length > 0, changedFiles, lastActivityTime };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Build drift warning message */
|
|
60
|
+
function buildDriftWarning(changedFiles, lastActivityTime) {
|
|
61
|
+
const timeSince = Date.now() - lastActivityTime;
|
|
62
|
+
const hours = Math.floor(timeSince / 3600000);
|
|
63
|
+
|
|
64
|
+
return `
|
|
65
|
+
## ⚠️ RESUME NOTICE
|
|
66
|
+
|
|
67
|
+
This session is being resumed after a pause. **The file system has changed since your last message.**
|
|
68
|
+
|
|
69
|
+
**Time since last activity:** ${hours > 0 ? hours + ' hours' : 'Less than an hour'}
|
|
70
|
+
|
|
71
|
+
**Changed files:**
|
|
72
|
+
${changedFiles.map(f => `- ${f}`).join('\n')}
|
|
73
|
+
|
|
74
|
+
Please verify your previous findings against the current state of these files before continuing.
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build user message for headless resume, including conversation history */
|
|
79
|
+
function buildResumeUserMessage(briefing, conversation) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
|
|
82
|
+
if (conversation) {
|
|
83
|
+
parts.push('## PREVIOUS CONVERSATION\n');
|
|
84
|
+
parts.push(conversation);
|
|
85
|
+
parts.push('\n---\n');
|
|
86
|
+
parts.push('## RESUME\n');
|
|
87
|
+
parts.push('You are resuming a previous session. Continue from where you left off.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (briefing) {
|
|
91
|
+
if (parts.length === 0) {
|
|
92
|
+
parts.push(briefing);
|
|
93
|
+
} else {
|
|
94
|
+
parts.push(`\nOriginal task: ${briefing}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return parts.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Update session metadata status */
|
|
102
|
+
function updateSessionStatus(sessionDir, status) {
|
|
103
|
+
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
104
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
105
|
+
meta.status = status;
|
|
106
|
+
if (status === 'running') {
|
|
107
|
+
meta.resumedAt = new Date().toISOString();
|
|
108
|
+
}
|
|
109
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
110
|
+
return meta;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Resume a previous sidecar session - Spec Reference: §4.3, §8.3 */
|
|
114
|
+
async function resumeSidecar(options) {
|
|
115
|
+
const {
|
|
116
|
+
taskId, project = process.cwd(), headless = false, timeout = 15,
|
|
117
|
+
mcp, mcpConfig, client, noMcp, excludeMcp
|
|
118
|
+
} = options;
|
|
119
|
+
|
|
120
|
+
// Resume operates on an EXISTING session — resolve dual-dir (amicus, then legacy).
|
|
121
|
+
const sessionDir = SessionPaths.resolveSessionDir(project, taskId);
|
|
122
|
+
if (!fs.existsSync(sessionDir)) {
|
|
123
|
+
throw new Error(`Session ${taskId} not found`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Load previous session data
|
|
127
|
+
const metadata = loadSessionMetadata(sessionDir);
|
|
128
|
+
const systemPrompt = loadInitialContext(sessionDir);
|
|
129
|
+
|
|
130
|
+
// Dead-process detection: log if the previous process is no longer alive
|
|
131
|
+
const liveness = checkSessionLiveness(metadata);
|
|
132
|
+
if (liveness !== 'alive') {
|
|
133
|
+
logger.info('Session process is dead, restoring from disk', {
|
|
134
|
+
taskId, liveness, pid: metadata.pid,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Acquire lock to prevent concurrent resume operations
|
|
139
|
+
acquireLock(sessionDir, headless ? 'headless' : 'interactive');
|
|
140
|
+
|
|
141
|
+
let heartbeat;
|
|
142
|
+
try {
|
|
143
|
+
const mcpServers = buildMcpConfig({ mcp, mcpConfig, clientType: client, noMcp, excludeMcp });
|
|
144
|
+
logger.info('Resuming session', { taskId, model: metadata.model, briefing: metadata.briefing });
|
|
145
|
+
// Inherited model: advisory only (F5) — never block reopening a session.
|
|
146
|
+
const { warnIfNotInCatalog } = require('../utils/model-validator');
|
|
147
|
+
await warnIfNotInCatalog(metadata.model);
|
|
148
|
+
|
|
149
|
+
// Check for file drift
|
|
150
|
+
const drift = checkFileDrift(metadata, project);
|
|
151
|
+
let resumePrompt = systemPrompt;
|
|
152
|
+
|
|
153
|
+
if (drift.hasChanges) {
|
|
154
|
+
const driftWarning = buildDriftWarning(drift.changedFiles, drift.lastActivityTime);
|
|
155
|
+
resumePrompt = systemPrompt + '\n' + driftWarning;
|
|
156
|
+
logger.warn('Files changed since last activity', { taskId, changedFileCount: drift.changedFiles.length });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Update metadata (get updated metadata with resumedAt)
|
|
160
|
+
const updatedMetadata = updateSessionStatus(sessionDir, 'running');
|
|
161
|
+
|
|
162
|
+
// Start heartbeat
|
|
163
|
+
heartbeat = createHeartbeat();
|
|
164
|
+
|
|
165
|
+
let summary;
|
|
166
|
+
const effectiveAgent = metadata.agent || 'Build';
|
|
167
|
+
|
|
168
|
+
// Load conversation for both paths (interactive already did this, headless didn't)
|
|
169
|
+
const conversationPath = SessionPaths.conversationFile(sessionDir);
|
|
170
|
+
const existingConversation = fs.existsSync(conversationPath)
|
|
171
|
+
? fs.readFileSync(conversationPath, 'utf-8')
|
|
172
|
+
: '';
|
|
173
|
+
|
|
174
|
+
if (headless) {
|
|
175
|
+
const userMessage = buildResumeUserMessage(metadata.briefing || '', existingConversation);
|
|
176
|
+
const result = await runHeadless(
|
|
177
|
+
metadata.model, resumePrompt, userMessage,
|
|
178
|
+
taskId, project, timeout * 60 * 1000, effectiveAgent, { mcp: mcpServers }
|
|
179
|
+
);
|
|
180
|
+
summary = result.summary || '## Sidecar Results: No Output\n\nResumed session completed without summary.';
|
|
181
|
+
|
|
182
|
+
if (result.timedOut) { logger.warn('Resume task timed out', { taskId }); }
|
|
183
|
+
if (result.error) { logger.error('Resume task error', { taskId, error: result.error }); }
|
|
184
|
+
} else {
|
|
185
|
+
logger.info('Launching interactive resume', { taskId, model: metadata.model });
|
|
186
|
+
|
|
187
|
+
const result = await runInteractive(
|
|
188
|
+
metadata.model, resumePrompt, metadata.briefing || '',
|
|
189
|
+
taskId, project,
|
|
190
|
+
{
|
|
191
|
+
agent: effectiveAgent,
|
|
192
|
+
isResume: true,
|
|
193
|
+
conversation: existingConversation,
|
|
194
|
+
opencodeSessionId: metadata.opencodeSessionId,
|
|
195
|
+
mcp: mcpServers
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
summary = result.summary || '';
|
|
199
|
+
if (result.error) { logger.error('Interactive resume error', { taskId, error: result.error }); }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Output summary
|
|
203
|
+
outputSummary(summary);
|
|
204
|
+
|
|
205
|
+
// Finalize session (use updatedMetadata which has resumedAt)
|
|
206
|
+
finalizeSession(sessionDir, summary, project, updatedMetadata);
|
|
207
|
+
} finally {
|
|
208
|
+
if (heartbeat) { heartbeat.stop(); }
|
|
209
|
+
releaseLock(sessionDir);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
loadSessionMetadata,
|
|
215
|
+
loadInitialContext,
|
|
216
|
+
checkFileDrift,
|
|
217
|
+
buildDriftWarning,
|
|
218
|
+
buildResumeUserMessage,
|
|
219
|
+
updateSessionStatus,
|
|
220
|
+
resumeSidecar
|
|
221
|
+
};
|