@bahulam/code 0.1.11 → 0.1.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bahulam/code",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Bahulam Code — abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Bahulam Code Authentication — GitHub OAuth + config management.
3
+ * Reads/writes ~/.bahulam/config.json (fallback: ~/.kepler/config.json for
4
+ * legacy installs — see src/core/paths.mjs for the resolver).
5
+ */
6
+
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import * as http from 'node:http';
10
+ import { getLoginSuccessHTML } from '../ui/banner.mjs';
11
+ import { resolveBackendUrl } from '../core/backend-url.mjs';
12
+ import { bahulamHome } from '../core/paths.mjs';
13
+
14
+ // Note: computed via a function (not a constant) so that BAHULAM_HOME /
15
+ // KEPLER_HOME env-var swaps mid-process still work.
16
+ function configDir() { return bahulamHome(); }
17
+ function configPath() { return path.join(configDir(), 'config.json'); }
18
+
19
+ // Legacy exports kept for backwards compat with any tests/scripts that
20
+ // still import CONFIG_DIR / CONFIG_PATH by name.
21
+ const CONFIG_DIR = configDir();
22
+ const CONFIG_PATH = configPath();
23
+
24
+ let _tokenEnvNoticeShown = false;
25
+ function readTokenFromEnv() {
26
+ if (process.env.B0_TOKEN) return process.env.B0_TOKEN;
27
+ if (process.env.KEPLER_TOKEN) {
28
+ if (!_tokenEnvNoticeShown && process.env.B0_QUIET_MIGRATION !== '1') {
29
+ _tokenEnvNoticeShown = true;
30
+ try {
31
+ process.stderr.write(
32
+ ' \x1b[2mnote: KEPLER_TOKEN is deprecated; set B0_TOKEN instead.\x1b[0m\n'
33
+ );
34
+ } catch {}
35
+ }
36
+ return process.env.KEPLER_TOKEN;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ export class TarangAuth {
42
+ constructor() {
43
+ this._config = null;
44
+ }
45
+
46
+ /** Ensure ~/.bahulam/ (or legacy ~/.kepler/) exists with secure permissions. */
47
+ _ensureConfigDir() {
48
+ const dir = configDir();
49
+ if (!fs.existsSync(dir)) {
50
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
51
+ }
52
+ }
53
+
54
+ /** Load credentials and settings from config.json. */
55
+ loadCredentials() {
56
+ const cfgPath = configPath();
57
+ try {
58
+ if (fs.existsSync(cfgPath)) {
59
+ const raw = fs.readFileSync(cfgPath, 'utf-8');
60
+ this._config = JSON.parse(raw);
61
+ } else {
62
+ this._config = {};
63
+ }
64
+ } catch {
65
+ this._config = {};
66
+ }
67
+ return {
68
+ token: readTokenFromEnv() || this._config.token || null,
69
+ openRouterKey: this._config.openrouter_key || process.env.OPENROUTER_API_KEY || null,
70
+ anthropicKey: this._config.anthropic_api_key || process.env.ANTHROPIC_API_KEY || null,
71
+ openaiKey: this._config.openai_api_key || process.env.OPENAI_API_KEY || null,
72
+ googleKey: this._config.google_api_key || process.env.GOOGLE_API_KEY || null,
73
+ backendUrl: resolveBackendUrl(),
74
+ mode: this._config.mode || 'auto',
75
+ gatewayType: this._config.gateway_type || 'openrouter',
76
+ models: this._config.models || {},
77
+ configuredProviders: this._config.configured_providers || [],
78
+ gatewayConfig: this._config.gateway_config || {},
79
+ // PRD-076 W7: persisted /model picks. `modelConfig` mirrors the
80
+ // Python-side key read_local_model_config() looks for; keep the
81
+ // snake_case JSON key so the runtime picks it up unchanged.
82
+ modelConfig: this._config.model_config || {},
83
+ modelMode: this._config.model_mode || null,
84
+ routePreference: this._config.route_preference || null,
85
+ };
86
+ }
87
+
88
+ /** Get the raw config object. */
89
+ getRawConfig() {
90
+ if (!this._config) this.loadCredentials();
91
+ return this._config || {};
92
+ }
93
+
94
+ /** Clear credentials — remove token and keys from config. */
95
+ logout() {
96
+ try {
97
+ const cfgPath = configPath();
98
+ if (fs.existsSync(cfgPath)) {
99
+ fs.unlinkSync(cfgPath);
100
+ }
101
+ this._config = null;
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /** Save credentials atomically (temp-file + rename). */
109
+ saveCredentials(updates) {
110
+ this._ensureConfigDir();
111
+ const current = this._config || {};
112
+ const merged = { ...current, ...updates };
113
+ const cfgPath = configPath();
114
+ const tmpPath = `${cfgPath}.tmp.${process.pid}`;
115
+ fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2), { mode: 0o600 });
116
+ fs.renameSync(tmpPath, cfgPath);
117
+ this._config = merged;
118
+ }
119
+
120
+ /** Check if user has a valid auth token. */
121
+ isAuthenticated() {
122
+ const creds = this.loadCredentials();
123
+ return !!creds.token;
124
+ }
125
+
126
+ /** Check if OpenRouter key is configured. */
127
+ hasOpenRouterKey() {
128
+ const creds = this.loadCredentials();
129
+ return !!creds.openRouterKey;
130
+ }
131
+
132
+ /** Save an API key by provider name. */
133
+ saveProviderKey(provider, key) {
134
+ const keyMap = {
135
+ openrouter: 'openrouter_key',
136
+ anthropic: 'anthropic_api_key',
137
+ openai: 'openai_api_key',
138
+ googleai: 'google_api_key',
139
+ azureopenai: 'azure_api_key',
140
+ bedrock: 'aws_access_key',
141
+ databricks: 'databricks_token',
142
+ };
143
+ const field = keyMap[provider];
144
+ if (!field) throw new Error(`Unknown provider: ${provider}`);
145
+ this.saveCredentials({ [field]: key });
146
+ }
147
+
148
+ /** Save OpenRouter API key. */
149
+ saveOpenRouterKey(key) {
150
+ this.saveProviderKey('openrouter', key);
151
+ }
152
+
153
+ /** Save Anthropic API key. */
154
+ saveAnthropicKey(key) {
155
+ this.saveProviderKey('anthropic', key);
156
+ }
157
+
158
+ /** Save OpenAI API key. */
159
+ saveOpenAIKey(key) {
160
+ this.saveProviderKey('openai', key);
161
+ }
162
+
163
+ /** Save Google AI API key. */
164
+ saveGoogleKey(key) {
165
+ this.saveProviderKey('googleai', key);
166
+ }
167
+
168
+ /** Sync settings from web backend and save locally. */
169
+ async syncSettings() {
170
+ const { fetchRemoteSettings, mergeRemoteSettings } = await import('../core/settings-sync.mjs');
171
+ const creds = this.loadCredentials();
172
+ if (!creds.token) throw new Error('Not logged in. Run `bahulam-code login` first.');
173
+
174
+ const remote = await fetchRemoteSettings(creds.token);
175
+ if (!remote) throw new Error('Failed to fetch settings from server.');
176
+
177
+ const merged = mergeRemoteSettings(this.getRawConfig(), remote);
178
+ this.saveCredentials(merged);
179
+ return remote;
180
+ }
181
+
182
+ /** Set default mode. */
183
+ setMode(mode) {
184
+ const valid = ['local', 'remote', 'auto'];
185
+ if (!valid.includes(mode)) {
186
+ throw new Error(`Invalid mode: ${mode}. Must be one of: ${valid.join(', ')}`);
187
+ }
188
+ this.saveCredentials({ mode });
189
+ }
190
+
191
+ /** Display config (styled). */
192
+ printConfig() {
193
+ const creds = this.loadCredentials();
194
+ const GREEN = '\x1b[32m', RED = '\x1b[31m', DIM = '\x1b[2m', BOLD = '\x1b[1m', CYAN = '\x1b[36m', RESET = '\x1b[0m';
195
+ const check = `${GREEN}\u2713${RESET}`;
196
+ const cross = `${RED}\u2717${RESET}`;
197
+
198
+ const env = process.env.TARANG_ENV || process.env.NODE_ENV || 'production';
199
+
200
+ process.stderr.write(`\n${BOLD}Bahulam Code Configuration${RESET}\n`);
201
+ process.stderr.write(`${'─'.repeat(50)}\n`);
202
+ process.stderr.write(` Auth: ${creds.token ? `${check} logged in` : `${cross} not logged in ${DIM}(/login)${RESET}`}\n`);
203
+ process.stderr.write(` Environment: ${DIM}${env}${RESET}\n`);
204
+ process.stderr.write(` Backend: ${DIM}${creds.backendUrl}${RESET}\n`);
205
+ process.stderr.write(` Mode: ${DIM}${creds.mode || 'auto'}${RESET}\n`);
206
+ process.stderr.write(` Gateway: ${DIM}${creds.gatewayType}${RESET}\n`);
207
+
208
+ const models = creds.models || {};
209
+ const planningModel = models.planning || models.orchestrator;
210
+ if (planningModel || models.reasoning || models.local) {
211
+ process.stderr.write(`\n${BOLD} Models${RESET}\n`);
212
+ if (planningModel) process.stderr.write(` Planning: ${DIM}${planningModel}${RESET}\n`);
213
+ if (models.reasoning) process.stderr.write(` Coding: ${DIM}${models.reasoning}${RESET}\n`);
214
+ if (models.local) process.stderr.write(` Local: ${DIM}${models.local}${RESET}\n`);
215
+ }
216
+
217
+ const providers = creds.configuredProviders || [];
218
+ if (providers.length > 0) {
219
+ process.stderr.write(` Providers: ${DIM}${providers.join(', ')}${RESET}\n`);
220
+ }
221
+
222
+ const raw = this.getRawConfig();
223
+ if (raw.last_synced_at) {
224
+ process.stderr.write(` Last synced: ${DIM}${new Date(raw.last_synced_at).toLocaleString()}${RESET}\n`);
225
+ }
226
+
227
+ process.stderr.write(`\n ${DIM}Run ${RESET}${CYAN}bahulam-code sync${RESET}${DIM} to sync settings from web.${RESET}\n`);
228
+ process.stderr.write(` ${DIM}Run ${RESET}${CYAN}bahulam-code configure${RESET}${DIM} to open settings in browser.${RESET}\n`);
229
+ process.stderr.write('\n');
230
+ }
231
+
232
+ /**
233
+ * Run login flow via web app.
234
+ *
235
+ * Flow:
236
+ * 1. CLI starts local HTTP server on random port
237
+ * 2. Opens browser to web /auth/cli?callback=http://127.0.0.1:{port}/callback
238
+ * 3. Web checks Supabase session (if none → GitHub OAuth → Supabase)
239
+ * 4. Web generates CLI token via /api/cli/token
240
+ * 5. Web redirects browser to CLI callback with token
241
+ * 6. CLI receives token, saves to ~/.bahulam/config.json
242
+ */
243
+ async login() {
244
+ const { resolveWebUrl } = await import('../core/backend-url.mjs');
245
+ const webUrl = resolveWebUrl();
246
+
247
+ return new Promise((resolve, reject) => {
248
+ const server = http.createServer(async (req, res) => {
249
+ const url = new URL(req.url, `http://localhost`);
250
+
251
+ // The web app redirects here with ?token=<cli_token>
252
+ const token = url.searchParams.get('token');
253
+
254
+ if (!token) {
255
+ // Maybe an error or missing token
256
+ const error = url.searchParams.get('error');
257
+ if (error) {
258
+ res.writeHead(200, { 'Content-Type': 'text/html' });
259
+ res.end(`<html><body><h2>Login failed</h2><p>${error}</p></body></html>`);
260
+ server.close();
261
+ reject(new Error(error));
262
+ return;
263
+ }
264
+ // Ignore other requests (favicon, etc.)
265
+ res.writeHead(200);
266
+ res.end('');
267
+ return;
268
+ }
269
+
270
+ // Save the CLI token
271
+ this.saveCredentials({ token });
272
+
273
+ res.writeHead(200, { 'Content-Type': 'text/html' });
274
+ res.end(getLoginSuccessHTML());
275
+
276
+ server.close();
277
+ resolve(true);
278
+ });
279
+
280
+ server.listen(0, '127.0.0.1', () => {
281
+ const port = server.address().port;
282
+ const callbackUrl = `http://127.0.0.1:${port}/callback`;
283
+ const authUrl = `${webUrl}/auth/cli?callback=${encodeURIComponent(callbackUrl)}`;
284
+
285
+ process.stderr.write(`\n\x1b[36mOpening browser for login...\x1b[0m\n`);
286
+ process.stderr.write(`\x1b[2mIf browser doesn't open, visit:\x1b[0m\n \x1b[4m${authUrl}\x1b[0m\n\n`);
287
+
288
+ // Open browser
289
+ const openCmd = process.platform === 'darwin' ? 'open' :
290
+ process.platform === 'win32' ? 'start' : 'xdg-open';
291
+ import('node:child_process').then(({ exec }) => {
292
+ exec(`${openCmd} "${authUrl}"`, () => {});
293
+ });
294
+ });
295
+
296
+ // Timeout after 120s
297
+ setTimeout(() => {
298
+ server.close();
299
+ reject(new Error('Login timed out after 120s'));
300
+ }, 120_000);
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Ensure user is authenticated, prompt login if not.
306
+ */
307
+ async ensureAuth() {
308
+ if (!this.isAuthenticated()) {
309
+ process.stderr.write('\x1b[33mNot logged in.\x1b[0m Starting login flow...\n');
310
+ await this.login();
311
+ }
312
+ }
313
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Agent CLI Commands — list, get, sync user-defined agents.
2
+ * Agent CLI Commands — list, get, sync backend-published user-defined agents.
3
3
  *
4
4
  * Commands:
5
5
  * bahulam-code agent list
@@ -47,9 +47,9 @@ export async function handleAgentCommand(args) {
47
47
 
48
48
  function printAgentUsage() {
49
49
  process.stderr.write(`${BOLD}AGENT COMMANDS${RESET}\n`);
50
- process.stderr.write(` ${CYAN}bahulam-code agent list${RESET} List user-defined agents\n`);
51
- process.stderr.write(` ${CYAN}bahulam-code agent get <slug>${RESET} Show agent details\n`);
52
- process.stderr.write(` ${CYAN}bahulam-code agent sync [--dir <path>]${RESET} Sync agent YAML files\n`);
50
+ process.stderr.write(` ${CYAN}bahulam-code agent list${RESET} List backend-published agents\n`);
51
+ process.stderr.write(` ${CYAN}bahulam-code agent get <slug>${RESET} Show backend-published agent details\n`);
52
+ process.stderr.write(` ${CYAN}bahulam-code agent sync [--dir <path>]${RESET} Publish local agent YAML files for account/cloud reuse\n`);
53
53
  process.stderr.write('\n');
54
54
  }
55
55
 
@@ -110,11 +110,11 @@ async function handleList(args) {
110
110
 
111
111
  const agents = result.agents || [];
112
112
  if (agents.length === 0) {
113
- process.stderr.write(`${DIM}No user-defined agents found.${RESET}\n`);
113
+ process.stderr.write(`${DIM}No backend-published agents found. Local .bahulam/agents files can still be delegated in their workspace.${RESET}\n`);
114
114
  process.exit(0);
115
115
  }
116
116
 
117
- process.stderr.write(`${BOLD}User-defined Agents:${RESET}\n`);
117
+ process.stderr.write(`${BOLD}Backend-published Agents:${RESET}\n`);
118
118
  for (const a of agents) {
119
119
  const source = a.source === 'platform' ? `${DIM}(platform)${RESET}` : `${DIM}(user)${RESET}`;
120
120
  process.stderr.write(` ${CYAN}${a.slug}${RESET} ${a.name || a.slug} ${source}\n`);
@@ -184,7 +184,7 @@ async function handleSync(args) {
184
184
  agents: selected,
185
185
  });
186
186
  const synced = result.synced ?? selected.length;
187
- process.stderr.write(`${GREEN}✓ Synced ${synced} agent${synced === 1 ? '' : 's'} to Supabase.${RESET}\n`);
187
+ process.stderr.write(`${GREEN}✓ Synced ${synced} agent${synced === 1 ? '' : 's'} to the backend for account/cloud reuse.${RESET}\n`);
188
188
  process.stdout.write(JSON.stringify({ synced, agents: result.agents || [] }, null, 2) + '\n');
189
189
  } catch (err) {
190
190
  process.stderr.write(`${RED}✗ Agent sync failed: ${err.message}${RESET}\n`);
@@ -11,6 +11,8 @@
11
11
  * --max-turns Maximum conversation turns
12
12
  * --allowedTools Comma-separated allowed tools
13
13
  * --disallowedTools Comma-separated denied tools
14
+ * --agent <slug> Run a named agent (local deterministic graph)
15
+ * --workflow <name> Run a named workflow (local deterministic graph)
14
16
  * --verbose, -v Verbose output
15
17
  * --debug, -d Debug mode
16
18
  * --version Show version
@@ -34,6 +36,8 @@ export function parseArgs(args) {
34
36
  resumeSessionId: null,
35
37
  headless: false,
36
38
  skipPermissions: false,
39
+ agent: null,
40
+ workflow: null,
37
41
  vision: [],
38
42
  verbose: false,
39
43
  debug: false,
@@ -109,6 +113,14 @@ export function parseArgs(args) {
109
113
  result.skipPermissions = true; // headless implies skip permissions
110
114
  break;
111
115
 
116
+ case '--agent':
117
+ result.agent = args[++i];
118
+ break;
119
+
120
+ case '--workflow':
121
+ result.workflow = args[++i];
122
+ break;
123
+
112
124
  case '--cache-report':
113
125
  // PRD-071 §1.5 — write a machine-readable cache summary to
114
126
  // <path> at end of run. Consumed by benchmark/cache-check.sh.
@@ -184,6 +196,8 @@ Options:
184
196
  --disallowedTools <tools> Comma-separated list of denied tools
185
197
  --resume, -r [sessionId] Resume last session (or specific session)
186
198
  --continue Alias for --resume
199
+ --agent <slug> Run a named agent as a deterministic local graph
200
+ --workflow <name> Run a named workflow as a deterministic local graph
187
201
  --headless Non-interactive mode: auto-approve, JSONL output
188
202
  --cache-report <file> Write prompt-cache summary JSON to <file> (headless only)
189
203
  --vision <image-path> Attach image path in headless mode
@@ -197,6 +211,8 @@ Examples:
197
211
  occ Start interactive REPL
198
212
  occ -p "What is 2+2?" Run prompt and exit
199
213
  occ -m claude-haiku-4-5 Use Haiku model
214
+ occ --agent explore -p "Map auth flow" Run the explore agent headlessly
215
+ occ --workflow deploy -p "Deploy" Run a workflow headlessly
200
216
  occ --debug -p "Fix bug" Debug mode with prompt
201
217
  `.trim();
202
218
  }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * BackgroundTasks — the one registry for long-running processes the agent
3
+ * starts (docker build, npm run dev, test suites). Jobs get a run id,
4
+ * a per-job timeout with SIGTERM→SIGKILL escalation, output spooled to
5
+ * .bahulam/tmp/jobs/<id>.log (with a bounded in-memory tail), completion
6
+ * listeners for wake-on-finish delivery, and best-effort cleanup of the
7
+ * whole process group when the CLI exits.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+
13
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
14
+ const KILL_ESCALATION_MS = 5000;
15
+ const MAX_TAIL_BYTES = 64 * 1024;
16
+
17
+ function stripAnsi(str) {
18
+ // eslint-disable-next-line no-control-regex
19
+ return String(str || '').replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
20
+ }
21
+
22
+ class BackgroundTasks {
23
+ constructor() {
24
+ this.jobs = new Map();
25
+ this._seq = 0;
26
+ this._listeners = new Set();
27
+ this._exitHookInstalled = false;
28
+ }
29
+
30
+ onExit(listener) {
31
+ this._listeners.add(listener);
32
+ return () => this._listeners.delete(listener);
33
+ }
34
+
35
+ start({ command, cwd = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS, name = '', on_complete = null }) {
36
+ this._installExitHook();
37
+ const id = `job-${++this._seq}-${Date.now().toString(36)}`;
38
+ const logDir = path.join(cwd, '.bahulam', 'tmp', 'jobs');
39
+ fs.mkdirSync(logDir, { recursive: true });
40
+ const logPath = path.join(logDir, `${id}.log`);
41
+ const logStream = fs.createWriteStream(logPath);
42
+
43
+ const proc = spawn('bash', ['-c', command], {
44
+ cwd,
45
+ env: { ...process.env },
46
+ stdio: ['ignore', 'pipe', 'pipe'],
47
+ detached: process.platform !== 'win32',
48
+ });
49
+
50
+ const job = {
51
+ id,
52
+ name: name || command.slice(0, 60),
53
+ command,
54
+ cwd,
55
+ pid: proc.pid,
56
+ status: 'running',
57
+ exit_code: null,
58
+ started_at: Date.now(),
59
+ ended_at: null,
60
+ log_path: logPath,
61
+ tail: '',
62
+ timed_out: false,
63
+ on_complete,
64
+ _proc: proc,
65
+ _done: null,
66
+ };
67
+
68
+ const appendTail = (chunk) => {
69
+ const next = job.tail + chunk.toString();
70
+ job.tail = next.length > MAX_TAIL_BYTES ? next.slice(next.length - MAX_TAIL_BYTES) : next;
71
+ };
72
+ proc.stdout.on('data', (d) => { logStream.write(d); appendTail(d); });
73
+ proc.stderr.on('data', (d) => { logStream.write(d); appendTail(d); });
74
+
75
+ let killTimer = null;
76
+ const timer = timeoutMs > 0 ? setTimeout(() => {
77
+ job.timed_out = true;
78
+ this._kill(job, 'SIGTERM');
79
+ killTimer = setTimeout(() => this._kill(job, 'SIGKILL'), KILL_ESCALATION_MS);
80
+ }, timeoutMs) : null;
81
+ if (timer?.unref) timer.unref();
82
+
83
+ job._done = new Promise((resolve) => {
84
+ proc.on('close', (code) => {
85
+ clearTimeout(timer);
86
+ clearTimeout(killTimer);
87
+ job.exit_code = code;
88
+ job.ended_at = Date.now();
89
+ job.status = job.timed_out ? 'timeout'
90
+ : job.status === 'killed' ? 'killed'
91
+ : code === 0 ? 'completed' : 'failed';
92
+ job.tail = stripAnsi(job.tail);
93
+ logStream.end();
94
+ for (const listener of this._listeners) {
95
+ try { listener(this.describe(job.id)); } catch { /* listeners are best-effort */ }
96
+ }
97
+ resolve(this.describe(job.id));
98
+ });
99
+ proc.on('error', (err) => {
100
+ job.status = 'failed';
101
+ job.tail = `${job.tail}\n${err.message}`.trim();
102
+ job.ended_at = Date.now();
103
+ logStream.end();
104
+ resolve(this.describe(job.id));
105
+ });
106
+ });
107
+
108
+ proc.unref();
109
+ this.jobs.set(id, job);
110
+ return this.describe(id);
111
+ }
112
+
113
+ /** Await a job's completion; resolves with its final description. */
114
+ wait(id) {
115
+ const job = this.jobs.get(id);
116
+ if (!job) return Promise.resolve(null);
117
+ if (job.status !== 'running') return Promise.resolve(this.describe(id));
118
+ // Background jobs are unref'd so fire-and-forget tasks do not pin the CLI
119
+ // open. When a caller explicitly awaits wait(id), temporarily ref the
120
+ // process so fast commands still get their close event before Node decides
121
+ // the top-level await is unsettled.
122
+ try { job._proc?.ref?.(); } catch { /* best effort */ }
123
+ return job._done.finally(() => {
124
+ try { job._proc?.unref?.(); } catch { /* best effort */ }
125
+ });
126
+ }
127
+
128
+ describe(id) {
129
+ const job = this.jobs.get(id);
130
+ if (!job) return null;
131
+ return {
132
+ id: job.id,
133
+ name: job.name,
134
+ command: job.command,
135
+ pid: job.pid,
136
+ status: job.status,
137
+ exit_code: job.exit_code,
138
+ duration_s: Math.round(((job.ended_at || Date.now()) - job.started_at) / 1000),
139
+ log_path: job.log_path,
140
+ tail: job.tail,
141
+ timed_out: job.timed_out,
142
+ on_complete: job.on_complete || null,
143
+ };
144
+ }
145
+
146
+ list() {
147
+ return [...this.jobs.keys()].map(id => {
148
+ const d = this.describe(id);
149
+ return { ...d, tail: undefined };
150
+ });
151
+ }
152
+
153
+ kill(id) {
154
+ const job = this.jobs.get(id);
155
+ if (!job) return null;
156
+ if (job.status === 'running') {
157
+ job.status = 'killed';
158
+ this._kill(job, 'SIGTERM');
159
+ setTimeout(() => this._kill(job, 'SIGKILL'), KILL_ESCALATION_MS)?.unref?.();
160
+ }
161
+ return this.describe(id);
162
+ }
163
+
164
+ _kill(job, signal) {
165
+ if (!job?._proc?.pid) return;
166
+ try {
167
+ if (process.platform !== 'win32') {
168
+ process.kill(-job._proc.pid, signal);
169
+ return;
170
+ }
171
+ } catch { /* fall through */ }
172
+ try { job._proc.kill(signal); } catch { /* already exited */ }
173
+ }
174
+
175
+ _installExitHook() {
176
+ if (this._exitHookInstalled) return;
177
+ this._exitHookInstalled = true;
178
+ process.on('exit', () => {
179
+ for (const job of this.jobs.values()) {
180
+ if (job.status === 'running') this._kill(job, 'SIGKILL');
181
+ }
182
+ });
183
+ }
184
+ }
185
+
186
+ export const backgroundTasks = new BackgroundTasks();
@@ -47,7 +47,7 @@ import {
47
47
  * @param {number} [opts.maxCost] - abort if cost exceeds this USD amount
48
48
  * @param {boolean} [opts.verbose] - show progress on stderr
49
49
  */
50
- export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false, cacheReport = null, local = false, vision = [] }) {
50
+ export async function runHeadless({ instruction, model, timeout = 300, maxCost, verbose = false, cacheReport = null, local = false, vision = [], agent = null, workflow = null }) {
51
51
  const startTime = Date.now();
52
52
 
53
53
  const log = (msg) => {
@@ -61,12 +61,63 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
61
61
  // ── Auth ──
62
62
  const auth = new BahulamAuth();
63
63
  const creds = auth.loadCredentials();
64
- if (!creds.token) {
64
+ const graphTarget = agent || workflow;
65
+ const anthKey = process.env.ANTHROPIC_API_KEY || creds.anthropicKey;
66
+ const orKey = process.env.OPENROUTER_API_KEY || creds.openRouterKey;
67
+ // Graph runs execute locally and only need a model key; everything
68
+ // else still requires login (the backend runs the agent loop).
69
+ if (!creds.token && !(graphTarget && (anthKey || orKey))) {
65
70
  emit({ type: 'error', error: 'Not logged in. Run: bahulam login' });
66
71
  process.exit(1);
67
72
  }
68
73
 
69
- // Scan plugins so client_tools and client_agents are sent to the backend.
74
+ // ── Deterministic graph target: --agent <slug> / --workflow <name> ──
75
+ if (graphTarget) {
76
+ const { dispatch } = await import('../orchestration/dispatch.mjs');
77
+ const { listLocalWorkflows } = await import('../agents/workflow_scaffold.mjs');
78
+ const pluginRegistry = new PluginRegistry().scan();
79
+ const toolExecutor = createToolExecutor({ pluginRegistry });
80
+ const timer = setTimeout(() => {
81
+ emit({ type: 'timeout', duration_s: timeout });
82
+ process.exit(2);
83
+ }, timeout * 1000);
84
+
85
+ const outcome = await dispatch({
86
+ type: 'invoke',
87
+ source: 'cli:headless',
88
+ target: agent ? { kind: 'agent', slug: agent } : { kind: 'workflow', slug: workflow },
89
+ params: { instruction: instruction || '' },
90
+ channel: null,
91
+ substrate: 'direct',
92
+ }, {
93
+ toolExecutor,
94
+ listRunnables: () => toolExecutor.listRunnables(),
95
+ listLocalWorkflows: () => listLocalWorkflows(process.cwd()),
96
+ renderEvent: (event) => emit({ type: event.type, ...event.data }),
97
+ credentials: { apiKey: anthKey, openRouterKey: orKey },
98
+ defaultModel: model || null,
99
+ cwd: process.cwd(),
100
+ });
101
+
102
+ clearTimeout(timer);
103
+ if (!outcome.dispatched) {
104
+ emit({ type: 'error', error: outcome.reason });
105
+ process.exit(1);
106
+ }
107
+ const result = outcome.result || {};
108
+ emit({
109
+ type: 'result',
110
+ status: result.status || (result.success === false ? 'failed' : 'completed'),
111
+ channel: outcome.channel,
112
+ output: result.output || '',
113
+ node_results: result.node_results || undefined,
114
+ duration_s: Math.round((Date.now() - startTime) / 1000),
115
+ });
116
+ process.exit(result.status === 'failed' || result.success === false ? 1 : 0);
117
+ }
118
+
119
+ // Scan plugins so client_agents and agent-scoped plugin tool schemas
120
+ // are sent to the backend.
70
121
  const pluginRegistry = new PluginRegistry().scan();
71
122
 
72
123
  // Projects are registered and indexed only when the agent requests an overview.