dual-brain 0.2.14 → 0.2.16

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.
@@ -0,0 +1,169 @@
1
+ // narrative.mjs — HEAD's running narrative: prose it writes to itself between turns.
2
+ // Not structured data. A paragraph or two that captures where we are, what just
3
+ // happened, what's brewing. Loaded at the top of each turn so HEAD is immediately
4
+ // "in the song" without reconstructing from scattered JSON.
5
+
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ const STATE_DIR = join(process.cwd(), '.dualbrain');
10
+ const NARRATIVE_FILE = join(STATE_DIR, 'narrative.md');
11
+ const NARRATIVE_HISTORY = join(STATE_DIR, 'narrative-history.jsonl');
12
+
13
+ const MAX_NARRATIVE_LENGTH = 2000;
14
+ const MAX_HISTORY_ENTRIES = 20;
15
+
16
+ /**
17
+ * Load the current running narrative. Returns empty string if none exists.
18
+ * This is meant to be injected at the top of HEAD's context each turn.
19
+ */
20
+ export function load() {
21
+ try {
22
+ if (existsSync(NARRATIVE_FILE)) {
23
+ return readFileSync(NARRATIVE_FILE, 'utf8').trim();
24
+ }
25
+ } catch {}
26
+ return '';
27
+ }
28
+
29
+ /**
30
+ * Write a new narrative, replacing the old one.
31
+ * The old narrative is archived to history before overwrite.
32
+ *
33
+ * @param {string} prose - HEAD's current understanding in prose form.
34
+ * Should answer: Where are we? What just happened? What's brewing?
35
+ * What did the user care about? What should I not forget?
36
+ */
37
+ export function write(prose) {
38
+ if (!prose || typeof prose !== 'string') return;
39
+
40
+ const trimmed = prose.slice(0, MAX_NARRATIVE_LENGTH).trim();
41
+ if (!trimmed) return;
42
+
43
+ mkdirSync(STATE_DIR, { recursive: true });
44
+
45
+ // Archive current before overwriting
46
+ const current = load();
47
+ if (current) {
48
+ _appendHistory(current);
49
+ }
50
+
51
+ writeFileSync(NARRATIVE_FILE, trimmed + '\n');
52
+ }
53
+
54
+ /**
55
+ * Evolve the narrative — append new observations without replacing everything.
56
+ * Used after dispatches return, after user says something illuminating,
57
+ * or after a wave completes.
58
+ *
59
+ * @param {string} addition - New prose to weave into the narrative.
60
+ * @param {object} opts
61
+ * @param {boolean} opts.replace - If true, replace entirely instead of appending.
62
+ */
63
+ export function evolve(addition, { replace = false } = {}) {
64
+ if (!addition || typeof addition !== 'string') return;
65
+
66
+ if (replace) {
67
+ write(addition);
68
+ return;
69
+ }
70
+
71
+ const current = load();
72
+ const combined = current
73
+ ? current + '\n\n' + addition.trim()
74
+ : addition.trim();
75
+
76
+ // If too long, keep the newest portion (recency bias for immersion)
77
+ const final = combined.length > MAX_NARRATIVE_LENGTH
78
+ ? combined.slice(-MAX_NARRATIVE_LENGTH)
79
+ : combined;
80
+
81
+ mkdirSync(STATE_DIR, { recursive: true });
82
+ writeFileSync(NARRATIVE_FILE, final.trim() + '\n');
83
+ }
84
+
85
+ /**
86
+ * Generate a narrative excerpt suitable for a dispatch envelope.
87
+ * Shorter than the full narrative — just enough context for a worker
88
+ * to understand the "why" without the full stream of consciousness.
89
+ *
90
+ * @param {number} maxLength - Max chars for the excerpt (default 500)
91
+ * @returns {string}
92
+ */
93
+ export function excerpt(maxLength = 500) {
94
+ const full = load();
95
+ if (!full) return '';
96
+ if (full.length <= maxLength) return full;
97
+
98
+ // Take the last N chars — most recent context is most relevant for workers
99
+ const trimmed = full.slice(-maxLength);
100
+ // Find the first sentence boundary to avoid mid-thought cuts
101
+ const firstPeriod = trimmed.indexOf('. ');
102
+ if (firstPeriod > 0 && firstPeriod < maxLength * 0.4) {
103
+ return trimmed.slice(firstPeriod + 2);
104
+ }
105
+ return trimmed;
106
+ }
107
+
108
+ /**
109
+ * Get recent narrative history entries (for warm memory tier).
110
+ * @param {number} n - Number of recent entries to retrieve
111
+ * @returns {Array<{ts: number, text: string}>}
112
+ */
113
+ export function recentHistory(n = 5) {
114
+ try {
115
+ if (!existsSync(NARRATIVE_HISTORY)) return [];
116
+ const lines = readFileSync(NARRATIVE_HISTORY, 'utf8').trim().split('\n').filter(Boolean);
117
+ return lines.slice(-n).map(line => {
118
+ try { return JSON.parse(line); } catch { return null; }
119
+ }).filter(Boolean);
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Persist the current narrative for precompact survival.
127
+ * Called by the precompact hook before context compression.
128
+ * Returns the narrative that was persisted (for confirmation).
129
+ */
130
+ export function persist() {
131
+ const current = load();
132
+ if (!current) return '';
133
+ // Narrative is already on disk — this just ensures it's fresh
134
+ // and archives a snapshot with explicit "precompact" marker
135
+ _appendHistory(current, { reason: 'precompact' });
136
+ return current;
137
+ }
138
+
139
+ /**
140
+ * Clear the narrative (used in testing or session reset).
141
+ */
142
+ export function clear() {
143
+ try {
144
+ if (existsSync(NARRATIVE_FILE)) {
145
+ writeFileSync(NARRATIVE_FILE, '');
146
+ }
147
+ } catch {}
148
+ }
149
+
150
+ // ── Internal ──────────────────────────────────────────────────────────────────
151
+
152
+ function _appendHistory(text, meta = {}) {
153
+ try {
154
+ const entry = JSON.stringify({ ts: Date.now(), text: text.slice(0, 800), ...meta });
155
+ mkdirSync(STATE_DIR, { recursive: true });
156
+
157
+ // Cap history file
158
+ let existing = '';
159
+ if (existsSync(NARRATIVE_HISTORY)) {
160
+ existing = readFileSync(NARRATIVE_HISTORY, 'utf8');
161
+ const lines = existing.trim().split('\n').filter(Boolean);
162
+ if (lines.length >= MAX_HISTORY_ENTRIES) {
163
+ existing = lines.slice(-MAX_HISTORY_ENTRIES + 1).join('\n') + '\n';
164
+ }
165
+ }
166
+
167
+ writeFileSync(NARRATIVE_HISTORY, existing + entry + '\n');
168
+ } catch {}
169
+ }
@@ -0,0 +1,250 @@
1
+ /** Predictive Dispatch — Layer 3: anticipates failure modes BEFORE dispatching. */
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ const DIAG_DIR = join(process.cwd(), '.dualbrain', 'diagnostic');
6
+ const STATE_PATH = join(DIAG_DIR, 'current.json');
7
+ const WEIGHTS_PATH = join(DIAG_DIR, 'pattern-weights.json');
8
+
9
+ export function loadSessionPatterns() {
10
+ const empty = { frequencies: [], avgSeverity: 0, precedingTools: {}, timeTrends: [] };
11
+ if (!existsSync(STATE_PATH)) return empty;
12
+
13
+ let state;
14
+ try { state = JSON.parse(readFileSync(STATE_PATH, 'utf8')); }
15
+ catch { return empty; }
16
+
17
+ const noticings = state.noticings || [];
18
+ if (!noticings.length) return empty;
19
+
20
+ const counts = {};
21
+ const severityMap = { low: 1, medium: 2, high: 3 };
22
+ let totalSeverity = 0;
23
+ for (const n of noticings) {
24
+ counts[n.type] = (counts[n.type] || 0) + 1;
25
+ totalSeverity += severityMap[n.severity] || 1;
26
+ }
27
+ const frequencies = Object.entries(counts)
28
+ .map(([type, count]) => ({ type, count }))
29
+ .sort((a, b) => b.count - a.count);
30
+
31
+ const toolCalls = state.toolCalls || [];
32
+ const precedingTools = {};
33
+ for (const n of noticings) {
34
+ const before = toolCalls.filter(tc => tc.ts < n.ts).slice(-3).map(tc => tc.tool);
35
+ if (!precedingTools[n.type]) precedingTools[n.type] = [];
36
+ precedingTools[n.type].push(...before);
37
+ }
38
+
39
+ const sessionStart = state.startedAt || noticings[0]?.ts || 0;
40
+ const sessionEnd = state.lastActivity || noticings[noticings.length - 1]?.ts || Date.now();
41
+ const duration = sessionEnd - sessionStart || 1;
42
+ const timeTrends = noticings.map(n => ({
43
+ type: n.type, position: (n.ts - sessionStart) / duration,
44
+ }));
45
+
46
+ return { frequencies, avgSeverity: totalSeverity / noticings.length, precedingTools, timeTrends };
47
+ }
48
+
49
+ function loadWeights() {
50
+ if (!existsSync(WEIGHTS_PATH)) return {};
51
+ try { return JSON.parse(readFileSync(WEIGHTS_PATH, 'utf8')); }
52
+ catch { return {}; }
53
+ }
54
+
55
+ export function predictFailureModes(agentSpec, context = {}) {
56
+ const predictions = [];
57
+ const patterns = context.patterns || loadSessionPatterns();
58
+ const weights = loadWeights();
59
+ const objective = agentSpec.objective || '';
60
+ const scope = agentSpec.scope || {};
61
+ const files = scope.files || [];
62
+ const tier = agentSpec.tier || '';
63
+ const priorFailures = context.priorFailures || [];
64
+ const activeWaves = context.activeWaves || [];
65
+ const lastReadAge = context.lastReadAge || 0; // ms
66
+
67
+ const applyWeight = (mode, base) => {
68
+ const w = weights[mode];
69
+ if (!w) return base;
70
+ // Shift likelihood based on historical accuracy
71
+ return Math.min(1, Math.max(0, base + (w.accuracy - 0.5) * 0.3));
72
+ };
73
+
74
+ // scope-explosion
75
+ const multiTarget = files.length > 3 || /\band\b|multiple|several|all/i.test(objective);
76
+ const priorScopeCreep = patterns.frequencies.some(f => f.type === 'scope-creep' && f.count >= 2);
77
+ if (multiTarget || priorScopeCreep) {
78
+ const base = multiTarget && priorScopeCreep ? 0.8 : 0.7;
79
+ predictions.push({
80
+ mode: 'scope-explosion',
81
+ likelihood: applyWeight('scope-explosion', base),
82
+ basis: multiTarget
83
+ ? `Objective references ${files.length || 'multiple'} targets`
84
+ : 'Prior waves encountered scope creep',
85
+ prevention: `If scope grows beyond ${Math.max(files.length, 3)} files, STOP and report back rather than continuing.`,
86
+ });
87
+ }
88
+
89
+ // missing-context
90
+ const noFiles = files.length === 0;
91
+ const executeWithoutSearch = tier === 'execute' && !(context.priorSearchWave);
92
+ if (noFiles || executeWithoutSearch) {
93
+ const base = 0.6;
94
+ predictions.push({
95
+ mode: 'missing-context',
96
+ likelihood: applyWeight('missing-context', base),
97
+ basis: noFiles
98
+ ? 'No files specified in scope — agent may waste tokens searching'
99
+ : 'Execute tier dispatched without prior search wave',
100
+ prevention: `Read the current state of target files before making changes. If unsure what to modify, list candidates first.`,
101
+ });
102
+ }
103
+
104
+ // wrong-approach
105
+ const hasStuckLoop = patterns.frequencies.some(f => f.type === 'stuck-loop' && f.count >= 2);
106
+ const objectiveFailed = priorFailures.some(f =>
107
+ f.objective && objective.toLowerCase().includes(f.objective.toLowerCase().slice(0, 20))
108
+ );
109
+ if (hasStuckLoop || objectiveFailed) {
110
+ const base = objectiveFailed ? 0.8 : 0.6;
111
+ predictions.push({
112
+ mode: 'wrong-approach',
113
+ likelihood: applyWeight('wrong-approach', base),
114
+ basis: objectiveFailed
115
+ ? 'Prior attempt at this objective failed'
116
+ : 'Diagnostic shows stuck-loop pattern in session',
117
+ prevention: `Try a fundamentally different approach than previous attempts. If stuck after 2 tries, report back with what was tried.`,
118
+ });
119
+ }
120
+
121
+ // blocked-dependency
122
+ const refsOtherWave = activeWaves.some(w =>
123
+ objective.toLowerCase().includes(w.output?.toLowerCase()?.slice(0, 20) || '___none')
124
+ );
125
+ if (refsOtherWave) {
126
+ predictions.push({
127
+ mode: 'blocked-dependency',
128
+ likelihood: applyWeight('blocked-dependency', 0.5),
129
+ basis: 'Objective references output from an in-progress wave',
130
+ prevention: `If blocked by a dependency from another wave, pivot to independent work or report the blocker.`,
131
+ });
132
+ }
133
+
134
+ // stale-assumption
135
+ if (lastReadAge > 5 * 60 * 1000 && files.length > 0) {
136
+ predictions.push({
137
+ mode: 'stale-assumption',
138
+ likelihood: applyWeight('stale-assumption', 0.4),
139
+ basis: `Files in scope were last read ${Math.round(lastReadAge / 60000)}m ago — may have changed`,
140
+ prevention: `Re-read ${files.slice(0, 3).join(', ')} before assuming current state.`,
141
+ });
142
+ }
143
+
144
+ return predictions;
145
+ }
146
+
147
+ export function generatePreventions(predictions) {
148
+ const actionable = predictions
149
+ .filter(p => p.likelihood >= 0.4)
150
+ .sort((a, b) => b.likelihood - a.likelihood)
151
+ .slice(0, 5);
152
+
153
+ if (!actionable.length) return '';
154
+
155
+ const lines = actionable.map(p => `- [${p.prevention}]`);
156
+ return `⚠ Pre-flight awareness:\n${lines.join('\n')}`;
157
+ }
158
+
159
+ export function scoreDispatchReadiness(agentSpec, wavePlan = {}, predictions = []) {
160
+ const blockers = [];
161
+ const warnings = [];
162
+ let score = 1.0;
163
+
164
+ const files = agentSpec.scope?.files || [];
165
+ const priorSearch = wavePlan.completedSearchWave || false;
166
+ const hasContext = files.length > 0 || priorSearch;
167
+
168
+ // Scope researched?
169
+ if (!hasContext) {
170
+ blockers.push('No files in scope and no prior search wave — context unknown');
171
+ score -= 0.3;
172
+ }
173
+
174
+ // High-likelihood predictions
175
+ const highRisk = predictions.filter(p => p.likelihood >= 0.7);
176
+ const medRisk = predictions.filter(p => p.likelihood >= 0.4 && p.likelihood < 0.7);
177
+
178
+ for (const p of highRisk) {
179
+ blockers.push(`High-risk: ${p.mode} (${Math.round(p.likelihood * 100)}%) — ${p.basis}`);
180
+ score -= 0.25;
181
+ }
182
+ for (const p of medRisk) {
183
+ warnings.push(`${p.mode} (${Math.round(p.likelihood * 100)}%) — ${p.basis}`);
184
+ score -= 0.1;
185
+ }
186
+
187
+ // Unresolved blockers from prior waves
188
+ const unresolvedBlockers = wavePlan.unresolvedBlockers || [];
189
+ for (const b of unresolvedBlockers) {
190
+ blockers.push(`Unresolved from prior wave: ${b}`);
191
+ score -= 0.2;
192
+ }
193
+
194
+ score = Math.max(0, Math.min(1, score));
195
+ const ready = score >= 0.5 && blockers.length === 0;
196
+
197
+ let suggestion = '';
198
+ if (!ready) {
199
+ if (blockers.some(b => b.includes('context unknown'))) {
200
+ suggestion = 'Run a search/read wave first to establish context before executing.';
201
+ } else if (highRisk.length) {
202
+ suggestion = `Address high-risk predictions: ${highRisk.map(p => p.mode).join(', ')}. Add preventions to prompt or restructure scope.`;
203
+ } else {
204
+ suggestion = 'Resolve listed blockers before dispatching.';
205
+ }
206
+ }
207
+
208
+ return { ready, score, blockers, warnings, suggestion };
209
+ }
210
+
211
+ export function evolvePatterns(newDebrief, predictions) {
212
+ const weights = loadWeights();
213
+
214
+ for (const pred of predictions) {
215
+ if (!weights[pred.mode]) {
216
+ weights[pred.mode] = { correct: 0, incorrect: 0, accuracy: 0.5 };
217
+ }
218
+
219
+ const w = weights[pred.mode];
220
+ const occurred = debriefContainsPattern(newDebrief, pred.mode);
221
+
222
+ if (occurred && pred.likelihood >= 0.4) {
223
+ w.correct++;
224
+ } else if (!occurred && pred.likelihood >= 0.4) {
225
+ w.incorrect++;
226
+ } else if (occurred && pred.likelihood < 0.4) {
227
+ // Missed prediction — we should have predicted higher
228
+ w.incorrect++;
229
+ }
230
+
231
+ const total = w.correct + w.incorrect;
232
+ w.accuracy = total > 0 ? w.correct / total : 0.5;
233
+ }
234
+
235
+ mkdirSync(DIAG_DIR, { recursive: true });
236
+ writeFileSync(WEIGHTS_PATH, JSON.stringify(weights, null, 2));
237
+ }
238
+
239
+ function debriefContainsPattern(debrief, mode) {
240
+ if (!debrief) return false;
241
+ const modeMap = {
242
+ 'scope-explosion': ['scope-creep', 'scope_explosion', 'expanded'],
243
+ 'missing-context': ['missing-context', 'no_context', 'searched'],
244
+ 'wrong-approach': ['stuck-loop', 'wrong_approach', 'retry'],
245
+ 'blocked-dependency': ['blocked', 'dependency', 'waiting'],
246
+ 'stale-assumption': ['stale', 'outdated', 'changed'],
247
+ };
248
+ const text = JSON.stringify([...(debrief.issues || []), ...(debrief.patterns || [])]).toLowerCase();
249
+ return (modeMap[mode] || [mode]).some(k => text.includes(k));
250
+ }
@@ -102,7 +102,7 @@ export function buildSurvivalBlock(provider, state) {
102
102
  /**
103
103
  * Generate a handoff receipt that works for both providers.
104
104
  * Accounts for Codex's lack of native resume support by writing
105
- * to a shared .dual-brain/handoffs/ directory that both providers can read.
105
+ * to a shared .dualbrain/handoffs/ directory that both providers can read.
106
106
  */
107
107
  export function generateProviderHandoff(sessionState, provider) {
108
108
  const caps = getProviderCaps(provider);
@@ -136,7 +136,7 @@ export function generateProviderHandoff(sessionState, provider) {
136
136
  * Codex gets a more verbose brief since it has no native session memory.
137
137
  */
138
138
  export function buildProviderResumeBrief(cwd, targetProvider) {
139
- const dir = join(cwd || process.cwd(), '.dual-brain', 'handoffs');
139
+ const dir = join(cwd || process.cwd(), '.dualbrain', 'handoffs');
140
140
  if (!existsSync(dir)) return null;
141
141
 
142
142
  const files = readdirSync(dir)
package/src/receipt.mjs CHANGED
@@ -111,7 +111,7 @@ export function formatFailureReceipt(receipt, failureContext) {
111
111
 
112
112
  // ─── Persistent session receipt ──────────────────────────────────────────────
113
113
 
114
- const RECEIPTS_DIR = '.dual-brain/receipts';
114
+ const RECEIPTS_DIR = '.dualbrain/receipts';
115
115
 
116
116
  function receiptsDir(cwd) {
117
117
  return join(cwd, RECEIPTS_DIR);
@@ -130,7 +130,7 @@ function gitChangedFiles(cwd) {
130
130
 
131
131
  function readDecisionsRecent(cwd, limit = 5) {
132
132
  try {
133
- const raw = readFileSync(join(cwd, '.dual-brain', 'decisions.jsonl'), 'utf8');
133
+ const raw = readFileSync(join(cwd, '.dualbrain', 'decisions.jsonl'), 'utf8');
134
134
  const lines = raw.split('\n').filter(l => l.trim());
135
135
  return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
136
136
  } catch {
@@ -0,0 +1,154 @@
1
+ // session-lock.mjs — Ensures one active HEAD session at a time.
2
+ // If two shells/chats open, only one owns the cognitive state.
3
+ // The other gets read-only access (can observe but not dispatch).
4
+ //
5
+ // "One ring rules them all" — no split-brain.
6
+
7
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ const STATE_DIR = join(process.cwd(), '.dualbrain');
11
+ const LOCK_FILE = join(STATE_DIR, 'session.lock');
12
+
13
+ const STALE_THRESHOLD_MS = 90_000; // 90 seconds without heartbeat = stale
14
+ const HEARTBEAT_INTERVAL_MS = 30_000;
15
+
16
+ let _heartbeatTimer = null;
17
+ let _sessionId = null;
18
+
19
+ /**
20
+ * @typedef {object} LockResult
21
+ * @property {boolean} acquired - Whether this session owns HEAD
22
+ * @property {string} sessionId - This session's ID
23
+ * @property {string|null} existingSession - ID of the session that already holds the lock (if not acquired)
24
+ * @property {string} mode - 'primary' | 'takeover' | 'readonly'
25
+ */
26
+
27
+ /**
28
+ * Attempt to acquire the session lock.
29
+ * - If no lock exists or lock is stale: acquire as primary
30
+ * - If lock is fresh and held by another: return readonly
31
+ *
32
+ * @param {object} opts
33
+ * @param {boolean} opts.force - Force takeover even if existing session is fresh
34
+ * @returns {LockResult}
35
+ */
36
+ export function acquire({ force = false } = {}) {
37
+ mkdirSync(STATE_DIR, { recursive: true });
38
+ _sessionId = _generateSessionId();
39
+
40
+ const existing = _readLock();
41
+
42
+ if (!existing) {
43
+ // No lock — claim it
44
+ _writeLock(_sessionId);
45
+ _startHeartbeat();
46
+ return { acquired: true, sessionId: _sessionId, existingSession: null, mode: 'primary' };
47
+ }
48
+
49
+ const age = Date.now() - existing.heartbeat;
50
+
51
+ if (age > STALE_THRESHOLD_MS || force) {
52
+ // Stale or forced takeover
53
+ _writeLock(_sessionId);
54
+ _startHeartbeat();
55
+ return { acquired: true, sessionId: _sessionId, existingSession: existing.sessionId, mode: 'takeover' };
56
+ }
57
+
58
+ // Another session is active — go readonly
59
+ return { acquired: false, sessionId: _sessionId, existingSession: existing.sessionId, mode: 'readonly' };
60
+ }
61
+
62
+ /**
63
+ * Release the session lock (called at session end).
64
+ */
65
+ export function release() {
66
+ if (_heartbeatTimer) {
67
+ clearInterval(_heartbeatTimer);
68
+ _heartbeatTimer = null;
69
+ }
70
+
71
+ try {
72
+ const existing = _readLock();
73
+ if (existing && existing.sessionId === _sessionId) {
74
+ unlinkSync(LOCK_FILE);
75
+ }
76
+ } catch {}
77
+ }
78
+
79
+ /**
80
+ * Check if this session currently holds the lock.
81
+ * @returns {boolean}
82
+ */
83
+ export function isOwner() {
84
+ const existing = _readLock();
85
+ return existing?.sessionId === _sessionId;
86
+ }
87
+
88
+ /**
89
+ * Get current lock status without modifying it.
90
+ * @returns {{active: boolean, sessionId: string|null, age: number|null}}
91
+ */
92
+ export function status() {
93
+ const existing = _readLock();
94
+ if (!existing) return { active: false, sessionId: null, age: null };
95
+ return {
96
+ active: (Date.now() - existing.heartbeat) < STALE_THRESHOLD_MS,
97
+ sessionId: existing.sessionId,
98
+ age: Date.now() - existing.heartbeat,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Manually heartbeat (useful if the automatic timer isn't running).
104
+ */
105
+ export function heartbeat() {
106
+ if (!_sessionId) return;
107
+ const existing = _readLock();
108
+ if (existing && existing.sessionId === _sessionId) {
109
+ _writeLock(_sessionId);
110
+ }
111
+ }
112
+
113
+ // ── Internal ──────────────────────────────────────────────────────────────────
114
+
115
+ function _generateSessionId() {
116
+ return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
117
+ }
118
+
119
+ function _readLock() {
120
+ try {
121
+ if (!existsSync(LOCK_FILE)) return null;
122
+ return JSON.parse(readFileSync(LOCK_FILE, 'utf8'));
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ function _writeLock(sessionId) {
129
+ const lock = {
130
+ sessionId,
131
+ heartbeat: Date.now(),
132
+ pid: process.pid,
133
+ };
134
+ writeFileSync(LOCK_FILE, JSON.stringify(lock));
135
+ }
136
+
137
+ function _startHeartbeat() {
138
+ if (_heartbeatTimer) clearInterval(_heartbeatTimer);
139
+ _heartbeatTimer = setInterval(() => {
140
+ try {
141
+ const existing = _readLock();
142
+ if (existing && existing.sessionId === _sessionId) {
143
+ _writeLock(_sessionId);
144
+ } else {
145
+ // Someone else took over — stop heartbeating
146
+ clearInterval(_heartbeatTimer);
147
+ _heartbeatTimer = null;
148
+ }
149
+ } catch {}
150
+ }, HEARTBEAT_INTERVAL_MS);
151
+
152
+ // Don't keep the process alive just for heartbeats
153
+ if (_heartbeatTimer.unref) _heartbeatTimer.unref();
154
+ }