@bahulam/code 0.1.6 → 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 (178) 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 +9 -4
  21. package/src/core/tool-executor.mjs +105 -29
  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 +231 -53
  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/registry.mjs +4 -0
  51. package/src/ui/icons.mjs +2 -0
  52. package/src/ui/palette.mjs +1 -1
  53. package/src/ui/slash-commands.mjs +4 -6
  54. package/src/ui/tool-card.mjs +7 -3
  55. package/pulse/app/activity/page.tsx +0 -190
  56. package/pulse/app/api/activity/route.ts +0 -138
  57. package/pulse/app/api/benchmark/route.ts +0 -113
  58. package/pulse/app/api/benchmarks/route.ts +0 -195
  59. package/pulse/app/api/costs/route.ts +0 -88
  60. package/pulse/app/api/export/route.ts +0 -77
  61. package/pulse/app/api/history/route.ts +0 -11
  62. package/pulse/app/api/import/route.ts +0 -31
  63. package/pulse/app/api/memory/route.ts +0 -50
  64. package/pulse/app/api/plans/route.ts +0 -9
  65. package/pulse/app/api/projects/[slug]/route.ts +0 -96
  66. package/pulse/app/api/projects/route.ts +0 -121
  67. package/pulse/app/api/sessions/[id]/replay/route.ts +0 -20
  68. package/pulse/app/api/sessions/[id]/route.ts +0 -31
  69. package/pulse/app/api/sessions/route.ts +0 -112
  70. package/pulse/app/api/settings/route.ts +0 -14
  71. package/pulse/app/api/stats/route.ts +0 -143
  72. package/pulse/app/api/todos/route.ts +0 -9
  73. package/pulse/app/api/tools/route.ts +0 -160
  74. package/pulse/app/benchmarks/page.tsx +0 -224
  75. package/pulse/app/costs/page.tsx +0 -179
  76. package/pulse/app/export/page.tsx +0 -465
  77. package/pulse/app/favicon.ico +0 -0
  78. package/pulse/app/globals.css +0 -263
  79. package/pulse/app/help/page.tsx +0 -143
  80. package/pulse/app/history/page.tsx +0 -157
  81. package/pulse/app/layout.tsx +0 -46
  82. package/pulse/app/memory/page.tsx +0 -365
  83. package/pulse/app/overview-client.tsx +0 -393
  84. package/pulse/app/page.tsx +0 -14
  85. package/pulse/app/plans/page.tsx +0 -308
  86. package/pulse/app/projects/[slug]/page.tsx +0 -390
  87. package/pulse/app/projects/page.tsx +0 -110
  88. package/pulse/app/sessions/[id]/page.tsx +0 -243
  89. package/pulse/app/sessions/page.tsx +0 -39
  90. package/pulse/app/settings/page.tsx +0 -188
  91. package/pulse/app/todos/page.tsx +0 -211
  92. package/pulse/app/tools/page.tsx +0 -249
  93. package/pulse/cli.js +0 -164
  94. package/pulse/components/activity/day-of-week-chart.tsx +0 -35
  95. package/pulse/components/activity/streak-card.tsx +0 -36
  96. package/pulse/components/costs/cache-efficiency-panel.tsx +0 -76
  97. package/pulse/components/costs/cost-by-project-chart.tsx +0 -48
  98. package/pulse/components/costs/cost-over-time-chart.tsx +0 -95
  99. package/pulse/components/costs/model-token-table.tsx +0 -60
  100. package/pulse/components/global-search.tsx +0 -193
  101. package/pulse/components/keyboard-nav-provider.tsx +0 -23
  102. package/pulse/components/layout/bottom-nav.tsx +0 -53
  103. package/pulse/components/layout/client-layout.tsx +0 -31
  104. package/pulse/components/layout/sidebar-context.tsx +0 -50
  105. package/pulse/components/layout/sidebar.tsx +0 -183
  106. package/pulse/components/layout/top-bar.tsx +0 -121
  107. package/pulse/components/overview/activity-heatmap.tsx +0 -107
  108. package/pulse/components/overview/conversation-table.tsx +0 -148
  109. package/pulse/components/overview/model-breakdown-donut.tsx +0 -95
  110. package/pulse/components/overview/peak-hours-chart.tsx +0 -87
  111. package/pulse/components/overview/project-activity-donut.tsx +0 -96
  112. package/pulse/components/overview/stat-card.tsx +0 -102
  113. package/pulse/components/overview/usage-over-time-chart.tsx +0 -166
  114. package/pulse/components/projects/project-card.tsx +0 -175
  115. package/pulse/components/sessions/replay/assistant-markdown.tsx +0 -94
  116. package/pulse/components/sessions/replay/compaction-card.tsx +0 -25
  117. package/pulse/components/sessions/replay/session-sidebar.tsx +0 -231
  118. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +0 -98
  119. package/pulse/components/sessions/replay/tool-call-badge.tsx +0 -127
  120. package/pulse/components/sessions/replay/turn-cards.tsx +0 -220
  121. package/pulse/components/sessions/replay/user-tool-result.tsx +0 -158
  122. package/pulse/components/sessions/session-badges.tsx +0 -49
  123. package/pulse/components/sessions/session-table.tsx +0 -299
  124. package/pulse/components/theme-provider.tsx +0 -44
  125. package/pulse/components/tools/feature-adoption-table.tsx +0 -58
  126. package/pulse/components/tools/mcp-server-panel.tsx +0 -45
  127. package/pulse/components/tools/tool-ranking-chart.tsx +0 -57
  128. package/pulse/components/tools/version-history-table.tsx +0 -32
  129. package/pulse/components/ui/alert.tsx +0 -66
  130. package/pulse/components/ui/badge.tsx +0 -48
  131. package/pulse/components/ui/breadcrumb.tsx +0 -109
  132. package/pulse/components/ui/button.tsx +0 -64
  133. package/pulse/components/ui/calendar.tsx +0 -220
  134. package/pulse/components/ui/card.tsx +0 -92
  135. package/pulse/components/ui/command.tsx +0 -158
  136. package/pulse/components/ui/dialog.tsx +0 -158
  137. package/pulse/components/ui/input.tsx +0 -21
  138. package/pulse/components/ui/popover.tsx +0 -89
  139. package/pulse/components/ui/progress.tsx +0 -31
  140. package/pulse/components/ui/select.tsx +0 -190
  141. package/pulse/components/ui/separator.tsx +0 -28
  142. package/pulse/components/ui/sheet.tsx +0 -143
  143. package/pulse/components/ui/skeleton.tsx +0 -13
  144. package/pulse/components/ui/table.tsx +0 -116
  145. package/pulse/components/ui/tabs.tsx +0 -91
  146. package/pulse/components/ui/tooltip.tsx +0 -57
  147. package/pulse/components/use-global-keyboard-nav.ts +0 -79
  148. package/pulse/components.json +0 -23
  149. package/pulse/eslint.config.mjs +0 -18
  150. package/pulse/lib/bahulam-paths.ts +0 -23
  151. package/pulse/lib/claude-reader.ts +0 -592
  152. package/pulse/lib/decode.ts +0 -129
  153. package/pulse/lib/pricing.ts +0 -102
  154. package/pulse/lib/replay-parser.ts +0 -165
  155. package/pulse/lib/tool-categories.ts +0 -140
  156. package/pulse/lib/utils.ts +0 -6
  157. package/pulse/next-env.d.ts +0 -6
  158. package/pulse/next.config.ts +0 -16
  159. package/pulse/package.json +0 -45
  160. package/pulse/postcss.config.mjs +0 -7
  161. package/pulse/public/activity.png +0 -0
  162. package/pulse/public/cc-lens.png +0 -0
  163. package/pulse/public/command-k.png +0 -0
  164. package/pulse/public/costs.png +0 -0
  165. package/pulse/public/dashboard-dark.png +0 -0
  166. package/pulse/public/dashboard-white.png +0 -0
  167. package/pulse/public/export.png +0 -0
  168. package/pulse/public/file.svg +0 -1
  169. package/pulse/public/globe.svg +0 -1
  170. package/pulse/public/next.svg +0 -1
  171. package/pulse/public/projects.png +0 -0
  172. package/pulse/public/session-chat.png +0 -0
  173. package/pulse/public/todos.png +0 -0
  174. package/pulse/public/tools.png +0 -0
  175. package/pulse/public/vercel.svg +0 -1
  176. package/pulse/public/window.svg +0 -1
  177. package/pulse/tsconfig.json +0 -34
  178. package/pulse/types/claude.ts +0 -294
@@ -820,7 +820,7 @@ export class TarangStreamClient {
820
820
  tool,
821
821
  args || {},
822
822
  true,
823
- { risk, reason },
823
+ { risk, reason, tool_id, call_id: tool_id, request_id: tool_id },
824
824
  );
825
825
 
826
826
  // Map ApprovalManager decision to framework scope
@@ -930,9 +930,10 @@ export class TarangStreamClient {
930
930
  }
931
931
 
932
932
  /**
933
- * Submit a live-steering follow-up on the current running task (PRD-081 §5.2).
934
- * Unlike resume(), this does NOT pause/unpause — the text is queued and
935
- * 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.
936
937
  *
937
938
  * Returns a status object; callers should NOT swallow errors:
938
939
  * { status: 'accepted', interventionId } — queued on backend, SSE ack forthcoming
@@ -946,6 +947,7 @@ export class TarangStreamClient {
946
947
  * @param {string} [opts.idempotencyKey] Optional client-generated id.
947
948
  * Auto-generated when omitted; pass the same key
948
949
  * for retries to stay idempotent.
950
+ * @param {string} [opts.priority] User-message priority metadata.
949
951
  */
950
952
  async sendIntervention(instruction, opts = {}) {
951
953
  if (!this.currentTaskId) {
@@ -967,6 +969,9 @@ export class TarangStreamClient {
967
969
  body: JSON.stringify({
968
970
  instruction: text,
969
971
  intervention_id: interventionId,
972
+ role: 'user',
973
+ message_type: 'user_intervention',
974
+ priority: opts.priority || 'high',
970
975
  }),
971
976
  },
972
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';
@@ -487,15 +489,6 @@ export function createToolExecutor({
487
489
 
488
490
  // ── Auto-lint after file writes ────────────────────────────
489
491
 
490
- const LINT_COMMANDS = {
491
- '.py': (file) => `python3 -m py_compile "${file}" 2>&1`,
492
- '.js': (file) => `npx eslint --no-eslintrc --rule '{}' "${file}" 2>&1 || true`,
493
- '.ts': (file) => `npx tsc --noEmit --pretty "${file}" 2>&1 || true`,
494
- '.tsx': (file) => `npx tsc --noEmit --pretty "${file}" 2>&1 || true`,
495
- '.go': (file) => `go vet "${file}" 2>&1`,
496
- '.rs': (file) => `rustfmt --check "${file}" 2>&1`,
497
- };
498
-
499
492
  // tsc --pretty and eslint emit ANSI codes (including background-red
500
493
  // highlights) which bleed when our renderer slices the first 80 chars.
501
494
  // Strip color codes so the stored lint string is always plain text.
@@ -503,15 +496,19 @@ export function createToolExecutor({
503
496
  function stripAnsi(s) { return String(s || '').replace(ANSI_RE, ''); }
504
497
 
505
498
  function autoLint(filePath) {
506
- const ext = path.extname(filePath);
507
- const cmdFn = LINT_COMMANDS[ext];
508
- 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;
509
506
 
510
507
  try {
511
- const output = execSync(cmdFn(filePath), {
508
+ const output = execSync(lint.command, {
512
509
  encoding: 'utf-8',
513
510
  timeout: 15_000,
514
- cwd: process.cwd(),
511
+ cwd: lint.cwd,
515
512
  stdio: ['pipe', 'pipe', 'pipe'],
516
513
  env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb' },
517
514
  });
@@ -695,6 +692,52 @@ export function createToolExecutor({
695
692
  };
696
693
  },
697
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
+
698
741
  // 0b. read_attachment → chunked text extraction from a local document.
699
742
  // Backend registers the schema; execution happens client-side because
700
743
  // only the CLI has the user's filesystem. Supports the `path` mode
@@ -734,6 +777,17 @@ export function createToolExecutor({
734
777
  return { success: false, output: String(err?.message || err), _tool: 'read_attachment' };
735
778
  }
736
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
+
737
791
  const { extractFromPath } = await import('../context/prose-chunker.mjs');
738
792
  let mime, chunks;
739
793
  try {
@@ -747,7 +801,7 @@ export function createToolExecutor({
747
801
  if (!chunks.length) {
748
802
  return {
749
803
  success: false,
750
- 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.`,
751
805
  _tool: 'read_attachment',
752
806
  };
753
807
  }
@@ -966,10 +1020,12 @@ export function createToolExecutor({
966
1020
  filteredLines: rawOutput.split('\n').length,
967
1021
  }
968
1022
  : filterOutput(rawOutput, args.command, success);
1023
+ const agentOutput = observationTimeout && timedOut ? filtered.output : rawOutput;
969
1024
 
970
1025
  return {
971
1026
  success,
972
- output: filtered.output,
1027
+ output: agentOutput,
1028
+ output_preview: filtered.output,
973
1029
  exit_code: exitCode,
974
1030
  _tool: 'shell',
975
1031
  _classification: args._classification,
@@ -1608,24 +1664,44 @@ print('OK: replaced')
1608
1664
  try {
1609
1665
  throwIfAborted(options.signal);
1610
1666
  const filePath = await resolvePath(args.file_path || args.path, args);
1611
- const ext = path.extname(filePath);
1612
- let cmd;
1613
- if (ext === '.py') cmd = `python3 -m ruff check "${filePath}" 2>&1 || true`;
1614
- else if (['.js', '.mjs', '.ts', '.tsx'].includes(ext)) cmd = `npx eslint "${filePath}" 2>&1 || true`;
1615
- 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
+ }
1616
1681
 
1617
- const output = await occRegistry.call('shell', {
1618
- command: cmd,
1682
+ const shellResult = await executeToolWithHooks('shell', {
1683
+ command: lint.command,
1619
1684
  timeout: 30_000,
1620
- description: `Lint: ${path.basename(filePath)}`,
1621
- cwd: projectRootFor(filePath),
1622
- signal: options.signal,
1623
- });
1624
- 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));
1625
1691
  if (/^Error:\s*Command cancelled by user/i.test(rawOutput)) {
1626
1692
  return { success: false, output: 'Cancelled by user', _cancelled: true, _tool: 'lint_check' };
1627
1693
  }
1628
- 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
+ };
1629
1705
  } catch (err) {
1630
1706
  if (isAbortError(err) || options.signal?.aborted) return cancelledToolResult('lint_check');
1631
1707
  return { success: false, output: err.message, _tool: 'lint_check' };
@@ -0,0 +1,400 @@
1
+ /**
2
+ * HTTP + WebSocket dashboard — mirrors the CLI session in a browser.
3
+ *
4
+ * Uses ONLY Node built-ins (http, crypto, buffer). No npm dependencies.
5
+ * Serves an inline single-page HTML dashboard with a WebSocket-backed event
6
+ * stream showing agent/sub-agent activity, tool calls, handoffs, spawns.
7
+ *
8
+ * Architecture (matching socket-server.mjs):
9
+ * startHttpDashboard({ sessionId, port }) -> { url, broadcastEvent, close }
10
+ * - Registers itself via registerBroadcaster(fn) from event-tap.mjs
11
+ * - Binds to 127.0.0.1 only
12
+ * - WebSocket at /ws (RFC 6455 handshake)
13
+ * - Dashboard HTML served at /
14
+ * - Auth token in URL path for basic access control
15
+ *
16
+ * @module
17
+ */
18
+ import http from 'node:http';
19
+ import crypto from 'node:crypto';
20
+ import { registerBroadcaster } from './event-tap.mjs';
21
+
22
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
23
+
24
+ /**
25
+ * @param {object} opts
26
+ * @param {string} opts.sessionId
27
+ * @param {number} [opts.port=0] — 0 = OS-assigned
28
+ * @param {string} [opts.secret] — random token for URL path
29
+ * @returns {Promise<{url:string, port:number, broadcastEvent:(obj:any)=>void, close:()=>void}>}
30
+ */
31
+ export async function startHttpDashboard({ sessionId, port = 0, secret } = {}) {
32
+ const token = secret || crypto.randomBytes(12).toString('hex');
33
+ const pathPrefix = `/dashboard/${token}`;
34
+
35
+ /** @type {Set<import('net').Socket>} */
36
+ const wsClients = new Set();
37
+
38
+ const server = http.createServer((req, res) => {
39
+ // WebSocket upgrade
40
+ if (req.headers.upgrade?.toLowerCase() === 'websocket') {
41
+ return handleWebSocketUpgrade(req, res, wsClients);
42
+ }
43
+ // Serve the HTML dashboard
44
+ if (req.url.startsWith(pathPrefix) || req.url === '/') {
45
+ res.writeHead(200, {
46
+ 'Content-Type': 'text/html; charset=utf-8',
47
+ 'Cache-Control': 'no-store',
48
+ });
49
+ res.end(DASHBOARD_HTML(token));
50
+ return;
51
+ }
52
+ // Health endpoint
53
+ if (req.url === '/health') {
54
+ res.writeHead(200, { 'Content-Type': 'application/json' });
55
+ res.end(JSON.stringify({ ok: true, sessionId, clients: wsClients.size }));
56
+ return;
57
+ }
58
+ res.writeHead(404);
59
+ res.end('Not found');
60
+ });
61
+
62
+ // Bind to localhost only
63
+ await new Promise((resolve, reject) => {
64
+ server.listen(port, '127.0.0.1', () => {
65
+ const addr = server.address();
66
+ resolve(addr);
67
+ });
68
+ server.once('error', reject);
69
+ });
70
+
71
+ const addr = server.address();
72
+ const actualPort = addr.port;
73
+ const url = `http://127.0.0.1:${actualPort}/${pathPrefix}`;
74
+
75
+ // Register as event broadcaster so tapSseEvent pushes to us
76
+ const unregister = registerBroadcaster((event) => {
77
+ broadcastEvent(event);
78
+ });
79
+
80
+ function broadcastEvent(obj) {
81
+ if (wsClients.size === 0) return;
82
+ const frame = encodeWSFrame(JSON.stringify(obj));
83
+ for (const sock of wsClients) {
84
+ try { sock.write(frame); } catch { /* single-client failure doesn't cascade */ }
85
+ }
86
+ }
87
+
88
+ function close() {
89
+ unregister();
90
+ for (const sock of wsClients) {
91
+ try { sock.end(); } catch { /* no-op */ }
92
+ }
93
+ wsClients.clear();
94
+ server.close();
95
+ }
96
+
97
+ return { url, port: actualPort, broadcastEvent, close };
98
+ }
99
+
100
+ // ── WebSocket helpers (RFC 6455, text frames only) ──
101
+
102
+ function handleWebSocketUpgrade(req, sock, clients) {
103
+ const key = req.headers['sec-websocket-key'];
104
+ if (!key) {
105
+ sock.destroy();
106
+ return;
107
+ }
108
+ const accept = crypto.createHash('sha1').update(key + WS_GUID).digest('base64');
109
+ sock.write(
110
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
111
+ 'Upgrade: websocket\r\n' +
112
+ 'Connection: Upgrade\r\n' +
113
+ `Sec-WebSocket-Accept: ${accept}\r\n` +
114
+ '\r\n'
115
+ );
116
+ clients.add(sock);
117
+ sock.once('close', () => clients.delete(sock));
118
+ sock.once('error', () => clients.delete(sock));
119
+ }
120
+
121
+ function encodeWSFrame(payload) {
122
+ const data = Buffer.from(payload, 'utf-8');
123
+ const len = data.length;
124
+ let header;
125
+ if (len < 126) {
126
+ header = Buffer.alloc(2);
127
+ header[0] = 0x81; // FIN + text opcode
128
+ header[1] = len;
129
+ } else if (len < 65536) {
130
+ header = Buffer.alloc(4);
131
+ header[0] = 0x81;
132
+ header[1] = 126;
133
+ header.writeUInt16BE(len, 2);
134
+ } else {
135
+ header = Buffer.alloc(10);
136
+ header[0] = 0x81;
137
+ header[1] = 127;
138
+ header.writeBigUInt64BE(BigInt(len), 2);
139
+ }
140
+ return Buffer.concat([header, data]);
141
+ }
142
+
143
+ // ── Inline Dashboard HTML ──
144
+
145
+ function DASHBOARD_HTML(token) {
146
+ return `<!DOCTYPE html>
147
+ <html lang="en">
148
+ <head>
149
+ <meta charset="UTF-8">
150
+ <meta name="viewport" content="width=device-width,initial-scale=1.0">
151
+ <title>Bahulam Agent Dashboard</title>
152
+ <style>
153
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
154
+ :root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--text:#c9d1d9;
155
+ --dim:#8b949e;--green:#3fb950;--cyan:#58a6ff;--yellow:#d29922;
156
+ --red:#f85149;--brand:#58a6ff}
157
+ body{background:var(--bg);color:var(--text);font-family:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,monospace;
158
+ font-size:13px;line-height:1.5;padding:16px;overflow-y:scroll}
159
+ h1{font-size:16px;font-weight:600;color:var(--brand);margin-bottom:8px;display:flex;align-items:center;gap:8px}
160
+ h1 small{font-size:11px;color:var(--dim);font-weight:400}
161
+ #status{display:flex;gap:16px;margin-bottom:12px;flex-wrap:wrap}
162
+ .stat{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:6px 12px;font-size:12px}
163
+ .stat .num{color:var(--brand);font-weight:600}
164
+ .stat .lbl{color:var(--dim)}
165
+ .toolbar{margin-bottom:8px;display:flex;gap:6px;flex-wrap:wrap}
166
+ .toolbar button{background:var(--surface);border:1px solid var(--border);color:var(--text);
167
+ padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px;font-family:inherit}
168
+ .toolbar button.active{background:var(--brand);color:#fff;border-color:var(--brand)}
169
+ .toolbar button:hover{background:var(--border)}
170
+ .split{display:flex;gap:12px;height:calc(100vh - 160px)}
171
+ .panel{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:6px;
172
+ overflow-y:auto;padding:4px 0;min-width:0}
173
+ .panel h2{font-size:11px;font-weight:600;color:var(--dim);text-transform:uppercase;
174
+ letter-spacing:0.5px;padding:6px 10px;border-bottom:1px solid var(--border);position:sticky;top:0;
175
+ background:var(--surface);z-index:1}
176
+ .event-line{padding:2px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px;border-bottom:1px solid #1c2128}
177
+ .event-line:hover{background:#1c2128}
178
+ .tag{display:inline-block;padding:0 5px;border-radius:3px;font-size:10px;font-weight:600;margin-right:4px}
179
+ .tag-spawn{background:#1b3628;color:var(--green)}
180
+ .tag-active{background:#1c2d45;color:var(--cyan)}
181
+ .tag-done{background:#1b3628;color:var(--green)}
182
+ .tag-tool{background:#26242b;color:var(--dim)}
183
+ .tag-handoff{background:#2a2318;color:var(--yellow)}
184
+ .tag-error{background:#2d1b1b;color:var(--red)}
185
+ .time{color:var(--dim);font-size:11px;margin-left:6px}
186
+ .detail{color:var(--dim)}
187
+ ::-webkit-scrollbar{width:6px}
188
+ ::-webkit-scrollbar-track{background:transparent}
189
+ ::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
190
+ </style>
191
+ </head>
192
+ <body>
193
+ <h1>⚡ Agent Dashboard <small id="sessionLabel">connecting…</small></h1>
194
+ <div id="status">
195
+ <div class="stat"><span class="num" id="statEvents">0</span> <span class="lbl">events</span></div>
196
+ <div class="stat"><span class="num" id="statSubAgents">0</span> <span class="lbl">sub-agents</span></div>
197
+ <div class="stat"><span class="num" id="statTools">0</span> <span class="lbl">tools</span></div>
198
+ <div class="stat"><span class="num" id="statHandoffs">0</span> <span class="lbl">handoffs</span></div>
199
+ <div class="stat"><span class="lbl" id="connStatus">● disconnected</span></div>
200
+ </div>
201
+ <div class="toolbar">
202
+ <button data-filter="all" class="active">All</button>
203
+ <button data-filter="sub_agent_start,sub_agent_complete">Sub-Agents</button>
204
+ <button data-filter="delegation">Handoffs</button>
205
+ <button data-filter="tool_call,tool_done">Tools</button>
206
+ <button data-filter="error">Errors</button>
207
+ <button id="btnAutoScroll" class="active">Auto-scroll</button>
208
+ <button id="btnClear">Clear</button>
209
+ </div>
210
+ <div class="split">
211
+ <div class="panel" id="eventLog"><h2>Event Log</h2></div>
212
+ <div class="panel" id="subAgentTree"><h2>Sub-Agent Activity</h2></div>
213
+ </div>
214
+ <script>
215
+ (function(){
216
+ const token = '${token}';
217
+ const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
218
+ const wsUrl = wsProto + '//' + location.host + '/ws';
219
+ let ws;
220
+ let filters = ['all'];
221
+ let autoScroll = true;
222
+ let eventCount = 0;
223
+ let subAgentCount = 0;
224
+ let toolCount = 0;
225
+ let handoffCount = 0;
226
+ // Sub-agent state: id -> { id, type, query, startTime, tools, status }
227
+ const subAgents = new Map();
228
+
229
+ function connect() {
230
+ ws = new WebSocket(wsUrl);
231
+ ws.onopen = () => { document.getElementById('connStatus').textContent = '● connected'; };
232
+ ws.onclose = () => {
233
+ document.getElementById('connStatus').textContent = '● disconnected';
234
+ setTimeout(connect, 3000);
235
+ };
236
+ ws.onmessage = (msg) => {
237
+ try {
238
+ const ev = JSON.parse(msg.data);
239
+ handleEvent(ev);
240
+ } catch(e) { /* ignore parse errors */ }
241
+ };
242
+ }
243
+
244
+ function handleEvent(ev) {
245
+ eventCount++;
246
+ updateStats();
247
+ const line = renderEventLine(ev);
248
+ if (line) appendEvent(line);
249
+ updateSubAgentTree(ev);
250
+ }
251
+
252
+ function renderEventLine(ev) {
253
+ const type = ev.type || 'unknown';
254
+ const data = ev.data || {};
255
+ const now = Date.now();
256
+ let tag, label, detail = '';
257
+
258
+ switch(type) {
259
+ case 'sub_agent_start':
260
+ tag = '<span class="tag tag-spawn">spawn</span>';
261
+ label = (data.type || 'agent') + (data.query ? ' "' + data.query.slice(0, 60) + '"' : '');
262
+ subAgentCount++; toolCount++; break;
263
+ case 'sub_agent_complete':
264
+ tag = '<span class="tag tag-done">done</span>';
265
+ label = (data.type || 'agent');
266
+ detail = data.tool_calls != null ? ' · ' + data.tool_calls + ' tools' : '';
267
+ if (data.duration_s) detail += ' · ' + data.duration_s.toFixed(1) + 's';
268
+ break;
269
+ case 'delegation':
270
+ tag = '<span class="tag tag-handoff">handoff</span>';
271
+ label = (data.from || '?') + ' → ' + (data.to || '?');
272
+ if (data.instruction) detail = ' "' + data.instruction.slice(0, 50) + '"';
273
+ handoffCount++; break;
274
+ case 'tool_call':
275
+ case 'tool_request':
276
+ tag = '<span class="tag tag-tool">▶ tool</span>';
277
+ label = (data.tool || '?') + (data.args && data.args.file_path ? ' ' + data.args.file_path : '');
278
+ toolCount++; break;
279
+ case 'tool_done':
280
+ case 'tool_result':
281
+ tag = '<span class="tag tag-tool">✓ tool</span>';
282
+ label = (data.tool || '?');
283
+ if (data.duration_s) detail = ' · ' + data.duration_s.toFixed(1) + 's';
284
+ break;
285
+ case 'error':
286
+ tag = '<span class="tag tag-error">error</span>';
287
+ label = (data.message || '').slice(0, 80);
288
+ break;
289
+ case 'complete':
290
+ tag = '<span class="tag tag-done">complete</span>';
291
+ label = 'Agent finished';
292
+ if (data.tool_calls) detail = ' · ' + data.tool_calls + ' tools';
293
+ break;
294
+ case 'thinking':
295
+ return null; // too noisy
296
+ case 'content':
297
+ case 'content_partial':
298
+ return null;
299
+ default:
300
+ tag = '<span class="tag tag-active">' + type.slice(0, 8) + '</span>';
301
+ label = type;
302
+ }
303
+
304
+ eventCount++;
305
+ updateStats();
306
+ return '<div class="event-line">' + tag + esc(label) + '<span class="detail">' + esc(detail) + '</span></div>';
307
+ }
308
+
309
+ function appendEvent(html) {
310
+ if (!html) return;
311
+ const log = document.getElementById('eventLog');
312
+ log.insertAdjacentHTML('beforeend', html);
313
+ if (autoScroll) log.scrollTop = log.scrollHeight;
314
+ }
315
+
316
+ function updateSubAgentTree(ev) {
317
+ const type = ev.type;
318
+ const data = ev.data || {};
319
+ const container = document.getElementById('subAgentTree');
320
+ let changed = false;
321
+
322
+ if (type === 'sub_agent_start') {
323
+ const id = data.id || 'sa-' + Date.now();
324
+ subAgents.set(id, { id, type: data.type || 'agent', query: data.query || '', startTime: Date.now(), tools: 0, status: 'active' });
325
+ changed = true;
326
+ } else if (type === 'sub_agent_complete') {
327
+ const id = data.id;
328
+ if (id && subAgents.has(id)) {
329
+ const sa = subAgents.get(id);
330
+ sa.status = 'done';
331
+ sa.tools = data.tool_calls || sa.tools;
332
+ changed = true;
333
+ }
334
+ } else if (type === 'sub_agent_tool') {
335
+ const agentType = data.type || '';
336
+ // Find the most recent active sub-agent of this type
337
+ let found = null;
338
+ for (const [id, sa] of subAgents) {
339
+ if (sa.type === agentType && sa.status === 'active') { found = sa; }
340
+ }
341
+ if (found) { found.tools++; changed = true; }
342
+ }
343
+
344
+ if (changed) {
345
+ let html = '<h2>Sub-Agent Activity</h2>';
346
+ if (subAgents.size === 0) { html += '<div class="event-line" style="color:var(--dim)">(none yet)</div>'; }
347
+ else {
348
+ for (const [id, sa] of subAgents) {
349
+ const elapsed = ((Date.now() - sa.startTime) / 1000).toFixed(1);
350
+ const statusTag = sa.status === 'active'
351
+ ? '<span class="tag tag-active">active</span>'
352
+ : '<span class="tag tag-done">done</span>';
353
+ html += '<div class="event-line">' + statusTag + ' <b>' + esc(sa.type) + '</b>';
354
+ if (sa.query) html += ' ' + esc(sa.query.slice(0, 40));
355
+ html += ' · <span class="time">' + sa.tools + ' tools · ' + elapsed + 's</span></div>';
356
+ }
357
+ }
358
+ container.innerHTML = html;
359
+ }
360
+ }
361
+
362
+ function updateStats() {
363
+ document.getElementById('statEvents').textContent = eventCount;
364
+ document.getElementById('statSubAgents').textContent = subAgents.size;
365
+ document.getElementById('statTools').textContent = toolCount;
366
+ document.getElementById('statHandoffs').textContent = handoffCount;
367
+ }
368
+
369
+ function esc(s) { return String(s || '').replace(/[&<>"]/g, function(m) {
370
+ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]; });
371
+ }
372
+
373
+ // Toolbar filter buttons
374
+ document.querySelectorAll('[data-filter]').forEach(btn => {
375
+ btn.addEventListener('click', () => {
376
+ document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
377
+ btn.classList.add('active');
378
+ filters = btn.dataset.filter === 'all' ? ['all'] : btn.dataset.filter.split(',');
379
+ });
380
+ });
381
+
382
+ document.getElementById('btnAutoScroll').addEventListener('click', function() {
383
+ autoScroll = !autoScroll;
384
+ this.classList.toggle('active');
385
+ });
386
+
387
+ document.getElementById('btnClear').addEventListener('click', () => {
388
+ document.getElementById('eventLog').innerHTML = '<h2>Event Log</h2>';
389
+ document.getElementById('subAgentTree').innerHTML = '<h2>Sub-Agent Activity</h2>';
390
+ subAgents.clear();
391
+ eventCount = 0; subAgentCount = 0; toolCount = 0; handoffCount = 0;
392
+ updateStats();
393
+ });
394
+
395
+ connect();
396
+ })();
397
+ </script>
398
+ </body>
399
+ </html>`;
400
+ }
@@ -58,6 +58,7 @@ export const DAEMON_OWNED_FIELDS = Object.freeze([
58
58
  'user',
59
59
  'model',
60
60
  'modelLimits',
61
+ 'subAgentModels',
61
62
  'blockedOps',
62
63
  'delegations',
63
64
  'phases',