@bahulam/code 0.1.5 → 0.1.6
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 +1 -1
- package/src/core/stream-client.mjs +103 -12
- package/src/core/tool-executor.mjs +18 -2
- package/src/terminal/repl.mjs +25 -3
- package/src/tools/project-overview.mjs +5 -2
package/package.json
CHANGED
|
@@ -76,6 +76,23 @@ function sleep(ms) {
|
|
|
76
76
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
function envMs(name, fallback) {
|
|
80
|
+
const raw = Number(process.env[name] || fallback);
|
|
81
|
+
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function transportDebugEnabled() {
|
|
85
|
+
const raw = String(process.env.BAHULAM_TRANSPORT_DEBUG || '').toLowerCase();
|
|
86
|
+
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function transportDebug(message, data = {}) {
|
|
90
|
+
if (!transportDebugEnabled()) return;
|
|
91
|
+
try {
|
|
92
|
+
process.stderr.write(`[transport] ${message} ${JSON.stringify(data)}\n`);
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
// Full jitter around the scheduled delay: pick a value in [delay*0.5, delay*1.5].
|
|
80
97
|
// Spreads out reconnect storms so N clients dropping simultaneously don't
|
|
81
98
|
// synchronize their retries. Clamped to the same 30s ceiling as the base delay.
|
|
@@ -223,6 +240,7 @@ export class TarangStreamClient {
|
|
|
223
240
|
async *execute(instruction, context = {}, messages = null) {
|
|
224
241
|
this._cancelled = false;
|
|
225
242
|
this.currentTaskId = null;
|
|
243
|
+
this._firstEventTimedOut = false;
|
|
226
244
|
|
|
227
245
|
// Bundled mode: spawn the local Python runtime on first turn.
|
|
228
246
|
// After this, this.baseUrl points at http://127.0.0.1:<random-port>
|
|
@@ -233,6 +251,7 @@ export class TarangStreamClient {
|
|
|
233
251
|
const body = { instruction, context };
|
|
234
252
|
if (messages && messages.length > 0) body.messages = messages;
|
|
235
253
|
if (this.sessionId) body.session_id = this.sessionId;
|
|
254
|
+
const requestId = `cli-${_uuidLike()}`;
|
|
236
255
|
|
|
237
256
|
// daemon cache-guard hook. If BAHULAM_CAPTURE_REQUEST is set to a file
|
|
238
257
|
// path, serialize the exact body that would go to /api/execute, write
|
|
@@ -260,6 +279,8 @@ export class TarangStreamClient {
|
|
|
260
279
|
const headers = this._headers({
|
|
261
280
|
'Accept': 'text/event-stream',
|
|
262
281
|
'Content-Type': 'application/json',
|
|
282
|
+
'X-Bahulam-Request-ID': requestId,
|
|
283
|
+
'X-Request-ID': requestId,
|
|
263
284
|
});
|
|
264
285
|
|
|
265
286
|
// Abort controller so cancel() can break out of a stalled reader
|
|
@@ -268,6 +289,21 @@ export class TarangStreamClient {
|
|
|
268
289
|
this._toolAbort = new AbortController();
|
|
269
290
|
|
|
270
291
|
let response;
|
|
292
|
+
let connectTimedOut = false;
|
|
293
|
+
const connectTimeoutMs = envMs('BAHULAM_EXECUTE_CONNECT_TIMEOUT_MS', 60_000);
|
|
294
|
+
const connectStarted = Date.now();
|
|
295
|
+
const connectTimer = setTimeout(() => {
|
|
296
|
+
connectTimedOut = true;
|
|
297
|
+
try { this._abort.abort(); } catch {}
|
|
298
|
+
}, connectTimeoutMs);
|
|
299
|
+
transportDebug('execute.start', {
|
|
300
|
+
request_id: requestId,
|
|
301
|
+
url,
|
|
302
|
+
mode: this.mode,
|
|
303
|
+
session_id: this.sessionId || null,
|
|
304
|
+
messages: Array.isArray(messages) ? messages.length : 0,
|
|
305
|
+
body_bytes: Buffer.byteLength(JSON.stringify(body), 'utf8'),
|
|
306
|
+
});
|
|
271
307
|
try {
|
|
272
308
|
response = await fetch(url, {
|
|
273
309
|
method: 'POST',
|
|
@@ -276,15 +312,39 @@ export class TarangStreamClient {
|
|
|
276
312
|
signal: this._abort.signal,
|
|
277
313
|
});
|
|
278
314
|
} catch (err) {
|
|
315
|
+
clearTimeout(connectTimer);
|
|
279
316
|
if (err.name === 'AbortError') {
|
|
317
|
+
if (connectTimedOut) {
|
|
318
|
+
const message = `Backend did not accept /api/execute within ${Math.round(connectTimeoutMs / 1000)}s (request ${requestId}).`;
|
|
319
|
+
transportDebug('execute.connect_timeout', {
|
|
320
|
+
request_id: requestId,
|
|
321
|
+
ms: Date.now() - connectStarted,
|
|
322
|
+
});
|
|
323
|
+
yield { type: EVENT_TYPES.ERROR, data: { message, request_id: requestId, fatal: true } };
|
|
324
|
+
}
|
|
280
325
|
return;
|
|
281
326
|
}
|
|
282
|
-
|
|
327
|
+
transportDebug('execute.network_error', {
|
|
328
|
+
request_id: requestId,
|
|
329
|
+
ms: Date.now() - connectStarted,
|
|
330
|
+
error: err.message,
|
|
331
|
+
code: err?.cause?.code || err?.code || '',
|
|
332
|
+
});
|
|
333
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: `Network error: ${err.message}. Check your connection or use --local mode.`, request_id: requestId, fatal: true } };
|
|
283
334
|
return;
|
|
284
335
|
}
|
|
336
|
+
clearTimeout(connectTimer);
|
|
337
|
+
const responseRequestId = response.headers.get('x-request-id') || requestId;
|
|
338
|
+
transportDebug('execute.response', {
|
|
339
|
+
request_id: responseRequestId,
|
|
340
|
+
status: response.status,
|
|
341
|
+
ok: response.ok,
|
|
342
|
+
ms: Date.now() - connectStarted,
|
|
343
|
+
task_id: response.headers.get('x-task-id') || null,
|
|
344
|
+
});
|
|
285
345
|
|
|
286
346
|
if (response.status === 401) {
|
|
287
|
-
yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `bahulam login` to re-authenticate.', fatal: true } };
|
|
347
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `bahulam login` to re-authenticate.', request_id: responseRequestId, fatal: true } };
|
|
288
348
|
return;
|
|
289
349
|
}
|
|
290
350
|
if (response.status === 429) {
|
|
@@ -305,6 +365,7 @@ export class TarangStreamClient {
|
|
|
305
365
|
rate_limit: detail?.rate_limit || null,
|
|
306
366
|
action: detail?.action || null,
|
|
307
367
|
pricing_url: normalizeBillingBrandCopy(detail?.pricing_url || null),
|
|
368
|
+
request_id: responseRequestId,
|
|
308
369
|
fatal: true,
|
|
309
370
|
},
|
|
310
371
|
};
|
|
@@ -312,7 +373,7 @@ export class TarangStreamClient {
|
|
|
312
373
|
}
|
|
313
374
|
if (!response.ok) {
|
|
314
375
|
const text = await response.text().catch(() => 'Unknown error');
|
|
315
|
-
yield { type: EVENT_TYPES.ERROR, data: { message: `Backend error ${response.status}: ${text}`, fatal: true } };
|
|
376
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: `Backend error ${response.status}: ${text}`, request_id: responseRequestId, fatal: true } };
|
|
316
377
|
return;
|
|
317
378
|
}
|
|
318
379
|
|
|
@@ -322,6 +383,17 @@ export class TarangStreamClient {
|
|
|
322
383
|
if (this._cancelled) {
|
|
323
384
|
return;
|
|
324
385
|
}
|
|
386
|
+
if (this._firstEventTimedOut) {
|
|
387
|
+
yield {
|
|
388
|
+
type: EVENT_TYPES.ERROR,
|
|
389
|
+
data: {
|
|
390
|
+
message: `Backend opened the stream but sent no SSE events within ${Math.round(envMs('BAHULAM_FIRST_EVENT_TIMEOUT_MS', 60_000) / 1000)}s (request ${responseRequestId}).`,
|
|
391
|
+
request_id: responseRequestId,
|
|
392
|
+
fatal: true,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
325
397
|
yield* this._reconnectAfterDrop(err);
|
|
326
398
|
}
|
|
327
399
|
}
|
|
@@ -330,18 +402,37 @@ export class TarangStreamClient {
|
|
|
330
402
|
const taskId = response.headers.get('X-Task-ID');
|
|
331
403
|
if (taskId) this.currentTaskId = taskId;
|
|
332
404
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (this._cancelled) return;
|
|
405
|
+
let sawFirstEvent = false;
|
|
406
|
+
const firstEventTimeoutMs = envMs('BAHULAM_FIRST_EVENT_TIMEOUT_MS', 60_000);
|
|
407
|
+
const firstEventTimer = setTimeout(() => {
|
|
408
|
+
if (sawFirstEvent || this._cancelled) return;
|
|
409
|
+
this._firstEventTimedOut = true;
|
|
410
|
+
try { this._abort?.abort(); } catch {}
|
|
411
|
+
}, firstEventTimeoutMs);
|
|
412
|
+
try {
|
|
413
|
+
for await (const parsed of this._parseSSE(response)) {
|
|
414
|
+
if (!sawFirstEvent) {
|
|
415
|
+
sawFirstEvent = true;
|
|
416
|
+
clearTimeout(firstEventTimer);
|
|
417
|
+
transportDebug('execute.first_event', {
|
|
418
|
+
task_id: this.currentTaskId || null,
|
|
419
|
+
event: parsed.event || null,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (this._cancelled) return;
|
|
423
|
+
await this._waitIfPaused();
|
|
424
|
+
if (this._cancelled) return;
|
|
337
425
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
426
|
+
if (parsed.id != null) this.lastEventId = parsed.id;
|
|
427
|
+
if (parsed.retry != null) this.retryDelayMs = parsed.retry;
|
|
428
|
+
if (parsed.data?.task_id) this.currentTaskId = parsed.data.task_id;
|
|
341
429
|
|
|
342
|
-
|
|
343
|
-
|
|
430
|
+
for await (const event of this._handleStreamEvent(parsed)) {
|
|
431
|
+
yield event;
|
|
432
|
+
}
|
|
344
433
|
}
|
|
434
|
+
} finally {
|
|
435
|
+
clearTimeout(firstEventTimer);
|
|
345
436
|
}
|
|
346
437
|
}
|
|
347
438
|
|
|
@@ -44,6 +44,8 @@ export function createToolExecutor({
|
|
|
44
44
|
checkpoints = null,
|
|
45
45
|
hookRunner = null,
|
|
46
46
|
interactionHandler = null,
|
|
47
|
+
onAutoRegisterStart = null,
|
|
48
|
+
onAutoRegisterDone = null,
|
|
47
49
|
} = {}) {
|
|
48
50
|
// Cross-session memory cache. Ships in getAgentContext() on every turn,
|
|
49
51
|
// so we need it to be byte-identical when the underlying disk file hasn't
|
|
@@ -94,9 +96,19 @@ export function createToolExecutor({
|
|
|
94
96
|
// for the current directory to be usable — the user chose to be here.
|
|
95
97
|
// Opt out via BAHULAM_SKIP_AUTO_REGISTER=true for tests or headless
|
|
96
98
|
// scripts that want a truly empty registry.
|
|
99
|
+
let autoRegisterPromise = Promise.resolve(null);
|
|
97
100
|
if (process.env.BAHULAM_SKIP_AUTO_REGISTER !== 'true') {
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
const autoRegisterRoot = process.cwd();
|
|
102
|
+
try { onAutoRegisterStart?.(autoRegisterRoot); } catch { /* status hooks are best-effort */ }
|
|
103
|
+
autoRegisterPromise = projectRegistry.register(autoRegisterRoot, { bypassProjectMarkers: true })
|
|
104
|
+
.then((result) => {
|
|
105
|
+
try { onAutoRegisterDone?.(null, result); } catch { /* status hooks are best-effort */ }
|
|
106
|
+
return result;
|
|
107
|
+
})
|
|
108
|
+
.catch((err) => {
|
|
109
|
+
try { onAutoRegisterDone?.(err, null); } catch { /* status hooks are best-effort */ }
|
|
110
|
+
return null;
|
|
111
|
+
});
|
|
100
112
|
}
|
|
101
113
|
let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
|
|
102
114
|
let _readOnlyCacheGeneration = 0;
|
|
@@ -2203,6 +2215,10 @@ print('OK: replaced')
|
|
|
2203
2215
|
return projectRegistry.resources();
|
|
2204
2216
|
},
|
|
2205
2217
|
|
|
2218
|
+
waitForAutoRegister() {
|
|
2219
|
+
return autoRegisterPromise;
|
|
2220
|
+
},
|
|
2221
|
+
|
|
2206
2222
|
async registerProjectRoots(roots, { forceRefresh = false } = {}) {
|
|
2207
2223
|
const results = [];
|
|
2208
2224
|
const seen = new Set();
|
package/src/terminal/repl.mjs
CHANGED
|
@@ -74,6 +74,7 @@ import { buildResumeHistory, combineResumeSummaries, getRecentSessions, getSessi
|
|
|
74
74
|
import { decideResumeMode, projectedTokensForChoice, formatTokens as formatCtxTokens } from '../core/resume-mode.mjs';
|
|
75
75
|
import { appendTask, ensureTaskFiles, loadTaskBoard, moveTask, removeTask, taskCounts, TASK_FILES, updateTask } from '../core/tasks.mjs';
|
|
76
76
|
import { applyCompactSummary, localCompactSummary, parseCompactTailCount, prepareCompactHistory } from '../core/compact-history.mjs';
|
|
77
|
+
import { startSpinner as startInlineSpinner } from '../ui/spinner.mjs';
|
|
77
78
|
import {
|
|
78
79
|
appendVisionAnalysisToInstruction,
|
|
79
80
|
appendDocumentsToInstruction,
|
|
@@ -3674,7 +3675,28 @@ export async function startTerminalRepl() {
|
|
|
3674
3675
|
return res;
|
|
3675
3676
|
};
|
|
3676
3677
|
|
|
3677
|
-
|
|
3678
|
+
function makeToolExecutor({ showIndexStatus = false } = {}) {
|
|
3679
|
+
const shouldShowIndexStatus = showIndexStatus && process.stderr.isTTY && !term().plain;
|
|
3680
|
+
let stopIndexSpinner = null;
|
|
3681
|
+
return createToolExecutor({
|
|
3682
|
+
checkpoints,
|
|
3683
|
+
hookRunner,
|
|
3684
|
+
interactionHandler: askUserInteraction,
|
|
3685
|
+
onAutoRegisterStart: shouldShowIndexStatus ? (root) => {
|
|
3686
|
+
const name = path.basename(root || safeCwd()) || root || 'project';
|
|
3687
|
+
stopIndexSpinner?.();
|
|
3688
|
+
stopIndexSpinner = startInlineSpinner(
|
|
3689
|
+
`Indexing ${name} so tools can read and search this project...`
|
|
3690
|
+
);
|
|
3691
|
+
} : null,
|
|
3692
|
+
onAutoRegisterDone: shouldShowIndexStatus ? () => {
|
|
3693
|
+
stopIndexSpinner?.();
|
|
3694
|
+
stopIndexSpinner = null;
|
|
3695
|
+
} : null,
|
|
3696
|
+
});
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
let toolExecutor = makeToolExecutor({ showIndexStatus: true });
|
|
3678
3700
|
const skipPerms = cliArgs.skipPermissions;
|
|
3679
3701
|
let approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
|
|
3680
3702
|
|
|
@@ -3768,7 +3790,7 @@ export async function startTerminalRepl() {
|
|
|
3768
3790
|
checkpoints = new CheckpointManager(safeCwd());
|
|
3769
3791
|
effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
|
|
3770
3792
|
hookRunner = new HookRunner({ cwd: safeCwd() });
|
|
3771
|
-
toolExecutor =
|
|
3793
|
+
toolExecutor = makeToolExecutor();
|
|
3772
3794
|
approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
|
|
3773
3795
|
if (ctx._rl) approval.setReadline(ctx._rl);
|
|
3774
3796
|
sessionMgr = new SessionManager(safeCwd());
|
|
@@ -3948,7 +3970,7 @@ export async function startTerminalRepl() {
|
|
|
3948
3970
|
checkpoints = new CheckpointManager(safeCwd());
|
|
3949
3971
|
effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
|
|
3950
3972
|
hookRunner = new HookRunner({ cwd: safeCwd(), sessionId });
|
|
3951
|
-
toolExecutor =
|
|
3973
|
+
toolExecutor = makeToolExecutor();
|
|
3952
3974
|
approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
|
|
3953
3975
|
if (ctx._rl) approval.setReadline(ctx._rl);
|
|
3954
3976
|
sessionMgr = new SessionManager(safeCwd());
|
|
@@ -54,6 +54,10 @@ const IGNORED_DIRS = new Set([
|
|
|
54
54
|
'.git', '.bahulam', '.next', '.venv', '__pycache__',
|
|
55
55
|
'build', 'dist', 'node_modules', 'venv',
|
|
56
56
|
]);
|
|
57
|
+
const ENV_PROBE_TIMEOUT_MS = Math.max(
|
|
58
|
+
50,
|
|
59
|
+
Number(process.env.BAHULAM_ENV_PROBE_TIMEOUT_MS || 500) || 500,
|
|
60
|
+
);
|
|
57
61
|
|
|
58
62
|
// Files or directories whose presence at the root implies this IS a project.
|
|
59
63
|
// One is enough. Kept broad so we accept Node/Python/Rust/Go/Ruby/Java/C++
|
|
@@ -227,7 +231,7 @@ function commandVersion(command, args = ['--version']) {
|
|
|
227
231
|
try {
|
|
228
232
|
const result = spawnSync(command, args, {
|
|
229
233
|
encoding: 'utf-8',
|
|
230
|
-
timeout:
|
|
234
|
+
timeout: ENV_PROBE_TIMEOUT_MS,
|
|
231
235
|
windowsHide: true,
|
|
232
236
|
});
|
|
233
237
|
if (result.error || result.status !== 0) return '';
|
|
@@ -244,7 +248,6 @@ function detectEnvironment() {
|
|
|
244
248
|
['git', 'git'],
|
|
245
249
|
['npm', 'npm'],
|
|
246
250
|
['uv', 'uv'],
|
|
247
|
-
['pytest', 'pytest'],
|
|
248
251
|
['docker', 'docker'],
|
|
249
252
|
];
|
|
250
253
|
const tools = {};
|