@bahulam/code 2.6.1 → 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.
@@ -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); });
@@ -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-code login' });
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-code login` to re-authenticate.\x1b[0m\n');
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.TARANG_PRODUCT || process.env.KEPLER_PRODUCT || 'kepler';
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-code login` to re-authenticate.', fatal: true } };
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-code login first.');
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-code login first.');
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
- const result = await projectRegistry.register(root, { forceRefresh });
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-code "instruction"${R} Execute instruction\n`);
156
- process.stderr.write(` ${C}bahulam-code${R} Interactive mode (REPL)\n`);
157
- process.stderr.write(` ${C}bahulam-code login${R} Authenticate via GitHub OAuth\n`);
158
- process.stderr.write(` ${C}bahulam-code configure${R} Open settings in browser\n`);
159
- process.stderr.write(` ${C}bahulam-code config --show${R} Display local configuration\n`);
160
- process.stderr.write(` ${C}bahulam-code resume${R} Resume a paused session\n`);
161
- process.stderr.write(` ${C}bahulam-code workflow create --file <path>${R} Create workflow from YAML\n`);
162
- process.stderr.write(` ${C}bahulam-code workflow run <name>${R} Run a workflow\n`);
163
- process.stderr.write(` ${C}bahulam-code workflow list${R} List workflows\n`);
164
- process.stderr.write(` ${C}bahulam-code workflow get <name>${R} Show workflow details\n`);
165
- process.stderr.write(` ${C}bahulam-code workflow delete <name>${R} Delete a workflow\n`);
166
- process.stderr.write(` ${C}bahulam-code workflow sync${R} Sync workflow YAML files\n`);
167
- process.stderr.write(` ${C}bahulam-code agent list${R} List user-defined agents\n`);
168
- process.stderr.write(` ${C}bahulam-code agent get <slug>${R} Show agent details\n`);
169
- process.stderr.write(` ${C}bahulam-code agent sync${R} Sync agent YAML files\n`);
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-code configure${R}\n`);
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 || '');