@bahulam/code 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +39 -0
- package/package.json +8 -9
- package/pulse/lib/tool-categories.ts +13 -0
- package/src/commands/device.mjs +121 -0
- package/src/commands/pair.mjs +190 -0
- package/src/commands/remote.mjs +110 -0
- package/src/config/env.mjs +2 -2
- package/src/core/event-log.mjs +393 -0
- package/src/core/headless.mjs +198 -0
- package/src/core/loop.mjs +276 -0
- package/src/core/memory-disk.mjs +210 -0
- package/src/core/paths.mjs +36 -0
- package/src/core/stream-client.mjs +28 -9
- package/src/core/tool-executor.mjs +64 -16
- package/src/daemon/approval-store.mjs +253 -0
- package/src/daemon/attach-client.mjs +361 -0
- package/src/daemon/daemonize.mjs +151 -0
- package/src/daemon/event-tap.mjs +197 -0
- package/src/daemon/input-lock.mjs +191 -0
- package/src/daemon/relay-client.mjs +258 -0
- package/src/daemon/session-core.mjs +179 -0
- package/src/daemon/session-list.mjs +26 -0
- package/src/daemon/session-publisher.mjs +78 -0
- package/src/daemon/socket-server.mjs +329 -0
- package/src/daemon/stop-daemon.mjs +18 -0
- package/src/permissions/checker.mjs +6 -6
- package/src/permissions/prompt.mjs +8 -7
- package/src/skills/installer.mjs +8 -0
- package/src/terminal/ansi.mjs +85 -9
- package/src/terminal/main.mjs +97 -3
- package/src/terminal/repl.mjs +389 -6
- package/src/terminal/skills-picker.mjs +121 -0
- package/src/terminal/skills.mjs +3 -3
- package/src/tools/analyze-code.mjs +39 -0
- package/src/tools/bash.mjs +1 -1
- package/src/tools/edit.mjs +18 -18
- package/src/tools/git-diff.mjs +34 -0
- package/src/tools/git-status.mjs +30 -0
- package/src/tools/glob.mjs +5 -2
- package/src/tools/grep.mjs +1 -1
- package/src/tools/meta-tools.mjs +85 -0
- package/src/tools/read-files.mjs +37 -0
- package/src/tools/read.mjs +20 -10
- package/src/tools/registry.mjs +20 -0
- package/src/tools/remember.mjs +147 -0
- package/src/tools/search-files.mjs +41 -0
- package/src/tools/write-project.mjs +62 -0
- package/src/tools/write.mjs +1 -1
- package/src/ui/banner.mjs +1 -1
- package/src/ui/slash-commands.mjs +16 -0
- package/src/ui/sub-agent.mjs +8 -2
- package/src/ui/transcript-block.mjs +4 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* . — auto-daemon spawn + start-or-attach.
|
|
3
|
+
*
|
|
4
|
+
* Two entrypoints:
|
|
5
|
+
*
|
|
6
|
+
* spawnDetachedDaemon(cwd, {prompt, extraEnv})
|
|
7
|
+
* Forks a background bahulam child, detached from the current
|
|
8
|
+
* terminal, with BAHULAM_DAEMON_EVENTLOG=1 forced so it starts a
|
|
9
|
+
* socket server on session_info. Returns { pid, waitForSession() }
|
|
10
|
+
* — the parent can await a session_id becoming visible in
|
|
11
|
+
* ~/.bahulam/sessions/, or exit immediately (typical case: user
|
|
12
|
+
* types `bahulam daemonize "fix this bug"`, we spawn + print the
|
|
13
|
+
* session id + exit; they attach later with `bahulam attach <id>`).
|
|
14
|
+
*
|
|
15
|
+
* findSessionForCwd(cwd)
|
|
16
|
+
* Scans ~/.bahulam/sessions/<id>/meta.json for entries where meta.cwd
|
|
17
|
+
* matches (after realpath), and where the pid is still alive and
|
|
18
|
+
* the socket file exists. Returns the newest such session id or null.
|
|
19
|
+
* Used by `bahulam` (no args) to decide start-vs-attach.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from 'node:fs';
|
|
23
|
+
import * as fsp from 'node:fs/promises';
|
|
24
|
+
import * as path from 'node:path';
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import { daemonSessionsRoot, daemonSocketPath } from '../core/paths.mjs';
|
|
27
|
+
|
|
28
|
+
const POLL_INTERVAL_MS = 100;
|
|
29
|
+
const DEFAULT_WAIT_MS = 15_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Look for a live daemon session bound to `cwd`. A session is "live" if:
|
|
33
|
+
* 1. `~/.bahulam/sessions/<id>/meta.json` has `cwd` matching (realpath).
|
|
34
|
+
* 2. `~/.bahulam/sockets/<id>.sock` exists.
|
|
35
|
+
* 3. `meta.pid` is alive (`kill -0` succeeds — we don't send SIGTERM,
|
|
36
|
+
* just probe existence with signal 0).
|
|
37
|
+
*
|
|
38
|
+
* Returns the newest matching session id or null. Newest = highest
|
|
39
|
+
* `opened_at` in meta.json (falls back to directory mtime).
|
|
40
|
+
*/
|
|
41
|
+
export async function findSessionForCwd(cwd) {
|
|
42
|
+
const root = daemonSessionsRoot();
|
|
43
|
+
let target;
|
|
44
|
+
try { target = fs.realpathSync(cwd); } catch { target = cwd; }
|
|
45
|
+
|
|
46
|
+
let entries;
|
|
47
|
+
try { entries = await fsp.readdir(root, { withFileTypes: true }); }
|
|
48
|
+
catch { return null; }
|
|
49
|
+
|
|
50
|
+
const candidates = [];
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (!entry.isDirectory() || !entry.name.startsWith('sess_')) continue;
|
|
53
|
+
const sid = entry.name;
|
|
54
|
+
const dir = path.join(root, sid);
|
|
55
|
+
let meta;
|
|
56
|
+
try { meta = JSON.parse(await fsp.readFile(path.join(dir, 'meta.json'), 'utf-8')); }
|
|
57
|
+
catch { continue; }
|
|
58
|
+
if (!meta.cwd) continue;
|
|
59
|
+
let metaCwd;
|
|
60
|
+
try { metaCwd = fs.realpathSync(meta.cwd); } catch { metaCwd = meta.cwd; }
|
|
61
|
+
if (metaCwd !== target) continue;
|
|
62
|
+
if (!fs.existsSync(daemonSocketPath(sid))) continue;
|
|
63
|
+
if (!_pidAlive(meta.pid)) continue;
|
|
64
|
+
candidates.push({ sid, openedAt: meta.opened_at || '', pid: meta.pid });
|
|
65
|
+
}
|
|
66
|
+
if (candidates.length === 0) return null;
|
|
67
|
+
candidates.sort((a, b) => (b.openedAt || '').localeCompare(a.openedAt || ''));
|
|
68
|
+
return candidates[0].sid;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Spawn a detached bahulam child. The child inherits nothing on stdio
|
|
73
|
+
* (piped to /dev/null via 'ignore') — it lives in the background,
|
|
74
|
+
* writes its transcript to the event log, and any attach client renders
|
|
75
|
+
* from there.
|
|
76
|
+
*
|
|
77
|
+
* Returns { pid, waitForSession(ms?) → sess_id | null }. Caller can
|
|
78
|
+
* await the session_id becoming visible before exiting the parent so
|
|
79
|
+
* the printed "sess_..." line isn't stale.
|
|
80
|
+
*/
|
|
81
|
+
export function spawnDetachedDaemon({
|
|
82
|
+
cwd = process.cwd(),
|
|
83
|
+
prompt = null,
|
|
84
|
+
binPath = process.argv[1], // the bahulam entrypoint that spawned US
|
|
85
|
+
extraEnv = {},
|
|
86
|
+
} = {}) {
|
|
87
|
+
const beforeSet = _listCurrentSessionsSync();
|
|
88
|
+
|
|
89
|
+
const env = {
|
|
90
|
+
...process.env,
|
|
91
|
+
BAHULAM_DAEMON_EVENTLOG: '1',
|
|
92
|
+
// Spawned children auto-quit after the first turn's agent_complete
|
|
93
|
+
// unless the operator opts into idle-hold. Cheap default that
|
|
94
|
+
// matches the "bahulam daemonize <prompt>; check back later"
|
|
95
|
+
// mental model. Override with BAHULAM_DAEMON_HOLD=1 to keep the
|
|
96
|
+
// socket up for follow-up send_message commands.
|
|
97
|
+
BAHULAM_DAEMON_SPAWNED: '1',
|
|
98
|
+
...extraEnv,
|
|
99
|
+
};
|
|
100
|
+
if (prompt) env.BAHULAM_DAEMON_INITIAL_PROMPT = String(prompt);
|
|
101
|
+
|
|
102
|
+
const child = spawn(process.execPath, [binPath], {
|
|
103
|
+
cwd,
|
|
104
|
+
env,
|
|
105
|
+
detached: true,
|
|
106
|
+
stdio: 'ignore',
|
|
107
|
+
});
|
|
108
|
+
child.unref(); // parent can exit without waiting on child
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
pid: child.pid,
|
|
112
|
+
async waitForSession(ms = DEFAULT_WAIT_MS) {
|
|
113
|
+
const deadline = Date.now() + ms;
|
|
114
|
+
while (Date.now() < deadline) {
|
|
115
|
+
// Find a session_id that's NEW since we spawned + belongs to our cwd.
|
|
116
|
+
const now = _listCurrentSessionsSync();
|
|
117
|
+
for (const sid of now) {
|
|
118
|
+
if (beforeSet.has(sid)) continue;
|
|
119
|
+
const dir = path.join(daemonSessionsRoot(), sid);
|
|
120
|
+
try {
|
|
121
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf-8'));
|
|
122
|
+
if (meta.pid === child.pid) return sid;
|
|
123
|
+
// pid mismatch is fine early — the daemon may not have written
|
|
124
|
+
// meta.json yet; keep polling.
|
|
125
|
+
} catch { /* not written yet */ }
|
|
126
|
+
}
|
|
127
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── internals ────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function _pidAlive(pid) {
|
|
137
|
+
if (typeof pid !== 'number' || pid <= 0) return false;
|
|
138
|
+
try { process.kill(pid, 0); return true; }
|
|
139
|
+
catch (err) {
|
|
140
|
+
// EPERM = process exists but we can't signal it → still alive.
|
|
141
|
+
return err && err.code === 'EPERM';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _listCurrentSessionsSync() {
|
|
146
|
+
try {
|
|
147
|
+
const root = daemonSessionsRoot();
|
|
148
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
149
|
+
return new Set(entries.filter(e => e.isDirectory() && e.name.startsWith('sess_')).map(e => e.name));
|
|
150
|
+
} catch { return new Set(); }
|
|
151
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event tap — bridges SSE events from the agent loop to the relay.
|
|
3
|
+
*
|
|
4
|
+
* Each SSE event type is mapped to a relay event type. The mapping
|
|
5
|
+
* is a fixed table. Keeping it centralized avoids scattering
|
|
6
|
+
* relay-specific concerns across the agent loop. * • Cache-invariance ( §7): this tap is READ-ONLY on the SSE
|
|
7
|
+
* event. It never mutates `event`, never changes what stream-client
|
|
8
|
+
* yields, and writes to a local file only. `/api/execute` body is
|
|
9
|
+
* untouched.
|
|
10
|
+
*
|
|
11
|
+
* NOT in scope for .3:
|
|
12
|
+
* • Broadcasting events to attached socket clients (.4).
|
|
13
|
+
* • Compact snapshot writing (.4 — the daemon supervisor
|
|
14
|
+
* decides when to snapshot; the tap doesn't).
|
|
15
|
+
* • Deriving events that don't have a direct SSE origin
|
|
16
|
+
* (e.g. `attach_joined`, `input_lock_changed` — those come from
|
|
17
|
+
* the socket server, not the SSE stream).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createEventLog } from '../core/event-log.mjs';
|
|
21
|
+
|
|
22
|
+
// ── env-var gate ─────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function _enabled() {
|
|
25
|
+
const v = process.env.BAHULAM_DAEMON_EVENTLOG;
|
|
26
|
+
return v === '1' || v === 'true';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── per-session lazy log holder ──────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// One active log at a time. Session id change (via new turn's session_info)
|
|
32
|
+
// closes the old log and opens a new one. `_current` is null when the tap
|
|
33
|
+
// hasn't seen a session id yet — session_info events don't always arrive
|
|
34
|
+
// before other events do, so we buffer nothing and simply drop events
|
|
35
|
+
// that arrive before the id is known. First real event a re-attach sees
|
|
36
|
+
// is session_started; anything before that is renderer-only anyway.
|
|
37
|
+
|
|
38
|
+
let _current = null; // { sessionId, log }
|
|
39
|
+
|
|
40
|
+
function _openLogFor(sessionId) {
|
|
41
|
+
if (_current && _current.sessionId === sessionId) return _current.log;
|
|
42
|
+
if (_current) {
|
|
43
|
+
// Fire-and-forget close on the previous log. The write chain resolves
|
|
44
|
+
// internally; we don't await here because tapSseEvent is called from
|
|
45
|
+
// a hot loop that can't afford to block.
|
|
46
|
+
try { _current.log.close().catch(() => {}); } catch { /* ignore */ }
|
|
47
|
+
}
|
|
48
|
+
const log = createEventLog({ sessionId });
|
|
49
|
+
_current = { sessionId, log };
|
|
50
|
+
return log;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Test / bahulam-stop hook — close the active log and drop the ref. */
|
|
54
|
+
export async function closeActiveEventLog() {
|
|
55
|
+
if (!_current) return;
|
|
56
|
+
const { log } = _current;
|
|
57
|
+
_current = null;
|
|
58
|
+
try { await log.close(); } catch { /* ignore */ }
|
|
59
|
+
_broadcasters.length = 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── broadcasters (.4) ─────────────────────────────────────────
|
|
63
|
+
//
|
|
64
|
+
// The socket server registers itself here so live events fan out to
|
|
65
|
+
// attached clients as they're written to the durable log. Broadcasters
|
|
66
|
+
// are fire-and-forget; a slow/broken one MUST NOT slow down or block
|
|
67
|
+
// the tap. Each broadcaster is invoked in its own try/catch.
|
|
68
|
+
//
|
|
69
|
+
// Registration is process-global (one bahulamd = one process = one tap
|
|
70
|
+
// registry). Multiple socket servers CAN register; the tap doesn't care.
|
|
71
|
+
|
|
72
|
+
const _broadcasters = [];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Register a broadcaster. Returns an unregister function.
|
|
76
|
+
* The broadcaster receives the fully-formed event AFTER it has
|
|
77
|
+
* been written to the log (so `seq` and `ts` are populated).
|
|
78
|
+
*/
|
|
79
|
+
export function registerBroadcaster(fn) {
|
|
80
|
+
if (typeof fn !== 'function') throw new Error('registerBroadcaster: fn must be a function');
|
|
81
|
+
_broadcasters.push(fn);
|
|
82
|
+
return () => {
|
|
83
|
+
const i = _broadcasters.indexOf(fn);
|
|
84
|
+
if (i >= 0) _broadcasters.splice(i, 1);
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── SSE → event mapping ──────────────────────────────────────
|
|
89
|
+
//
|
|
90
|
+
// Table (protocol spec §5.1). Only events that map to a first-class
|
|
91
|
+
// type are logged. Everything else (status, thinking with
|
|
92
|
+
// empty text, phase_update, worker_update, etc.) is renderer-transient
|
|
93
|
+
// and doesn't belong in a durable event log.
|
|
94
|
+
|
|
95
|
+
const SSE_TO_PRD092 = Object.freeze({
|
|
96
|
+
session_info: 'session_started',
|
|
97
|
+
turn_started: 'turn_started',
|
|
98
|
+
turn_ended: 'turn_ended',
|
|
99
|
+
thinking: 'thinking_delta',
|
|
100
|
+
tool_request: 'tool_call',
|
|
101
|
+
tool_call: 'tool_call',
|
|
102
|
+
tool_result: 'tool_result',
|
|
103
|
+
tool_done: 'tool_result',
|
|
104
|
+
approval_required: 'approval_required',
|
|
105
|
+
approval_decided: 'approval_decided',
|
|
106
|
+
file_diff: 'diff',
|
|
107
|
+
diff: 'diff',
|
|
108
|
+
test_result: 'test_result',
|
|
109
|
+
tokens_used: 'tokens_used',
|
|
110
|
+
usage_update: 'usage_update',
|
|
111
|
+
complete: 'agent_complete',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Some SSE event bodies carry more than we want to log. Trim what makes
|
|
115
|
+
// sense per type so we don't bloat the log with, e.g., a 200KB rendered
|
|
116
|
+
// tool output when a summary would do. Everything else round-trips as-is.
|
|
117
|
+
function _projectData(prd092Type, data) {
|
|
118
|
+
const src = data || {};
|
|
119
|
+
switch (prd092Type) {
|
|
120
|
+
case 'tool_call':
|
|
121
|
+
return {
|
|
122
|
+
name: src.tool || src.name,
|
|
123
|
+
tool_id: src.tool_call_id || src.tool_id || src.id,
|
|
124
|
+
args: src.args || src.tool_input || src.input || {},
|
|
125
|
+
subagent_id: src.subagent_id || undefined,
|
|
126
|
+
cwd: src.cwd || undefined,
|
|
127
|
+
};
|
|
128
|
+
case 'tool_result':
|
|
129
|
+
return {
|
|
130
|
+
tool_id: src.tool_call_id || src.tool_id || src.id,
|
|
131
|
+
ok: src.error ? false : (src.ok !== false),
|
|
132
|
+
summary: typeof src.output === 'string'
|
|
133
|
+
? src.output.slice(0, 4096)
|
|
134
|
+
: (src.summary || undefined),
|
|
135
|
+
duration_ms: src.duration_ms || src.durationMs || undefined,
|
|
136
|
+
output_truncated: typeof src.output === 'string' && src.output.length > 4096 ? true : undefined,
|
|
137
|
+
};
|
|
138
|
+
case 'thinking_delta':
|
|
139
|
+
return { chunk: src.message || src.text || '' };
|
|
140
|
+
case 'session_started':
|
|
141
|
+
return {
|
|
142
|
+
cwd: src.cwd,
|
|
143
|
+
model: src.model,
|
|
144
|
+
product: src.product,
|
|
145
|
+
session_id_from_backend: src.session_id,
|
|
146
|
+
};
|
|
147
|
+
case 'agent_complete':
|
|
148
|
+
return { ok: src.error == null, summary: src.summary, duration_ms: src.duration_ms };
|
|
149
|
+
default:
|
|
150
|
+
return src;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── the tap ──────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Tap one SSE event to the event log.
|
|
158
|
+
*
|
|
159
|
+
* @param {{type: string, data?: object}} event the SSE frame from stream-client
|
|
160
|
+
* @param {object} opts
|
|
161
|
+
* @param {string} opts.sessionId from session.id — required for log routing
|
|
162
|
+
* @param {string} [opts.turnId] optional; stamped on turn-scoped events
|
|
163
|
+
*
|
|
164
|
+
* Failure modes are ALL silent — a broken tap must never affect the
|
|
165
|
+
* user-facing render path. If the env var isn't set or sessionId is
|
|
166
|
+
* missing we skip; if the file write throws internally, event-log.mjs
|
|
167
|
+
* logs to stderr and drops the event.
|
|
168
|
+
*/
|
|
169
|
+
export function tapSseEvent(event, { sessionId, turnId } = {}) {
|
|
170
|
+
if (!_enabled()) return;
|
|
171
|
+
if (!event || !event.type || !sessionId) return;
|
|
172
|
+
const prd092Type = SSE_TO_PRD092[event.type];
|
|
173
|
+
if (!prd092Type) return;
|
|
174
|
+
const log = _openLogFor(sessionId);
|
|
175
|
+
const data = _projectData(prd092Type, event.data);
|
|
176
|
+
let seq;
|
|
177
|
+
try {
|
|
178
|
+
seq = log.writeEvent(prd092Type, data, turnId ? { turnId } : undefined);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
try { process.stderr.write(`[event-tap] writeEvent failed: ${err.message}\n`); } catch {}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (_broadcasters.length === 0) return;
|
|
184
|
+
const wireEvent = {
|
|
185
|
+
seq,
|
|
186
|
+
ts: new Date().toISOString(),
|
|
187
|
+
type: prd092Type,
|
|
188
|
+
session_id: sessionId,
|
|
189
|
+
v: 1,
|
|
190
|
+
...(turnId ? { turn_id: turnId } : {}),
|
|
191
|
+
data,
|
|
192
|
+
};
|
|
193
|
+
for (const fn of _broadcasters) {
|
|
194
|
+
try { fn(wireEvent); }
|
|
195
|
+
catch (err) { try { process.stderr.write(`[event-tap] broadcaster failed: ${err.message}\n`); } catch {} }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* — Input lock state (multi-attach input arbitration).
|
|
3
|
+
*
|
|
4
|
+
* Per PRD §6.5:
|
|
5
|
+
* - The first attach implicitly holds the input lock.
|
|
6
|
+
* - Later attaches join in WATCH mode (may render events, may
|
|
7
|
+
* approve/deny, may NOT send_message / interrupt / switch_model).
|
|
8
|
+
* - `take_input_lock` triggers a STEAL WITH GRACE:
|
|
9
|
+
* 1. Emit input_lock_changed { holder: current, pending_transfer: att_new }
|
|
10
|
+
* 2. Current holder has 3s to release_input_lock gracefully
|
|
11
|
+
* 3. After 3s (or on release) → holder := att_new,
|
|
12
|
+
* emit input_lock_changed { holder: att_new }
|
|
13
|
+
* - Approvals bypass the lock. That's an authorization act, not
|
|
14
|
+
* a typing act; enforced by the socket-server, not this module.
|
|
15
|
+
*
|
|
16
|
+
* State model:
|
|
17
|
+
* holder : attach_id | null currently owns typing input
|
|
18
|
+
* since : ISO-8601 UTC when holder was assigned
|
|
19
|
+
* pending : attach_id | null the challenger waiting on a steal
|
|
20
|
+
* pendingTimer: NodeTimeout | null the 3s grace timer
|
|
21
|
+
*
|
|
22
|
+
* All mutations go through the exported functions so the emitters
|
|
23
|
+
* (repl.mjs wiring) always publish an `input_lock_changed` event.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const GRACE_MS = 3000;
|
|
27
|
+
|
|
28
|
+
const _state = {
|
|
29
|
+
holder: null, // attach_id | null
|
|
30
|
+
since: null, // ISO string
|
|
31
|
+
pending: null, // attach_id | null
|
|
32
|
+
pendingTimer: null, // NodeTimeout | null
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let _emit = null; // (type, data) → void — set by wireEmit()
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wire the event emitter. The daemon (repl.mjs) sets this to a function
|
|
39
|
+
* that writes to the event tap so `input_lock_changed` fans out to all
|
|
40
|
+
* attached clients.
|
|
41
|
+
*/
|
|
42
|
+
export function wireEmit(emitFn) {
|
|
43
|
+
_emit = typeof emitFn === 'function' ? emitFn : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _now() { return new Date().toISOString(); }
|
|
47
|
+
|
|
48
|
+
function _publish(extra = {}) {
|
|
49
|
+
if (!_emit) return;
|
|
50
|
+
try {
|
|
51
|
+
_emit('input_lock_changed', {
|
|
52
|
+
holder: _state.holder,
|
|
53
|
+
since: _state.since,
|
|
54
|
+
pending_transfer: _state.pending,
|
|
55
|
+
...extra,
|
|
56
|
+
});
|
|
57
|
+
} catch { /* never blocks a state change */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── attach lifecycle ─────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* First attach auto-takes the lock. Later attaches join in watch mode.
|
|
64
|
+
* Call from the socket-server hello handler.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} attachId
|
|
67
|
+
* @returns {{holder: string, kind: 'holder'|'watch'}}
|
|
68
|
+
*/
|
|
69
|
+
export function onAttachJoined(attachId) {
|
|
70
|
+
if (_state.holder == null) {
|
|
71
|
+
_state.holder = attachId;
|
|
72
|
+
_state.since = _now();
|
|
73
|
+
_publish();
|
|
74
|
+
return { holder: attachId, kind: 'holder' };
|
|
75
|
+
}
|
|
76
|
+
return { holder: _state.holder, kind: 'watch' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* If the leaving attach was the holder, transfer the lock — to the pending
|
|
81
|
+
* challenger if there is one, else to nobody (holder=null). If it was a
|
|
82
|
+
* watcher, no state change.
|
|
83
|
+
*/
|
|
84
|
+
export function onAttachLeft(attachId) {
|
|
85
|
+
const wasHolder = _state.holder === attachId;
|
|
86
|
+
const wasPending = _state.pending === attachId;
|
|
87
|
+
if (wasPending) _clearPending();
|
|
88
|
+
if (!wasHolder) return;
|
|
89
|
+
if (_state.pending) {
|
|
90
|
+
_state.holder = _state.pending;
|
|
91
|
+
_state.since = _now();
|
|
92
|
+
_clearPending();
|
|
93
|
+
} else {
|
|
94
|
+
_state.holder = null;
|
|
95
|
+
_state.since = null;
|
|
96
|
+
}
|
|
97
|
+
_publish();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── commands ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `take_input_lock` command — steal-with-grace protocol.
|
|
104
|
+
*
|
|
105
|
+
* If the requester is already the holder → no-op (return current state).
|
|
106
|
+
* If there's no holder → immediate takeover.
|
|
107
|
+
* Otherwise → set pending, schedule 3s timer to force-transfer.
|
|
108
|
+
*
|
|
109
|
+
* Returns the state after the request is processed (not after the
|
|
110
|
+
* grace timer fires).
|
|
111
|
+
*/
|
|
112
|
+
export function takeInputLock(attachId) {
|
|
113
|
+
if (_state.holder === attachId) {
|
|
114
|
+
return { holder: attachId, pending: null, immediate: true };
|
|
115
|
+
}
|
|
116
|
+
if (_state.holder == null) {
|
|
117
|
+
_state.holder = attachId;
|
|
118
|
+
_state.since = _now();
|
|
119
|
+
_publish();
|
|
120
|
+
return { holder: attachId, pending: null, immediate: true };
|
|
121
|
+
}
|
|
122
|
+
// Someone else already requested — drop their pending in favor of the
|
|
123
|
+
// newer one (last-writer-wins). Restart the grace timer.
|
|
124
|
+
_clearPending();
|
|
125
|
+
_state.pending = attachId;
|
|
126
|
+
_publish();
|
|
127
|
+
_state.pendingTimer = setTimeout(() => {
|
|
128
|
+
// Grace elapsed → force transfer. Read pending BEFORE _clearPending
|
|
129
|
+
// (which nulls it) so the check + assign happen against the value at
|
|
130
|
+
// schedule time, not the intermediate cleared state.
|
|
131
|
+
if (_state.pending !== attachId) return; // preempted by another take
|
|
132
|
+
_state.holder = attachId;
|
|
133
|
+
_state.since = _now();
|
|
134
|
+
_state.pending = null;
|
|
135
|
+
_state.pendingTimer = null;
|
|
136
|
+
_publish();
|
|
137
|
+
}, GRACE_MS);
|
|
138
|
+
// No .unref(): we want the daemon event loop to be kept alive by a
|
|
139
|
+
// pending grace transfer. In tests, connected sockets already keep
|
|
140
|
+
// the loop alive, so no diff. In prod, this prevents a bahulamd whose
|
|
141
|
+
// ONLY outstanding work is a grace-steal from exiting mid-transfer.
|
|
142
|
+
return { holder: _state.holder, pending: attachId, immediate: false, graceMs: GRACE_MS };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `release_input_lock` command — the current holder yields.
|
|
147
|
+
* If there's a pending challenger, they take the lock immediately.
|
|
148
|
+
* If not, the lock becomes null (next `take_input_lock` from anyone wins).
|
|
149
|
+
*/
|
|
150
|
+
export function releaseInputLock(attachId) {
|
|
151
|
+
if (_state.holder !== attachId) return { ignored: true, reason: 'not_holder' };
|
|
152
|
+
if (_state.pending) {
|
|
153
|
+
_state.holder = _state.pending;
|
|
154
|
+
_state.since = _now();
|
|
155
|
+
_clearPending();
|
|
156
|
+
} else {
|
|
157
|
+
_state.holder = null;
|
|
158
|
+
_state.since = null;
|
|
159
|
+
}
|
|
160
|
+
_publish();
|
|
161
|
+
return { holder: _state.holder };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── query ────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/** Read current lock state. Used by socket-server to enforce writes. */
|
|
167
|
+
export function currentHolder() { return _state.holder; }
|
|
168
|
+
export function isHolder(attachId) { return _state.holder === attachId; }
|
|
169
|
+
|
|
170
|
+
/** For tests and `bahulam status`. */
|
|
171
|
+
export function snapshot() {
|
|
172
|
+
return {
|
|
173
|
+
holder: _state.holder,
|
|
174
|
+
since: _state.since,
|
|
175
|
+
pending: _state.pending,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Reset (tests only, or on daemon shutdown). */
|
|
180
|
+
export function resetInputLock() {
|
|
181
|
+
_clearPending();
|
|
182
|
+
_state.holder = null;
|
|
183
|
+
_state.since = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── internal ─────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function _clearPending() {
|
|
189
|
+
if (_state.pendingTimer) { clearTimeout(_state.pendingTimer); _state.pendingTimer = null; }
|
|
190
|
+
_state.pending = null;
|
|
191
|
+
}
|