@bahulam/code 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bahulam/code",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Bahulam Code — abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,9 @@
1
+ {
2
+ "schema": "bahulam.model_defaults.v1",
3
+ "source": "codekepler-backend/app/services/model_defaults.py",
4
+ "defaults": {
5
+ "reasoning": "deepseek/deepseek-v4-flash",
6
+ "fast": "deepseek/deepseek-v4-flash",
7
+ "planning": "deepseek/deepseek-v4-pro"
8
+ }
9
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shipped npm-side model defaults.
3
+ *
4
+ * Source of truth is backend `app/services/model_defaults.py`; release sync
5
+ * writes `model-defaults-default.json` next to the shipped catalog.
6
+ */
7
+
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ export const FALLBACK_MODEL_DEFAULTS = Object.freeze({
13
+ reasoning: 'deepseek/deepseek-v4-flash',
14
+ fast: 'deepseek/deepseek-v4-flash',
15
+ planning: 'deepseek/deepseek-v4-pro',
16
+ });
17
+
18
+ const _dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const DEFAULTS_PATH = path.join(_dirname, 'model-defaults-default.json');
20
+
21
+ function clean(value) {
22
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
23
+ }
24
+
25
+ export function readShippedModelDefaults() {
26
+ try {
27
+ const data = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf-8'));
28
+ const defaults = data?.defaults && typeof data.defaults === 'object' ? data.defaults : {};
29
+ return {
30
+ reasoning: clean(defaults.reasoning) || FALLBACK_MODEL_DEFAULTS.reasoning,
31
+ fast: clean(defaults.fast) || FALLBACK_MODEL_DEFAULTS.fast,
32
+ planning: clean(defaults.planning) || FALLBACK_MODEL_DEFAULTS.planning,
33
+ };
34
+ } catch {
35
+ return { ...FALLBACK_MODEL_DEFAULTS };
36
+ }
37
+ }
38
+
39
+ export const SHIPPED_MODEL_DEFAULTS = readShippedModelDefaults();
40
+ export const DEFAULT_REASONING_MODEL = SHIPPED_MODEL_DEFAULTS.reasoning;
41
+ export const DEFAULT_FAST_MODEL = SHIPPED_MODEL_DEFAULTS.fast;
42
+ export const DEFAULT_PLANNING_MODEL = SHIPPED_MODEL_DEFAULTS.planning;
@@ -7,6 +7,7 @@
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
+ import { DEFAULT_FAST_MODEL, DEFAULT_REASONING_MODEL } from './model-defaults.mjs';
10
11
 
11
12
  /**
12
13
  * Full settings schema with defaults.
@@ -30,9 +31,9 @@ export const SETTINGS_SCHEMA = {
30
31
  Stop: [],
31
32
  SessionStart: [],
32
33
  },
33
- model: 'claude-sonnet-4-6',
34
+ model: DEFAULT_REASONING_MODEL,
34
35
  subagentModel: null,
35
- fastModel: 'claude-haiku-4-5',
36
+ fastModel: DEFAULT_FAST_MODEL,
36
37
  fastMode: false,
37
38
  alwaysThinkingEnabled: false,
38
39
  autoCompactEnabled: true,
@@ -68,7 +68,12 @@ export function resolveLintCommand(targetPath, {
68
68
  return resolvePythonLint(target, { stat, root });
69
69
  }
70
70
  if (language === 'typescript') {
71
- return resolveJavaScriptLint(target, { stat, root, typescript: true });
71
+ return resolveJavaScriptLint(target, {
72
+ stat,
73
+ root,
74
+ typescript: true,
75
+ preferTypeCheck: !allowProjectScript,
76
+ });
72
77
  }
73
78
  if (language === 'javascript') {
74
79
  return resolveJavaScriptLint(target, { stat, root, typescript: false });
@@ -279,7 +284,47 @@ function captured(command) {
279
284
  return `${command} 2>&1 || true`;
280
285
  }
281
286
 
282
- function resolveJavaScriptLint(target, { stat, root, typescript }) {
287
+ function resolveTypeScriptProjectCheck(target, { stat, root }) {
288
+ const baseDir = stat?.isDirectory() ? target : path.dirname(target);
289
+ const tsconfig = findUpFile(baseDir, root, ['tsconfig.json']);
290
+ if (!tsconfig) return null;
291
+ const cwd = path.dirname(tsconfig);
292
+ const hasTypescript = hasNodeTool(baseDir, root, 'tsc', ['typescript']);
293
+ if (!hasTypescript) return null;
294
+ return {
295
+ command: captured(`npx --no-install tsc --noEmit --pretty false -p ${shellPathArg(tsconfig, cwd)}`),
296
+ cwd,
297
+ language: 'typescript',
298
+ target,
299
+ scope: 'project',
300
+ source: 'typescript',
301
+ reason: 'tsconfig project check',
302
+ };
303
+ }
304
+
305
+ export function normalizeLintOutput(lint, { stdout = '', stderr = '', errored = false } = {}) {
306
+ const output = String(errored ? (stderr || stdout || '') : (stdout || stderr || '')).trim();
307
+ if (!output) return null;
308
+ if (lint?.source !== 'eslint' || !/Oops!\s+Something went wrong!/i.test(output)) {
309
+ return output;
310
+ }
311
+ const lines = output.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
312
+ const detail = lines.find(line => (
313
+ !/^Oops!/i.test(line) &&
314
+ !/^ESLint:/i.test(line) &&
315
+ !/^If you still have problems/i.test(line) &&
316
+ !/^Please include/i.test(line)
317
+ ));
318
+ const suffix = detail ? ` ${detail}` : ' Check the project ESLint config, parser, and plugins.';
319
+ return `eslint failed:${suffix}`;
320
+ }
321
+
322
+ function resolveJavaScriptLint(target, { stat, root, typescript, preferTypeCheck = false }) {
323
+ if (typescript && preferTypeCheck) {
324
+ const typecheck = resolveTypeScriptProjectCheck(target, { stat, root });
325
+ if (typecheck) return typecheck;
326
+ }
327
+
283
328
  const baseDir = stat?.isDirectory() ? target : path.dirname(target);
284
329
  const eslintReady = hasConfig(baseDir, root, ESLINT_CONFIGS) ||
285
330
  hasNodeTool(baseDir, root, 'eslint', ['eslint']);
@@ -312,20 +357,7 @@ function resolveJavaScriptLint(target, { stat, root, typescript }) {
312
357
  }
313
358
 
314
359
  if (typescript) {
315
- const tsconfig = findUpFile(baseDir, root, ['tsconfig.json']);
316
- if (!tsconfig) return null;
317
- const cwd = path.dirname(tsconfig);
318
- const hasTypescript = hasNodeTool(baseDir, root, 'tsc', ['typescript']);
319
- if (!hasTypescript) return null;
320
- return {
321
- command: captured(`npx --no-install tsc --noEmit --pretty false -p ${shellPathArg(tsconfig, cwd)}`),
322
- cwd,
323
- language: 'typescript',
324
- target,
325
- scope: 'project',
326
- source: 'typescript',
327
- reason: 'tsconfig project check',
328
- };
360
+ return resolveTypeScriptProjectCheck(target, { stat, root });
329
361
  }
330
362
 
331
363
  const ext = path.extname(target).toLowerCase();
@@ -16,6 +16,9 @@ const DEFAULT_CONTENT = Object.freeze({
16
16
  'done.md': '# Done\n\n',
17
17
  });
18
18
 
19
+ const MANAGED_START = '<!-- bahulam:todo-write:start -->';
20
+ const MANAGED_END = '<!-- bahulam:todo-write:end -->';
21
+
19
22
  export function ensureTaskFiles({ cwd = process.cwd() } = {}) {
20
23
  const dir = path.join(cwd, '.bahulam', 'tasks');
21
24
  fs.mkdirSync(dir, { recursive: true });
@@ -125,6 +128,38 @@ export function taskCounts(board) {
125
128
  );
126
129
  }
127
130
 
131
+ export function syncTodoWriteToTaskFiles({ cwd = process.cwd(), todos = [] } = {}) {
132
+ ensureTaskFiles({ cwd });
133
+ const grouped = { active: [], backlog: [], blocked: [], done: [] };
134
+ for (const todo of Array.isArray(todos) ? todos : []) {
135
+ const text = String(todo?.content || '').trim();
136
+ if (!text) continue;
137
+ const status = String(todo?.status || 'pending').toLowerCase();
138
+ const list = status === 'completed'
139
+ ? 'done'
140
+ : status === 'in_progress'
141
+ ? 'active'
142
+ : 'backlog';
143
+ grouped[list].push({ text, checked: list === 'done' });
144
+ }
145
+
146
+ const written = [];
147
+ for (const [list, fileName] of Object.entries(TASK_FILES)) {
148
+ const filePath = path.join(cwd, '.bahulam', 'tasks', fileName);
149
+ const existing = readText(filePath) || DEFAULT_CONTENT[fileName] || `# ${list}\n\n`;
150
+ const managed = grouped[list]
151
+ .map(task => taskLine(task.text, list, task.checked))
152
+ .join('\n');
153
+ const block = `${MANAGED_START}\n${managed}${managed ? '\n' : ''}${MANAGED_END}`;
154
+ const next = replaceManagedBlock(existing, block);
155
+ if (next !== existing) {
156
+ fs.writeFileSync(filePath, next);
157
+ written.push(filePath);
158
+ }
159
+ }
160
+ return { written, counts: Object.fromEntries(Object.entries(grouped).map(([list, items]) => [list, items.length])) };
161
+ }
162
+
128
163
  export function normalizeList(value) {
129
164
  const key = String(value || '').toLowerCase();
130
165
  if (key === 'todo' || key === 'pending') return 'backlog';
@@ -149,6 +184,18 @@ function taskLine(text, list, checked = false) {
149
184
  return `- [${mark}] ${text}`;
150
185
  }
151
186
 
187
+ function replaceManagedBlock(content, block) {
188
+ const value = String(content || '');
189
+ const pattern = new RegExp(`${escapeRegExp(MANAGED_START)}[\\s\\S]*?${escapeRegExp(MANAGED_END)}`);
190
+ if (pattern.test(value)) return value.replace(pattern, block);
191
+ const trimmed = value.replace(/\s*$/, '');
192
+ return `${trimmed}\n\n${block}\n`;
193
+ }
194
+
195
+ function escapeRegExp(value) {
196
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
197
+ }
198
+
152
199
  function readText(filePath) {
153
200
  try {
154
201
  return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
@@ -28,7 +28,7 @@ import { buildFileDiff } from './file-diff.mjs';
28
28
  import { buildWorkScope } from './work-scope.mjs';
29
29
  import { loadDiskMemory, ensureBahulamDir, globalMemoryPath, projectMemoryPath } from './memory-disk.mjs';
30
30
  import { backgroundTasks } from './background-tasks.mjs';
31
- import { resolveLintCommand } from './lint-resolver.mjs';
31
+ import { normalizeLintOutput, resolveLintCommand } from './lint-resolver.mjs';
32
32
  import { PluginRegistry } from '../plugins/registry.mjs';
33
33
  import { loadPluginTool } from '../plugins/executor.mjs';
34
34
  import * as fs from 'node:fs';
@@ -137,7 +137,8 @@ export function createToolExecutor({
137
137
  });
138
138
  }
139
139
  let _searchCodeUsed = false; // tracks if search_code was called (for read_file nudge)
140
- let _readOnlyCacheGeneration = 0;
140
+ let _structureCacheGeneration = 0;
141
+ let _searchCacheGeneration = 0;
141
142
  const readOnlyResultCache = new Map();
142
143
 
143
144
  function resolvePath(p, args = {}, options = {}) {
@@ -339,11 +340,12 @@ export function createToolExecutor({
339
340
  );
340
341
  }
341
342
 
342
- function updateProjectIndex(filePath) {
343
+ function updateProjectIndex(filePath, { contentChanged = true, structureChanged = false } = {}) {
343
344
  try {
344
345
  projectRegistry.projectForPath(filePath)?.retriever.updateFile(filePath);
345
346
  } catch { /* best effort */ }
346
- _readOnlyCacheGeneration++;
347
+ if (contentChanged) _searchCacheGeneration++;
348
+ if (structureChanged) _structureCacheGeneration++;
347
349
  }
348
350
 
349
351
  function readTextIfExists(filePath) {
@@ -390,7 +392,6 @@ export function createToolExecutor({
390
392
  kind,
391
393
  args,
392
394
  fingerprint,
393
- generation: _readOnlyCacheGeneration,
394
395
  }));
395
396
  }
396
397
 
@@ -498,9 +499,11 @@ export function createToolExecutor({
498
499
  maxBuffer: 1_000_000,
499
500
  env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb' },
500
501
  }, (err, stdout = '', stderr = '') => {
501
- const output = err
502
- ? stripAnsi(stderr || stdout || '').trim()
503
- : stripAnsi(stdout || stderr || '').trim();
502
+ const output = normalizeLintOutput(lint, {
503
+ stdout: stripAnsi(stdout),
504
+ stderr: stripAnsi(stderr),
505
+ errored: Boolean(err),
506
+ });
504
507
  resolve(output || null);
505
508
  });
506
509
  });
@@ -920,6 +923,28 @@ export function createToolExecutor({
920
923
  };
921
924
  },
922
925
 
926
+ TodoWrite: async (args, options = {}) => {
927
+ throwIfAborted(options.signal);
928
+ const result = await occRegistry.call('TodoWrite', args || {}, {
929
+ ...options,
930
+ cwd: process.cwd(),
931
+ onTaskFilesWritten: (files) => {
932
+ for (const file of Array.isArray(files) ? files : []) {
933
+ updateProjectIndex(file, { contentChanged: true, structureChanged: true });
934
+ }
935
+ },
936
+ });
937
+ return {
938
+ success: !/^Validation error:/i.test(String(result || '')),
939
+ output: String(result || ''),
940
+ _tool: 'TodoWrite',
941
+ };
942
+ },
943
+
944
+ todo_write: async (args, options = {}) => {
945
+ return toolMap.TodoWrite(args, options);
946
+ },
947
+
923
948
  // Reserved meta-tool adapter. Cloud backends may implement Delegate
924
949
  // natively; local callbacks use this to route through the exact same
925
950
  // registry + dispatch funnel as /run and workflows.
@@ -1421,6 +1446,7 @@ export function createToolExecutor({
1421
1446
  return { success: false, output: `Error: Invalid file path "${rawPath || ''}". Register the project, then use an absolute path.`, _tool: 'write_file' };
1422
1447
  }
1423
1448
  const filePath = await resolvePath(rawPath, args, { allowMissing: true });
1449
+ const existedBefore = fs.existsSync(filePath);
1424
1450
  const before = readTextIfExists(filePath);
1425
1451
  const writeCheck = validateWrite(filePath, args.content, projectRootFor(filePath));
1426
1452
  if (!writeCheck.safe) {
@@ -1443,7 +1469,10 @@ export function createToolExecutor({
1443
1469
  const wrapped = wrapResult(result, 'write_file');
1444
1470
  const after = readTextIfExists(filePath);
1445
1471
  attachFileDiff(wrapped, filePath, before, after);
1446
- updateProjectIndex(filePath);
1472
+ updateProjectIndex(filePath, {
1473
+ contentChanged: before !== after,
1474
+ structureChanged: !existedBefore && fs.existsSync(filePath),
1475
+ });
1447
1476
 
1448
1477
  // Auto-lint the written file
1449
1478
  const lintOutput = await autoLint(filePath);
@@ -1489,6 +1518,7 @@ export function createToolExecutor({
1489
1518
  // Ensure parent directory exists
1490
1519
  const dir = path.dirname(filePath);
1491
1520
  fs.mkdirSync(dir, { recursive: true });
1521
+ const existedBefore = fs.existsSync(filePath);
1492
1522
  const before = readTextIfExists(filePath);
1493
1523
 
1494
1524
  // Read first if exists (OCC Write requirement)
@@ -1501,7 +1531,10 @@ export function createToolExecutor({
1501
1531
  await occRegistry.call('write_file', { file_path: filePath, content });
1502
1532
  const after = readTextIfExists(filePath);
1503
1533
  diffs.push(buildResultFileDiff(filePath, before, after));
1504
- updateProjectIndex(filePath);
1534
+ updateProjectIndex(filePath, {
1535
+ contentChanged: before !== after,
1536
+ structureChanged: !existedBefore && fs.existsSync(filePath),
1537
+ });
1505
1538
  results.push(rawPath);
1506
1539
  } catch (err) {
1507
1540
  errors.push(`${rawPath}: ${err.message}`);
@@ -1615,7 +1648,7 @@ export function createToolExecutor({
1615
1648
  };
1616
1649
  }
1617
1650
  attachFileDiff(wrapped, filePath, before, after);
1618
- updateProjectIndex(filePath);
1651
+ updateProjectIndex(filePath, { contentChanged: before !== after, structureChanged: false });
1619
1652
  _hasEdited = true;
1620
1653
 
1621
1654
  // Auto-lint the edited file
@@ -1643,7 +1676,7 @@ export function createToolExecutor({
1643
1676
  format: args.format || (args.tree === true ? 'tree' : 'glob'),
1644
1677
  max_depth: args.max_depth ?? args.maxDepth ?? null,
1645
1678
  },
1646
- { generation: _readOnlyCacheGeneration },
1679
+ { structureGeneration: _structureCacheGeneration },
1647
1680
  async () => {
1648
1681
  if (args.format === 'tree' || args.tree === true) {
1649
1682
  const requestedDepth = Number(args.max_depth ?? args.maxDepth ?? 2);
@@ -1764,7 +1797,7 @@ export function createToolExecutor({
1764
1797
  return await withReadOnlyCache(
1765
1798
  'search_files',
1766
1799
  { query, path: searchPath, mode: 'glob' },
1767
- { generation: _readOnlyCacheGeneration },
1800
+ { structureGeneration: _structureCacheGeneration },
1768
1801
  async () => {
1769
1802
  const result = await occRegistry.call('list_files', {
1770
1803
  pattern: query,
@@ -1785,7 +1818,7 @@ export function createToolExecutor({
1785
1818
  return await withReadOnlyCache(
1786
1819
  'search_files',
1787
1820
  { query, path: searchPath, mode: 'grep' },
1788
- { generation: _readOnlyCacheGeneration },
1821
+ { searchGeneration: _searchCacheGeneration },
1789
1822
  async () => {
1790
1823
  const result = await occRegistry.call('search_code', {
1791
1824
  pattern: query,
@@ -1889,8 +1922,9 @@ export function createToolExecutor({
1889
1922
  if (checkpoints) {
1890
1923
  try { checkpoints.save(filePath); } catch { /* best effort */ }
1891
1924
  }
1925
+ const existedBefore = fs.existsSync(filePath);
1892
1926
  fs.unlinkSync(filePath);
1893
- updateProjectIndex(filePath);
1927
+ updateProjectIndex(filePath, { contentChanged: existedBefore, structureChanged: existedBefore });
1894
1928
  return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
1895
1929
  } catch (err) {
1896
1930
  return { success: false, output: `Error: ${err.message}`, _tool: 'delete_file' };
@@ -380,7 +380,10 @@ async function main() {
380
380
  /help Show available commands
381
381
  /stats Session metrics (tokens, cost, tools)
382
382
  /cost Detailed cost breakdown by model
383
- /model [role] [model] Show or set session model override
383
+ /model Open interactive model overrides
384
+ /model status Show model overrides and catalog source
385
+ /model refresh Refresh model catalog from backend
386
+ /model list [category] List curated platform models
384
387
  /history Conversation history
385
388
  /new Start a new session
386
389
  /clear Clear conversation history
@@ -412,8 +412,34 @@ const NAMED_MODEL_MODES = new Set(NAMED_MODEL_MODES_LIST);
412
412
  let _modelCatalogCache = null;
413
413
  let _modelCatalogError = null;
414
414
  let _modelCatalogSource = null; // 'snapshot' | 'backend'
415
+ let _modelCatalogFetchedAt = null;
415
416
  let _backendRefreshInFlight = null;
416
417
 
418
+ function modelCatalogSummary(catalog = _modelCatalogCache) {
419
+ const rows = Array.isArray(catalog) ? catalog : [];
420
+ const curated = rows.filter(m => m?.harness_validated).length;
421
+ return { total: rows.length, curated };
422
+ }
423
+
424
+ function formatCatalogSource(source = _modelCatalogSource) {
425
+ if (source === 'backend') return 'backend';
426
+ if (source === 'snapshot') return 'shipped snapshot';
427
+ return 'unloaded';
428
+ }
429
+
430
+ function formatCatalogFetchedAt(value = _modelCatalogFetchedAt) {
431
+ if (!value) return 'not refreshed this session';
432
+ try {
433
+ return new Intl.DateTimeFormat(undefined, {
434
+ hour: '2-digit',
435
+ minute: '2-digit',
436
+ second: '2-digit',
437
+ }).format(value);
438
+ } catch {
439
+ return value.toISOString();
440
+ }
441
+ }
442
+
417
443
  async function fetchModelCatalog(ctx) {
418
444
  // Seed from the shipped snapshot so /model list, /model form and the
419
445
  // curation warnings work offline. The backend refresh below overlays
@@ -423,6 +449,7 @@ async function fetchModelCatalog(ctx) {
423
449
  if (shipped && shipped.length) {
424
450
  _modelCatalogCache = shipped;
425
451
  _modelCatalogSource = 'snapshot';
452
+ _modelCatalogFetchedAt = new Date();
426
453
  _modelCatalogError = null;
427
454
  }
428
455
  }
@@ -467,6 +494,7 @@ async function refreshCatalogFromBackend(ctx) {
467
494
  if (models && models.length) {
468
495
  _modelCatalogCache = models;
469
496
  _modelCatalogSource = 'backend';
497
+ _modelCatalogFetchedAt = new Date();
470
498
  _modelCatalogError = null;
471
499
  } else if (!_modelCatalogCache) {
472
500
  _modelCatalogError = models ? 'catalog is empty' : 'unexpected response shape';
@@ -565,6 +593,8 @@ async function printModelCatalog(ctx, filterCategory = null) {
565
593
  if (rest > 0) {
566
594
  process.stderr.write(` ${c.dim(`+${rest} more models available on the BYOK route (--route byok, own API key)`)}\n`);
567
595
  }
596
+ const summary = modelCatalogSummary(catalog);
597
+ process.stderr.write(` ${c.dim(`source: ${formatCatalogSource()} · ${summary.total} models · ${summary.curated} curated · refreshed ${formatCatalogFetchedAt()}`)}\n`);
568
598
  process.stderr.write('\n');
569
599
  }
570
600
 
@@ -611,7 +641,7 @@ function applyLaunchModelArgs(cliArgs, ctx) {
611
641
  return Promise.all(pending);
612
642
  }
613
643
 
614
- function printModelStatus() {
644
+ async function printModelStatus(ctx = null) {
615
645
  process.stderr.write(`\n ${c.bold('Models')}\n`);
616
646
  process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
617
647
  process.stderr.write(` ${c.gray('Active coding')} ${session.model || 'backend default'}\n`);
@@ -620,6 +650,17 @@ function printModelStatus() {
620
650
  process.stderr.write(` ${c.gray('Mode ')} ${session.modelMode}\n`);
621
651
  }
622
652
 
653
+ const catalog = await fetchModelCatalog(ctx);
654
+ const summary = modelCatalogSummary(catalog);
655
+ process.stderr.write(`\n ${c.bold('Catalog')}\n`);
656
+ if (catalog) {
657
+ process.stderr.write(` ${c.gray('Source ')} ${formatCatalogSource()}\n`);
658
+ process.stderr.write(` ${c.gray('Models ')} ${summary.total} total · ${summary.curated} curated\n`);
659
+ process.stderr.write(` ${c.gray('Refreshed ')} ${formatCatalogFetchedAt()}\n`);
660
+ } else {
661
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(_modelCatalogError || 'unavailable')}\n`);
662
+ }
663
+
623
664
  const limits = session.modelLimits || {};
624
665
  const subAgentModels = session.subAgentModels || {};
625
666
  const planningModel = subAgentModels.plan || limits.planning?.model || limits.orchestrator?.model;
@@ -716,13 +757,13 @@ async function handleModelCommand(rest = '', ctx) {
716
757
  if (process.stdin.isTTY) {
717
758
  await openModelForm(ctx);
718
759
  } else {
719
- printModelStatus();
760
+ await printModelStatus(ctx);
720
761
  }
721
762
  return;
722
763
  }
723
764
 
724
765
  if (parts[0] === 'status') {
725
- printModelStatus();
766
+ await printModelStatus(ctx);
726
767
  return;
727
768
  }
728
769
 
@@ -751,8 +792,9 @@ async function handleModelCommand(rest = '', ctx) {
751
792
  _modelCatalogCache = null;
752
793
  _modelCatalogError = null;
753
794
  _modelCatalogSource = null;
795
+ _modelCatalogFetchedAt = null;
754
796
  _backendRefreshInFlight = null;
755
- process.stderr.write(` ${c.dim('Refreshing model catalog…')}\n`);
797
+ process.stderr.write(` ${c.dim('Refreshing model catalog from backend…')}\n`);
756
798
  await refreshCatalogFromBackend(ctx);
757
799
  if (_modelCatalogSource !== 'backend') {
758
800
  // Backend fetch failed — restore the shipped snapshot so subsequent
@@ -761,8 +803,12 @@ async function handleModelCommand(rest = '', ctx) {
761
803
  if (shipped && shipped.length) {
762
804
  _modelCatalogCache = shipped;
763
805
  _modelCatalogSource = 'snapshot';
806
+ _modelCatalogFetchedAt = new Date();
764
807
  }
765
808
  process.stderr.write(` ${c.yellow('!')} ${c.dim(`Backend refresh failed — ${_modelCatalogError || 'unknown error'}. Showing shipped snapshot.`)}\n`);
809
+ } else {
810
+ const summary = modelCatalogSummary();
811
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Refreshed ${summary.total} models from backend · ${summary.curated} curated · ${formatCatalogFetchedAt()}`)}\n`);
766
812
  }
767
813
  await printModelCatalog(ctx);
768
814
  return;
@@ -1247,9 +1293,32 @@ async function handleSkillsCommand(rest = '', ctx) {
1247
1293
  printSkillsUsage();
1248
1294
  }
1249
1295
 
1296
+ const MODEL_COMMAND_COMPLETIONS = [
1297
+ { command: '/model', description: 'Open interactive model overrides' },
1298
+ { command: '/model status', description: 'Show model overrides and catalog source' },
1299
+ { command: '/model refresh', description: 'Refresh model catalog from backend' },
1300
+ { command: '/model list', description: 'List curated platform models' },
1301
+ { command: '/model list text', description: 'List text models' },
1302
+ { command: '/model list image', description: 'List image models' },
1303
+ { command: '/model clear', description: 'Clear model overrides' },
1304
+ ];
1305
+
1306
+ function slashCompletionDescription(command) {
1307
+ const modelHint = MODEL_COMMAND_COMPLETIONS.find(item => item.command === command);
1308
+ if (modelHint) return modelHint.description;
1309
+ return COMMANDS[command] || (command === '/quit' ? 'Exit CLI' : '');
1310
+ }
1311
+
1250
1312
  function commandCompletions(line) {
1251
- if (line.startsWith('/help ')) {
1252
- const topic = line.slice('/help '.length).toLowerCase();
1313
+ const text = String(line || '').trimStart();
1314
+ if (text === '/model' || text.startsWith('/model ')) {
1315
+ const modelCompletions = MODEL_COMMAND_COMPLETIONS
1316
+ .map(item => item.command)
1317
+ .filter(cmd => cmd.startsWith(text));
1318
+ if (modelCompletions.length) return modelCompletions;
1319
+ }
1320
+ if (text.startsWith('/help ')) {
1321
+ const topic = text.slice('/help '.length).toLowerCase();
1253
1322
  const categories = ['all', ...HELP_GROUPS.map(g => g.key)];
1254
1323
  const hits = categories.map(c => `/help ${c}`).filter(cmd => cmd.startsWith(`/help ${topic}`));
1255
1324
  return hits.length ? hits : categories.map(c => `/help ${c}`);
@@ -1260,19 +1329,19 @@ function commandCompletions(line) {
1260
1329
  // No fallback-to-all: a non-matching prefix ("/Users/...", a pasted
1261
1330
  // path) must yield NOTHING so the hint overlay hides, not the full
1262
1331
  // catalog. Bare "/" still matches every command via startsWith.
1263
- return all.filter(cmd => cmd.startsWith(line));
1332
+ return all.filter(cmd => cmd.startsWith(text));
1264
1333
  }
1265
1334
 
1266
1335
  function slashCommandSuggestions(line, limit = 5) {
1267
1336
  const text = String(line || '').trimStart();
1268
1337
  if (!text.startsWith('/')) return [];
1269
- const partial = text.split(/\s+/)[0] || '/';
1338
+ const partial = text.startsWith('/model ') ? text : (text.split(/\s+/)[0] || '/');
1270
1339
  return commandCompletions(partial)
1271
1340
  .filter(cmd => cmd.startsWith('/'))
1272
1341
  .slice(0, limit)
1273
1342
  .map(cmd => ({
1274
1343
  command: cmd,
1275
- description: COMMANDS[cmd] || (cmd === '/quit' ? 'Exit CLI' : ''),
1344
+ description: slashCompletionDescription(cmd),
1276
1345
  }));
1277
1346
  }
1278
1347
 
@@ -1399,12 +1468,13 @@ function buildContextStrip() {
1399
1468
  return parts.join(c.dim(' · '));
1400
1469
  }
1401
1470
 
1402
- // ── Dock meta line (cwd ⎇ branch · turn N) ─────────────────────────────
1471
+ // ── Dock meta line (cwd ⎇ branch · turn N · task …) ────────────────────
1403
1472
  //
1404
- // The dock's meta row shows durable session context. Git branch is cached
1405
- // so we don't shell out on every keystroke; refreshed at most every 5s.
1473
+ // The dock's meta row shows durable session context. Git branch and task
1474
+ // state are cached so we don't hit disk/shell on every keystroke.
1406
1475
 
1407
1476
  const _dockGitCache = { branch: null, at: 0, cwd: null };
1477
+ const _dockTaskCache = { summary: '', at: 0, cwd: null };
1408
1478
 
1409
1479
  function activeDockModel() {
1410
1480
  return session.modelOverrides?.reasoning
@@ -1438,6 +1508,36 @@ function _probeGitBranch(cwd) {
1438
1508
  return branch;
1439
1509
  }
1440
1510
 
1511
+ function compactTaskText(text, max = 36) {
1512
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
1513
+ if (value.length <= max) return value;
1514
+ return value.slice(0, Math.max(0, max - 1)) + '…';
1515
+ }
1516
+
1517
+ function _probeTaskSummary(cwd) {
1518
+ const now = Date.now();
1519
+ if (_dockTaskCache.cwd === cwd && (now - _dockTaskCache.at) < 1500) {
1520
+ return _dockTaskCache.summary;
1521
+ }
1522
+ let summary = '';
1523
+ try {
1524
+ const board = loadTaskBoard({ cwd });
1525
+ const counts = taskCounts(board);
1526
+ const active = board.lists.active?.tasks?.find(task => !task.checked) || board.lists.active?.tasks?.[0];
1527
+ if (active?.text) {
1528
+ summary = `task ${compactTaskText(active.text)}`;
1529
+ } else if (counts.blocked > 0 || counts.backlog > 0) {
1530
+ summary = `tasks ${counts.active} active, ${counts.blocked} blocked, ${counts.backlog} backlog`;
1531
+ }
1532
+ } catch {
1533
+ summary = '';
1534
+ }
1535
+ _dockTaskCache.cwd = cwd;
1536
+ _dockTaskCache.at = now;
1537
+ _dockTaskCache.summary = summary;
1538
+ return summary;
1539
+ }
1540
+
1441
1541
  function buildDockMeta() {
1442
1542
  const parts = [];
1443
1543
 
@@ -1446,6 +1546,9 @@ function buildDockMeta() {
1446
1546
  const branch = _probeGitBranch(cwd);
1447
1547
  parts.push(branch ? `${projectName} ⎇ ${branch}` : projectName);
1448
1548
 
1549
+ const taskSummary = _probeTaskSummary(cwd);
1550
+ if (taskSummary) parts.push(taskSummary);
1551
+
1449
1552
  if (session.turns > 0) {
1450
1553
  parts.push(`turn ${session.turns}`);
1451
1554
  }
@@ -2236,6 +2339,9 @@ function renderEvent(event) {
2236
2339
  case 'tool_result':
2237
2340
  case 'tool_done': {
2238
2341
  const eventData = normalizeSubAgentRunData(data);
2342
+ if (String(eventData?.tool || '').toLowerCase() === 'todowrite' || String(eventData?._tool || '').toLowerCase() === 'todowrite') {
2343
+ _dockTaskCache.at = 0;
2344
+ }
2239
2345
  if (watchState.active) {
2240
2346
  const success = eventData?.success !== false;
2241
2347
  watchState.addEntry('done', { label: eventData?.tool, detail: success ? '✓' : '✗' });
@@ -3062,6 +3168,7 @@ function refreshTaskContext(ctx) {
3062
3168
  const previous = ctx.latestProjectContext || null;
3063
3169
  ctx.latestProjectContext = loadProjectContext({ cwd: safeCwd(), previous });
3064
3170
  ctx.latestEnvelope = null;
3171
+ _dockTaskCache.at = 0;
3065
3172
  } catch { /* best effort */ }
3066
3173
  }
3067
3174
 
@@ -5236,10 +5343,12 @@ export async function startTerminalRepl() {
5236
5343
  function selectedSlashCommandFor(line) {
5237
5344
  const input = String(line || '').trim();
5238
5345
  if (!input.startsWith('/')) return null;
5239
- if (COMMANDS[input] || input.startsWith('/help ')) return input;
5346
+ const parts = input.split(/\s+/);
5347
+ const firstToken = parts[0] || '';
5348
+ if (COMMANDS[firstToken] || firstToken === '/help') return input;
5240
5349
  const item = slashHintItems[slashHintSelected];
5241
5350
  if (!item) return input;
5242
- return item.command;
5351
+ return parts.length > 1 ? `${item.command} ${parts.slice(1).join(' ')}` : item.command;
5243
5352
  }
5244
5353
 
5245
5354
  function reservePromptBottomPadding() {
@@ -11,6 +11,7 @@
11
11
  import { createAgentLoop } from '../core/agent-loop.mjs';
12
12
  import { createToolRegistry } from './registry.mjs';
13
13
  import { createPermissionChecker } from '../permissions/checker.mjs';
14
+ import { DEFAULT_REASONING_MODEL } from '../config/model-defaults.mjs';
14
15
 
15
16
  export const AgentTool = {
16
17
  name: 'Agent',
@@ -59,7 +60,7 @@ export const AgentTool = {
59
60
  _nextBgId: 0,
60
61
 
61
62
  async call(input, options = {}) {
62
- const model = input.model || process.env.SUBAGENT_MODEL || 'claude-sonnet-4-6';
63
+ const model = input.model || process.env.SUBAGENT_MODEL || DEFAULT_REASONING_MODEL;
63
64
  const tools = createToolRegistry({
64
65
  pluginRegistry: options.pluginRegistry || null,
65
66
  stateEmit: options.stateEmit || null,
@@ -1,3 +1,5 @@
1
+ import { syncTodoWriteToTaskFiles } from '../core/tasks.mjs';
2
+
1
3
  /**
2
4
  * TodoWrite Tool — in-memory task management.
3
5
  *
@@ -45,7 +47,7 @@ export const TodoWriteTool = {
45
47
  return [];
46
48
  },
47
49
 
48
- async call(input) {
50
+ async call(input, options = {}) {
49
51
  // Replace entire todo list (matches Claude Code behavior)
50
52
  todos.length = 0;
51
53
  nextId = 1;
@@ -63,6 +65,17 @@ export const TodoWriteTool = {
63
65
  `[${t.status === 'completed' ? 'x' : t.status === 'in_progress' ? '~' : ' '}] ${t.id}. ${t.content} (${t.priority})`
64
66
  ).join('\n');
65
67
 
66
- return `Updated ${todos.length} todos:\n${summary}`;
68
+ let syncLine = '';
69
+ try {
70
+ const synced = syncTodoWriteToTaskFiles({ cwd: options.cwd || process.cwd(), todos });
71
+ if (typeof options.onTaskFilesWritten === 'function') {
72
+ options.onTaskFilesWritten(synced.written);
73
+ }
74
+ syncLine = `\nSynced to .bahulam/tasks: ${synced.counts.active} active, ${synced.counts.backlog} backlog, ${synced.counts.done} done`;
75
+ } catch (err) {
76
+ syncLine = `\nTask markdown sync skipped: ${err.message || String(err)}`;
77
+ }
78
+
79
+ return `Updated ${todos.length} todos:\n${summary}${syncLine}`;
67
80
  },
68
81
  };
@@ -9,6 +9,7 @@ import { SessionManager } from '../core/session.mjs';
9
9
  import { CheckpointManager } from '../core/checkpoints.mjs';
10
10
  import { readEnv, listEnvVars } from '../config/env.mjs';
11
11
  import * as telemetry from '../telemetry/index.mjs';
12
+ import { DEFAULT_FAST_MODEL, DEFAULT_REASONING_MODEL } from '../config/model-defaults.mjs';
12
13
 
13
14
  const checkpoints = new CheckpointManager();
14
15
  let sessionManager = null;
@@ -134,12 +135,14 @@ export const COMMANDS = {
134
135
  '/fast': {
135
136
  description: 'Toggle fast mode (uses faster, cheaper model)',
136
137
  handler(args, state) {
137
- if (state.model?.includes('haiku')) {
138
- state.model = 'claude-sonnet-4-6';
139
- return 'Fast mode OFF — using claude-sonnet-4-6';
138
+ if (state.fastMode) {
139
+ state.fastMode = false;
140
+ state.model = DEFAULT_REASONING_MODEL;
141
+ return `Fast mode OFF — using ${DEFAULT_REASONING_MODEL}`;
140
142
  }
141
- state.model = 'claude-haiku-4-5';
142
- return 'Fast mode ON — using claude-haiku-4-5';
143
+ state.fastMode = true;
144
+ state.model = DEFAULT_FAST_MODEL;
145
+ return `Fast mode ON — using ${DEFAULT_FAST_MODEL}`;
143
146
  },
144
147
  },
145
148
 
@@ -242,7 +242,7 @@ function overlayRowsForWrapped(wrappedLength, requestedMaxRows = DEFAULT_OVERLAY
242
242
  // takes over inside drawInputLines so at most inputRowsMax rows render.
243
243
  function computeInputRowsForBuffer(prefix, value) {
244
244
  const budget = inputTextBudget();
245
- const wrapped = wrapToLines(`${prefix || ''}${value || ''}`, budget);
245
+ const wrapped = wrapToLines(`${prefix || ''}${value || ''}`, budget, { preserveTrailingWhitespace: true });
246
246
  const wanted = Math.max(MIN_INPUT_ROWS, wrapped.length);
247
247
  return Math.min(inputRowsMax, wanted);
248
248
  }
@@ -485,7 +485,7 @@ export function clearDockArea({ restore = true, geometry = null } = {}) {
485
485
  function layoutInput(prefix, value) {
486
486
  const budget = inputTextBudget();
487
487
  const combined = `${prefix || ''}${value || ''}`;
488
- const wrapped = wrapToLines(combined, budget);
488
+ const wrapped = wrapToLines(combined, budget, { preserveTrailingWhitespace: true });
489
489
  const tail = tailWithEllipsis(wrapped, inputRows);
490
490
  return {
491
491
  lines: tail.visible,
@@ -88,7 +88,11 @@ export const HELP_GROUPS = [
88
88
  ['/status context', 'Loaded .bahulam context'],
89
89
  ['/status metrics', 'Progress bars and runtime metrics'],
90
90
  ['/status cost', 'Credits and message window'],
91
- ['/model [role] [model]', 'Show or set session model override'],
91
+ ['/model', 'Open interactive model overrides'],
92
+ ['/model status', 'Show model overrides and catalog source'],
93
+ ['/model refresh', 'Refresh model catalog from backend'],
94
+ ['/model list [category]', 'List curated platform models'],
95
+ ['/model [role] [model]', 'Set session model override'],
92
96
  ['/attach <image>', 'Attach image to the next prompt'],
93
97
  ['/attach clipboard', 'Attach image currently copied to macOS/Windows clipboard'],
94
98
  ['/attachments', 'List pending image attachments'],
@@ -19,11 +19,14 @@ import { strip as stripAnsi, width as visibleWidth } from './palette.mjs';
19
19
  *
20
20
  * @param {string} text
21
21
  * @param {number} maxWidth
22
+ * @param {{ preserveTrailingWhitespace?: boolean }} [options]
22
23
  * @returns {string[]}
23
24
  */
24
- export function wrapToLines(text, maxWidth) {
25
+ export function wrapToLines(text, maxWidth, options = {}) {
25
26
  const width = Math.max(1, Math.floor(maxWidth));
26
27
  const source = String(text ?? '');
28
+ const preserveTrailingWhitespace = Boolean(options?.preserveTrailingWhitespace);
29
+ const finishLine = (line) => preserveTrailingWhitespace ? line : line.replace(/\s+$/, '');
27
30
  if (!source) return [''];
28
31
 
29
32
  const out = [];
@@ -38,8 +41,8 @@ export function wrapToLines(text, maxWidth) {
38
41
  const isSpace = /^\s+$/.test(stripAnsi(token));
39
42
  const candidate = current + token;
40
43
  if (visibleWidth(candidate) <= width) { current = candidate; continue; }
41
- if (current) { out.push(current.replace(/\s+$/, '')); current = ''; }
42
- if (isSpace) continue; // don't start a new line with pure whitespace
44
+ if (current) { out.push(finishLine(current)); current = ''; }
45
+ if (isSpace && !preserveTrailingWhitespace) continue; // don't start a new line with pure whitespace
43
46
  // Token alone still too wide — chunk it.
44
47
  if (visibleWidth(token) > width) {
45
48
  for (const chunk of chunkByVisibleWidth(token, width)) out.push(chunk);
@@ -47,7 +50,7 @@ export function wrapToLines(text, maxWidth) {
47
50
  current = token;
48
51
  }
49
52
  }
50
- if (current) out.push(current.replace(/\s+$/, ''));
53
+ if (current) out.push(finishLine(current));
51
54
  }
52
55
  return out.length ? out : [''];
53
56
  }