@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
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Local workspace session store.
3
+ *
4
+ * A local service session grants one local directory to the browser shell and
5
+ * to the remote-agent bridge. If the user opens a single file, the grant root
6
+ * is the file's parent and focus_path records the selected file.
7
+ */
8
+
9
+ import * as crypto from 'node:crypto';
10
+ import * as fs from 'node:fs';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ import {
14
+ localServiceSessionDir,
15
+ localServiceSessionPath,
16
+ localServiceSessionsRoot,
17
+ } from './paths.mjs';
18
+ import { getLocalMachineIdentity } from './machine.mjs';
19
+
20
+ const SESSION_PREFIX = 'lws';
21
+
22
+ export function createLocalAccessToken() {
23
+ return crypto.randomBytes(24).toString('base64url');
24
+ }
25
+
26
+ export function hashLocalAccessToken(token) {
27
+ return crypto.createHash('sha256').update(String(token || '')).digest('hex');
28
+ }
29
+
30
+ export function verifyLocalAccessToken(session, token) {
31
+ if (!session?.token_hash || !token) return false;
32
+ const expected = Buffer.from(session.token_hash, 'hex');
33
+ const actual = Buffer.from(hashLocalAccessToken(token), 'hex');
34
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
35
+ }
36
+
37
+ export function createLocalWorkspaceSession({
38
+ targetPath = process.cwd(),
39
+ cwd = process.cwd(),
40
+ kind = null,
41
+ title = null,
42
+ token = createLocalAccessToken(),
43
+ } = {}) {
44
+ const grant = resolveGrant(targetPath, cwd);
45
+ const machine = getLocalMachineIdentity();
46
+ const now = new Date().toISOString();
47
+ const session = {
48
+ id: createSessionId(),
49
+ product: 'bahulam-local-service',
50
+ machine_id: machine.id,
51
+ machine_hostname: machine.hostname,
52
+ kind: kind || inferSessionKind(grant),
53
+ title: title || defaultSessionTitle(grant),
54
+ status: 'active',
55
+ root_path: grant.rootPath,
56
+ focus_path: grant.focusPath,
57
+ resource_type: grant.resourceType,
58
+ capabilities: inferCapabilities(grant),
59
+ created_at: now,
60
+ updated_at: now,
61
+ token_hash: hashLocalAccessToken(token),
62
+ remote_bridge: {
63
+ status: 'cli_relay',
64
+ contract: 'browser -> local-service -> cli-authenticated remote agent -> local tools',
65
+ },
66
+ };
67
+
68
+ writeLocalWorkspaceSession(session);
69
+ return { session: publicSession(session), token };
70
+ }
71
+
72
+ export function writeLocalWorkspaceSession(session) {
73
+ if (!session?.id) throw new Error('session.id is required');
74
+ const dir = localServiceSessionDir(session.id);
75
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
76
+ fs.writeFileSync(localServiceSessionPath(session.id), `${JSON.stringify(session, null, 2)}\n`, {
77
+ mode: 0o600,
78
+ });
79
+ }
80
+
81
+ export function loadLocalWorkspaceSession(sessionId) {
82
+ if (!sessionId) return null;
83
+ const file = localServiceSessionPath(sessionId);
84
+ try {
85
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ export function listLocalWorkspaceSessions({ limit = 20 } = {}) {
92
+ let names = [];
93
+ try {
94
+ names = fs.readdirSync(localServiceSessionsRoot());
95
+ } catch {
96
+ return [];
97
+ }
98
+
99
+ return names
100
+ .map((name) => loadLocalWorkspaceSession(name))
101
+ .filter(Boolean)
102
+ .sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')))
103
+ .slice(0, limit)
104
+ .map(publicSession);
105
+ }
106
+
107
+ export function touchLocalWorkspaceSession(sessionId, patch = {}) {
108
+ const session = loadLocalWorkspaceSession(sessionId);
109
+ if (!session) return null;
110
+ const updated = {
111
+ ...session,
112
+ ...patch,
113
+ updated_at: new Date().toISOString(),
114
+ };
115
+ writeLocalWorkspaceSession(updated);
116
+ return publicSession(updated);
117
+ }
118
+
119
+ export function publicSession(session) {
120
+ if (!session) return null;
121
+ const { token_hash, ...safe } = session;
122
+ return safe;
123
+ }
124
+
125
+ export function resolveGrant(inputPath, cwd = process.cwd()) {
126
+ const resolved = expandPath(inputPath || cwd, cwd);
127
+ let stat;
128
+ try {
129
+ stat = fs.statSync(resolved);
130
+ } catch {
131
+ throw new Error(`Path does not exist: ${resolved}`);
132
+ }
133
+
134
+ if (stat.isDirectory()) {
135
+ return {
136
+ rootPath: fs.realpathSync(resolved),
137
+ focusPath: '',
138
+ resourceType: 'directory',
139
+ };
140
+ }
141
+
142
+ if (stat.isFile()) {
143
+ const parent = fs.realpathSync(path.dirname(resolved));
144
+ return {
145
+ rootPath: parent,
146
+ focusPath: path.basename(resolved),
147
+ resourceType: 'file',
148
+ };
149
+ }
150
+
151
+ throw new Error(`Path is not a file or directory: ${resolved}`);
152
+ }
153
+
154
+ export function expandPath(inputPath, cwd = process.cwd()) {
155
+ let value = String(inputPath || '').trim();
156
+ if (!value) value = cwd;
157
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
158
+ value = value.slice(1, -1);
159
+ }
160
+ if (value === '~') value = os.homedir();
161
+ else if (value.startsWith('~/')) value = path.join(os.homedir(), value.slice(2));
162
+ if (!path.isAbsolute(value)) value = path.resolve(cwd, value);
163
+ return path.normalize(value);
164
+ }
165
+
166
+ function createSessionId() {
167
+ const stamp = Date.now().toString(36);
168
+ const rand = crypto.randomBytes(5).toString('hex');
169
+ return `${SESSION_PREFIX}_${stamp}_${rand}`;
170
+ }
171
+
172
+ function defaultSessionTitle(grant) {
173
+ if (grant.focusPath) return grant.focusPath;
174
+ return path.basename(grant.rootPath) || grant.rootPath;
175
+ }
176
+
177
+ function inferSessionKind(grant) {
178
+ if (grant.resourceType === 'file' && isDocumentLike(grant.focusPath)) return 'documents';
179
+ if (looksLikeCodeWorkspace(grant.rootPath)) return 'coding';
180
+ return 'workspace';
181
+ }
182
+
183
+ function inferCapabilities(grant) {
184
+ const caps = new Set([
185
+ 'local_files',
186
+ 'local_shell',
187
+ 'browser_workspace',
188
+ 'remote_agent_bridge',
189
+ ]);
190
+ if (grant.resourceType === 'file' && isDocumentLike(grant.focusPath)) caps.add('documents');
191
+ if (looksLikeCodeWorkspace(grant.rootPath)) caps.add('coding');
192
+ return [...caps];
193
+ }
194
+
195
+ function isDocumentLike(fileName) {
196
+ return /\.(?:pdf|docx?|xlsx?|pptx?|csv|tsv|rtf|odt|ods)$/i.test(String(fileName || ''));
197
+ }
198
+
199
+ function looksLikeCodeWorkspace(rootPath) {
200
+ const markers = [
201
+ '.git',
202
+ 'package.json',
203
+ 'pyproject.toml',
204
+ 'requirements.txt',
205
+ 'Cargo.toml',
206
+ 'go.mod',
207
+ 'pom.xml',
208
+ 'Makefile',
209
+ 'Dockerfile',
210
+ 'AGENTS.md',
211
+ 'CLAUDE.md',
212
+ 'BAHULAM.md',
213
+ ];
214
+ return markers.some((marker) => {
215
+ try {
216
+ return fs.existsSync(path.join(rootPath, marker));
217
+ } catch {
218
+ return false;
219
+ }
220
+ });
221
+ }
@@ -4,7 +4,7 @@
4
4
  * Prints a non-blocking summary of the runtime environment before the REPL
5
5
  * starts so the user can see what is and is not aligned:
6
6
  *
7
- * 🔭 Bahulam Code v2.6.12 · initializing orbit
7
+ * 🔭 Bahulam Code v0.1.7 · initializing
8
8
  *
9
9
  * [✓] Auth token
10
10
  * [✓] OpenRouter key
@@ -202,11 +202,7 @@ export async function runHistoryCommand(args = []) {
202
202
  process.stdout.write(formatHistoryReport(history, limit));
203
203
  }
204
204
 
205
- // Dashboard removed now using Bahulam Pulse (pulse/cli.js)
206
-
207
- export async function _runDashboardCommand_REMOVED() { /* see pulse/cli.js */ }
208
-
209
- export async function _OLD_runDashboardCommand(args = []) {
205
+ export async function runDashboardCommand(args = []) {
210
206
  const requestedPort = parseNumber(readOption(args, '--port', '4318'), 4318);
211
207
  const host = readOption(args, '--host', '127.0.0.1') || '127.0.0.1';
212
208
  const shouldOpen = !hasFlag(args, '--no-open');
@@ -272,7 +268,7 @@ export async function _OLD_runDashboardCommand(args = []) {
272
268
  const actualPort = typeof address === 'object' && address ? address.port : requestedPort;
273
269
  const url = `http://${host}:${actualPort}`;
274
270
 
275
- process.stderr.write(`\x1b[36mb0 dashboard\x1b[0m ${url}\n`);
271
+ process.stderr.write(`\x1b[36mBahulam dashboard\x1b[0m ${url}\n`);
276
272
  process.stderr.write(`\x1b[2mReading local analytics from ${getStorePaths().bahulamDir}\x1b[0m\n`);
277
273
  process.stderr.write(`\x1b[2mPress Ctrl+C to stop the dashboard server.\x1b[0m\n`);
278
274
 
@@ -2,7 +2,7 @@
2
2
  * ANSI Terminal Renderer — cursor control, box drawing, status bars.
3
3
  *
4
4
  * Color helpers (the `c` object) now route through the semantic palette
5
- * (`src/ui/palette.mjs`) so the entire CLI honors the Kepler brand and
5
+ * (`src/ui/palette.mjs`) so the entire CLI honors the Bahulam brand and
6
6
  * tier fallbacks (truecolor, ansi256, ansi16, none) without touching
7
7
  * each call site. Hot-swap-friendly: external semantics like `c.red`,
8
8
  * `c.bold`, `c.cyan` are preserved as the legacy contract; new code
@@ -35,7 +35,7 @@ export const cursor = {
35
35
  // ── Colors ──
36
36
  // Legacy color names re-mapped onto semantic palette tokens. The CLI's
37
37
  // branding is centralized in palette.mjs; this object is preserved only
38
- // so existing imports keep compiling. Internal Kepler color choices are
38
+ // so existing imports keep compiling. Internal Bahulam color choices are
39
39
  // documented next to each mapping for the next code review.
40
40
 
41
41
  const identity = (s) => String(s ?? '');
@@ -132,12 +132,20 @@ export function inPlace(text) {
132
132
  }
133
133
  if (text) {
134
134
  write(text + '\n');
135
- _lastLineCount = text.split('\n').length;
135
+ _lastLineCount = physicalLineCount(text);
136
136
  } else {
137
137
  _lastLineCount = 0;
138
138
  }
139
139
  }
140
140
 
141
+ function physicalLineCount(text) {
142
+ const width = Math.max(1, process.stderr.columns || process.stdout.columns || 80);
143
+ return String(text || '').split('\n').reduce((total, line) => {
144
+ const cells = visibleWidth(line);
145
+ return total + Math.max(1, Math.ceil(cells / width));
146
+ }, 0);
147
+ }
148
+
141
149
  // ── Status Bar (persistent bottom) ──
142
150
 
143
151
  export function statusBar(parts) {
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import { startTerminalRepl } from './repl.mjs';
10
10
  import {
11
+ runDashboardCommand,
11
12
  runSessionsCommand,
12
13
  runStatsCommand,
13
14
  runHistoryCommand,
@@ -123,16 +124,29 @@ async function main() {
123
124
  } catch {}
124
125
 
125
126
  if (subcommand === 'dashboard') {
126
- // Launch Bahulam Pulse Next.js dashboard
127
- const { spawn } = await import('node:child_process');
128
- const { fileURLToPath } = await import('node:url');
129
- const path = await import('node:path');
130
- const cliPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'pulse', 'cli.js');
131
- const child = spawn(process.execPath, [cliPath, ...subcommandArgs], {
132
- stdio: 'inherit',
133
- env: process.env,
134
- });
135
- child.on('exit', (code) => process.exit(code ?? 0));
127
+ // Try to open the live daemon's HTTP dashboard first
128
+ try {
129
+ const { findSessionForCwd } = await import('../daemon/daemonize.mjs');
130
+ const sid = await findSessionForCwd(process.cwd());
131
+ if (sid) {
132
+ const { daemonSessionDir } = await import('../core/paths.mjs');
133
+ const metaPath = path.join(daemonSessionDir(sid), 'meta.json');
134
+ if (fs.existsSync(metaPath)) {
135
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
136
+ if (meta.dashboard_url) {
137
+ process.stderr.write(`\x1b[2mOpening dashboard for session ${sid}…\x1b[0m\n`);
138
+ const { execSync } = await import('node:child_process');
139
+ const cmd = process.platform === 'darwin' ? 'open'
140
+ : process.platform === 'win32' ? 'start'
141
+ : 'xdg-open';
142
+ execSync(`${cmd} "${meta.dashboard_url}"`, { stdio: 'ignore', timeout: 3000 });
143
+ process.exit(0);
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ } catch {}
149
+ await runDashboardCommand(subcommandArgs);
136
150
  return;
137
151
  }
138
152
 
@@ -227,6 +241,7 @@ async function main() {
227
241
  Attach an image via the vision analysis pipeline
228
242
  bahulam --resume Resume last conversation
229
243
  bahulam dashboard Open analytics dashboard
244
+ bahulam workspace open [path] Open a local browser workspace
230
245
  bahulam login Sign in via browser
231
246
  bahulam logout Sign out and clear credentials
232
247
  bahulam init Scaffold .bahulam config, memory, hooks, tasks
@@ -240,6 +255,11 @@ async function main() {
240
255
  bahulam remote enable Enable relay connection
241
256
  bahulam remote disable Disable relay connection (kill switch)
242
257
 
258
+ \x1b[1mLocal Workspaces:\x1b[0m
259
+ bahulam workspace open [path] Start localhost workspace service
260
+ bahulam workspace list List recent local workspace sessions
261
+ bahulam local open [path] Alias for workspace open
262
+
243
263
  \x1b[1mAnalytics:\x1b[0m
244
264
  bahulam sessions List recent local sessions
245
265
  bahulam stats Show aggregate local session stats
@@ -261,7 +281,7 @@ async function main() {
261
281
  /new Start a new session
262
282
  /clear Clear conversation history
263
283
  /safety Show safety guardrail status
264
- /revoke Revoke auto-approvals
284
+ /approvals clear Revoke all session auto-approvals
265
285
  /explore <query> Spawn read-only codebase explorer
266
286
  /review <query> Spawn code review agent
267
287
  /architect <query> Spawn architecture planning agent
@@ -331,6 +351,12 @@ async function main() {
331
351
  return;
332
352
  }
333
353
 
354
+ if (subcommand === 'workspace' || subcommand === 'workspaces' || subcommand === 'local') {
355
+ const { runLocalWorkspaceCommand } = await import('../local-service/command.mjs');
356
+ await runLocalWorkspaceCommand(subcommandArgs, { cwd: process.cwd() });
357
+ return;
358
+ }
359
+
334
360
  // auto-daemon spawn.
335
361
  // bahulam daemonize [prompt] → fork a detached bahulam child with
336
362
  // the socket server up. Parent waits briefly for the child's session
@@ -0,0 +1,111 @@
1
+ import { c } from './ansi.mjs';
2
+
3
+ export const MODEL_CATEGORY_ORDER = [
4
+ 'text',
5
+ 'image_analysis',
6
+ 'image_generation',
7
+ 'image',
8
+ 'multimodal',
9
+ 'embedding',
10
+ 'audio',
11
+ 'video',
12
+ 'other',
13
+ ];
14
+
15
+ export function modelCategory(model) {
16
+ const category = String(model?.category || 'text').trim().toLowerCase();
17
+ return category === 'chat' ? 'text' : (category || 'text');
18
+ }
19
+
20
+ export function isImageGenerationModel(model) {
21
+ const text = `${model?.id || ''} ${model?.label || ''}`.toLowerCase();
22
+ return (
23
+ text.includes('image generation') ||
24
+ text.includes('generate image') ||
25
+ text.includes('gemini-3-pro-image') ||
26
+ text.includes('nano banana')
27
+ );
28
+ }
29
+
30
+ export function modelDisplayCategory(model) {
31
+ const category = modelCategory(model);
32
+ if (category === 'image') {
33
+ return isImageGenerationModel(model) ? 'image_generation' : 'image_analysis';
34
+ }
35
+ return category;
36
+ }
37
+
38
+ export function normalizeCatalogCategory(value) {
39
+ const raw = String(value || '').trim().toLowerCase();
40
+ if (!raw) return null;
41
+ if (raw === 'chat') return 'text';
42
+ if (raw === 'image-analysis' || raw === 'vision') return 'image_analysis';
43
+ if (raw === 'image-generation' || raw === 'image-gen') return 'image_generation';
44
+ return raw;
45
+ }
46
+
47
+ export function modelMatchesCatalogFilter(model, filter) {
48
+ if (!filter) return true;
49
+ if (filter === 'image') return modelCategory(model) === 'image';
50
+ return modelDisplayCategory(model) === filter || modelCategory(model) === filter;
51
+ }
52
+
53
+ export function modelCategoryLabel(category, { plural = false } = {}) {
54
+ const labels = {
55
+ text: 'text',
56
+ image: 'image',
57
+ image_analysis: 'image analysis',
58
+ image_generation: 'image generation',
59
+ embedding: 'embedding',
60
+ audio: 'audio',
61
+ video: 'video',
62
+ other: 'other',
63
+ multimodal: 'multimodal',
64
+ };
65
+ const base = labels[String(category || '').trim().toLowerCase()] || 'other';
66
+ return plural ? `${base} models` : base;
67
+ }
68
+
69
+ function categoryColor(category) {
70
+ switch (String(category || '').trim().toLowerCase()) {
71
+ case 'text':
72
+ return c.brand;
73
+ case 'image':
74
+ case 'image_analysis':
75
+ return c.magenta;
76
+ case 'image_generation':
77
+ return c.yellow;
78
+ case 'multimodal':
79
+ return c.cyanBold;
80
+ case 'embedding':
81
+ return c.green;
82
+ case 'audio':
83
+ return c.cyanRegular;
84
+ case 'video':
85
+ return c.red;
86
+ default:
87
+ return c.gray;
88
+ }
89
+ }
90
+
91
+ export function formatCategoryBadge(category, { plural = false } = {}) {
92
+ const normalized = String(category || 'text').trim().toLowerCase() || 'text';
93
+ return categoryColor(normalized)(`[${modelCategoryLabel(normalized, { plural })}]`);
94
+ }
95
+
96
+ export function formatCategoryHeading(category, count = null) {
97
+ const label = modelCategoryLabel(category, { plural: true });
98
+ const countText = Number.isFinite(Number(count)) ? ` ${c.dim(`(${count})`)}` : '';
99
+ return `${categoryColor(category)(c.bold(label))}${countText}`;
100
+ }
101
+
102
+ export function cacheProfileLabel(value) {
103
+ if (!value) return '';
104
+ try {
105
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
106
+ if (parsed?.type === 'prefix_hash') return 'prefix cache';
107
+ } catch {
108
+ // Fall through to the generic cache hint.
109
+ }
110
+ return 'cache';
111
+ }
@@ -11,6 +11,8 @@ import * as path from 'node:path';
11
11
  import { c, stripAnsi, formatElapsed, inPlace } from './ansi.mjs';
12
12
  import * as rqueue from '../ui/render-queue.mjs';
13
13
 
14
+ const ANSI_RESET = '\x1b[0m';
15
+
14
16
  // Transient one-line status writer that is safe under the render queue.
15
17
  // Raw inPlace() writes get REDIRECTED into transcript content when the
16
18
  // queue is active (cursor codes stripped) — that leaked one line per
@@ -102,7 +104,7 @@ export function fitAnsiLine(text, maxColumns) {
102
104
  out += value[i];
103
105
  visible++;
104
106
  }
105
- return `${out}${c.dim('…')}`;
107
+ return `${out}${ANSI_RESET}${c.dim('…')}`;
106
108
  }
107
109
 
108
110
  // ── PRD-068 §5.14 helpers ────────────────────────────────────────────
@@ -284,6 +286,7 @@ export function normalizeResumableSession(s) {
284
286
  resumeSummary: s.resumeSummary || null, // latest resume_summary checkpoint metadata
285
287
  models: Array.isArray(s.models) ? s.models : [],
286
288
  modelLimits: s.modelLimits && typeof s.modelLimits === 'object' ? s.modelLimits : {},
289
+ subAgentModels: s.subAgentModels && typeof s.subAgentModels === 'object' ? s.subAgentModels : {},
287
290
  costUsd: typeof s.costUsd === 'number' ? s.costUsd : 0,
288
291
  partial: !!s.partial, // true if the transcript file was partially malformed
289
292
  source: 'transcript',
@@ -8,8 +8,15 @@
8
8
  * mode on, redraw in place with cursor-up + erase-down, restore on exit.
9
9
  */
10
10
 
11
- import { c } from './ansi.mjs';
11
+ import { c, stripAnsi } from './ansi.mjs';
12
12
  import { fitAnsiLine, writeOverlayFrame, eraseOverlayFrame } from './repl-format.mjs';
13
+ import {
14
+ cacheProfileLabel,
15
+ formatCategoryBadge,
16
+ isImageGenerationModel,
17
+ modelCategory,
18
+ modelDisplayCategory,
19
+ } from './model-catalog-display.mjs';
13
20
 
14
21
  const DEFAULT_SENTINEL = '__default__';
15
22
 
@@ -20,10 +27,33 @@ function creditBadge(row) {
20
27
  return `~${credits < 10 ? credits.toFixed(1) : String(Math.round(credits))} cr/M`;
21
28
  }
22
29
 
30
+ function formatTokenLimit(value, label) {
31
+ const n = Number(value);
32
+ if (!Number.isFinite(n) || n <= 0) return '';
33
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M ${label}`;
34
+ if (n >= 1_000) return `${Math.round(n / 1_000)}K ${label}`;
35
+ return `${Math.round(n)} ${label}`;
36
+ }
37
+
38
+ function optionRowsForRole(catalog, row) {
39
+ const curated = (catalog || []).filter(m => m?.harness_validated && m?.id);
40
+ const group = String(row?.optionGroup || 'text').toLowerCase();
41
+ if (group === 'image_analysis') {
42
+ return curated.filter(m => (
43
+ ['image', 'multimodal'].includes(modelCategory(m))
44
+ && !isImageGenerationModel(m)
45
+ ));
46
+ }
47
+ if (group === 'image_generation') {
48
+ return curated.filter(m => modelCategory(m) === 'image' && isImageGenerationModel(m));
49
+ }
50
+ return curated.filter(m => ['text', 'chat'].includes(modelCategory(m)));
51
+ }
52
+
23
53
  /**
24
54
  * @param {object} opts
25
55
  * @param {object|null} opts.rl readline instance to pause/resume
26
- * @param {Array} opts.roles [{ role, label, current, defaultLabel }]
56
+ * @param {Array} opts.roles [{ role, label, description, current, defaultLabel, optionGroup }]
27
57
  * @param {Array} opts.catalog raw /api/models rows (may be empty)
28
58
  * @param {Array} [opts.fallbackIds] model ids to cycle when no curated catalog
29
59
  * @param {string} [opts.unavailableNote] why the catalog is missing (shown in header)
@@ -33,25 +63,24 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
33
63
  if (!process.stdin.isTTY) return null;
34
64
  if (rl) rl.pause();
35
65
 
36
- const curated = (catalog || []).filter(m => m?.harness_validated && m?.id);
37
- const usingFallback = curated.length === 0;
38
- const optionIds = usingFallback
39
- ? [...new Set((fallbackIds || []).filter(Boolean))]
40
- : curated.map(m => m.id);
41
- const baseOptions = [DEFAULT_SENTINEL, ...optionIds];
42
- const byId = new Map(curated.map(m => [m.id, m]));
66
+ const catalogRows = (catalog || []).filter(m => m?.harness_validated && m?.id);
67
+ const usingFallback = catalogRows.length === 0;
68
+ const byId = new Map(catalogRows.map(m => [m.id, m]));
43
69
 
44
70
  // Per-row option list; a current override that isn't in the curated list
45
71
  // is appended so it stays visible and selectable.
46
72
  const rows = roles.map(r => {
47
- let opts = baseOptions;
73
+ const optionIds = usingFallback
74
+ ? [...new Set((fallbackIds || []).filter(Boolean))]
75
+ : optionRowsForRole(catalogRows, r).map(m => m.id);
76
+ let opts = [DEFAULT_SENTINEL, ...optionIds];
48
77
  let idx = 0;
49
78
  if (r.current) {
50
- const found = baseOptions.indexOf(r.current);
79
+ const found = opts.indexOf(r.current);
51
80
  if (found >= 0) {
52
81
  idx = found;
53
82
  } else {
54
- opts = [...baseOptions, r.current];
83
+ opts = [...opts, r.current];
55
84
  idx = opts.length - 1;
56
85
  }
57
86
  }
@@ -76,8 +105,38 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
76
105
  return `${c.brand(value)}${badge ? ` ${c.dim(badge)}` : ''}${flag}`;
77
106
  };
78
107
 
108
+ const valueDescription = (row) => {
109
+ const value = row.opts[row.idx];
110
+ if (value === DEFAULT_SENTINEL) {
111
+ return row.description || 'backend default for this role';
112
+ }
113
+ const meta = byId.get(value);
114
+ if (!meta) {
115
+ return usingFallback ? 'backend configured model' : 'custom override outside curated catalog';
116
+ }
117
+ const label = String(meta.label || '').trim();
118
+ const parts = [];
119
+ if (label && label !== value) parts.push(c.white(label));
120
+ if (meta.provider) parts.push(c.dim(String(meta.provider)));
121
+ const category = modelDisplayCategory(meta);
122
+ if (category) parts.push(formatCategoryBadge(category));
123
+ const context = formatTokenLimit(meta.context_length, 'ctx');
124
+ if (context) parts.push(c.dim(context));
125
+ const output = formatTokenLimit(meta.max_output, 'out');
126
+ if (output) parts.push(c.dim(output));
127
+ if (meta.supports_tools) parts.push(c.green('tools'));
128
+ if (meta.supports_reasoning) parts.push(c.yellow('reasoning'));
129
+ const cache = cacheProfileLabel(meta.cache_profile);
130
+ if (cache) parts.push(c.cyan(cache));
131
+ return parts.join(c.dim(' · '));
132
+ };
133
+
79
134
  const render = () => {
80
135
  const cols = Math.max(60, process.stderr.columns || 120);
136
+ const labelWidth = Math.min(
137
+ 18,
138
+ Math.max(14, ...rows.map(row => stripAnsi(String(row.label || row.role)).length)),
139
+ );
81
140
  const lines = [];
82
141
  lines.push(` ${c.bold('Models')} ${c.dim('· session overrides · curated platform catalog')}`);
83
142
  if (usingFallback) {
@@ -87,9 +146,17 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
87
146
  lines.push('');
88
147
  rows.forEach((row, i) => {
89
148
  const marker = i === cursor ? c.brand('▸') : ' ';
90
- const rawLabel = String(row.label || row.role).padEnd(14, ' ');
149
+ const rawLabel = String(row.label || row.role).padEnd(labelWidth, ' ');
91
150
  const label = i === cursor ? c.brand(rawLabel) : rawLabel;
92
- lines.push(fitAnsiLine(` ${marker} ${label} ${c.dim('‹')} ${valueLabel(row)} ${c.dim('›')}`, cols - 1));
151
+ const prefix = ` ${marker} ${label} ${c.dim('‹')} `;
152
+ const suffix = ` ${c.dim('›')}`;
153
+ const descText = valueDescription(row);
154
+ const descMaxCols = Math.max(0, Math.min(72, cols - stripAnsi(prefix + suffix).length - 24));
155
+ const desc = descText && descMaxCols > 12
156
+ ? ` ${fitAnsiLine(descText, descMaxCols)}`
157
+ : '';
158
+ const maxValueCols = Math.max(18, cols - stripAnsi(prefix + suffix + desc).length - 1);
159
+ lines.push(fitAnsiLine(`${prefix}${fitAnsiLine(valueLabel(row), maxValueCols)}${suffix}${desc}`, cols - 1));
93
160
  });
94
161
  lines.push('');
95
162
  lines.push(fitAnsiLine(` ${c.dim('↑↓ role · ←→ model · Enter apply · c defaults · Esc cancel')}`, cols - 1));