@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.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +39 -0
  3. package/package.json +8 -9
  4. package/pulse/lib/tool-categories.ts +13 -0
  5. package/src/commands/device.mjs +121 -0
  6. package/src/commands/pair.mjs +190 -0
  7. package/src/commands/remote.mjs +110 -0
  8. package/src/config/env.mjs +2 -2
  9. package/src/core/event-log.mjs +393 -0
  10. package/src/core/headless.mjs +198 -0
  11. package/src/core/loop.mjs +276 -0
  12. package/src/core/memory-disk.mjs +210 -0
  13. package/src/core/paths.mjs +36 -0
  14. package/src/core/stream-client.mjs +28 -9
  15. package/src/core/tool-executor.mjs +64 -16
  16. package/src/daemon/approval-store.mjs +253 -0
  17. package/src/daemon/attach-client.mjs +361 -0
  18. package/src/daemon/daemonize.mjs +151 -0
  19. package/src/daemon/event-tap.mjs +197 -0
  20. package/src/daemon/input-lock.mjs +191 -0
  21. package/src/daemon/relay-client.mjs +258 -0
  22. package/src/daemon/session-core.mjs +179 -0
  23. package/src/daemon/session-list.mjs +26 -0
  24. package/src/daemon/session-publisher.mjs +78 -0
  25. package/src/daemon/socket-server.mjs +329 -0
  26. package/src/daemon/stop-daemon.mjs +18 -0
  27. package/src/permissions/checker.mjs +6 -6
  28. package/src/permissions/prompt.mjs +8 -7
  29. package/src/skills/installer.mjs +8 -0
  30. package/src/terminal/ansi.mjs +85 -9
  31. package/src/terminal/main.mjs +97 -3
  32. package/src/terminal/repl.mjs +389 -6
  33. package/src/terminal/skills-picker.mjs +121 -0
  34. package/src/terminal/skills.mjs +3 -3
  35. package/src/tools/analyze-code.mjs +39 -0
  36. package/src/tools/bash.mjs +1 -1
  37. package/src/tools/edit.mjs +18 -18
  38. package/src/tools/git-diff.mjs +34 -0
  39. package/src/tools/git-status.mjs +30 -0
  40. package/src/tools/glob.mjs +5 -2
  41. package/src/tools/grep.mjs +1 -1
  42. package/src/tools/meta-tools.mjs +85 -0
  43. package/src/tools/read-files.mjs +37 -0
  44. package/src/tools/read.mjs +20 -10
  45. package/src/tools/registry.mjs +20 -0
  46. package/src/tools/remember.mjs +147 -0
  47. package/src/tools/search-files.mjs +41 -0
  48. package/src/tools/write-project.mjs +62 -0
  49. package/src/tools/write.mjs +1 -1
  50. package/src/ui/banner.mjs +1 -1
  51. package/src/ui/slash-commands.mjs +16 -0
  52. package/src/ui/sub-agent.mjs +8 -2
  53. 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
+ }