@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.
- package/package.json +4 -7
- 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/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 +56 -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/terminal/ansi.mjs +20 -3
- package/src/terminal/main.mjs +97 -3
- package/src/terminal/repl.mjs +201 -2
- 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/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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
1189
|
+
result = await occRegistry.call('edit_file', {
|
|
1159
1190
|
file_path: filePath,
|
|
1160
|
-
|
|
1161
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
+
}
|