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,261 @@
1
+ // src/utils/result-schema.js
2
+ 'use strict';
3
+
4
+ /**
5
+ * @module result-schema
6
+ * Versioned, machine-parseable result documents for `--json` output (F4).
7
+ *
8
+ * Stability contract: fields are only ADDED within a SCHEMA_VERSION;
9
+ * any rename/removal bumps SCHEMA_VERSION.
10
+ */
11
+
12
+ const SCHEMA_VERSION = 1;
13
+
14
+ /** Leg/run statuses that count as terminal for wave aggregation. */
15
+ const TERMINAL_STATUSES = ['complete', 'error', 'timeout', 'aborted', 'crashed', 'idle-timeout'];
16
+
17
+ /**
18
+ * Map a runHeadless-style result object to a run status.
19
+ * Precedence: aborted > timeout > error > complete.
20
+ * @param {{aborted?: boolean, timedOut?: boolean, error?: string}} result
21
+ * @returns {string}
22
+ */
23
+ function statusFromResult(result) {
24
+ if (result.aborted) { return 'aborted'; }
25
+ if (result.timedOut) { return 'timeout'; }
26
+ if (result.error) { return 'error'; }
27
+ return 'complete';
28
+ }
29
+
30
+ /** Millisecond delta between two ISO timestamps, or null if either is missing/malformed. */
31
+ function durationBetween(createdAt, completedAt) {
32
+ if (!createdAt || !completedAt) { return null; }
33
+ const ms = new Date(completedAt).getTime() - new Date(createdAt).getTime();
34
+ return Number.isNaN(ms) ? null : ms;
35
+ }
36
+
37
+ /**
38
+ * Build a run document (single session result).
39
+ * Used by `start --json`, `read <taskId> --json`, and every wave leg.
40
+ *
41
+ * @param {object} opts
42
+ * @param {string} opts.taskId
43
+ * @param {object} [opts.metadata] - Session metadata (model, agent, timestamps, status…)
44
+ * @param {object|null} [opts.result] - Live runHeadless result (flags win over metadata.status)
45
+ * @param {string|null} [opts.summary]
46
+ * @param {string|null} [opts.modelInput] - What the caller typed (alias), if known
47
+ * @param {string|null} [opts.sessionDir]
48
+ * @param {string|null} [opts.waveId] - Explicit wave id (falls back to metadata.parentWave)
49
+ * @returns {object} run document
50
+ */
51
+ function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null }) {
52
+ const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
53
+ const createdAt = metadata.createdAt || null;
54
+ const completedAt = metadata.completedAt || metadata.abortedAt || null;
55
+ const durationMs = durationBetween(createdAt, completedAt);
56
+ return {
57
+ schemaVersion: SCHEMA_VERSION,
58
+ type: 'run',
59
+ taskId,
60
+ waveId: waveId !== null ? waveId : (metadata.parentWave || null),
61
+ model: metadata.model || null,
62
+ modelInput: modelInput !== null ? modelInput : (metadata.modelInput || null),
63
+ agent: metadata.agent || null,
64
+ status,
65
+ summary,
66
+ error: status === 'complete' ? null : ((result && result.error) || metadata.reason || null),
67
+ createdAt,
68
+ completedAt,
69
+ durationMs,
70
+ sessionDir,
71
+ opencodeSessionId: metadata.opencodeSessionId || null,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Aggregate wave status from leg documents.
77
+ * any leg running → 'running' (live rebuild of an in-flight wave);
78
+ * all complete → 'complete'; ≥1 complete → 'partial';
79
+ * 0 complete + ≥1 aborted → 'aborted'; else 'error'.
80
+ * @param {Array<{status: string}>} legs
81
+ * @returns {string}
82
+ */
83
+ function waveStatusFromLegs(legs) {
84
+ if (legs.some(l => l.status === 'running')) { return 'running'; }
85
+ const complete = legs.filter(l => l.status === 'complete').length;
86
+ if (complete === legs.length && legs.length > 0) { return 'complete'; }
87
+ if (complete > 0) { return 'partial'; }
88
+ if (legs.some(l => l.status === 'aborted')) { return 'aborted'; }
89
+ return 'error';
90
+ }
91
+
92
+ /**
93
+ * Map a wave status to a CLI exit code: complete=0, partial=2, error/aborted=1.
94
+ * @param {string} waveStatus
95
+ * @returns {number}
96
+ */
97
+ function waveExitCode(waveStatus) {
98
+ if (waveStatus === 'complete') { return 0; }
99
+ if (waveStatus === 'partial') { return 2; }
100
+ return 1;
101
+ }
102
+
103
+ /**
104
+ * Build a wave document from leg run documents.
105
+ * @param {object} opts
106
+ * @param {string} opts.waveId
107
+ * @param {Array<object>} opts.legs - run documents (in --models order)
108
+ * Counts track the four primary terminal statuses (complete/error/timeout/aborted);
109
+ * legs with other statuses (e.g. 'crashed', 'running' in a rebuilt wave) count toward
110
+ * `total` only, so total may exceed the sum of the named buckets.
111
+ * @param {{source: string, file: string|null, chars: number}|null} [opts.promptMeta]
112
+ * @param {string|null} [opts.createdAt]
113
+ * @param {string|null} [opts.completedAt]
114
+ * @param {string|null} [opts.status] - Override (e.g. 'aborted' on signal); default aggregates legs
115
+ * @returns {object} wave document
116
+ */
117
+ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null }) {
118
+ const counts = {
119
+ total: legs.length,
120
+ complete: legs.filter(l => l.status === 'complete').length,
121
+ error: legs.filter(l => l.status === 'error').length,
122
+ timeout: legs.filter(l => l.status === 'timeout').length,
123
+ aborted: legs.filter(l => l.status === 'aborted').length,
124
+ };
125
+ const durationMs = durationBetween(createdAt, completedAt);
126
+ return {
127
+ schemaVersion: SCHEMA_VERSION,
128
+ type: 'wave',
129
+ waveId,
130
+ status: (status !== null && status !== undefined) ? status : waveStatusFromLegs(legs),
131
+ error: null,
132
+ counts,
133
+ legs,
134
+ prompt: promptMeta,
135
+ createdAt,
136
+ completedAt,
137
+ durationMs,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Rebuild a run document from a persisted session directory.
143
+ * @param {string} project - Project dir
144
+ * @param {string} taskId
145
+ * @returns {object} run document
146
+ * @throws {Error} if the session does not exist
147
+ * @throws {Error} if metadata.json is missing or corrupt
148
+ */
149
+ function buildRunResultFromSession(project, taskId) {
150
+ const fs = require('fs');
151
+ const path = require('path');
152
+ const { resolveExistingSessionDir } = require('../session-manager');
153
+ const sessionDir = resolveExistingSessionDir(project, taskId);
154
+ const metaPath = path.join(sessionDir, 'metadata.json');
155
+ if (!fs.existsSync(metaPath)) {
156
+ throw new Error(`Session ${taskId} not found`);
157
+ }
158
+ let metadata;
159
+ try {
160
+ metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
161
+ } catch (err) {
162
+ throw new Error(`Session ${taskId}: metadata is corrupt (${err.message})`);
163
+ }
164
+ const summaryPath = path.join(sessionDir, 'summary.md');
165
+ const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : null;
166
+ return buildRunResult({ taskId, metadata, summary, sessionDir });
167
+ }
168
+
169
+ /**
170
+ * Rebuild a wave document. Prefers the stored wave.json (written atomically at
171
+ * fanout exit); falls back to a live rebuild from leg sessions (e.g. after a
172
+ * hard kill of the fanout process).
173
+ * @param {string} project
174
+ * @param {string} waveId
175
+ * @returns {object} wave document
176
+ * @throws {Error} if the wave session does not exist
177
+ * @throws {Error} if metadata.json is missing or corrupt
178
+ */
179
+ function buildWaveResultFromSession(project, waveId) {
180
+ const fs = require('fs');
181
+ const path = require('path');
182
+ const { resolveExistingSessionDir } = require('../session-manager');
183
+ const waveDir = resolveExistingSessionDir(project, waveId);
184
+ const wavePath = path.join(waveDir, 'wave.json');
185
+ if (fs.existsSync(wavePath)) {
186
+ try {
187
+ return JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
188
+ } catch {
189
+ // Corrupt wave.json (e.g. hard-kill mid-write) — fall through to live rebuild
190
+ }
191
+ }
192
+ const metaPath = path.join(waveDir, 'metadata.json');
193
+ if (!fs.existsSync(metaPath)) {
194
+ throw new Error(`Wave ${waveId} not found`);
195
+ }
196
+ let meta;
197
+ try {
198
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
199
+ } catch (err) {
200
+ throw new Error(`Wave ${waveId}: metadata is corrupt (${err.message})`);
201
+ }
202
+ const legs = (meta.legs || []).map((legId) => {
203
+ try { return buildRunResultFromSession(project, legId); }
204
+ catch (err) {
205
+ const { logger } = require('./logger');
206
+ logger.warn('Failed to rebuild leg session; using unknown stub', { legId, error: err.message });
207
+ return buildRunResult({ taskId: legId, metadata: { status: 'unknown', parentWave: waveId } });
208
+ }
209
+ });
210
+ return buildWaveResult({
211
+ waveId,
212
+ legs,
213
+ promptMeta: meta.promptMeta || null,
214
+ createdAt: meta.createdAt || null,
215
+ completedAt: meta.completedAt || null,
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Build a model-catalog document (`models [--search] [--refresh] --json`).
221
+ * @param {{models: Array, fetchedAt: number|null, refreshed?: boolean, search?: string|null}} opts
222
+ */
223
+ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null }) {
224
+ return {
225
+ schemaVersion: SCHEMA_VERSION,
226
+ type: 'model-catalog',
227
+ fetchedAt: fetchedAt || null,
228
+ refreshed,
229
+ search,
230
+ count: models.length,
231
+ models,
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Build an alias-audit document (`models --check --json`).
237
+ * @param {{stale: Array<{alias,model,source,suggestions}>, catalogAvailable: boolean}} opts
238
+ */
239
+ function buildAuditDoc({ stale, catalogAvailable }) {
240
+ return {
241
+ schemaVersion: SCHEMA_VERSION,
242
+ type: 'alias-audit',
243
+ catalogAvailable,
244
+ staleCount: stale.length,
245
+ stale,
246
+ };
247
+ }
248
+
249
+ module.exports = {
250
+ SCHEMA_VERSION,
251
+ TERMINAL_STATUSES,
252
+ statusFromResult,
253
+ buildRunResult,
254
+ buildWaveResult,
255
+ waveStatusFromLegs,
256
+ waveExitCode,
257
+ buildRunResultFromSession,
258
+ buildWaveResultFromSession,
259
+ buildCatalogDoc,
260
+ buildAuditDoc,
261
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Server Setup Utilities
3
+ *
4
+ * Handles port management and cleanup for the OpenCode server.
5
+ */
6
+
7
+ const { execFileSync } = require('child_process');
8
+ const { logger } = require('./logger');
9
+
10
+ const DEFAULT_PORT = 4096;
11
+
12
+ /**
13
+ * Check if a port is in use and get PID
14
+ * @param {number} port - Port to check
15
+ * @returns {number|null} PID or null if not in use
16
+ */
17
+ function getPortPid(port) {
18
+ try {
19
+ // Use execFileSync with arguments array (safe from injection)
20
+ const result = execFileSync('lsof', ['-ti', `:${port}`], {
21
+ encoding: 'utf8',
22
+ stdio: ['pipe', 'pipe', 'pipe']
23
+ });
24
+ const pid = parseInt(result.trim(), 10);
25
+ return isNaN(pid) ? null : pid;
26
+ } catch {
27
+ // lsof returns non-zero if no process found
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Check if a port is in use
34
+ * @param {number} port - Port to check
35
+ * @returns {boolean} True if port is in use
36
+ */
37
+ function isPortInUse(port) {
38
+ return getPortPid(port) !== null;
39
+ }
40
+
41
+ /**
42
+ * Kill process using a port
43
+ * @param {number} port - Port to free
44
+ * @returns {boolean} True if process was killed or port was already free
45
+ */
46
+ function killPortProcess(port) {
47
+ const pid = getPortPid(port);
48
+ if (!pid) {
49
+ return true; // Port already free
50
+ }
51
+
52
+ try {
53
+ process.kill(pid, 'SIGTERM');
54
+ logger.debug('Killed stale process', { port, pid });
55
+ return true;
56
+ } catch (error) {
57
+ logger.warn('Failed to kill process', { port, pid, error: error.message });
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Ensure the OpenCode server port is available
64
+ * Kills any stale process using the port
65
+ * @param {number} [port=4440] - Port to ensure is available
66
+ * @returns {boolean} True if port is now available
67
+ */
68
+ function ensurePortAvailable(port = DEFAULT_PORT) {
69
+ if (!isPortInUse(port)) {
70
+ return true;
71
+ }
72
+
73
+ logger.info('Port in use, cleaning up stale process', { port });
74
+
75
+ if (killPortProcess(port)) {
76
+ // Give the OS a moment to release the port
77
+ const start = Date.now();
78
+ while (isPortInUse(port) && Date.now() - start < 2000) {
79
+ // Busy wait for up to 2 seconds
80
+ }
81
+ return !isPortInUse(port);
82
+ }
83
+
84
+ return false;
85
+ }
86
+
87
+ module.exports = {
88
+ DEFAULT_PORT,
89
+ isPortInUse,
90
+ getPortPid,
91
+ killPortProcess,
92
+ ensurePortAvailable
93
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Session abort on process signals (F3 #20).
3
+ *
4
+ * When the parent process is killed (SIGTERM/SIGINT/SIGBREAK), the running
5
+ * headless session must be aborted so no orphaned OpenCode session keeps
6
+ * burning API credits. `markAborted` is the synchronous metadata write that
7
+ * guarantees `amicus list` won't show an orphan afterward.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ /**
14
+ * Synchronously mark a session's metadata as aborted. Best-effort: never throws.
15
+ * @param {string} sessionDir
16
+ * @param {string} reason - e.g. a signal name
17
+ * @returns {boolean} true if the session was marked aborted
18
+ */
19
+ function markAborted(sessionDir, reason) {
20
+ try {
21
+ const metaPath = path.join(sessionDir, 'metadata.json');
22
+ if (!fs.existsSync(metaPath)) { return false; }
23
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
24
+ meta.status = 'aborted';
25
+ meta.reason = `Aborted (${reason})`;
26
+ meta.abortedAt = new Date().toISOString();
27
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Register signal handlers that call onAbort(signal). Returns an uninstall fn.
36
+ * @param {{onAbort: (signal: string) => void, signals?: string[]}} opts
37
+ * @returns {() => void} uninstall
38
+ */
39
+ function installSignalAbort({ onAbort, signals = ['SIGINT', 'SIGTERM', 'SIGBREAK'] }) {
40
+ const registered = [];
41
+ for (const sig of signals) {
42
+ try {
43
+ const sigHandler = () => { try { onAbort(sig); } catch { /* best-effort */ } };
44
+ process.on(sig, sigHandler);
45
+ registered.push({ sig, sigHandler });
46
+ } catch { /* unsupported signal */ }
47
+ }
48
+ return function uninstall() {
49
+ for (const { sig, sigHandler } of registered) { process.removeListener(sig, sigHandler); }
50
+ };
51
+ }
52
+
53
+ module.exports = { markAborted, installSignalAbort };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module session-lock
5
+ * Atomic session lock files to prevent concurrent resume/continue.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ const LOCK_FILENAME = 'session.lock';
13
+
14
+ const MODE_TIMEOUTS = {
15
+ headless: 15 * 60 * 1000,
16
+ interactive: 60 * 60 * 1000,
17
+ mcp: 15 * 60 * 1000,
18
+ };
19
+
20
+ function isPidAlive(pid) {
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function isLockStale(lockData) {
30
+ if (!isPidAlive(lockData.pid)) {
31
+ return true;
32
+ }
33
+ const modeTimeout = MODE_TIMEOUTS[lockData.mode] || MODE_TIMEOUTS.headless;
34
+ const ageMs = Date.now() - new Date(lockData.timestamp).getTime();
35
+ if (ageMs > modeTimeout * 2) {
36
+ return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ function acquireLock(sessionDir, mode) {
42
+ const lockPath = path.join(sessionDir, LOCK_FILENAME);
43
+ const lockData = {
44
+ pid: process.pid,
45
+ timestamp: new Date().toISOString(),
46
+ hostname: os.hostname(),
47
+ mode,
48
+ };
49
+
50
+ // First attempt: atomic create
51
+ try {
52
+ fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2), { flag: 'wx', mode: 0o600 });
53
+ return;
54
+ } catch (err) {
55
+ if (err.code !== 'EEXIST') { throw err; }
56
+ }
57
+
58
+ // Lock file exists - check if stale
59
+ let existingLock;
60
+ try {
61
+ existingLock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
62
+ } catch {
63
+ try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
64
+ fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2), { flag: 'wx', mode: 0o600 });
65
+ return;
66
+ }
67
+
68
+ if (isLockStale(existingLock)) {
69
+ try { fs.unlinkSync(lockPath); } catch { /* race */ }
70
+ try {
71
+ fs.writeFileSync(lockPath, JSON.stringify(lockData, null, 2), { flag: 'wx', mode: 0o600 });
72
+ return;
73
+ } catch (retryErr) {
74
+ if (retryErr.code === 'EEXIST') {
75
+ throw new Error('Session already active (concurrent lock acquisition)');
76
+ }
77
+ throw retryErr;
78
+ }
79
+ }
80
+
81
+ throw new Error(
82
+ `Session already active (PID ${existingLock.pid}, started ${existingLock.timestamp})`
83
+ );
84
+ }
85
+
86
+ function releaseLock(sessionDir) {
87
+ const lockPath = path.join(sessionDir, LOCK_FILENAME);
88
+ try {
89
+ fs.unlinkSync(lockPath);
90
+ } catch {
91
+ // Lock may not exist
92
+ }
93
+ }
94
+
95
+ module.exports = { acquireLock, releaseLock, isLockStale, isPidAlive };