@bahulam/code 0.1.2 → 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 (44) hide show
  1. package/package.json +4 -7
  2. package/pulse/lib/tool-categories.ts +13 -0
  3. package/src/commands/device.mjs +121 -0
  4. package/src/commands/pair.mjs +190 -0
  5. package/src/commands/remote.mjs +110 -0
  6. package/src/core/event-log.mjs +393 -0
  7. package/src/core/headless.mjs +198 -0
  8. package/src/core/loop.mjs +276 -0
  9. package/src/core/memory-disk.mjs +210 -0
  10. package/src/core/paths.mjs +36 -0
  11. package/src/core/stream-client.mjs +28 -9
  12. package/src/core/tool-executor.mjs +56 -16
  13. package/src/daemon/approval-store.mjs +253 -0
  14. package/src/daemon/attach-client.mjs +361 -0
  15. package/src/daemon/daemonize.mjs +151 -0
  16. package/src/daemon/event-tap.mjs +197 -0
  17. package/src/daemon/input-lock.mjs +191 -0
  18. package/src/daemon/relay-client.mjs +258 -0
  19. package/src/daemon/session-core.mjs +179 -0
  20. package/src/daemon/session-list.mjs +26 -0
  21. package/src/daemon/session-publisher.mjs +78 -0
  22. package/src/daemon/socket-server.mjs +329 -0
  23. package/src/daemon/stop-daemon.mjs +18 -0
  24. package/src/permissions/checker.mjs +6 -6
  25. package/src/permissions/prompt.mjs +8 -7
  26. package/src/terminal/ansi.mjs +20 -3
  27. package/src/terminal/main.mjs +97 -3
  28. package/src/terminal/repl.mjs +201 -2
  29. package/src/tools/analyze-code.mjs +39 -0
  30. package/src/tools/bash.mjs +1 -1
  31. package/src/tools/edit.mjs +18 -18
  32. package/src/tools/git-diff.mjs +34 -0
  33. package/src/tools/git-status.mjs +30 -0
  34. package/src/tools/glob.mjs +5 -2
  35. package/src/tools/grep.mjs +1 -1
  36. package/src/tools/meta-tools.mjs +85 -0
  37. package/src/tools/read-files.mjs +37 -0
  38. package/src/tools/read.mjs +20 -10
  39. package/src/tools/registry.mjs +20 -0
  40. package/src/tools/remember.mjs +147 -0
  41. package/src/tools/search-files.mjs +41 -0
  42. package/src/tools/write-project.mjs +62 -0
  43. package/src/tools/write.mjs +1 -1
  44. package/src/ui/sub-agent.mjs +8 -2
@@ -24,9 +24,11 @@ import { sendApprovalDecision, sendCallback } from './callback-client.mjs';
24
24
  import { HookRunner } from '../config/hook-runner.mjs';
25
25
  import { buildFileDiff } from './file-diff.mjs';
26
26
  import { buildWorkScope } from './work-scope.mjs';
27
+ import { loadDiskMemory, ensureBahulamDir, globalMemoryPath, projectMemoryPath } from './memory-disk.mjs';
27
28
  import * as fs from 'node:fs';
28
29
  import * as os from 'node:os';
29
30
  import * as path from 'node:path';
31
+ import * as crypto from 'node:crypto';
30
32
  import { execSync } from 'node:child_process';
31
33
 
32
34
  /**
@@ -43,6 +45,35 @@ export function createToolExecutor({
43
45
  hookRunner = null,
44
46
  interactionHandler = null,
45
47
  } = {}) {
48
+ // Cross-session memory cache. Ships in getAgentContext() on every turn,
49
+ // so we need it to be byte-identical when the underlying disk file hasn't
50
+ // changed — otherwise the backend's prompt cache invalidates on every
51
+ // ExecuteRequest.
52
+ //
53
+ // Strategy: mtime-driven cache. Read the mtimes of the global +
54
+ // project memory files; if unchanged since the last call, return the
55
+ // cached snapshot. The `remember` tool writes to disk directly, which
56
+ // bumps mtime and forces a reload on the next getAgentContext() call.
57
+ //
58
+ // Self-heal ~/.bahulam/ once at construction; loadDiskMemory() also
59
+ // guards it, but doing it here means the first read is a plain fs stat
60
+ // rather than a mkdir round-trip.
61
+ try { ensureBahulamDir('global'); } catch { /* ignore */ }
62
+ let _memoryCache = null; // { key: string, facts: Fact[], digest: string }
63
+ function _readMemorySnapshot() {
64
+ const gPath = globalMemoryPath();
65
+ const pPath = projectMemoryPath(process.cwd());
66
+ const gStat = fs.existsSync(gPath) ? fs.statSync(gPath).mtimeMs : 0;
67
+ const pStat = fs.existsSync(pPath) ? fs.statSync(pPath).mtimeMs : 0;
68
+ const key = `${gStat}|${pStat}|${process.cwd()}`;
69
+ if (_memoryCache && _memoryCache.key === key) return _memoryCache;
70
+ const facts = loadDiskMemory(process.cwd());
71
+ const digest = crypto.createHash('sha256')
72
+ .update(JSON.stringify(facts.map(f => [f.fact_id, f.content, f.updated_at])))
73
+ .digest('hex').slice(0, 16);
74
+ _memoryCache = { key, facts, digest };
75
+ return _memoryCache;
76
+ }
46
77
  const occRegistry = createToolRegistry();
47
78
  const skillTool = occRegistry.get('Skill');
48
79
  if (skillTool) skillTool._skillsLoader = skillsLoader;
@@ -898,7 +929,7 @@ export function createToolExecutor({
898
929
 
899
930
  const observationTimeout = args.timeout == null && isLikelyLongRunningCommand(args.command);
900
931
  const effectiveTimeout = observationTimeout ? longRunningObservationTimeoutMs() : args.timeout;
901
- const result = await occRegistry.call('Bash', {
932
+ const result = await occRegistry.call('shell', {
902
933
  command: args.command,
903
934
  timeout: effectiveTimeout,
904
935
  description: args.description || `Run: ${(args.command || '').slice(0, 50)}`,
@@ -985,7 +1016,7 @@ export function createToolExecutor({
985
1016
  } catch { /* let Read handle the error */ }
986
1017
  }
987
1018
 
988
- const result = await occRegistry.call('Read', {
1019
+ const result = await occRegistry.call('read_file', {
989
1020
  file_path: filePath,
990
1021
  offset,
991
1022
  limit,
@@ -1027,14 +1058,14 @@ export function createToolExecutor({
1027
1058
  // OCC Write requires Read first for existing files — handle gracefully
1028
1059
  try {
1029
1060
  if (fs.existsSync(filePath)) {
1030
- await occRegistry.call('Read', { file_path: filePath, limit: 1 });
1061
+ await occRegistry.call('read_file', { file_path: filePath, limit: 1 });
1031
1062
  }
1032
1063
  } catch { /* file may not exist yet */ }
1033
1064
  // Checkpoint before overwrite so /undo can restore the previous content.
1034
1065
  if (checkpoints && fs.existsSync(filePath)) {
1035
1066
  try { checkpoints.save(filePath); } catch { /* best effort */ }
1036
1067
  }
1037
- const result = await occRegistry.call('Write', {
1068
+ const result = await occRegistry.call('write_file', {
1038
1069
  file_path: filePath,
1039
1070
  content: args.content,
1040
1071
  });
@@ -1092,11 +1123,11 @@ export function createToolExecutor({
1092
1123
  // Read first if exists (OCC Write requirement)
1093
1124
  try {
1094
1125
  if (fs.existsSync(filePath)) {
1095
- await occRegistry.call('Read', { file_path: filePath, limit: 1 });
1126
+ await occRegistry.call('read_file', { file_path: filePath, limit: 1 });
1096
1127
  }
1097
1128
  } catch { /* file may not exist yet */ }
1098
1129
 
1099
- await occRegistry.call('Write', { file_path: filePath, content });
1130
+ await occRegistry.call('write_file', { file_path: filePath, content });
1100
1131
  const after = readTextIfExists(filePath);
1101
1132
  diffs.push(buildResultFileDiff(filePath, before, after));
1102
1133
  updateProjectIndex(filePath);
@@ -1145,7 +1176,7 @@ export function createToolExecutor({
1145
1176
  }
1146
1177
  // OCC Edit requires Read first
1147
1178
  try {
1148
- await occRegistry.call('Read', { file_path: filePath, limit: 1 });
1179
+ await occRegistry.call('read_file', { file_path: filePath, limit: 1 });
1149
1180
  } catch { /* best effort */ }
1150
1181
 
1151
1182
  // Checkpoint before edit so /undo can restore the previous content.
@@ -1155,10 +1186,10 @@ export function createToolExecutor({
1155
1186
 
1156
1187
  let result;
1157
1188
  try {
1158
- result = await occRegistry.call('Edit', {
1189
+ result = await occRegistry.call('edit_file', {
1159
1190
  file_path: filePath,
1160
- old_string: args.search,
1161
- new_string: args.replace,
1191
+ search: args.search,
1192
+ replace: args.replace,
1162
1193
  replace_all: args.replace_all || false,
1163
1194
  });
1164
1195
  } catch (editErr) {
@@ -1239,7 +1270,7 @@ print('OK: replaced')
1239
1270
  _format: 'tree',
1240
1271
  };
1241
1272
  }
1242
- const result = await occRegistry.call('Glob', {
1273
+ const result = await occRegistry.call('list_files', {
1243
1274
  pattern: args.pattern || '**/*',
1244
1275
  path: searchPath,
1245
1276
  });
@@ -1343,7 +1374,7 @@ print('OK: replaced')
1343
1374
  { query, path: searchPath, mode: 'glob' },
1344
1375
  { generation: _readOnlyCacheGeneration },
1345
1376
  async () => {
1346
- const result = await occRegistry.call('Glob', {
1377
+ const result = await occRegistry.call('list_files', {
1347
1378
  pattern: query,
1348
1379
  path: searchPath,
1349
1380
  });
@@ -1364,7 +1395,7 @@ print('OK: replaced')
1364
1395
  { query, path: searchPath, mode: 'grep' },
1365
1396
  { generation: _readOnlyCacheGeneration },
1366
1397
  async () => {
1367
- const result = await occRegistry.call('Grep', {
1398
+ const result = await occRegistry.call('search_code', {
1368
1399
  pattern: query,
1369
1400
  path: searchPath,
1370
1401
  output_mode: 'content',
@@ -1521,7 +1552,7 @@ print('OK: replaced')
1521
1552
  else if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) cmd = 'cargo build';
1522
1553
  else return { success: false, output: 'No build system detected', _tool: 'validate_build' };
1523
1554
  }
1524
- const output = await occRegistry.call('Bash', {
1555
+ const output = await occRegistry.call('shell', {
1525
1556
  command: cmd,
1526
1557
  timeout: Math.min(args.timeout || 120_000, 600_000),
1527
1558
  description: `Validate build: ${cmd.slice(0, 80)}`,
@@ -1571,7 +1602,7 @@ print('OK: replaced')
1571
1602
  else if (['.js', '.mjs', '.ts', '.tsx'].includes(ext)) cmd = `npx eslint "${filePath}" 2>&1 || true`;
1572
1603
  else return { success: true, issues: [], message: 'No linter for this file type', _tool: 'lint_check' };
1573
1604
 
1574
- const output = await occRegistry.call('Bash', {
1605
+ const output = await occRegistry.call('shell', {
1575
1606
  command: cmd,
1576
1607
  timeout: 30_000,
1577
1608
  description: `Lint: ${path.basename(filePath)}`,
@@ -1595,7 +1626,7 @@ print('OK: replaced')
1595
1626
  throwIfAborted(options.signal);
1596
1627
  const cmd = args.command || 'npm test';
1597
1628
  const cwd = await commandCwd(args);
1598
- const output = await occRegistry.call('Bash', {
1629
+ const output = await occRegistry.call('shell', {
1599
1630
  command: cmd,
1600
1631
  timeout: Math.min(args.timeout || 120_000, 600_000),
1601
1632
  description: `Run tests: ${cmd.slice(0, 80)}`,
@@ -2197,10 +2228,19 @@ print('OK: replaced')
2197
2228
 
2198
2229
  getAgentContext() {
2199
2230
  const global = projectRegistry.getGlobalContext();
2231
+ const mem = _readMemorySnapshot();
2200
2232
  return {
2201
2233
  identity: global.identity,
2202
2234
  preferences: global.preferences,
2203
2235
  global_skills: skillsLoader.list(),
2236
+ // Cross-session memory read from disk (CLI-only source of truth).
2237
+ // Backend prefers this over the Supabase agent_memory table when
2238
+ // ctx.agent_ctx.source === 'cli'. `memory_digest` is a stable
2239
+ // sha256 prefix so the backend can hash-compare without
2240
+ // re-serializing — helps keep the prompt cacheable when memory
2241
+ // hasn't changed between turns.
2242
+ memory_facts: mem.facts,
2243
+ memory_digest: mem.digest,
2204
2244
  available_agents: listLocalAgents(process.cwd()).map(agent => ({
2205
2245
  slug: agent.slug,
2206
2246
  name: agent.name,
@@ -0,0 +1,253 @@
1
+ /**
2
+ * — Pending-approval registry + timeout policy.
3
+ *
4
+ * Bridges the two paths that can answer an approval request:
5
+ *
6
+ * 1. Local TTY — the existing approval.mjs prompt (arrow keys, dock).
7
+ * 2. Remote — a socket attach client (or later, mobile via relay)
8
+ * sending an `approve` / `deny` command with an apr_id.
9
+ *
10
+ * Both race the same promise. Whichever resolves first wins; the loser's
11
+ * resolver becomes a no-op. Cleanup on either resolution.
12
+ *
13
+ * Also owns the timeout policy from PRD §6.7:
14
+ * - `hold` : never times out (default; safe for attended sessions)
15
+ * - `deny <sec>` : auto-deny after N seconds if nobody has answered
16
+ * - `allow <sec>` : auto-approve — gated behind the existing
17
+ * `--dangerously-skip-permissions` opt-in, checked
18
+ * by the caller (this module doesn't enforce it).
19
+ *
20
+ * NOT in this module:
21
+ * - The dispatch that turns a `check()` call into an `approval_required`
22
+ * event. That's an intercept wrapper wired at the ApprovalManager
23
+ * call sites (see attach-approval-bridge below).
24
+ * - The socket-server side — `socket-server.mjs` already parses
25
+ * `approve`/`deny` commands; the daemon wiring passes them here.
26
+ */
27
+
28
+ import { randomBytes } from 'node:crypto';
29
+
30
+ /** Approvals we're waiting on. Key: apr_id → { resolve, sourceType, timer } */
31
+ const _pending = new Map();
32
+
33
+ /** Default timeout policy: never expires. Change via setTimeoutPolicy(). */
34
+ let _policy = { mode: 'hold', durationMs: 0 };
35
+
36
+ // ── policy ────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Set the session-level timeout policy for pending approvals.
40
+ * @param {{mode: 'hold'|'deny'|'allow', durationSec?: number}} p
41
+ */
42
+ export function setTimeoutPolicy(p) {
43
+ const mode = p?.mode || 'hold';
44
+ const durationMs = Math.max(0, Number(p?.durationSec || 0)) * 1000;
45
+ _policy = { mode, durationMs };
46
+ return { mode, durationMs };
47
+ }
48
+
49
+ export function getTimeoutPolicy() {
50
+ return { ..._policy };
51
+ }
52
+
53
+ // ── registry ──────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Register a new pending approval. Returns { apr_id, race } where
57
+ *
58
+ * race — a Promise<{decision, decided_by, note}> that resolves
59
+ * when ANY source (local TTY, socket, timeout) answers.
60
+ * cancel(id) — called after another source has already resolved to
61
+ * mark this pending entry as consumed and clean up the
62
+ * timer. Idempotent.
63
+ *
64
+ * The caller (typically the approval manager intercept) also races this
65
+ * against the local TTY prompt. When the TTY prompt resolves, the caller
66
+ * should call `cancel(apr_id)` so a late socket `approve` becomes a
67
+ * no-op instead of getting an "unknown apr_id" error.
68
+ *
69
+ * @param {object} meta
70
+ * @param {string} meta.kind tool name / classifier tag
71
+ * @param {string} meta.subject human-readable one-liner
72
+ * @param {number} [meta.expiresAtMs] caller-provided override; otherwise
73
+ * we compute from the active policy.
74
+ */
75
+ export function registerPending(meta = {}) {
76
+ const apr_id = `apr_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`;
77
+ let resolve;
78
+ const race = new Promise(r => { resolve = r; });
79
+
80
+ const entry = {
81
+ apr_id,
82
+ kind: meta.kind || 'unknown',
83
+ subject: meta.subject || '',
84
+ resolve,
85
+ consumed: false,
86
+ timer: null,
87
+ };
88
+ _pending.set(apr_id, entry);
89
+
90
+ // Wire policy timeout — only if a duration was set AND a mode that
91
+ // implies an automatic decision. `hold` never schedules a timer.
92
+ const policy = getTimeoutPolicy();
93
+ if (policy.mode !== 'hold' && policy.durationMs > 0) {
94
+ entry.timer = setTimeout(() => {
95
+ if (entry.consumed) return;
96
+ entry.consumed = true;
97
+ _pending.delete(apr_id);
98
+ resolve({
99
+ decision: policy.mode === 'allow' ? 'approve' : 'deny',
100
+ decided_by: 'timeout',
101
+ note: `timeout:${policy.mode}:${policy.durationMs}ms`,
102
+ });
103
+ }, policy.durationMs);
104
+ if (typeof entry.timer.unref === 'function') entry.timer.unref();
105
+ }
106
+
107
+ return {
108
+ apr_id,
109
+ race,
110
+ cancel() {
111
+ if (entry.consumed) return;
112
+ entry.consumed = true;
113
+ _pending.delete(apr_id);
114
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
115
+ },
116
+ expiresAt: policy.mode !== 'hold' && policy.durationMs > 0
117
+ ? new Date(Date.now() + policy.durationMs).toISOString()
118
+ : null,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Called by the socket server's approve/deny handlers. Resolves the
124
+ * pending approval with the given decision. Returns true if the
125
+ * apr_id existed and was resolved; false if unknown or already consumed.
126
+ *
127
+ * @param {'approve'|'deny'} decision
128
+ * @param {string} apr_id
129
+ * @param {string} decided_by attach_id of the answering client
130
+ * @param {string} [note]
131
+ */
132
+ export function resolvePending(decision, apr_id, decided_by, note = '') {
133
+ const entry = _pending.get(apr_id);
134
+ if (!entry || entry.consumed) return false;
135
+ entry.consumed = true;
136
+ _pending.delete(apr_id);
137
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
138
+ entry.resolve({ decision, decided_by, note });
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Introspection — used by the socket server's `attach_joined` replay
144
+ * ( follow-up) and by `bahulam status` to show pending approvals.
145
+ */
146
+ export function listPending() {
147
+ return Array.from(_pending.values(), e => ({
148
+ apr_id: e.apr_id,
149
+ kind: e.kind,
150
+ subject: e.subject,
151
+ }));
152
+ }
153
+
154
+ /**
155
+ * Clean up all pending approvals (session end, daemon shutdown). Every
156
+ * pending entry gets a synthetic `deny` decision so no `check()` call
157
+ * hangs forever.
158
+ */
159
+ export function shutdownAllPending(reason = 'shutdown') {
160
+ for (const entry of Array.from(_pending.values())) {
161
+ if (entry.consumed) continue;
162
+ entry.consumed = true;
163
+ if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; }
164
+ entry.resolve({ decision: 'deny', decided_by: 'shutdown', note: reason });
165
+ }
166
+ _pending.clear();
167
+ }
168
+
169
+ // ── intercept helper for ApprovalManager ─────────────────────────────
170
+
171
+ /**
172
+ * Wrap an ApprovalManager.check() call so that:
173
+ * (a) an approval_required event is emitted (broadcast to attaches),
174
+ * (b) a socket approve/deny can resolve the check before the TTY does.
175
+ *
176
+ * Usage in repl.mjs ( wiring):
177
+ *
178
+ * const orig = approval.check.bind(approval);
179
+ * approval.check = (tool, args, req, ctx) =>
180
+ * interceptApproval(orig, { tool, args, req, ctx, sessionId, emit });
181
+ *
182
+ * `emit(event)` is the caller's hook that writes the approval_required
183
+ * event to the daemon event log (tap) so it also fans out to sockets.
184
+ * We do the emission here rather than inside registerPending so the
185
+ * store stays transport-agnostic.
186
+ */
187
+ export async function interceptApproval(origCheck, { tool, args, req, ctx, sessionId, emit } = {}) {
188
+ const pending = registerPending({ kind: tool, subject: _subjectFromArgs(tool, args) });
189
+ const eventData = {
190
+ apr_id: pending.apr_id,
191
+ kind: tool,
192
+ subject: _subjectFromArgs(tool, args),
193
+ expires_at: pending.expiresAt,
194
+ };
195
+ if (typeof emit === 'function') {
196
+ try { emit('approval_required', eventData); } catch { /* never blocks approval */ }
197
+ }
198
+
199
+ // Race the local TTY prompt against the remote resolution promise.
200
+ // Whichever resolves first wins; cancel the other.
201
+ const local = origCheck(tool, args, req, ctx).then(v => ({ __src: 'local', v }));
202
+ const remote = pending.race.then(v => ({ __src: 'remote', v }));
203
+
204
+ const first = await Promise.race([local, remote]);
205
+ pending.cancel();
206
+
207
+ if (first.__src === 'local') {
208
+ // TTY already answered; nothing else to do. Emit approval_decided so
209
+ // remote watchers see the outcome.
210
+ if (typeof emit === 'function') {
211
+ try {
212
+ emit('approval_decided', {
213
+ apr_id: pending.apr_id,
214
+ decision: first.v?.approved ? 'approve' : 'deny',
215
+ decided_by: 'local_tty',
216
+ });
217
+ } catch { /* ignore */ }
218
+ }
219
+ return first.v;
220
+ }
221
+
222
+ // Remote answered. Build the same shape ApprovalManager.check() returns
223
+ // so the caller (stream-client's tool_request handler) doesn't need to
224
+ // care where the decision came from.
225
+ if (typeof emit === 'function') {
226
+ try {
227
+ emit('approval_decided', {
228
+ apr_id: pending.apr_id,
229
+ decision: first.v.decision,
230
+ decided_by: first.v.decided_by,
231
+ note: first.v.note,
232
+ });
233
+ } catch { /* ignore */ }
234
+ }
235
+ return {
236
+ approved: first.v.decision === 'approve',
237
+ tier: 'destructive', // remote answers always treated as an explicit tier decision
238
+ reason: first.v.note || `Decided remotely by ${first.v.decided_by}`,
239
+ remoteDecision: true,
240
+ };
241
+ }
242
+
243
+ // ── helpers ──────────────────────────────────────────────────────────
244
+
245
+ function _subjectFromArgs(tool, args) {
246
+ if (!args || typeof args !== 'object') return tool;
247
+ // Best-effort short summary of the most common tool args.
248
+ if (typeof args.command === 'string') return `${tool}: ${args.command.slice(0, 120)}`;
249
+ if (typeof args.cmd === 'string') return `${tool}: ${args.cmd.slice(0, 120)}`;
250
+ if (typeof args.path === 'string') return `${tool}: ${args.path}`;
251
+ if (typeof args.file_path === 'string') return `${tool}: ${args.file_path}`;
252
+ return tool;
253
+ }