@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.
Files changed (179) hide show
  1. package/LICENSE +1 -1
  2. package/NOTICE +6 -6
  3. package/README.md +10 -17
  4. package/package.json +9 -9
  5. package/src/agents/loader.mjs +1 -1
  6. package/src/agents/workflow_loader.mjs +2 -2
  7. package/src/auth/tarang-auth.mjs +3 -2
  8. package/src/config/model-catalog-default.json +4 -3
  9. package/src/config/model-catalog.mjs +2 -0
  10. package/src/context/prose-chunker.mjs +162 -5
  11. package/src/core/attachments.mjs +65 -3
  12. package/src/core/bundled-runtime.mjs +1 -1
  13. package/src/core/headless.mjs +14 -0
  14. package/src/core/jsonl-writer.mjs +3 -3
  15. package/src/core/lint-resolver.mjs +472 -0
  16. package/src/core/local-agent.mjs +15 -4
  17. package/src/core/local-store.mjs +4 -0
  18. package/src/core/output-filter.mjs +21 -25
  19. package/src/core/pricing.mjs +10 -3
  20. package/src/core/stream-client.mjs +112 -16
  21. package/src/core/tool-executor.mjs +123 -31
  22. package/src/daemon/http-dashboard.mjs +400 -0
  23. package/src/daemon/session-core.mjs +1 -0
  24. package/src/local-service/agent-relay.mjs +868 -0
  25. package/src/local-service/approval-bridge.mjs +222 -0
  26. package/src/local-service/browser.mjs +24 -0
  27. package/src/local-service/command.mjs +155 -0
  28. package/src/local-service/file-access.mjs +393 -0
  29. package/src/local-service/machine.mjs +86 -0
  30. package/src/local-service/paths.mjs +29 -0
  31. package/src/local-service/preview-converters.mjs +260 -0
  32. package/src/local-service/server.mjs +2644 -0
  33. package/src/local-service/session-store.mjs +221 -0
  34. package/src/onboarding/preflight.mjs +1 -1
  35. package/src/terminal/analytics.mjs +2 -6
  36. package/src/terminal/ansi.mjs +11 -3
  37. package/src/terminal/main.mjs +37 -11
  38. package/src/terminal/model-catalog-display.mjs +111 -0
  39. package/src/terminal/repl-format.mjs +4 -1
  40. package/src/terminal/repl-model-form.mjs +81 -14
  41. package/src/terminal/repl-render.mjs +35 -5
  42. package/src/terminal/repl-state.mjs +12 -0
  43. package/src/terminal/repl.mjs +255 -55
  44. package/src/terminal/tool-display.mjs +32 -0
  45. package/src/terminal/watch-state.mjs +118 -0
  46. package/src/tools/analyze-image.mjs +158 -0
  47. package/src/tools/bash.mjs +11 -9
  48. package/src/tools/generate-image.mjs +411 -0
  49. package/src/tools/lint.mjs +24 -43
  50. package/src/tools/project-overview.mjs +5 -2
  51. package/src/tools/registry.mjs +4 -0
  52. package/src/ui/icons.mjs +2 -0
  53. package/src/ui/palette.mjs +1 -1
  54. package/src/ui/slash-commands.mjs +4 -6
  55. package/src/ui/tool-card.mjs +7 -3
  56. package/pulse/app/activity/page.tsx +0 -190
  57. package/pulse/app/api/activity/route.ts +0 -138
  58. package/pulse/app/api/benchmark/route.ts +0 -113
  59. package/pulse/app/api/benchmarks/route.ts +0 -195
  60. package/pulse/app/api/costs/route.ts +0 -88
  61. package/pulse/app/api/export/route.ts +0 -77
  62. package/pulse/app/api/history/route.ts +0 -11
  63. package/pulse/app/api/import/route.ts +0 -31
  64. package/pulse/app/api/memory/route.ts +0 -50
  65. package/pulse/app/api/plans/route.ts +0 -9
  66. package/pulse/app/api/projects/[slug]/route.ts +0 -96
  67. package/pulse/app/api/projects/route.ts +0 -121
  68. package/pulse/app/api/sessions/[id]/replay/route.ts +0 -20
  69. package/pulse/app/api/sessions/[id]/route.ts +0 -31
  70. package/pulse/app/api/sessions/route.ts +0 -112
  71. package/pulse/app/api/settings/route.ts +0 -14
  72. package/pulse/app/api/stats/route.ts +0 -143
  73. package/pulse/app/api/todos/route.ts +0 -9
  74. package/pulse/app/api/tools/route.ts +0 -160
  75. package/pulse/app/benchmarks/page.tsx +0 -224
  76. package/pulse/app/costs/page.tsx +0 -179
  77. package/pulse/app/export/page.tsx +0 -465
  78. package/pulse/app/favicon.ico +0 -0
  79. package/pulse/app/globals.css +0 -263
  80. package/pulse/app/help/page.tsx +0 -143
  81. package/pulse/app/history/page.tsx +0 -157
  82. package/pulse/app/layout.tsx +0 -46
  83. package/pulse/app/memory/page.tsx +0 -365
  84. package/pulse/app/overview-client.tsx +0 -393
  85. package/pulse/app/page.tsx +0 -14
  86. package/pulse/app/plans/page.tsx +0 -308
  87. package/pulse/app/projects/[slug]/page.tsx +0 -390
  88. package/pulse/app/projects/page.tsx +0 -110
  89. package/pulse/app/sessions/[id]/page.tsx +0 -243
  90. package/pulse/app/sessions/page.tsx +0 -39
  91. package/pulse/app/settings/page.tsx +0 -188
  92. package/pulse/app/todos/page.tsx +0 -211
  93. package/pulse/app/tools/page.tsx +0 -249
  94. package/pulse/cli.js +0 -164
  95. package/pulse/components/activity/day-of-week-chart.tsx +0 -35
  96. package/pulse/components/activity/streak-card.tsx +0 -36
  97. package/pulse/components/costs/cache-efficiency-panel.tsx +0 -76
  98. package/pulse/components/costs/cost-by-project-chart.tsx +0 -48
  99. package/pulse/components/costs/cost-over-time-chart.tsx +0 -95
  100. package/pulse/components/costs/model-token-table.tsx +0 -60
  101. package/pulse/components/global-search.tsx +0 -193
  102. package/pulse/components/keyboard-nav-provider.tsx +0 -23
  103. package/pulse/components/layout/bottom-nav.tsx +0 -53
  104. package/pulse/components/layout/client-layout.tsx +0 -31
  105. package/pulse/components/layout/sidebar-context.tsx +0 -50
  106. package/pulse/components/layout/sidebar.tsx +0 -183
  107. package/pulse/components/layout/top-bar.tsx +0 -121
  108. package/pulse/components/overview/activity-heatmap.tsx +0 -107
  109. package/pulse/components/overview/conversation-table.tsx +0 -148
  110. package/pulse/components/overview/model-breakdown-donut.tsx +0 -95
  111. package/pulse/components/overview/peak-hours-chart.tsx +0 -87
  112. package/pulse/components/overview/project-activity-donut.tsx +0 -96
  113. package/pulse/components/overview/stat-card.tsx +0 -102
  114. package/pulse/components/overview/usage-over-time-chart.tsx +0 -166
  115. package/pulse/components/projects/project-card.tsx +0 -175
  116. package/pulse/components/sessions/replay/assistant-markdown.tsx +0 -94
  117. package/pulse/components/sessions/replay/compaction-card.tsx +0 -25
  118. package/pulse/components/sessions/replay/session-sidebar.tsx +0 -231
  119. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +0 -98
  120. package/pulse/components/sessions/replay/tool-call-badge.tsx +0 -127
  121. package/pulse/components/sessions/replay/turn-cards.tsx +0 -220
  122. package/pulse/components/sessions/replay/user-tool-result.tsx +0 -158
  123. package/pulse/components/sessions/session-badges.tsx +0 -49
  124. package/pulse/components/sessions/session-table.tsx +0 -299
  125. package/pulse/components/theme-provider.tsx +0 -44
  126. package/pulse/components/tools/feature-adoption-table.tsx +0 -58
  127. package/pulse/components/tools/mcp-server-panel.tsx +0 -45
  128. package/pulse/components/tools/tool-ranking-chart.tsx +0 -57
  129. package/pulse/components/tools/version-history-table.tsx +0 -32
  130. package/pulse/components/ui/alert.tsx +0 -66
  131. package/pulse/components/ui/badge.tsx +0 -48
  132. package/pulse/components/ui/breadcrumb.tsx +0 -109
  133. package/pulse/components/ui/button.tsx +0 -64
  134. package/pulse/components/ui/calendar.tsx +0 -220
  135. package/pulse/components/ui/card.tsx +0 -92
  136. package/pulse/components/ui/command.tsx +0 -158
  137. package/pulse/components/ui/dialog.tsx +0 -158
  138. package/pulse/components/ui/input.tsx +0 -21
  139. package/pulse/components/ui/popover.tsx +0 -89
  140. package/pulse/components/ui/progress.tsx +0 -31
  141. package/pulse/components/ui/select.tsx +0 -190
  142. package/pulse/components/ui/separator.tsx +0 -28
  143. package/pulse/components/ui/sheet.tsx +0 -143
  144. package/pulse/components/ui/skeleton.tsx +0 -13
  145. package/pulse/components/ui/table.tsx +0 -116
  146. package/pulse/components/ui/tabs.tsx +0 -91
  147. package/pulse/components/ui/tooltip.tsx +0 -57
  148. package/pulse/components/use-global-keyboard-nav.ts +0 -79
  149. package/pulse/components.json +0 -23
  150. package/pulse/eslint.config.mjs +0 -18
  151. package/pulse/lib/bahulam-paths.ts +0 -23
  152. package/pulse/lib/claude-reader.ts +0 -592
  153. package/pulse/lib/decode.ts +0 -129
  154. package/pulse/lib/pricing.ts +0 -102
  155. package/pulse/lib/replay-parser.ts +0 -165
  156. package/pulse/lib/tool-categories.ts +0 -140
  157. package/pulse/lib/utils.ts +0 -6
  158. package/pulse/next-env.d.ts +0 -6
  159. package/pulse/next.config.ts +0 -16
  160. package/pulse/package.json +0 -45
  161. package/pulse/postcss.config.mjs +0 -7
  162. package/pulse/public/activity.png +0 -0
  163. package/pulse/public/cc-lens.png +0 -0
  164. package/pulse/public/command-k.png +0 -0
  165. package/pulse/public/costs.png +0 -0
  166. package/pulse/public/dashboard-dark.png +0 -0
  167. package/pulse/public/dashboard-white.png +0 -0
  168. package/pulse/public/export.png +0 -0
  169. package/pulse/public/file.svg +0 -1
  170. package/pulse/public/globe.svg +0 -1
  171. package/pulse/public/next.svg +0 -1
  172. package/pulse/public/projects.png +0 -0
  173. package/pulse/public/session-chat.png +0 -0
  174. package/pulse/public/todos.png +0 -0
  175. package/pulse/public/tools.png +0 -0
  176. package/pulse/public/vercel.svg +0 -1
  177. package/pulse/public/window.svg +0 -1
  178. package/pulse/tsconfig.json +0 -34
  179. 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
- yield { type: EVENT_TYPES.ERROR, data: { message: `Network error: ${err.message}. Check your connection or use --local mode.`, fatal: true } };
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
- for await (const parsed of this._parseSSE(response)) {
334
- if (this._cancelled) return;
335
- await this._waitIfPaused();
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
- if (parsed.id != null) this.lastEventId = parsed.id;
339
- if (parsed.retry != null) this.retryDelayMs = parsed.retry;
340
- if (parsed.data?.task_id) this.currentTaskId = parsed.data.task_id;
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
- for await (const event of this._handleStreamEvent(parsed)) {
343
- yield event;
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 (PRD-081 §5.2).
843
- * Unlike resume(), this does NOT pause/unpause — the text is queued and
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
- projectRegistry.register(process.cwd(), { bypassProjectMarkers: true })
99
- .catch(() => { /* silent model can register explicitly */ });
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 ext = path.extname(filePath);
495
- const cmdFn = LINT_COMMANDS[ext];
496
- if (!cmdFn) return null;
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(cmdFn(filePath), {
508
+ const output = execSync(lint.command, {
500
509
  encoding: 'utf-8',
501
510
  timeout: 15_000,
502
- cwd: process.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: filtered.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 ext = path.extname(filePath);
1600
- let cmd;
1601
- if (ext === '.py') cmd = `python3 -m ruff check "${filePath}" 2>&1 || true`;
1602
- else if (['.js', '.mjs', '.ts', '.tsx'].includes(ext)) cmd = `npx eslint "${filePath}" 2>&1 || true`;
1603
- else return { success: true, issues: [], message: 'No linter for this file type', _tool: 'lint_check' };
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 output = await occRegistry.call('shell', {
1606
- command: cmd,
1682
+ const shellResult = await executeToolWithHooks('shell', {
1683
+ command: lint.command,
1607
1684
  timeout: 30_000,
1608
- description: `Lint: ${path.basename(filePath)}`,
1609
- cwd: projectRootFor(filePath),
1610
- signal: options.signal,
1611
- });
1612
- const rawOutput = typeof output === 'string' ? output : String(output);
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
- return { success: true, output: rawOutput, issues: rawOutput.split('\n').filter(Boolean), _tool: 'lint_check' };
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();