@bahulam/code 0.1.5 → 0.1.7
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/LICENSE +1 -1
- package/NOTICE +6 -6
- package/README.md +10 -17
- package/package.json +9 -9
- package/src/agents/loader.mjs +1 -1
- package/src/agents/workflow_loader.mjs +2 -2
- package/src/auth/tarang-auth.mjs +3 -2
- package/src/config/model-catalog-default.json +4 -3
- package/src/config/model-catalog.mjs +2 -0
- package/src/context/prose-chunker.mjs +162 -5
- package/src/core/attachments.mjs +65 -3
- package/src/core/bundled-runtime.mjs +1 -1
- package/src/core/headless.mjs +14 -0
- package/src/core/jsonl-writer.mjs +3 -3
- package/src/core/lint-resolver.mjs +472 -0
- package/src/core/local-agent.mjs +15 -4
- package/src/core/local-store.mjs +4 -0
- package/src/core/output-filter.mjs +21 -25
- package/src/core/pricing.mjs +10 -3
- package/src/core/stream-client.mjs +112 -16
- package/src/core/tool-executor.mjs +123 -31
- package/src/daemon/http-dashboard.mjs +400 -0
- package/src/daemon/session-core.mjs +1 -0
- package/src/local-service/agent-relay.mjs +868 -0
- package/src/local-service/approval-bridge.mjs +222 -0
- package/src/local-service/browser.mjs +24 -0
- package/src/local-service/command.mjs +155 -0
- package/src/local-service/file-access.mjs +393 -0
- package/src/local-service/machine.mjs +86 -0
- package/src/local-service/paths.mjs +29 -0
- package/src/local-service/preview-converters.mjs +260 -0
- package/src/local-service/server.mjs +2644 -0
- package/src/local-service/session-store.mjs +221 -0
- package/src/onboarding/preflight.mjs +1 -1
- package/src/terminal/analytics.mjs +2 -6
- package/src/terminal/ansi.mjs +11 -3
- package/src/terminal/main.mjs +37 -11
- package/src/terminal/model-catalog-display.mjs +111 -0
- package/src/terminal/repl-format.mjs +4 -1
- package/src/terminal/repl-model-form.mjs +81 -14
- package/src/terminal/repl-render.mjs +35 -5
- package/src/terminal/repl-state.mjs +12 -0
- package/src/terminal/repl.mjs +255 -55
- package/src/terminal/tool-display.mjs +32 -0
- package/src/terminal/watch-state.mjs +118 -0
- package/src/tools/analyze-image.mjs +158 -0
- package/src/tools/bash.mjs +11 -9
- package/src/tools/generate-image.mjs +411 -0
- package/src/tools/lint.mjs +24 -43
- package/src/tools/project-overview.mjs +5 -2
- package/src/tools/registry.mjs +4 -0
- package/src/ui/icons.mjs +2 -0
- package/src/ui/palette.mjs +1 -1
- package/src/ui/slash-commands.mjs +4 -6
- package/src/ui/tool-card.mjs +7 -3
- package/pulse/app/activity/page.tsx +0 -190
- package/pulse/app/api/activity/route.ts +0 -138
- package/pulse/app/api/benchmark/route.ts +0 -113
- package/pulse/app/api/benchmarks/route.ts +0 -195
- package/pulse/app/api/costs/route.ts +0 -88
- package/pulse/app/api/export/route.ts +0 -77
- package/pulse/app/api/history/route.ts +0 -11
- package/pulse/app/api/import/route.ts +0 -31
- package/pulse/app/api/memory/route.ts +0 -50
- package/pulse/app/api/plans/route.ts +0 -9
- package/pulse/app/api/projects/[slug]/route.ts +0 -96
- package/pulse/app/api/projects/route.ts +0 -121
- package/pulse/app/api/sessions/[id]/replay/route.ts +0 -20
- package/pulse/app/api/sessions/[id]/route.ts +0 -31
- package/pulse/app/api/sessions/route.ts +0 -112
- package/pulse/app/api/settings/route.ts +0 -14
- package/pulse/app/api/stats/route.ts +0 -143
- package/pulse/app/api/todos/route.ts +0 -9
- package/pulse/app/api/tools/route.ts +0 -160
- package/pulse/app/benchmarks/page.tsx +0 -224
- package/pulse/app/costs/page.tsx +0 -179
- package/pulse/app/export/page.tsx +0 -465
- package/pulse/app/favicon.ico +0 -0
- package/pulse/app/globals.css +0 -263
- package/pulse/app/help/page.tsx +0 -143
- package/pulse/app/history/page.tsx +0 -157
- package/pulse/app/layout.tsx +0 -46
- package/pulse/app/memory/page.tsx +0 -365
- package/pulse/app/overview-client.tsx +0 -393
- package/pulse/app/page.tsx +0 -14
- package/pulse/app/plans/page.tsx +0 -308
- package/pulse/app/projects/[slug]/page.tsx +0 -390
- package/pulse/app/projects/page.tsx +0 -110
- package/pulse/app/sessions/[id]/page.tsx +0 -243
- package/pulse/app/sessions/page.tsx +0 -39
- package/pulse/app/settings/page.tsx +0 -188
- package/pulse/app/todos/page.tsx +0 -211
- package/pulse/app/tools/page.tsx +0 -249
- package/pulse/cli.js +0 -164
- package/pulse/components/activity/day-of-week-chart.tsx +0 -35
- package/pulse/components/activity/streak-card.tsx +0 -36
- package/pulse/components/costs/cache-efficiency-panel.tsx +0 -76
- package/pulse/components/costs/cost-by-project-chart.tsx +0 -48
- package/pulse/components/costs/cost-over-time-chart.tsx +0 -95
- package/pulse/components/costs/model-token-table.tsx +0 -60
- package/pulse/components/global-search.tsx +0 -193
- package/pulse/components/keyboard-nav-provider.tsx +0 -23
- package/pulse/components/layout/bottom-nav.tsx +0 -53
- package/pulse/components/layout/client-layout.tsx +0 -31
- package/pulse/components/layout/sidebar-context.tsx +0 -50
- package/pulse/components/layout/sidebar.tsx +0 -183
- package/pulse/components/layout/top-bar.tsx +0 -121
- package/pulse/components/overview/activity-heatmap.tsx +0 -107
- package/pulse/components/overview/conversation-table.tsx +0 -148
- package/pulse/components/overview/model-breakdown-donut.tsx +0 -95
- package/pulse/components/overview/peak-hours-chart.tsx +0 -87
- package/pulse/components/overview/project-activity-donut.tsx +0 -96
- package/pulse/components/overview/stat-card.tsx +0 -102
- package/pulse/components/overview/usage-over-time-chart.tsx +0 -166
- package/pulse/components/projects/project-card.tsx +0 -175
- package/pulse/components/sessions/replay/assistant-markdown.tsx +0 -94
- package/pulse/components/sessions/replay/compaction-card.tsx +0 -25
- package/pulse/components/sessions/replay/session-sidebar.tsx +0 -231
- package/pulse/components/sessions/replay/token-accumulation-chart.tsx +0 -98
- package/pulse/components/sessions/replay/tool-call-badge.tsx +0 -127
- package/pulse/components/sessions/replay/turn-cards.tsx +0 -220
- package/pulse/components/sessions/replay/user-tool-result.tsx +0 -158
- package/pulse/components/sessions/session-badges.tsx +0 -49
- package/pulse/components/sessions/session-table.tsx +0 -299
- package/pulse/components/theme-provider.tsx +0 -44
- package/pulse/components/tools/feature-adoption-table.tsx +0 -58
- package/pulse/components/tools/mcp-server-panel.tsx +0 -45
- package/pulse/components/tools/tool-ranking-chart.tsx +0 -57
- package/pulse/components/tools/version-history-table.tsx +0 -32
- package/pulse/components/ui/alert.tsx +0 -66
- package/pulse/components/ui/badge.tsx +0 -48
- package/pulse/components/ui/breadcrumb.tsx +0 -109
- package/pulse/components/ui/button.tsx +0 -64
- package/pulse/components/ui/calendar.tsx +0 -220
- package/pulse/components/ui/card.tsx +0 -92
- package/pulse/components/ui/command.tsx +0 -158
- package/pulse/components/ui/dialog.tsx +0 -158
- package/pulse/components/ui/input.tsx +0 -21
- package/pulse/components/ui/popover.tsx +0 -89
- package/pulse/components/ui/progress.tsx +0 -31
- package/pulse/components/ui/select.tsx +0 -190
- package/pulse/components/ui/separator.tsx +0 -28
- package/pulse/components/ui/sheet.tsx +0 -143
- package/pulse/components/ui/skeleton.tsx +0 -13
- package/pulse/components/ui/table.tsx +0 -116
- package/pulse/components/ui/tabs.tsx +0 -91
- package/pulse/components/ui/tooltip.tsx +0 -57
- package/pulse/components/use-global-keyboard-nav.ts +0 -79
- package/pulse/components.json +0 -23
- package/pulse/eslint.config.mjs +0 -18
- package/pulse/lib/bahulam-paths.ts +0 -23
- package/pulse/lib/claude-reader.ts +0 -592
- package/pulse/lib/decode.ts +0 -129
- package/pulse/lib/pricing.ts +0 -102
- package/pulse/lib/replay-parser.ts +0 -165
- package/pulse/lib/tool-categories.ts +0 -140
- package/pulse/lib/utils.ts +0 -6
- package/pulse/next-env.d.ts +0 -6
- package/pulse/next.config.ts +0 -16
- package/pulse/package.json +0 -45
- package/pulse/postcss.config.mjs +0 -7
- package/pulse/public/activity.png +0 -0
- package/pulse/public/cc-lens.png +0 -0
- package/pulse/public/command-k.png +0 -0
- package/pulse/public/costs.png +0 -0
- package/pulse/public/dashboard-dark.png +0 -0
- package/pulse/public/dashboard-white.png +0 -0
- package/pulse/public/export.png +0 -0
- package/pulse/public/file.svg +0 -1
- package/pulse/public/globe.svg +0 -1
- package/pulse/public/next.svg +0 -1
- package/pulse/public/projects.png +0 -0
- package/pulse/public/session-chat.png +0 -0
- package/pulse/public/todos.png +0 -0
- package/pulse/public/tools.png +0 -0
- package/pulse/public/vercel.svg +0 -1
- package/pulse/public/window.svg +0 -1
- package/pulse/tsconfig.json +0 -34
- package/pulse/types/claude.ts +0 -294
|
@@ -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
|
|
|
@@ -729,7 +820,7 @@ export class TarangStreamClient {
|
|
|
729
820
|
tool,
|
|
730
821
|
args || {},
|
|
731
822
|
true,
|
|
732
|
-
{ risk, reason },
|
|
823
|
+
{ risk, reason, tool_id, call_id: tool_id, request_id: tool_id },
|
|
733
824
|
);
|
|
734
825
|
|
|
735
826
|
// Map ApprovalManager decision to framework scope
|
|
@@ -839,9 +930,10 @@ export class TarangStreamClient {
|
|
|
839
930
|
}
|
|
840
931
|
|
|
841
932
|
/**
|
|
842
|
-
* Submit a live-steering follow-up on the current running task
|
|
843
|
-
* Unlike resume(), this does NOT pause/unpause — the text
|
|
844
|
-
* delivered at the next tool boundary
|
|
933
|
+
* Submit a live-steering user follow-up on the current running task
|
|
934
|
+
* (PRD-081 §5.2). Unlike resume(), this does NOT pause/unpause — the text
|
|
935
|
+
* is queued and delivered at the next tool boundary as a high-priority user
|
|
936
|
+
* intervention, never as a tool call.
|
|
845
937
|
*
|
|
846
938
|
* Returns a status object; callers should NOT swallow errors:
|
|
847
939
|
* { status: 'accepted', interventionId } — queued on backend, SSE ack forthcoming
|
|
@@ -855,6 +947,7 @@ export class TarangStreamClient {
|
|
|
855
947
|
* @param {string} [opts.idempotencyKey] Optional client-generated id.
|
|
856
948
|
* Auto-generated when omitted; pass the same key
|
|
857
949
|
* for retries to stay idempotent.
|
|
950
|
+
* @param {string} [opts.priority] User-message priority metadata.
|
|
858
951
|
*/
|
|
859
952
|
async sendIntervention(instruction, opts = {}) {
|
|
860
953
|
if (!this.currentTaskId) {
|
|
@@ -876,6 +969,9 @@ export class TarangStreamClient {
|
|
|
876
969
|
body: JSON.stringify({
|
|
877
970
|
instruction: text,
|
|
878
971
|
intervention_id: interventionId,
|
|
972
|
+
role: 'user',
|
|
973
|
+
message_type: 'user_intervention',
|
|
974
|
+
priority: opts.priority || 'high',
|
|
879
975
|
}),
|
|
880
976
|
},
|
|
881
977
|
);
|
|
@@ -19,12 +19,14 @@ import { SkillsLoader } from '../skills/loader.mjs';
|
|
|
19
19
|
import { createAgentFile, listLocalAgents, syncAgentsToBackend } from '../agents/scaffold.mjs';
|
|
20
20
|
import { createWorkflowFile, listLocalWorkflows, WORKFLOW_SYNC_ENDPOINT, slugifyWorkflowName } from '../agents/workflow_scaffold.mjs';
|
|
21
21
|
import { TarangAuth } from '../auth/tarang-auth.mjs';
|
|
22
|
+
import { detectImageFile } from './attachments.mjs';
|
|
22
23
|
import { streamResponse } from './streaming.mjs';
|
|
23
24
|
import { sendApprovalDecision, sendCallback } from './callback-client.mjs';
|
|
24
25
|
import { HookRunner } from '../config/hook-runner.mjs';
|
|
25
26
|
import { buildFileDiff } from './file-diff.mjs';
|
|
26
27
|
import { buildWorkScope } from './work-scope.mjs';
|
|
27
28
|
import { loadDiskMemory, ensureBahulamDir, globalMemoryPath, projectMemoryPath } from './memory-disk.mjs';
|
|
29
|
+
import { resolveLintCommand } from './lint-resolver.mjs';
|
|
28
30
|
import * as fs from 'node:fs';
|
|
29
31
|
import * as os from 'node:os';
|
|
30
32
|
import * as path from 'node:path';
|
|
@@ -44,6 +46,8 @@ export function createToolExecutor({
|
|
|
44
46
|
checkpoints = null,
|
|
45
47
|
hookRunner = null,
|
|
46
48
|
interactionHandler = null,
|
|
49
|
+
onAutoRegisterStart = null,
|
|
50
|
+
onAutoRegisterDone = null,
|
|
47
51
|
} = {}) {
|
|
48
52
|
// Cross-session memory cache. Ships in getAgentContext() on every turn,
|
|
49
53
|
// so we need it to be byte-identical when the underlying disk file hasn't
|
|
@@ -94,9 +98,19 @@ export function createToolExecutor({
|
|
|
94
98
|
// for the current directory to be usable — the user chose to be here.
|
|
95
99
|
// Opt out via BAHULAM_SKIP_AUTO_REGISTER=true for tests or headless
|
|
96
100
|
// scripts that want a truly empty registry.
|
|
101
|
+
let autoRegisterPromise = Promise.resolve(null);
|
|
97
102
|
if (process.env.BAHULAM_SKIP_AUTO_REGISTER !== 'true') {
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
const autoRegisterRoot = process.cwd();
|
|
104
|
+
try { onAutoRegisterStart?.(autoRegisterRoot); } catch { /* status hooks are best-effort */ }
|
|
105
|
+
autoRegisterPromise = projectRegistry.register(autoRegisterRoot, { bypassProjectMarkers: true })
|
|
106
|
+
.then((result) => {
|
|
107
|
+
try { onAutoRegisterDone?.(null, result); } catch { /* status hooks are best-effort */ }
|
|
108
|
+
return result;
|
|
109
|
+
})
|
|
110
|
+
.catch((err) => {
|
|
111
|
+
try { onAutoRegisterDone?.(err, null); } catch { /* status hooks are best-effort */ }
|
|
112
|
+
return null;
|
|
113
|
+
});
|
|
100
114
|
}
|
|
101
115
|
let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
|
|
102
116
|
let _readOnlyCacheGeneration = 0;
|
|
@@ -475,15 +489,6 @@ export function createToolExecutor({
|
|
|
475
489
|
|
|
476
490
|
// ── Auto-lint after file writes ────────────────────────────
|
|
477
491
|
|
|
478
|
-
const LINT_COMMANDS = {
|
|
479
|
-
'.py': (file) => `python3 -m py_compile "${file}" 2>&1`,
|
|
480
|
-
'.js': (file) => `npx eslint --no-eslintrc --rule '{}' "${file}" 2>&1 || true`,
|
|
481
|
-
'.ts': (file) => `npx tsc --noEmit --pretty "${file}" 2>&1 || true`,
|
|
482
|
-
'.tsx': (file) => `npx tsc --noEmit --pretty "${file}" 2>&1 || true`,
|
|
483
|
-
'.go': (file) => `go vet "${file}" 2>&1`,
|
|
484
|
-
'.rs': (file) => `rustfmt --check "${file}" 2>&1`,
|
|
485
|
-
};
|
|
486
|
-
|
|
487
492
|
// tsc --pretty and eslint emit ANSI codes (including background-red
|
|
488
493
|
// highlights) which bleed when our renderer slices the first 80 chars.
|
|
489
494
|
// Strip color codes so the stored lint string is always plain text.
|
|
@@ -491,15 +496,19 @@ export function createToolExecutor({
|
|
|
491
496
|
function stripAnsi(s) { return String(s || '').replace(ANSI_RE, ''); }
|
|
492
497
|
|
|
493
498
|
function autoLint(filePath) {
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
|
|
499
|
+
const project = projectRegistry.projectForPath(filePath);
|
|
500
|
+
const lint = resolveLintCommand(filePath, {
|
|
501
|
+
projectRoot: project?.resource?.root || projectRootFor(filePath),
|
|
502
|
+
projectCommands: project?.resource?.commands || {},
|
|
503
|
+
allowProjectScript: false,
|
|
504
|
+
});
|
|
505
|
+
if (!lint?.command) return null;
|
|
497
506
|
|
|
498
507
|
try {
|
|
499
|
-
const output = execSync(
|
|
508
|
+
const output = execSync(lint.command, {
|
|
500
509
|
encoding: 'utf-8',
|
|
501
510
|
timeout: 15_000,
|
|
502
|
-
cwd:
|
|
511
|
+
cwd: lint.cwd,
|
|
503
512
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
504
513
|
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb' },
|
|
505
514
|
});
|
|
@@ -683,6 +692,52 @@ export function createToolExecutor({
|
|
|
683
692
|
};
|
|
684
693
|
},
|
|
685
694
|
|
|
695
|
+
analyze_image: async (args, options = {}) => {
|
|
696
|
+
throwIfAborted(options.signal);
|
|
697
|
+
const tool = occRegistry.get('analyze_image');
|
|
698
|
+
if (!tool) {
|
|
699
|
+
return { success: false, output: 'analyze_image tool is not registered in the CLI.', _tool: 'analyze_image' };
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
const result = await tool.call(args || {});
|
|
703
|
+
throwIfAborted(options.signal);
|
|
704
|
+
if (typeof result === 'string') {
|
|
705
|
+
return { success: !/^error\b/i.test(result), output: result, _tool: 'analyze_image' };
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
success: result?.success !== false,
|
|
709
|
+
output: result?.output || result?.summary || JSON.stringify(result),
|
|
710
|
+
...result,
|
|
711
|
+
_tool: 'analyze_image',
|
|
712
|
+
};
|
|
713
|
+
} catch (err) {
|
|
714
|
+
return { success: false, output: String(err?.message || err), _tool: 'analyze_image' };
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
|
|
718
|
+
generate_image: async (args, options = {}) => {
|
|
719
|
+
throwIfAborted(options.signal);
|
|
720
|
+
const tool = occRegistry.get('generate_image');
|
|
721
|
+
if (!tool) {
|
|
722
|
+
return { success: false, output: 'generate_image tool is not registered in the CLI.', _tool: 'generate_image' };
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const result = await tool.call(args || {});
|
|
726
|
+
throwIfAborted(options.signal);
|
|
727
|
+
if (typeof result === 'string') {
|
|
728
|
+
return { success: !/^error\b/i.test(result), output: result, _tool: 'generate_image' };
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
success: result?.success !== false,
|
|
732
|
+
output: result?.output || JSON.stringify(result),
|
|
733
|
+
...result,
|
|
734
|
+
_tool: 'generate_image',
|
|
735
|
+
};
|
|
736
|
+
} catch (err) {
|
|
737
|
+
return { success: false, output: String(err?.message || err), _tool: 'generate_image' };
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
|
|
686
741
|
// 0b. read_attachment → chunked text extraction from a local document.
|
|
687
742
|
// Backend registers the schema; execution happens client-side because
|
|
688
743
|
// only the CLI has the user's filesystem. Supports the `path` mode
|
|
@@ -722,6 +777,17 @@ export function createToolExecutor({
|
|
|
722
777
|
return { success: false, output: String(err?.message || err), _tool: 'read_attachment' };
|
|
723
778
|
}
|
|
724
779
|
|
|
780
|
+
const imageInfo = detectImageFile(abs);
|
|
781
|
+
if (imageInfo) {
|
|
782
|
+
return {
|
|
783
|
+
success: false,
|
|
784
|
+
output: `This file is an image (${imageInfo.mime_type}). read_attachment only extracts text documents. Use analyze_image with a specific question. If analyze_image already failed, report that vision error directly instead of retrying this image with read_attachment.`,
|
|
785
|
+
_tool: 'read_attachment',
|
|
786
|
+
_path: abs,
|
|
787
|
+
_mime: imageInfo.mime_type,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
725
791
|
const { extractFromPath } = await import('../context/prose-chunker.mjs');
|
|
726
792
|
let mime, chunks;
|
|
727
793
|
try {
|
|
@@ -735,7 +801,7 @@ export function createToolExecutor({
|
|
|
735
801
|
if (!chunks.length) {
|
|
736
802
|
return {
|
|
737
803
|
success: false,
|
|
738
|
-
output: `Unsupported or empty file (mime=${mime}). Supported: pdf, txt, md/mdx, csv, tsv, json, yaml, toml, html, log, rst. For CSV/Excel analysis use read_table; for images use analyze_image.`,
|
|
804
|
+
output: `Unsupported or empty file (mime=${mime}). Supported text: pdf, txt, md/mdx, ipynb, csv, tsv, json, yaml, toml, xml, html, log, rst, sql, sh, .env, .ini, Dockerfile, .gitignore — plus any file whose first 8KB is valid UTF-8 (auto-sniffed). For CSV/Excel analysis use read_table; for images use analyze_image. If analyze_image already failed for this file, report that vision error directly instead of retrying with read_attachment.`,
|
|
739
805
|
_tool: 'read_attachment',
|
|
740
806
|
};
|
|
741
807
|
}
|
|
@@ -954,10 +1020,12 @@ export function createToolExecutor({
|
|
|
954
1020
|
filteredLines: rawOutput.split('\n').length,
|
|
955
1021
|
}
|
|
956
1022
|
: filterOutput(rawOutput, args.command, success);
|
|
1023
|
+
const agentOutput = observationTimeout && timedOut ? filtered.output : rawOutput;
|
|
957
1024
|
|
|
958
1025
|
return {
|
|
959
1026
|
success,
|
|
960
|
-
output:
|
|
1027
|
+
output: agentOutput,
|
|
1028
|
+
output_preview: filtered.output,
|
|
961
1029
|
exit_code: exitCode,
|
|
962
1030
|
_tool: 'shell',
|
|
963
1031
|
_classification: args._classification,
|
|
@@ -1596,24 +1664,44 @@ print('OK: replaced')
|
|
|
1596
1664
|
try {
|
|
1597
1665
|
throwIfAborted(options.signal);
|
|
1598
1666
|
const filePath = await resolvePath(args.file_path || args.path, args);
|
|
1599
|
-
const
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1667
|
+
const project = projectRegistry.projectForPath(filePath);
|
|
1668
|
+
const lint = resolveLintCommand(filePath, {
|
|
1669
|
+
projectRoot: project?.resource?.root || projectRootFor(filePath),
|
|
1670
|
+
projectCommands: project?.resource?.commands || {},
|
|
1671
|
+
allowProjectScript: true,
|
|
1672
|
+
});
|
|
1673
|
+
if (!lint?.command) {
|
|
1674
|
+
return {
|
|
1675
|
+
success: true,
|
|
1676
|
+
issues: [],
|
|
1677
|
+
message: 'No project-aware linter for this path or file type',
|
|
1678
|
+
_tool: 'lint_check',
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1604
1681
|
|
|
1605
|
-
const
|
|
1606
|
-
command:
|
|
1682
|
+
const shellResult = await executeToolWithHooks('shell', {
|
|
1683
|
+
command: lint.command,
|
|
1607
1684
|
timeout: 30_000,
|
|
1608
|
-
description: `Lint: ${path.basename(filePath)}`,
|
|
1609
|
-
cwd:
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1685
|
+
description: `Lint: ${path.basename(filePath)} (${lint.source})`,
|
|
1686
|
+
cwd: lint.cwd,
|
|
1687
|
+
}, options);
|
|
1688
|
+
const rawOutput = typeof shellResult?.output === 'string'
|
|
1689
|
+
? shellResult.output
|
|
1690
|
+
: (typeof shellResult === 'string' ? shellResult : String(shellResult));
|
|
1613
1691
|
if (/^Error:\s*Command cancelled by user/i.test(rawOutput)) {
|
|
1614
1692
|
return { success: false, output: 'Cancelled by user', _cancelled: true, _tool: 'lint_check' };
|
|
1615
1693
|
}
|
|
1616
|
-
|
|
1694
|
+
const normalizedOutput = rawOutput === '(no output)' ? '' : rawOutput;
|
|
1695
|
+
return {
|
|
1696
|
+
success: true,
|
|
1697
|
+
output: normalizedOutput || 'No lint issues found.',
|
|
1698
|
+
command: lint.command,
|
|
1699
|
+
cwd: lint.cwd,
|
|
1700
|
+
language: lint.language,
|
|
1701
|
+
lint_source: lint.source,
|
|
1702
|
+
issues: normalizedOutput.split('\n').filter(Boolean),
|
|
1703
|
+
_tool: 'lint_check',
|
|
1704
|
+
};
|
|
1617
1705
|
} catch (err) {
|
|
1618
1706
|
if (isAbortError(err) || options.signal?.aborted) return cancelledToolResult('lint_check');
|
|
1619
1707
|
return { success: false, output: err.message, _tool: 'lint_check' };
|
|
@@ -2203,6 +2291,10 @@ print('OK: replaced')
|
|
|
2203
2291
|
return projectRegistry.resources();
|
|
2204
2292
|
},
|
|
2205
2293
|
|
|
2294
|
+
waitForAutoRegister() {
|
|
2295
|
+
return autoRegisterPromise;
|
|
2296
|
+
},
|
|
2297
|
+
|
|
2206
2298
|
async registerProjectRoots(roots, { forceRefresh = false } = {}) {
|
|
2207
2299
|
const results = [];
|
|
2208
2300
|
const seen = new Set();
|