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,414 @@
1
+ /**
2
+ * Session Manager Module
3
+ *
4
+ * Spec Reference: Section 8.1 What Gets Persisted, Section 7.4 Metadata Tracking
5
+ * Manages persistence of sidecar session data.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ /**
12
+ * Session status constants
13
+ */
14
+ const SESSION_STATUS = {
15
+ RUNNING: 'running',
16
+ COMPLETE: 'complete',
17
+ ERROR: 'error',
18
+ TIMEOUT: 'timeout'
19
+ };
20
+
21
+ /** Canonical session dir name — new sessions are written here. */
22
+ const SESSIONS_DIR = 'amicus_sessions';
23
+ // DEPRECATED(amicus-shim): legacy session dir read for pre-rebrand sessions.
24
+ // Remove in a future revision — see docs/SHIMS.md.
25
+ const LEGACY_SESSIONS_DIR = 'sidecar_sessions';
26
+
27
+ /**
28
+ * Get the canonical session directory path for a task (used for WRITES).
29
+ * Spec Reference: §8.1 Session directory structure
30
+ *
31
+ * @param {string} projectDir - Project directory path
32
+ * @param {string} taskId - Sidecar task ID
33
+ * @returns {string} Path to the session directory
34
+ *
35
+ * @example
36
+ * getSessionDir('/path/to/project', 'abc123')
37
+ * // Returns: '/path/to/project/.claude/amicus_sessions/abc123'
38
+ */
39
+ function getSessionDir(projectDir, taskId) {
40
+ return path.join(projectDir, '.claude', SESSIONS_DIR, taskId);
41
+ }
42
+
43
+ /**
44
+ * Resolve an EXISTING session dir for reads: prefer amicus, fall back to legacy.
45
+ * Backward-compat shim so pre-rebrand `.claude/sidecar_sessions/` stay visible.
46
+ *
47
+ * @param {string} projectDir - Project directory path
48
+ * @param {string} taskId - Sidecar task ID
49
+ * @returns {string} Path to the resolved session directory (defaults to the new path)
50
+ */
51
+ function resolveExistingSessionDir(projectDir, taskId) {
52
+ const current = getSessionDir(projectDir, taskId);
53
+ if (fs.existsSync(current)) { return current; }
54
+ const legacy = path.join(projectDir, '.claude', LEGACY_SESSIONS_DIR, taskId);
55
+ if (fs.existsSync(legacy)) { return legacy; }
56
+ return current; // default to the new path
57
+ }
58
+
59
+ /**
60
+ * Create a new sidecar session
61
+ * Spec Reference: §8.1 What Gets Persisted
62
+ *
63
+ * Creates the session directory structure:
64
+ * .claude/amicus_sessions/<taskId>/
65
+ * ├── metadata.json
66
+ * └── conversation.jsonl
67
+ *
68
+ * @param {string} projectDir - Project directory path
69
+ * @param {string} taskId - Unique task identifier
70
+ * @param {object} metadata - Session metadata
71
+ * @param {string} metadata.model - Model being used (e.g., "google/gemini-2.5")
72
+ * @param {string} metadata.project - Project path
73
+ * @param {string} [metadata.briefing] - Task briefing
74
+ * @param {string} [metadata.mode] - Mode: 'interactive' or 'headless'
75
+ * @param {string} [metadata.thinking='medium'] - Thinking/reasoning intensity level
76
+ * @throws {Error} If session already exists
77
+ */
78
+ function createSession(projectDir, taskId, metadata) {
79
+ const sessionDir = getSessionDir(projectDir, taskId);
80
+
81
+ // Check if session already exists
82
+ if (fs.existsSync(sessionDir)) {
83
+ throw new Error(`Session ${taskId} already exists`);
84
+ }
85
+
86
+ // Create session directory
87
+ fs.mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
88
+
89
+ // Build metadata per spec §7.4
90
+ const sessionMetadata = {
91
+ taskId,
92
+ model: metadata.model,
93
+ project: metadata.project || projectDir,
94
+ briefing: metadata.briefing || '',
95
+ mode: metadata.mode || 'interactive',
96
+ thinking: metadata.thinking || 'medium',
97
+ status: SESSION_STATUS.RUNNING,
98
+ createdAt: new Date().toISOString(),
99
+ completedAt: null,
100
+ // File tracking per spec §7.4
101
+ filesRead: [],
102
+ filesWritten: [],
103
+ conflicts: [],
104
+ contextDrift: null
105
+ };
106
+
107
+ // Write metadata.json
108
+ fs.writeFileSync(
109
+ path.join(sessionDir, 'metadata.json'),
110
+ JSON.stringify(sessionMetadata, null, 2),
111
+ { mode: 0o600 }
112
+ );
113
+
114
+ // Create empty conversation.jsonl
115
+ fs.writeFileSync(path.join(sessionDir, 'conversation.jsonl'), '', { mode: 0o600 });
116
+ }
117
+
118
+ /**
119
+ * Update session metadata
120
+ *
121
+ * @param {string} projectDir - Project directory path
122
+ * @param {string} taskId - Task identifier
123
+ * @param {object} updates - Fields to update
124
+ * @throws {Error} If session not found
125
+ */
126
+ function updateSession(projectDir, taskId, updates) {
127
+ const sessionDir = getSessionDir(projectDir, taskId);
128
+ const metaPath = path.join(sessionDir, 'metadata.json');
129
+
130
+ if (!fs.existsSync(metaPath)) {
131
+ throw new Error(`Session ${taskId} not found`);
132
+ }
133
+
134
+ // Read existing metadata
135
+ const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
136
+
137
+ // Merge updates
138
+ // For array fields, we append rather than replace
139
+ if (updates.filesRead) {
140
+ metadata.filesRead = [...new Set([...metadata.filesRead, ...updates.filesRead])];
141
+ delete updates.filesRead;
142
+ }
143
+ if (updates.filesWritten) {
144
+ metadata.filesWritten = [...new Set([...metadata.filesWritten, ...updates.filesWritten])];
145
+ delete updates.filesWritten;
146
+ }
147
+ if (updates.conflicts) {
148
+ metadata.conflicts = [...metadata.conflicts, ...updates.conflicts];
149
+ delete updates.conflicts;
150
+ }
151
+
152
+ // Apply remaining updates
153
+ Object.assign(metadata, updates);
154
+
155
+ // Write updated metadata
156
+ fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
157
+ }
158
+
159
+ /**
160
+ * Get session metadata
161
+ *
162
+ * @param {string} projectDir - Project directory path
163
+ * @param {string} taskId - Task identifier
164
+ * @returns {object|null} Session metadata or null if not found
165
+ */
166
+ function getSession(projectDir, taskId) {
167
+ const sessionDir = getSessionDir(projectDir, taskId);
168
+ const metaPath = path.join(sessionDir, 'metadata.json');
169
+
170
+ if (!fs.existsSync(metaPath)) {
171
+ return null;
172
+ }
173
+
174
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
175
+ }
176
+
177
+ /**
178
+ * Save a message to the conversation log
179
+ * Spec Reference: §8.2 Conversation Capture
180
+ *
181
+ * @param {string} projectDir - Project directory path
182
+ * @param {string} taskId - Task identifier
183
+ * @param {object} message - Message to save
184
+ * @param {string} message.role - Message role ('user', 'assistant', 'system')
185
+ * @param {string} message.content - Message content
186
+ * @param {string} [message.timestamp] - ISO timestamp (auto-generated if not provided)
187
+ * @throws {Error} If session not found
188
+ */
189
+ function saveConversation(projectDir, taskId, message) {
190
+ const sessionDir = getSessionDir(projectDir, taskId);
191
+ const convPath = path.join(sessionDir, 'conversation.jsonl');
192
+
193
+ if (!fs.existsSync(sessionDir)) {
194
+ throw new Error(`Session ${taskId} not found`);
195
+ }
196
+
197
+ // Ensure timestamp is present
198
+ const messageWithTimestamp = {
199
+ ...message,
200
+ timestamp: message.timestamp || new Date().toISOString()
201
+ };
202
+
203
+ // Append to conversation.jsonl
204
+ fs.appendFileSync(convPath, JSON.stringify(messageWithTimestamp) + '\n', { mode: 0o600 });
205
+ }
206
+
207
+ /**
208
+ * Save the session summary
209
+ * Spec Reference: §8.1 summary.md
210
+ *
211
+ * Also updates session status to complete with completedAt timestamp.
212
+ *
213
+ * @param {string} projectDir - Project directory path
214
+ * @param {string} taskId - Task identifier
215
+ * @param {string} summary - Summary content (markdown)
216
+ * @throws {Error} If session not found
217
+ */
218
+ function saveSummary(projectDir, taskId, summary) {
219
+ const sessionDir = getSessionDir(projectDir, taskId);
220
+ const summaryPath = path.join(sessionDir, 'summary.md');
221
+
222
+ if (!fs.existsSync(sessionDir)) {
223
+ throw new Error(`Session ${taskId} not found`);
224
+ }
225
+
226
+ // Write summary file
227
+ fs.writeFileSync(summaryPath, summary, { mode: 0o600 });
228
+
229
+ // Update session status
230
+ updateSession(projectDir, taskId, {
231
+ status: SESSION_STATUS.COMPLETE,
232
+ completedAt: new Date().toISOString()
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Get the sub-agent session directory path
238
+ *
239
+ * @param {string} projectDir - Project directory path
240
+ * @param {string} parentTaskId - Parent sidecar task ID
241
+ * @param {string} subagentId - Sub-agent ID
242
+ * @returns {string} Path to the sub-agent session directory
243
+ *
244
+ * @example
245
+ * getSubagentDir('/path/to/project', 'abc123', 'subagent-xyz')
246
+ * // Returns: '/path/to/project/.claude/amicus_sessions/abc123/subagents/subagent-xyz'
247
+ */
248
+ function getSubagentDir(projectDir, parentTaskId, subagentId) {
249
+ return path.join(getSessionDir(projectDir, parentTaskId), 'subagents', subagentId);
250
+ }
251
+
252
+ /**
253
+ * Create a sub-agent session
254
+ *
255
+ * Creates the sub-agent directory structure:
256
+ * .claude/amicus_sessions/<parentTaskId>/subagents/<subagentId>/
257
+ * ├── metadata.json
258
+ * └── conversation.jsonl
259
+ *
260
+ * @param {string} projectDir - Project directory path
261
+ * @param {string} parentTaskId - Parent sidecar task ID
262
+ * @param {string} subagentId - Sub-agent ID
263
+ * @param {object} metadata - Sub-agent metadata
264
+ * @param {string} metadata.agentType - Agent type (general, explore, security, test)
265
+ * @param {string} metadata.briefing - Task briefing
266
+ * @returns {string} Path to the created sub-agent directory
267
+ */
268
+ function createSubagentSession(projectDir, parentTaskId, subagentId, metadata) {
269
+ const subagentDir = getSubagentDir(projectDir, parentTaskId, subagentId);
270
+
271
+ // Create sub-agent directory
272
+ fs.mkdirSync(subagentDir, { recursive: true, mode: 0o700 });
273
+
274
+ // Build sub-agent metadata
275
+ const subagentMetadata = {
276
+ subagentId,
277
+ parentTaskId,
278
+ agentType: metadata.agentType,
279
+ briefing: metadata.briefing,
280
+ status: SESSION_STATUS.RUNNING,
281
+ createdAt: new Date().toISOString(),
282
+ completedAt: null
283
+ };
284
+
285
+ // Write metadata
286
+ fs.writeFileSync(
287
+ path.join(subagentDir, 'metadata.json'),
288
+ JSON.stringify(subagentMetadata, null, 2),
289
+ { mode: 0o600 }
290
+ );
291
+
292
+ // Initialize empty conversation file
293
+ fs.writeFileSync(path.join(subagentDir, 'conversation.jsonl'), '', { mode: 0o600 });
294
+
295
+ return subagentDir;
296
+ }
297
+
298
+ /**
299
+ * Update a sub-agent session
300
+ *
301
+ * @param {string} projectDir - Project directory path
302
+ * @param {string} parentTaskId - Parent sidecar task ID
303
+ * @param {string} subagentId - Sub-agent ID
304
+ * @param {object} updates - Fields to update
305
+ */
306
+ function updateSubagentSession(projectDir, parentTaskId, subagentId, updates) {
307
+ const subagentDir = getSubagentDir(projectDir, parentTaskId, subagentId);
308
+ const metadataPath = path.join(subagentDir, 'metadata.json');
309
+
310
+ if (!fs.existsSync(metadataPath)) {
311
+ throw new Error(`Sub-agent ${subagentId} not found`);
312
+ }
313
+
314
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
315
+ const updated = { ...metadata, ...updates };
316
+ fs.writeFileSync(metadataPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
317
+ }
318
+
319
+ /**
320
+ * Get a sub-agent session
321
+ *
322
+ * @param {string} projectDir - Project directory path
323
+ * @param {string} parentTaskId - Parent sidecar task ID
324
+ * @param {string} subagentId - Sub-agent ID
325
+ * @returns {object|null} Sub-agent metadata or null if not found
326
+ */
327
+ function getSubagentSession(projectDir, parentTaskId, subagentId) {
328
+ const metadataPath = path.join(getSubagentDir(projectDir, parentTaskId, subagentId), 'metadata.json');
329
+
330
+ if (!fs.existsSync(metadataPath)) {
331
+ return null;
332
+ }
333
+
334
+ return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
335
+ }
336
+
337
+ /**
338
+ * List all sub-agents for a parent session
339
+ *
340
+ * @param {string} projectDir - Project directory path
341
+ * @param {string} parentTaskId - Parent sidecar task ID
342
+ * @param {object} [filter] - Optional filter options
343
+ * @param {string} [filter.status] - Filter by status
344
+ * @param {string} [filter.agentType] - Filter by agent type
345
+ * @returns {object[]} Array of sub-agent metadata
346
+ */
347
+ function listSubagents(projectDir, parentTaskId, filter = {}) {
348
+ const subagentsDir = path.join(getSessionDir(projectDir, parentTaskId), 'subagents');
349
+
350
+ if (!fs.existsSync(subagentsDir)) {
351
+ return [];
352
+ }
353
+
354
+ const subagentIds = fs.readdirSync(subagentsDir).filter(name => {
355
+ const stat = fs.statSync(path.join(subagentsDir, name));
356
+ return stat.isDirectory();
357
+ });
358
+
359
+ let subagents = subagentIds.map(id => {
360
+ const metadata = getSubagentSession(projectDir, parentTaskId, id);
361
+ return metadata;
362
+ }).filter(Boolean);
363
+
364
+ // Apply filters
365
+ if (filter.status) {
366
+ subagents = subagents.filter(s => s.status === filter.status);
367
+ }
368
+ if (filter.agentType) {
369
+ subagents = subagents.filter(s => s.agentType === filter.agentType);
370
+ }
371
+
372
+ return subagents;
373
+ }
374
+
375
+ /**
376
+ * Save sub-agent summary
377
+ *
378
+ * @param {string} projectDir - Project directory path
379
+ * @param {string} parentTaskId - Parent sidecar task ID
380
+ * @param {string} subagentId - Sub-agent ID
381
+ * @param {string} summary - Summary content
382
+ */
383
+ function saveSubagentSummary(projectDir, parentTaskId, subagentId, summary) {
384
+ const subagentDir = getSubagentDir(projectDir, parentTaskId, subagentId);
385
+ const summaryPath = path.join(subagentDir, 'summary.md');
386
+
387
+ fs.writeFileSync(summaryPath, summary, { mode: 0o600 });
388
+
389
+ // Update sub-agent status
390
+ updateSubagentSession(projectDir, parentTaskId, subagentId, {
391
+ status: SESSION_STATUS.COMPLETE,
392
+ completedAt: new Date().toISOString()
393
+ });
394
+ }
395
+
396
+ module.exports = {
397
+ createSession,
398
+ updateSession,
399
+ getSession,
400
+ saveConversation,
401
+ saveSummary,
402
+ getSessionDir,
403
+ resolveExistingSessionDir,
404
+ SESSIONS_DIR,
405
+ LEGACY_SESSIONS_DIR,
406
+ SESSION_STATUS,
407
+ // Sub-agent functions
408
+ getSubagentDir,
409
+ createSubagentSession,
410
+ updateSubagentSession,
411
+ getSubagentSession,
412
+ listSubagents,
413
+ saveSubagentSummary
414
+ };
package/src/session.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Session Resolver
3
+ *
4
+ * Spec Reference: §5.1 Session Resolution, §5.2 Claude Code Conversation Storage
5
+ * Resolves Claude Code session files using primary (explicit ID) and fallback (most recent) strategies.
6
+ */
7
+
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+
12
+ /**
13
+ * Encode a project path for use as a directory name
14
+ * Spec Reference: §5.2 Path Encoding
15
+ *
16
+ * @param {string} projectPath - Absolute project path (e.g., /Users/john/myproject)
17
+ * @returns {string} Encoded path suitable for directory name (e.g., -Users-john-myproject)
18
+ *
19
+ * @example
20
+ * encodeProjectPath('/Users/john/myproject')
21
+ * // Returns: '-Users-john-myproject'
22
+ */
23
+ function encodeProjectPath(projectPath) {
24
+ // Replace slashes, backslashes, the drive-letter colon, and underscores with
25
+ // dashes (matching Claude Code behavior). On Windows: C:\Users\x -> C--Users-x.
26
+ return projectPath.replace(/[/\\:_]/g, '-');
27
+ }
28
+
29
+ /**
30
+ * Decode an encoded path back to original format
31
+ *
32
+ * @param {string} encodedPath - Encoded path (e.g., -Users-john-myproject)
33
+ * @returns {string} Decoded path with dashes converted back to slashes
34
+ */
35
+ function decodeProjectPath(encodedPath) {
36
+ // Replace dashes with slashes
37
+ return encodedPath.replace(/-/g, '/');
38
+ }
39
+
40
+ /**
41
+ * Get the session directory path for a project
42
+ * Spec Reference: §5.2 Claude Code Conversation Storage
43
+ *
44
+ * @param {string} projectPath - Absolute project path
45
+ * @param {string} [homeDir] - Optional home directory override (for testing)
46
+ * @returns {string} Full path to the session directory
47
+ *
48
+ * @example
49
+ * getSessionDirectory('/Users/john/myproject')
50
+ * // Returns: '~/.claude/projects/-Users-john-myproject'
51
+ */
52
+ function getSessionDirectory(projectPath, homeDir = os.homedir()) {
53
+ const encodedPath = encodeProjectPath(projectPath);
54
+ return path.join(homeDir, '.claude', 'projects', encodedPath);
55
+ }
56
+
57
+ /**
58
+ * Extract session ID from a filename
59
+ *
60
+ * @param {string} filename - Session filename (e.g., abc123.jsonl or abc123)
61
+ * @returns {string} Session ID without extension
62
+ */
63
+ function getSessionId(filename) {
64
+ return filename.replace(/\.jsonl$/, '');
65
+ }
66
+
67
+ /**
68
+ * Resolve a Claude Code session file
69
+ * Spec Reference: §5.1 Session Resolution (Primary + Fallback)
70
+ *
71
+ * Primary: Explicit session ID passed via --session
72
+ * Fallback: Most recently modified .jsonl file
73
+ *
74
+ * @param {string} projectDir - Path to the session directory
75
+ * @param {string} [sessionArg] - Session ID argument (explicit ID, 'current', or undefined)
76
+ * @returns {{ path: string|null, method: string, warning?: string }}
77
+ *
78
+ * @example
79
+ * // Explicit session
80
+ * resolveSession('/path/to/sessions', 'abc123-def456')
81
+ * // Returns: { path: '/path/to/sessions/abc123-def456.jsonl', method: 'explicit' }
82
+ *
83
+ * @example
84
+ * // Fallback to most recent
85
+ * resolveSession('/path/to/sessions', 'current')
86
+ * // Returns: { path: '/path/to/sessions/most-recent.jsonl', method: 'fallback' }
87
+ */
88
+ function resolveSession(projectDir, sessionArg) {
89
+ // Check if directory exists
90
+ if (!fs.existsSync(projectDir)) {
91
+ return { path: null, method: 'error' };
92
+ }
93
+
94
+ // Primary: explicit session ID
95
+ if (sessionArg && sessionArg !== 'current' && sessionArg !== '') {
96
+ // Handle both full filename and just the UUID
97
+ const filename = sessionArg.endsWith('.jsonl') ? sessionArg : `${sessionArg}.jsonl`;
98
+ const sessionPath = path.join(projectDir, filename);
99
+
100
+ if (fs.existsSync(sessionPath)) {
101
+ return { path: sessionPath, method: 'explicit' };
102
+ }
103
+
104
+ // Explicit session not found, fall back to most recent with warning
105
+ const fallback = findMostRecentSession(projectDir);
106
+ if (fallback.path) {
107
+ return {
108
+ path: fallback.path,
109
+ method: 'fallback',
110
+ warning: `Session ${sessionArg} not found, falling back to most recent. For reliability, pass --session <id> explicitly.`
111
+ };
112
+ }
113
+
114
+ return { path: null, method: 'error', warning: `Session ${sessionArg} not found and no fallback available.` };
115
+ }
116
+
117
+ // Fallback: most recently modified .jsonl file
118
+ return findMostRecentSession(projectDir);
119
+ }
120
+
121
+ /**
122
+ * Find the most recently modified .jsonl file in a directory
123
+ * Also checks for ambiguity (multiple recent sessions)
124
+ *
125
+ * @param {string} projectDir - Path to the session directory
126
+ * @returns {{ path: string|null, method: string, warning?: string }}
127
+ */
128
+ function findMostRecentSession(projectDir) {
129
+ let files;
130
+ try {
131
+ files = fs.readdirSync(projectDir);
132
+ } catch (error) {
133
+ return { path: null, method: 'error' };
134
+ }
135
+
136
+ // Get all .jsonl files with their modification times
137
+ const sessions = files
138
+ .filter(f => f.endsWith('.jsonl'))
139
+ .map(f => {
140
+ const filePath = path.join(projectDir, f);
141
+ try {
142
+ const stat = fs.statSync(filePath);
143
+ return {
144
+ name: f,
145
+ path: filePath,
146
+ mtime: stat.mtime.getTime()
147
+ };
148
+ } catch (error) {
149
+ return null;
150
+ }
151
+ })
152
+ .filter(Boolean)
153
+ .sort((a, b) => b.mtime - a.mtime); // Sort by mtime descending
154
+
155
+ if (sessions.length === 0) {
156
+ return { path: null, method: 'fallback' };
157
+ }
158
+
159
+ // Check for ambiguity: multiple sessions modified in last 5 minutes
160
+ const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
161
+ const recentSessions = sessions.filter(s => s.mtime > fiveMinutesAgo);
162
+
163
+ if (recentSessions.length > 1) {
164
+ return {
165
+ path: sessions[0].path,
166
+ method: 'fallback',
167
+ warning: `${recentSessions.length} active sessions detected. Using most recent. For reliability, pass --session <id> explicitly.`
168
+ };
169
+ }
170
+
171
+ return { path: sessions[0].path, method: 'fallback' };
172
+ }
173
+
174
+ module.exports = {
175
+ encodeProjectPath,
176
+ decodeProjectPath,
177
+ getSessionDirectory,
178
+ getSessionId,
179
+ resolveSession
180
+ };