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.
Files changed (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +477 -0
  4. package/bin/amicus.js +382 -0
  5. package/electron/assets/icon.png +0 -0
  6. package/electron/assets/icon.svg +5 -0
  7. package/electron/fold.js +163 -0
  8. package/electron/ipc-setup.js +176 -0
  9. package/electron/load-failsafe.js +85 -0
  10. package/electron/main.js +468 -0
  11. package/electron/preload-setup.js +38 -0
  12. package/electron/preload.js +33 -0
  13. package/electron/setup-ui-alias-script.js +218 -0
  14. package/electron/setup-ui-aliases.js +85 -0
  15. package/electron/setup-ui-keys-script.js +115 -0
  16. package/electron/setup-ui-keys.js +97 -0
  17. package/electron/setup-ui-model.js +138 -0
  18. package/electron/setup-ui-styles.js +327 -0
  19. package/electron/setup-ui.js +465 -0
  20. package/electron/summary.js +118 -0
  21. package/electron/toolbar.js +229 -0
  22. package/electron/window-position.js +35 -0
  23. package/package.json +98 -0
  24. package/scripts/postinstall.js +193 -0
  25. package/scripts/setup-hooks.js +42 -0
  26. package/skill/SKILL.md +976 -0
  27. package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +104 -0
  29. package/skills/second-opinion/SKILL.md +389 -0
  30. package/src/cli-handlers.js +188 -0
  31. package/src/cli.js +400 -0
  32. package/src/conflict.js +144 -0
  33. package/src/context-compression.js +102 -0
  34. package/src/context.js +199 -0
  35. package/src/drift.js +144 -0
  36. package/src/environment.js +157 -0
  37. package/src/headless.js +742 -0
  38. package/src/index.js +106 -0
  39. package/src/jsonl-parser.js +180 -0
  40. package/src/mcp-server.js +625 -0
  41. package/src/mcp-tools.js +407 -0
  42. package/src/opencode-client.js +615 -0
  43. package/src/prompt-builder.js +355 -0
  44. package/src/prompts/cowork-agent-prompt.js +118 -0
  45. package/src/session-manager.js +414 -0
  46. package/src/session.js +180 -0
  47. package/src/sidecar/context-builder.js +297 -0
  48. package/src/sidecar/continue.js +212 -0
  49. package/src/sidecar/crash-handler.js +56 -0
  50. package/src/sidecar/fanout-leg.js +107 -0
  51. package/src/sidecar/fanout-output.js +46 -0
  52. package/src/sidecar/fanout.js +236 -0
  53. package/src/sidecar/interactive.js +217 -0
  54. package/src/sidecar/models.js +135 -0
  55. package/src/sidecar/progress.js +218 -0
  56. package/src/sidecar/read.js +183 -0
  57. package/src/sidecar/resume.js +221 -0
  58. package/src/sidecar/session-utils.js +288 -0
  59. package/src/sidecar/setup-window.js +79 -0
  60. package/src/sidecar/setup.js +280 -0
  61. package/src/sidecar/start.js +251 -0
  62. package/src/utils/agent-mapping.js +138 -0
  63. package/src/utils/alias-audit.js +98 -0
  64. package/src/utils/alias-resolver.js +77 -0
  65. package/src/utils/api-key-store.js +259 -0
  66. package/src/utils/api-key-validation.js +97 -0
  67. package/src/utils/auth-json.js +109 -0
  68. package/src/utils/config.js +291 -0
  69. package/src/utils/curated-models.js +82 -0
  70. package/src/utils/env-compat.js +38 -0
  71. package/src/utils/env-loader.js +54 -0
  72. package/src/utils/idle-watchdog.js +225 -0
  73. package/src/utils/input-validators.js +127 -0
  74. package/src/utils/lifecycle.js +43 -0
  75. package/src/utils/logger.js +84 -0
  76. package/src/utils/mcp-discovery.js +194 -0
  77. package/src/utils/mcp-validators.js +78 -0
  78. package/src/utils/model-catalog.js +103 -0
  79. package/src/utils/model-fetcher.js +179 -0
  80. package/src/utils/model-validator.js +207 -0
  81. package/src/utils/path-setup.js +41 -0
  82. package/src/utils/port-pid.js +39 -0
  83. package/src/utils/prompt-source.js +53 -0
  84. package/src/utils/result-schema.js +261 -0
  85. package/src/utils/server-setup.js +93 -0
  86. package/src/utils/session-abort.js +53 -0
  87. package/src/utils/session-lock.js +95 -0
  88. package/src/utils/shared-server.js +216 -0
  89. package/src/utils/start-helpers.js +76 -0
  90. package/src/utils/thinking-validators.js +92 -0
  91. package/src/utils/update-notifier-loader.js +18 -0
  92. package/src/utils/updater.js +157 -0
  93. 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
+ };