@bahulam/code 2.6.0 → 2.6.2
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/README.md +13 -0
- package/package.json +5 -3
- package/pulse/app/activity/page.tsx +1 -1
- package/pulse/app/globals.css +3 -3
- package/pulse/app/help/page.tsx +10 -10
- package/pulse/app/layout.tsx +2 -2
- package/pulse/app/memory/page.tsx +1 -1
- package/pulse/app/overview-client.tsx +1 -1
- package/pulse/app/page.tsx +2 -2
- package/pulse/app/plans/page.tsx +1 -1
- package/pulse/app/sessions/page.tsx +1 -1
- package/pulse/app/settings/page.tsx +1 -1
- package/pulse/app/tools/page.tsx +1 -1
- package/pulse/cli.js +8 -8
- package/pulse/components/layout/sidebar.tsx +2 -2
- package/pulse/package.json +2 -2
- package/src/agents/workflow_scaffold.mjs +1 -1
- package/src/core/attachments.mjs +287 -1
- package/src/core/backend-url.mjs +18 -5
- package/src/core/bundled-runtime.mjs +348 -0
- package/src/core/headless.mjs +1 -1
- package/src/core/settings-sync.mjs +1 -1
- package/src/core/stream-client.mjs +122 -3
- package/src/core/tool-executor.mjs +10 -3
- package/src/index.mjs +16 -16
- package/src/onboarding/preflight.mjs +14 -0
- package/src/terminal/main.mjs +19 -19
- package/src/terminal/repl-explore.mjs +9 -0
- package/src/terminal/repl.mjs +122 -11
- package/src/terminal/skills.mjs +1 -1
- package/src/tools/project-overview.mjs +66 -4
- package/src/ui/banner.mjs +5 -5
- package/src/ui/input-dock.mjs +76 -18
- package/src/ui/palette.mjs +2 -2
- package/src/ui/tool-card.mjs +23 -3
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled Python runtime adapter (PRD-091 §6.3).
|
|
3
|
+
*
|
|
4
|
+
* The Bahulam Code CLI ships with a bundled Python `agent_framework`
|
|
5
|
+
* runtime — the SAME code the cloud/enterprise backend runs. This module
|
|
6
|
+
* spawns that runtime as a subprocess, waits for READY, and provides a
|
|
7
|
+
* `fetch`-like helper for the rest of the CLI to talk to it over a Unix
|
|
8
|
+
* socket.
|
|
9
|
+
*
|
|
10
|
+
* Contract: the runtime speaks the same HTTP/SSE vocabulary the cloud
|
|
11
|
+
* backend does (POST /api/execute, POST /api/callback/{task_id}, POST
|
|
12
|
+
* /api/intervention/{task_id}, GET /healthz). So stream-client.mjs can
|
|
13
|
+
* swap between "local runtime" and "cloud backend" by changing the
|
|
14
|
+
* `baseUrl` — everything else stays identical.
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle:
|
|
17
|
+
* 1. First call to ensureRuntimeReady() spawns bahulam-agent as a
|
|
18
|
+
* subprocess with a per-session Unix socket path.
|
|
19
|
+
* 2. Waits for /healthz to return {status:'ready'} (up to READY_TIMEOUT_MS).
|
|
20
|
+
* 3. Returns a `socketPath` the caller uses with runtimeFetch().
|
|
21
|
+
* 4. Subsequent calls reuse the warm daemon (no re-spawn cost).
|
|
22
|
+
* 5. Process exit tears down the subprocess cleanly.
|
|
23
|
+
*
|
|
24
|
+
* Fallbacks:
|
|
25
|
+
* - BAHULAM_JS_AGENT=1 → callers should bypass this module entirely
|
|
26
|
+
* and use the interim LocalAgent JS class.
|
|
27
|
+
* - BAHULAM_RUNTIME_ROOT=<path> → override the default install location
|
|
28
|
+
* (~/.bahulam/runtime/current) for dev/CI.
|
|
29
|
+
* - Windows: Unix sockets are not universal; falls back to a random
|
|
30
|
+
* localhost TCP port. The runtime supports --port for this reason.
|
|
31
|
+
*
|
|
32
|
+
* See PRD-090 §4a (Monetization Model) for the three-path routing that
|
|
33
|
+
* lives INSIDE the runtime; this adapter is transport-only.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
37
|
+
import * as os from 'node:os';
|
|
38
|
+
import * as path from 'node:path';
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import { randomBytes } from 'node:crypto';
|
|
41
|
+
import * as net from 'node:net';
|
|
42
|
+
import * as http from 'node:http';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_RUNTIME_ROOT = path.join(os.homedir(), '.bahulam', 'runtime', 'current');
|
|
45
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
46
|
+
const READY_POLL_MS = 100;
|
|
47
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
48
|
+
|
|
49
|
+
// TCP is the default transport so existing fetch-based call sites in
|
|
50
|
+
// stream-client.mjs work unchanged (baseUrl points at http://127.0.0.1:<port>).
|
|
51
|
+
// Unix socket is available via BAHULAM_RUNTIME_TRANSPORT=socket for stricter
|
|
52
|
+
// isolation but requires an undici dispatcher path for every fetch.
|
|
53
|
+
const USE_UNIX_SOCKET = process.env.BAHULAM_RUNTIME_TRANSPORT === 'socket';
|
|
54
|
+
|
|
55
|
+
let _daemon = null; // { proc, socketPath, port, ready }
|
|
56
|
+
|
|
57
|
+
function _runtimeRoot() {
|
|
58
|
+
return process.env.BAHULAM_RUNTIME_ROOT || DEFAULT_RUNTIME_ROOT;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _runtimeBin() {
|
|
62
|
+
return path.join(_runtimeRoot(), 'bin', IS_WINDOWS ? 'bahulam-agent.exe' : 'bahulam-agent');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Read the framework LICENSE_KEY from a config file so shipped end-users
|
|
66
|
+
// don't have to set an env var. Precedence: env var wins if already set,
|
|
67
|
+
// otherwise read ~/.bahulam/license.jwt. Returns null when neither is set.
|
|
68
|
+
function _readLicenseKey() {
|
|
69
|
+
if (process.env.LICENSE_KEY) return process.env.LICENSE_KEY;
|
|
70
|
+
const licensePath = path.join(os.homedir(), '.bahulam', 'license.jwt');
|
|
71
|
+
try {
|
|
72
|
+
return fs.readFileSync(licensePath, 'utf8').trim() || null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Read the saved bahulam CLI token from ~/.bahulam/config.json so the
|
|
79
|
+
// bundled Python runtime authenticates to gateway.bahulam.ai as the
|
|
80
|
+
// logged-in user without the shell having to export BAHULAM_API_KEY.
|
|
81
|
+
// Precedence: shell env wins > saved config. Returns null when neither
|
|
82
|
+
// is present (framework then errors with an "Not logged in" style message).
|
|
83
|
+
function _readCliToken() {
|
|
84
|
+
if (process.env.BAHULAM_API_KEY) return process.env.BAHULAM_API_KEY;
|
|
85
|
+
if (process.env.BAHULAM_CLI_TOKEN) return process.env.BAHULAM_CLI_TOKEN;
|
|
86
|
+
if (process.env.B0_TOKEN) return process.env.B0_TOKEN;
|
|
87
|
+
const configPath = path.join(os.homedir(), '.bahulam', 'config.json');
|
|
88
|
+
try {
|
|
89
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
90
|
+
const parsed = JSON.parse(raw);
|
|
91
|
+
const token = (parsed && typeof parsed.token === 'string') ? parsed.token.trim() : null;
|
|
92
|
+
return token || null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _newSocketPath() {
|
|
99
|
+
const dir = path.join(os.tmpdir(), 'bahulam-code');
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
return path.join(dir, `agent-${randomBytes(4).toString('hex')}.sock`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Remove the framework's license lock + keychain-shadow so activation
|
|
105
|
+
// re-runs fresh on the next import. All calls are best-effort — a missing
|
|
106
|
+
// file is fine, a locked keychain is fine.
|
|
107
|
+
function _clearLicenseActivationState() {
|
|
108
|
+
const paths = [
|
|
109
|
+
path.join(os.homedir(), '.agent_framework', '.license_lock'),
|
|
110
|
+
// macOS shadow file (see agent_framework/_lockfile.py:_shadow_path)
|
|
111
|
+
path.join(os.homedir(), 'Library', 'Caches', '.com.apple.dt.instruments', '.state'),
|
|
112
|
+
];
|
|
113
|
+
for (const p of paths) {
|
|
114
|
+
try { fs.rmSync(p, { force: true }); } catch { /* best-effort */ }
|
|
115
|
+
}
|
|
116
|
+
if (process.platform === 'darwin') {
|
|
117
|
+
try {
|
|
118
|
+
spawnSync('security',
|
|
119
|
+
['delete-generic-password', '-s', 'com.axplusb.agent.runtime', '-a', 'af_state'],
|
|
120
|
+
{ stdio: 'ignore' });
|
|
121
|
+
} catch { /* best-effort */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Health-probe the runtime over Unix socket or TCP.
|
|
127
|
+
* Returns the parsed JSON body or throws on non-2xx / non-JSON.
|
|
128
|
+
*/
|
|
129
|
+
function _probeHealth({ socketPath, port }) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const opts = { method: 'GET', path: '/healthz' };
|
|
132
|
+
if (socketPath) {
|
|
133
|
+
opts.socketPath = socketPath;
|
|
134
|
+
} else {
|
|
135
|
+
opts.host = '127.0.0.1';
|
|
136
|
+
opts.port = port;
|
|
137
|
+
}
|
|
138
|
+
const req = http.request(opts, (res) => {
|
|
139
|
+
let body = '';
|
|
140
|
+
res.setEncoding('utf8');
|
|
141
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
142
|
+
res.on('end', () => {
|
|
143
|
+
if (res.statusCode !== 200) return reject(new Error(`/healthz HTTP ${res.statusCode}`));
|
|
144
|
+
try { resolve(JSON.parse(body)); }
|
|
145
|
+
catch (e) { reject(new Error(`/healthz body not JSON: ${e.message}`)); }
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
req.on('error', reject);
|
|
149
|
+
req.end();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Ensure the bundled runtime is running and healthy.
|
|
155
|
+
* Returns { baseUrl, socketPath, port } for the caller to construct
|
|
156
|
+
* fetch requests against.
|
|
157
|
+
*/
|
|
158
|
+
export async function ensureRuntimeReady() {
|
|
159
|
+
if (_daemon && _daemon.ready) {
|
|
160
|
+
return _describeDaemon(_daemon);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const bin = _runtimeBin();
|
|
164
|
+
if (!fs.existsSync(bin)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Bahulam runtime not found at ${bin}. ` +
|
|
167
|
+
`Re-run \`npm install -g @bahulamai/code\` to fetch the runtime, ` +
|
|
168
|
+
`or set BAHULAM_RUNTIME_ROOT to a valid install path.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Transport: default to TCP on 127.0.0.1 so raw `fetch(baseUrl+path)`
|
|
173
|
+
// in stream-client.mjs works unchanged. Unix socket is available under
|
|
174
|
+
// BAHULAM_RUNTIME_TRANSPORT=socket, but callers must then route every
|
|
175
|
+
// request through runtimeFetch() (which uses an undici socket dispatcher).
|
|
176
|
+
// Windows has no Unix sockets so it's always TCP.
|
|
177
|
+
const useSocket = USE_UNIX_SOCKET && !IS_WINDOWS;
|
|
178
|
+
const socketPath = useSocket ? _newSocketPath() : null;
|
|
179
|
+
const port = useSocket ? null : await _pickFreePort();
|
|
180
|
+
const args = socketPath ? ['--socket', socketPath] : ['--port', String(port)];
|
|
181
|
+
|
|
182
|
+
const licenseKey = _readLicenseKey();
|
|
183
|
+
const cliToken = _readCliToken();
|
|
184
|
+
// Best-effort: clear stale license-activation state so a fresh env_id
|
|
185
|
+
// gets a fresh binding. Without this, restarts from a different socket
|
|
186
|
+
// or a bumped runtime version can trip the framework's activation guard.
|
|
187
|
+
_clearLicenseActivationState();
|
|
188
|
+
const proc = spawn(bin, args, {
|
|
189
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
190
|
+
env: {
|
|
191
|
+
...process.env,
|
|
192
|
+
BAHULAM_CLI_LOCAL: '1',
|
|
193
|
+
// Framework requires LICENSE_KEY at import time. Sourced from
|
|
194
|
+
// ~/.bahulam/license.jwt so end users don't need to export a var.
|
|
195
|
+
...(licenseKey ? { LICENSE_KEY: licenseKey } : {}),
|
|
196
|
+
// Bahulam CLI token from `bahulam-code login` (saved in
|
|
197
|
+
// ~/.bahulam/config.json). BahulamGateway reads it from any of
|
|
198
|
+
// BAHULAM_API_KEY / BAHULAM_CLI_TOKEN / BAHULAM_GATEWAY_API_KEY —
|
|
199
|
+
// we set both so it works whether the framework prefers one over
|
|
200
|
+
// the other. Shell env wins (see _readCliToken precedence).
|
|
201
|
+
...(cliToken ? {
|
|
202
|
+
BAHULAM_API_KEY: cliToken,
|
|
203
|
+
BAHULAM_CLI_TOKEN: cliToken,
|
|
204
|
+
} : {}),
|
|
205
|
+
},
|
|
206
|
+
detached: false,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
_daemon = { proc, socketPath, port, ready: false };
|
|
210
|
+
|
|
211
|
+
proc.on('exit', (code, signal) => {
|
|
212
|
+
if (_daemon && _daemon.proc === proc) {
|
|
213
|
+
_daemon.ready = false;
|
|
214
|
+
_daemon = null;
|
|
215
|
+
}
|
|
216
|
+
if (code !== 0 && code !== null) {
|
|
217
|
+
process.stderr.write(` ! bahulam-agent runtime exited (code ${code}, signal ${signal || 'none'})\n`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Surface stderr from the runtime — helps debug spawn / import errors.
|
|
222
|
+
proc.stderr.on('data', (chunk) => {
|
|
223
|
+
const text = chunk.toString('utf8').trim();
|
|
224
|
+
if (text && process.env.BAHULAM_RUNTIME_DEBUG) {
|
|
225
|
+
process.stderr.write(` [agent] ${text}\n`);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Wait for /healthz to return ready.
|
|
230
|
+
const started = Date.now();
|
|
231
|
+
let lastErr = null;
|
|
232
|
+
while (Date.now() - started < READY_TIMEOUT_MS) {
|
|
233
|
+
if (proc.exitCode !== null) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`bahulam-agent exited before becoming ready (code ${proc.exitCode}). ` +
|
|
236
|
+
`Re-run with BAHULAM_RUNTIME_DEBUG=1 to see runtime stderr.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (socketPath && !fs.existsSync(socketPath)) {
|
|
240
|
+
await _sleep(READY_POLL_MS);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const health = await _probeHealth({ socketPath, port });
|
|
245
|
+
if (health && health.status === 'ready') {
|
|
246
|
+
_daemon.ready = true;
|
|
247
|
+
_daemon.frameworkVersion = health.framework;
|
|
248
|
+
_daemon.runtimeVersion = health.runtime_version;
|
|
249
|
+
return _describeDaemon(_daemon);
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {
|
|
252
|
+
lastErr = e;
|
|
253
|
+
}
|
|
254
|
+
await _sleep(READY_POLL_MS);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Timeout — kill and report.
|
|
258
|
+
try { proc.kill('SIGTERM'); } catch {}
|
|
259
|
+
_daemon = null;
|
|
260
|
+
throw new Error(
|
|
261
|
+
`bahulam-agent did not become ready within ${READY_TIMEOUT_MS / 1000}s ` +
|
|
262
|
+
`(last probe error: ${lastErr ? lastErr.message : 'none'}). ` +
|
|
263
|
+
`Try BAHULAM_RUNTIME_DEBUG=1 for runtime stderr.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function _describeDaemon(d) {
|
|
268
|
+
const baseUrl = d.socketPath ? 'http://localhost' : `http://127.0.0.1:${d.port}`;
|
|
269
|
+
return {
|
|
270
|
+
baseUrl,
|
|
271
|
+
socketPath: d.socketPath,
|
|
272
|
+
port: d.port,
|
|
273
|
+
frameworkVersion: d.frameworkVersion || null,
|
|
274
|
+
runtimeVersion: d.runtimeVersion || null,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Fetch against the running runtime. Same signature as global fetch —
|
|
280
|
+
* pass paths (e.g. '/api/execute') and the adapter routes to the socket
|
|
281
|
+
* or TCP port automatically.
|
|
282
|
+
*/
|
|
283
|
+
export async function runtimeFetch(path, init = {}) {
|
|
284
|
+
const d = await ensureRuntimeReady();
|
|
285
|
+
if (d.socketPath) {
|
|
286
|
+
// Node's built-in fetch (undici) supports Unix sockets via dispatcher.
|
|
287
|
+
// Lazy-import undici so this file works even when the runtime is not
|
|
288
|
+
// installed (e.g., in test environments).
|
|
289
|
+
const { Agent } = await import('undici');
|
|
290
|
+
return fetch(`http://localhost${path}`, {
|
|
291
|
+
...init,
|
|
292
|
+
// @ts-expect-error — undici extension not in the TS DOM lib.
|
|
293
|
+
dispatcher: new Agent({
|
|
294
|
+
connect: { socketPath: d.socketPath },
|
|
295
|
+
}),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return fetch(`${d.baseUrl}${path}`, init);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Cleanly stop the runtime subprocess. Called on process exit. */
|
|
302
|
+
export function shutdownRuntime() {
|
|
303
|
+
if (!_daemon) return;
|
|
304
|
+
const { proc, socketPath } = _daemon;
|
|
305
|
+
_daemon = null;
|
|
306
|
+
try { proc.kill('SIGTERM'); } catch {}
|
|
307
|
+
if (socketPath) {
|
|
308
|
+
try { fs.unlinkSync(socketPath); } catch {}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** True when the bundled runtime binary is present. */
|
|
313
|
+
export function isRuntimeInstalled() {
|
|
314
|
+
return fs.existsSync(_runtimeBin());
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Diagnostic snapshot for `bahulam-code doctor`. */
|
|
318
|
+
export function runtimeInfo() {
|
|
319
|
+
return {
|
|
320
|
+
installed: isRuntimeInstalled(),
|
|
321
|
+
root: _runtimeRoot(),
|
|
322
|
+
bin: _runtimeBin(),
|
|
323
|
+
running: !!(_daemon && _daemon.ready),
|
|
324
|
+
frameworkVersion: _daemon && _daemon.frameworkVersion,
|
|
325
|
+
runtimeVersion: _daemon && _daemon.runtimeVersion,
|
|
326
|
+
transport: _daemon && (_daemon.socketPath ? 'unix' : 'tcp'),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function _sleep(ms) {
|
|
331
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function _pickFreePort() {
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
const srv = net.createServer();
|
|
337
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
338
|
+
const { port } = srv.address();
|
|
339
|
+
srv.close(() => resolve(port));
|
|
340
|
+
});
|
|
341
|
+
srv.on('error', reject);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Clean shutdown on Node process exit.
|
|
346
|
+
process.on('exit', shutdownRuntime);
|
|
347
|
+
process.on('SIGINT', () => { shutdownRuntime(); process.exit(130); });
|
|
348
|
+
process.on('SIGTERM', () => { shutdownRuntime(); process.exit(143); });
|
package/src/core/headless.mjs
CHANGED
|
@@ -47,7 +47,7 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
47
47
|
const auth = new TarangAuth();
|
|
48
48
|
const creds = auth.loadCredentials();
|
|
49
49
|
if (!creds.token) {
|
|
50
|
-
emit({ type: 'error', error: 'Not logged in. Run: bahulam
|
|
50
|
+
emit({ type: 'error', error: 'Not logged in. Run: bahulam login' });
|
|
51
51
|
process.exit(1);
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -29,7 +29,7 @@ export async function fetchRemoteSettings(token) {
|
|
|
29
29
|
|
|
30
30
|
if (!resp.ok) {
|
|
31
31
|
if (resp.status === 401) {
|
|
32
|
-
process.stderr.write('\x1b[33mSettings sync: token expired or invalid. Run `bahulam
|
|
32
|
+
process.stderr.write('\x1b[33mSettings sync: token expired or invalid. Run `bahulam login` to re-authenticate.\x1b[0m\n');
|
|
33
33
|
}
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
@@ -53,8 +53,25 @@ export const EVENT_TYPES = Object.freeze({
|
|
|
53
53
|
APPROVAL_REQUIRED: 'approval_required',
|
|
54
54
|
APPROVAL_GRANTED: 'approval_granted',
|
|
55
55
|
APPROVAL_DENIED: 'approval_denied',
|
|
56
|
+
// Live steering (PRD-081 §5.2)
|
|
57
|
+
USER_INTERVENTION_ACCEPTED: 'user_intervention_accepted',
|
|
58
|
+
USER_INTERVENTION_DELIVERED: 'user_intervention_delivered',
|
|
59
|
+
USER_INTERVENTION_QUEUED: 'user_intervention_queued',
|
|
56
60
|
});
|
|
57
61
|
|
|
62
|
+
// Lightweight UUID-ish generator for intervention ids. Node ≥18 has
|
|
63
|
+
// crypto.randomUUID but the fallback avoids importing node:crypto here.
|
|
64
|
+
function _uuidLike() {
|
|
65
|
+
try {
|
|
66
|
+
// eslint-disable-next-line no-undef
|
|
67
|
+
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
|
|
68
|
+
return globalThis.crypto.randomUUID();
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
const rand = () => Math.random().toString(16).slice(2, 10);
|
|
72
|
+
return `iv-${Date.now().toString(36)}-${rand()}${rand()}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
function sleep(ms) {
|
|
59
76
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
60
77
|
}
|
|
@@ -86,10 +103,13 @@ function isOfflineLikelyError(err) {
|
|
|
86
103
|
export class TarangStreamClient {
|
|
87
104
|
/**
|
|
88
105
|
* @param {Object} opts
|
|
89
|
-
* @param {string} opts.baseUrl - Tarang backend URL
|
|
106
|
+
* @param {string} opts.baseUrl - Tarang backend URL (ignored when mode='bundled')
|
|
90
107
|
* @param {string} opts.token - CLI auth token
|
|
91
108
|
* @param {Object} opts.toolExecutor - { execute(name, args) }
|
|
92
109
|
* @param {boolean} [opts.verbose=false]
|
|
110
|
+
* @param {'remote'|'bundled'} [opts.mode='remote'] - 'bundled' routes all
|
|
111
|
+
* requests through the local bahulam-agent runtime subprocess (PRD-091 §6).
|
|
112
|
+
* 'remote' uses baseUrl and standard fetch (default; matches cloud/dev/prod backend).
|
|
93
113
|
*/
|
|
94
114
|
constructor({
|
|
95
115
|
baseUrl,
|
|
@@ -99,13 +119,14 @@ export class TarangStreamClient {
|
|
|
99
119
|
approvalManager = null,
|
|
100
120
|
product = null,
|
|
101
121
|
reconnectMaxElapsedMs = null,
|
|
122
|
+
mode = null,
|
|
102
123
|
}) {
|
|
103
124
|
this.baseUrl = (baseUrl || '').replace(/\/$/, '');
|
|
104
125
|
this.token = token;
|
|
105
126
|
this.toolExecutor = toolExecutor;
|
|
106
127
|
this.verbose = verbose;
|
|
107
128
|
this.approval = approvalManager || new ApprovalManager();
|
|
108
|
-
this.product = product || process.env.
|
|
129
|
+
this.product = product || process.env.BAHULAM_PRODUCT || process.env.TARANG_PRODUCT || 'bahulam';
|
|
109
130
|
this.currentTaskId = null;
|
|
110
131
|
this.lastEventId = null;
|
|
111
132
|
this.retryDelayMs = null;
|
|
@@ -121,6 +142,32 @@ export class TarangStreamClient {
|
|
|
121
142
|
this._pauseWaiters = new Set();
|
|
122
143
|
this._abort = null;
|
|
123
144
|
this._toolAbort = null;
|
|
145
|
+
|
|
146
|
+
// Transport mode: 'bundled' → local Python runtime (PRD-091 §6), 'remote' → cloud backend.
|
|
147
|
+
// Explicit opt precedence: constructor arg > env var > default 'remote'.
|
|
148
|
+
this.mode = mode
|
|
149
|
+
|| (process.env.BAHULAM_RUNTIME_MODE === 'bundled' ? 'bundled' : null)
|
|
150
|
+
|| (process.env.TARANG_ENV === 'bundled' ? 'bundled' : null)
|
|
151
|
+
|| 'remote';
|
|
152
|
+
// Bundled runtime binds to a random localhost port on first use. Cached
|
|
153
|
+
// here so every method sees the same baseUrl without re-spawning.
|
|
154
|
+
this._bundledReady = false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Ensure the bundled runtime is spawned and this.baseUrl points at it.
|
|
159
|
+
* No-op in remote mode. Callers that hit the backend should invoke this
|
|
160
|
+
* at the top of their method (idempotent, cheap after the first call).
|
|
161
|
+
*/
|
|
162
|
+
async _ensureBundledRuntime() {
|
|
163
|
+
if (this.mode !== 'bundled' || this._bundledReady) return;
|
|
164
|
+
const { ensureRuntimeReady } = await import('./bundled-runtime.mjs');
|
|
165
|
+
const info = await ensureRuntimeReady();
|
|
166
|
+
// Runtime speaks the same HTTP shape as the cloud backend, just on a
|
|
167
|
+
// random localhost port. Overriding baseUrl means every existing
|
|
168
|
+
// ${this.baseUrl}/api/* fetch call keeps working unchanged.
|
|
169
|
+
this.baseUrl = info.baseUrl;
|
|
170
|
+
this._bundledReady = true;
|
|
124
171
|
}
|
|
125
172
|
|
|
126
173
|
_headers(extra = {}) {
|
|
@@ -172,6 +219,11 @@ export class TarangStreamClient {
|
|
|
172
219
|
this._cancelled = false;
|
|
173
220
|
this.currentTaskId = null;
|
|
174
221
|
|
|
222
|
+
// Bundled mode: spawn the local Python runtime on first turn.
|
|
223
|
+
// After this, this.baseUrl points at http://127.0.0.1:<random-port>
|
|
224
|
+
// and every existing fetch call in this class works unchanged.
|
|
225
|
+
await this._ensureBundledRuntime();
|
|
226
|
+
|
|
175
227
|
const url = `${this.baseUrl}/api/execute`;
|
|
176
228
|
const body = { instruction, context };
|
|
177
229
|
if (messages && messages.length > 0) body.messages = messages;
|
|
@@ -204,7 +256,7 @@ export class TarangStreamClient {
|
|
|
204
256
|
}
|
|
205
257
|
|
|
206
258
|
if (response.status === 401) {
|
|
207
|
-
yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `bahulam
|
|
259
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `bahulam login` to re-authenticate.', fatal: true } };
|
|
208
260
|
return;
|
|
209
261
|
}
|
|
210
262
|
if (response.status === 429) {
|
|
@@ -758,6 +810,73 @@ export class TarangStreamClient {
|
|
|
758
810
|
}
|
|
759
811
|
}
|
|
760
812
|
|
|
813
|
+
/**
|
|
814
|
+
* Submit a live-steering follow-up on the current running task (PRD-081 §5.2).
|
|
815
|
+
* Unlike resume(), this does NOT pause/unpause — the text is queued and
|
|
816
|
+
* delivered at the next tool boundary.
|
|
817
|
+
*
|
|
818
|
+
* Returns a status object; callers should NOT swallow errors:
|
|
819
|
+
* { status: 'accepted', interventionId } — queued on backend, SSE ack forthcoming
|
|
820
|
+
* { status: 'queued_next_turn', interventionId } — task already ended; hand back as next turn
|
|
821
|
+
* { status: 'duplicate', interventionId } — same intervention_id previously submitted
|
|
822
|
+
* { status: 'no_task' } — no currentTaskId (nothing to steer)
|
|
823
|
+
* { status: 'error', error, httpStatus? } — network or non-2xx response
|
|
824
|
+
*
|
|
825
|
+
* @param {string} instruction Follow-up text (non-empty, trimmed by caller).
|
|
826
|
+
* @param {object} [opts]
|
|
827
|
+
* @param {string} [opts.idempotencyKey] Optional client-generated id.
|
|
828
|
+
* Auto-generated when omitted; pass the same key
|
|
829
|
+
* for retries to stay idempotent.
|
|
830
|
+
*/
|
|
831
|
+
async sendIntervention(instruction, opts = {}) {
|
|
832
|
+
if (!this.currentTaskId) {
|
|
833
|
+
return { status: 'no_task' };
|
|
834
|
+
}
|
|
835
|
+
const text = String(instruction || '').trim();
|
|
836
|
+
if (!text) {
|
|
837
|
+
return { status: 'error', error: 'instruction is empty' };
|
|
838
|
+
}
|
|
839
|
+
const interventionId = opts.idempotencyKey || _uuidLike();
|
|
840
|
+
try {
|
|
841
|
+
const response = await fetch(
|
|
842
|
+
`${this.baseUrl}/api/intervention/${this.currentTaskId}`,
|
|
843
|
+
{
|
|
844
|
+
method: 'POST',
|
|
845
|
+
headers: this._headers({
|
|
846
|
+
'Content-Type': 'application/json',
|
|
847
|
+
}),
|
|
848
|
+
body: JSON.stringify({
|
|
849
|
+
instruction: text,
|
|
850
|
+
intervention_id: interventionId,
|
|
851
|
+
}),
|
|
852
|
+
},
|
|
853
|
+
);
|
|
854
|
+
if (!response.ok) {
|
|
855
|
+
let errText = '';
|
|
856
|
+
try { errText = (await response.text()).slice(0, 400); } catch {}
|
|
857
|
+
return {
|
|
858
|
+
status: 'error',
|
|
859
|
+
httpStatus: response.status,
|
|
860
|
+
error: errText || `HTTP ${response.status}`,
|
|
861
|
+
interventionId,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
const body = await response.json().catch(() => ({}));
|
|
865
|
+
const backendStatus = body.status || 'accepted';
|
|
866
|
+
const status = body.duplicate ? 'duplicate' : backendStatus;
|
|
867
|
+
return {
|
|
868
|
+
status,
|
|
869
|
+
interventionId: body.intervention_id || interventionId,
|
|
870
|
+
};
|
|
871
|
+
} catch (e) {
|
|
872
|
+
return {
|
|
873
|
+
status: 'error',
|
|
874
|
+
error: (e && e.message) || String(e),
|
|
875
|
+
interventionId,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
761
880
|
async _waitIfPaused() {
|
|
762
881
|
while (this._paused && !this._cancelled) {
|
|
763
882
|
await new Promise(resolve => this._pauseWaiters.add(resolve));
|
|
@@ -1623,7 +1623,7 @@ print('OK: replaced')
|
|
|
1623
1623
|
}
|
|
1624
1624
|
const creds = new TarangAuth().loadCredentials();
|
|
1625
1625
|
if (!creds.backendUrl || !creds.token) {
|
|
1626
|
-
throw new Error('Not logged in. Run bahulam
|
|
1626
|
+
throw new Error('Not logged in. Run bahulam login first.');
|
|
1627
1627
|
}
|
|
1628
1628
|
|
|
1629
1629
|
const headers = {
|
|
@@ -1695,7 +1695,7 @@ print('OK: replaced')
|
|
|
1695
1695
|
}
|
|
1696
1696
|
const creds = new TarangAuth().loadCredentials();
|
|
1697
1697
|
if (!creds.backendUrl || !creds.token) {
|
|
1698
|
-
throw new Error('Not logged in. Run bahulam
|
|
1698
|
+
throw new Error('Not logged in. Run bahulam login first.');
|
|
1699
1699
|
}
|
|
1700
1700
|
|
|
1701
1701
|
const workflowId = await resolveWorkflowId(creds, target);
|
|
@@ -1897,7 +1897,14 @@ print('OK: replaced')
|
|
|
1897
1897
|
if (!root || seen.has(root)) continue;
|
|
1898
1898
|
seen.add(root);
|
|
1899
1899
|
try {
|
|
1900
|
-
|
|
1900
|
+
// CLI-startup roots are declared by the user (via cwd, flag,
|
|
1901
|
+
// or preflight) and should not be second-guessed by the
|
|
1902
|
+
// project-marker guard. That guard exists to stop the AGENT
|
|
1903
|
+
// from calling get_project_overview on non-project paths.
|
|
1904
|
+
const result = await projectRegistry.register(root, {
|
|
1905
|
+
forceRefresh,
|
|
1906
|
+
bypassProjectMarkers: true,
|
|
1907
|
+
});
|
|
1901
1908
|
results.push({ success: true, root: result.resource.root, ...result });
|
|
1902
1909
|
} catch (err) {
|
|
1903
1910
|
results.push({ success: false, root, error: err.message });
|
package/src/index.mjs
CHANGED
|
@@ -152,21 +152,21 @@ function printUsage() {
|
|
|
152
152
|
const B = '\x1b[1m', C = '\x1b[36m', D = '\x1b[2m', G = '\x1b[32m', R = '\x1b[0m';
|
|
153
153
|
|
|
154
154
|
process.stderr.write(`${B}USAGE${R}\n`);
|
|
155
|
-
process.stderr.write(` ${C}bahulam
|
|
156
|
-
process.stderr.write(` ${C}bahulam
|
|
157
|
-
process.stderr.write(` ${C}bahulam
|
|
158
|
-
process.stderr.write(` ${C}bahulam
|
|
159
|
-
process.stderr.write(` ${C}bahulam
|
|
160
|
-
process.stderr.write(` ${C}bahulam
|
|
161
|
-
process.stderr.write(` ${C}bahulam
|
|
162
|
-
process.stderr.write(` ${C}bahulam
|
|
163
|
-
process.stderr.write(` ${C}bahulam
|
|
164
|
-
process.stderr.write(` ${C}bahulam
|
|
165
|
-
process.stderr.write(` ${C}bahulam
|
|
166
|
-
process.stderr.write(` ${C}bahulam
|
|
167
|
-
process.stderr.write(` ${C}bahulam
|
|
168
|
-
process.stderr.write(` ${C}bahulam
|
|
169
|
-
process.stderr.write(` ${C}bahulam
|
|
155
|
+
process.stderr.write(` ${C}bahulam "instruction"${R} Execute instruction\n`);
|
|
156
|
+
process.stderr.write(` ${C}bahulam${R} Interactive mode (REPL)\n`);
|
|
157
|
+
process.stderr.write(` ${C}bahulam login${R} Authenticate via GitHub OAuth\n`);
|
|
158
|
+
process.stderr.write(` ${C}bahulam configure${R} Open settings in browser\n`);
|
|
159
|
+
process.stderr.write(` ${C}bahulam config --show${R} Display local configuration\n`);
|
|
160
|
+
process.stderr.write(` ${C}bahulam resume${R} Resume a paused session\n`);
|
|
161
|
+
process.stderr.write(` ${C}bahulam workflow create --file <path>${R} Create workflow from YAML\n`);
|
|
162
|
+
process.stderr.write(` ${C}bahulam workflow run <name>${R} Run a workflow\n`);
|
|
163
|
+
process.stderr.write(` ${C}bahulam workflow list${R} List workflows\n`);
|
|
164
|
+
process.stderr.write(` ${C}bahulam workflow get <name>${R} Show workflow details\n`);
|
|
165
|
+
process.stderr.write(` ${C}bahulam workflow delete <name>${R} Delete a workflow\n`);
|
|
166
|
+
process.stderr.write(` ${C}bahulam workflow sync${R} Sync workflow YAML files\n`);
|
|
167
|
+
process.stderr.write(` ${C}bahulam agent list${R} List user-defined agents\n`);
|
|
168
|
+
process.stderr.write(` ${C}bahulam agent get <slug>${R} Show agent details\n`);
|
|
169
|
+
process.stderr.write(` ${C}bahulam agent sync${R} Sync agent YAML files\n`);
|
|
170
170
|
process.stderr.write('\n');
|
|
171
171
|
process.stderr.write(`${B}MODE FLAGS${R}\n`);
|
|
172
172
|
process.stderr.write(` ${G}--local${R} Direct LLM API ${D}(<100ms, offline)${R}\n`);
|
|
@@ -177,7 +177,7 @@ function printUsage() {
|
|
|
177
177
|
process.stderr.write(`${B}MODEL FLAGS${R}\n`);
|
|
178
178
|
process.stderr.write(` ${G}--system-prompt <text>${R} Override system prompt\n`);
|
|
179
179
|
process.stderr.write(` ${G}--max-turns <n>${R} Maximum conversation turns\n`);
|
|
180
|
-
process.stderr.write(` ${D}Models are configured via: bahulam
|
|
180
|
+
process.stderr.write(` ${D}Models are configured via: bahulam configure${R}\n`);
|
|
181
181
|
process.stderr.write('\n');
|
|
182
182
|
process.stderr.write(`${B}PERMISSION FLAGS${R}\n`);
|
|
183
183
|
process.stderr.write(` ${G}--yes, -y${R} Auto-approve all operations\n`);
|
|
@@ -42,6 +42,20 @@ export async function checkAuthAndBackend(auth, { timeoutMs } = {}) {
|
|
|
42
42
|
const creds = auth.loadCredentials();
|
|
43
43
|
const hasToken = !!creds.token;
|
|
44
44
|
const url = creds.backendUrl;
|
|
45
|
+
|
|
46
|
+
// Bundled runtime mode: the local Python runtime replaces the cloud
|
|
47
|
+
// backend for agent execution. Probing a remote /api/user/me here would
|
|
48
|
+
// falsely paint the CLI as "Offline" even though the local runtime
|
|
49
|
+
// is fully operational. Report bundled state directly.
|
|
50
|
+
const bundledMode =
|
|
51
|
+
process.env.BAHULAM_RUNTIME_MODE === 'bundled' ||
|
|
52
|
+
process.env.TARANG_ENV === 'bundled';
|
|
53
|
+
if (bundledMode) {
|
|
54
|
+
return hasToken
|
|
55
|
+
? { status: 'ok', label: 'Bundled runtime · authenticated' }
|
|
56
|
+
: { status: 'warn', label: 'Bundled runtime', hint: '/login to enable metered calls' };
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
// Local Docker backends round-trip Supabase and often take 2–4s. Give them
|
|
46
60
|
// more headroom so preflight doesn't falsely report Offline.
|
|
47
61
|
const isLocal = /^https?:\/\/(127\.0\.0\.1|localhost)(:|$|\/)/i.test(url || '');
|