@bahulam/code 0.1.2 → 0.1.4

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 (45) hide show
  1. package/package.json +5 -8
  2. package/pulse/lib/tool-categories.ts +13 -0
  3. package/src/commands/device.mjs +121 -0
  4. package/src/commands/pair.mjs +190 -0
  5. package/src/commands/remote.mjs +110 -0
  6. package/src/core/event-log.mjs +393 -0
  7. package/src/core/headless.mjs +198 -0
  8. package/src/core/loop.mjs +276 -0
  9. package/src/core/memory-disk.mjs +210 -0
  10. package/src/core/paths.mjs +36 -0
  11. package/src/core/stream-client.mjs +28 -9
  12. package/src/core/tool-executor.mjs +56 -16
  13. package/src/daemon/approval-store.mjs +253 -0
  14. package/src/daemon/attach-client.mjs +361 -0
  15. package/src/daemon/daemonize.mjs +151 -0
  16. package/src/daemon/event-tap.mjs +197 -0
  17. package/src/daemon/input-lock.mjs +191 -0
  18. package/src/daemon/relay-client.mjs +258 -0
  19. package/src/daemon/session-core.mjs +179 -0
  20. package/src/daemon/session-list.mjs +26 -0
  21. package/src/daemon/session-publisher.mjs +78 -0
  22. package/src/daemon/socket-server.mjs +329 -0
  23. package/src/daemon/stop-daemon.mjs +18 -0
  24. package/src/permissions/checker.mjs +6 -6
  25. package/src/permissions/prompt.mjs +8 -7
  26. package/src/terminal/ansi.mjs +20 -3
  27. package/src/terminal/main.mjs +97 -3
  28. package/src/terminal/repl-render.mjs +21 -8
  29. package/src/terminal/repl.mjs +201 -2
  30. package/src/tools/analyze-code.mjs +39 -0
  31. package/src/tools/bash.mjs +1 -1
  32. package/src/tools/edit.mjs +18 -18
  33. package/src/tools/git-diff.mjs +34 -0
  34. package/src/tools/git-status.mjs +30 -0
  35. package/src/tools/glob.mjs +5 -2
  36. package/src/tools/grep.mjs +1 -1
  37. package/src/tools/meta-tools.mjs +85 -0
  38. package/src/tools/read-files.mjs +37 -0
  39. package/src/tools/read.mjs +20 -10
  40. package/src/tools/registry.mjs +20 -0
  41. package/src/tools/remember.mjs +147 -0
  42. package/src/tools/search-files.mjs +41 -0
  43. package/src/tools/write-project.mjs +62 -0
  44. package/src/tools/write.mjs +1 -1
  45. package/src/ui/sub-agent.mjs +8 -2
@@ -74,13 +74,16 @@ function walkDir(dir, maxDepth = 20, depth = 0) {
74
74
  }
75
75
 
76
76
  export const GlobTool = {
77
- name: 'Glob',
77
+ name: 'list_files',
78
78
  description: 'Find files matching a glob pattern, sorted by modification time.',
79
79
  inputSchema: {
80
80
  type: 'object',
81
81
  properties: {
82
82
  pattern: { type: 'string', description: 'Glob pattern (e.g. "**/*.js")' },
83
- path: { type: 'string', description: 'Directory to search in' },
83
+ path: { type: 'string', description: 'Directory path' },
84
+ recursive: { type: 'boolean', description: 'Search recursively' },
85
+ format: { type: 'string', enum: ['files', 'tree'], description: "Output format. 'files' returns matching file paths; 'tree' returns a bounded directory tree including folders." },
86
+ max_depth: { type: 'integer', description: "Maximum tree depth when format='tree' (default 2, max 6)" },
84
87
  },
85
88
  required: ['pattern'],
86
89
  },
@@ -14,7 +14,7 @@ import { spawnSync } from 'child_process';
14
14
  import path from 'path';
15
15
 
16
16
  export const GrepTool = {
17
- name: 'Grep',
17
+ name: 'search_code',
18
18
  description: 'Search file contents with regex (powered by ripgrep or grep).',
19
19
  inputSchema: {
20
20
  type: 'object',
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Sub-agent meta-tools: explore, plan, verify, debug, refactor.
3
+ *
4
+ * These are the LLM-visible peer-agent tools that map to gateway
5
+ * /v1/agent/subagent calls. For branch-01 they are registered with
6
+ * full schema parity to the Python originals (tool_bridge.py);
7
+ * the actual dispatch becomes a gateway call in a later phase.
8
+ */
9
+
10
+ const HANDOFF_SCHEMA = {
11
+ type: 'object',
12
+ description: 'Optional compact handoff envelope. Pass known facts so the peer starts with context.',
13
+ properties: {
14
+ objective: { type: 'string' },
15
+ context: { type: 'string' },
16
+ files: { type: 'array', items: { type: 'object' } },
17
+ findings: { type: 'array', items: { type: 'object' } },
18
+ constraints: { type: 'array', items: { type: 'string' } },
19
+ acceptance_criteria: { type: 'array', items: { type: 'string' } },
20
+ open_questions: { type: 'array', items: { type: 'string' } },
21
+ output_contract: { type: 'string' },
22
+ },
23
+ };
24
+
25
+ /** Stub call — signals this is a gateway-dispatched sub-agent. */
26
+ async function stubCall(_input) {
27
+ // Actual dispatch goes through gateway /v1/agent/subagent in a later phase.
28
+ // The LLM sees this tool registered so the gateway can serve its schema.
29
+ return 'Sub-agent dispatch via gateway — not yet implemented client-side.';
30
+ }
31
+
32
+ function metaTool(name, description, params) {
33
+ return {
34
+ name,
35
+ description,
36
+ inputSchema: {
37
+ type: 'object',
38
+ properties: {
39
+ task: { type: 'string', description: 'The task to delegate to this sub-agent.' },
40
+ handoff: HANDOFF_SCHEMA,
41
+ ...params,
42
+ },
43
+ required: ['task'],
44
+ },
45
+ validateInput(input) { return input.task ? [] : ['task required']; },
46
+ async call(input) { return stubCall(input); },
47
+ };
48
+ }
49
+
50
+ export const ExploreTool = metaTool(
51
+ 'explore',
52
+ 'Read-only codebase investigation and orientation.',
53
+ {
54
+ thoroughness: {
55
+ type: 'string',
56
+ enum: ['quick', 'medium', 'thorough'],
57
+ description: 'How deep to investigate.',
58
+ default: 'medium',
59
+ },
60
+ },
61
+ );
62
+
63
+ export const PlanTool = metaTool(
64
+ 'plan',
65
+ 'Sequencing, design, and risk analysis for multi-file changes.',
66
+ {},
67
+ );
68
+
69
+ export const VerifyTool = metaTool(
70
+ 'verify',
71
+ 'Tests, lint, build checks, and evidence gathering.',
72
+ {},
73
+ );
74
+
75
+ export const DebugTool = metaTool(
76
+ 'debug',
77
+ 'Failure reproduction and root-cause investigation.',
78
+ {},
79
+ );
80
+
81
+ export const RefactorTool = metaTool(
82
+ 'refactor',
83
+ 'Mechanical broad cleanup, renames, and structure-only changes.',
84
+ {},
85
+ );
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Read Files Tool — read multiple files at once (matches Python schema).
3
+ */
4
+ import * as path from 'node:path';
5
+
6
+ export const ReadFilesTool = {
7
+ name: 'read_files',
8
+ description: 'Read multiple files at once',
9
+ inputSchema: {
10
+ type: 'object',
11
+ properties: {
12
+ file_paths: {
13
+ type: 'array',
14
+ items: { type: 'string' },
15
+ description: 'File paths',
16
+ },
17
+ },
18
+ required: ['file_paths'],
19
+ },
20
+ validateInput(input) {
21
+ return Array.isArray(input.file_paths) && input.file_paths.length > 0 ? [] : ['file_paths array required'];
22
+ },
23
+ async call(input) {
24
+ const { ReadTool } = await import('./read.mjs');
25
+ const results = [];
26
+ for (const fp of input.file_paths) {
27
+ try {
28
+ const result = await ReadTool.call({ file_path: path.resolve(fp) });
29
+ const text = typeof result === 'string' ? result : (result?.output || result?.content || String(result));
30
+ results.push(`## ${fp}\n${text}`);
31
+ } catch (err) {
32
+ results.push(`## ${fp}\nError: ${err.message}`);
33
+ }
34
+ }
35
+ return results.join('\n\n');
36
+ },
37
+ };
@@ -35,14 +35,14 @@ function isBinary(buffer) {
35
35
  }
36
36
 
37
37
  export const ReadTool = {
38
- name: 'Read',
38
+ name: 'read_file',
39
39
  description: 'Read a file from the local filesystem.',
40
40
  inputSchema: {
41
41
  type: 'object',
42
42
  properties: {
43
43
  file_path: { type: 'string', description: 'Absolute path to the file' },
44
- offset: { type: 'number', description: 'Line number to start reading from (0-based)' },
45
- limit: { type: 'number', description: 'Number of lines to read (default 2000)' },
44
+ start_line: { type: 'integer', description: 'Line number to start reading from (1-indexed)' },
45
+ end_line: { type: 'integer', description: 'Line number to stop reading at (1-indexed, inclusive)' },
46
46
  pages: { type: 'string', description: 'Page range for PDF files (e.g. "1-5")' },
47
47
  },
48
48
  required: ['file_path'],
@@ -87,9 +87,6 @@ export const ReadTool = {
87
87
  try {
88
88
  const content = fs.readFileSync(filePath, 'utf-8');
89
89
  const lines = content.split('\n');
90
- const start = input.offset || 0;
91
- const limit = input.limit || DEFAULT_LIMIT;
92
- const end = Math.min(start + limit, lines.length);
93
90
 
94
91
  // Track as read
95
92
  readFiles.add(filePath);
@@ -99,13 +96,26 @@ export const ReadTool = {
99
96
  return '[File exists but is empty]';
100
97
  }
101
98
 
99
+ // start_line/end_line: 1-indexed, inclusive
100
+ const hasStart = input.start_line != null;
101
+ const hasEnd = input.end_line != null;
102
+ const startIdx = hasStart ? Math.max(0, Number(input.start_line) - 1) : 0;
103
+ let endIdx;
104
+ if (hasEnd) {
105
+ endIdx = Math.min(Number(input.end_line), lines.length); // inclusive → exclusive bound
106
+ } else if (hasStart) {
107
+ endIdx = lines.length;
108
+ } else {
109
+ endIdx = Math.min(DEFAULT_LIMIT, lines.length);
110
+ }
111
+
102
112
  const output = lines
103
- .slice(start, end)
104
- .map((l, i) => `${start + i + 1}\t${l}`)
113
+ .slice(startIdx, endIdx)
114
+ .map((l, i) => `${startIdx + i + 1}\t${l}`)
105
115
  .join('\n');
106
116
 
107
- if (end < lines.length) {
108
- return output + `\n\n[File has ${lines.length} lines total. Showing lines ${start + 1}-${end}. Use offset/limit for more.]`;
117
+ if (endIdx < lines.length) {
118
+ return output + `\n\n[File has ${lines.length} lines total. Showing lines ${startIdx + 1}-${endIdx}. Use read_file with start_line/end_line for more.]`;
109
119
  }
110
120
 
111
121
  return output;
@@ -29,6 +29,14 @@ import { CronDeleteTool } from './cron-delete.mjs';
29
29
  import { CronListTool } from './cron-list.mjs';
30
30
  import { LspTool } from './lsp.mjs';
31
31
  import { ReadMcpResourceTool } from './read-mcp-resource.mjs';
32
+ import { GitDiffTool } from './git-diff.mjs';
33
+ import { GitStatusTool } from './git-status.mjs';
34
+ import { WriteProjectTool } from './write-project.mjs';
35
+ import { ReadFilesTool } from './read-files.mjs';
36
+ import { SearchFilesTool } from './search-files.mjs';
37
+ import { AnalyzeCodeTool } from './analyze-code.mjs';
38
+ import { ExploreTool, PlanTool, VerifyTool, DebugTool, RefactorTool } from './meta-tools.mjs';
39
+ import { RememberTool } from './remember.mjs';
32
40
 
33
41
  const BUILTIN_TOOLS = [
34
42
  BashTool,
@@ -41,6 +49,7 @@ const BUILTIN_TOOLS = [
41
49
  WebFetchTool,
42
50
  WebSearchTool,
43
51
  TodoWriteTool,
52
+ RememberTool,
44
53
  NotebookEditTool,
45
54
  MultiEditTool,
46
55
  LsTool,
@@ -56,6 +65,17 @@ const BUILTIN_TOOLS = [
56
65
  CronListTool,
57
66
  LspTool,
58
67
  ReadMcpResourceTool,
68
+ GitDiffTool,
69
+ GitStatusTool,
70
+ WriteProjectTool,
71
+ ReadFilesTool,
72
+ SearchFilesTool,
73
+ AnalyzeCodeTool,
74
+ ExploreTool,
75
+ PlanTool,
76
+ VerifyTool,
77
+ DebugTool,
78
+ RefactorTool,
59
79
  ];
60
80
 
61
81
  export function createToolRegistry() {
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Remember Tool — persist a cross-session memory fact to the user's disk.
3
+ *
4
+ * CLI-local tool. The agent calls this when it wants to remember something
5
+ * about the user, the project, or a preference. The fact lands in
6
+ * ~/.bahulam/memory.md (global) or <cwd>/.bahulam/memory.md (project),
7
+ * matching the disk-first CLI memory model (PRD-217).
8
+ *
9
+ * Contrast: chat / cloud-IDE / workspace surfaces continue writing to the
10
+ * Supabase agent_memory table via the framework's SupabaseMemoryBackend.
11
+ * Only CLI-originated requests use this local tool.
12
+ *
13
+ * The persisted shape round-trips 1:1 with the Supabase agent_memory
14
+ * schema — see src/core/memory-disk.mjs for the file format and helpers.
15
+ */
16
+
17
+ import { upsertFacts, loadDiskMemory, ensureBahulamDir } from '../core/memory-disk.mjs';
18
+
19
+ // Slug-safe generator when the caller didn't supply a fact_id. Prefer the
20
+ // caller's own id when they give one — that's how they update an existing
21
+ // fact instead of piling on near-duplicates.
22
+ function _makeFactId(hint) {
23
+ const base = String(hint || 'fact').toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
25
+ const stamp = Math.floor(Date.now() / 1000).toString(36);
26
+ return `${base || 'fact'}-${stamp}`;
27
+ }
28
+
29
+ export const RememberTool = {
30
+ name: 'remember',
31
+ description:
32
+ 'Save a cross-session memory fact to the user\'s disk (~/.bahulam/memory.md). ' +
33
+ 'Use when the user tells you a preference, when you learn a durable detail ' +
34
+ 'about their project, or when the current turn establishes a decision worth ' +
35
+ 'carrying forward. Do NOT use for ephemeral session state (that belongs in ' +
36
+ 'the current conversation) or for temporary task tracking (use TodoWrite).',
37
+ inputSchema: {
38
+ type: 'object',
39
+ properties: {
40
+ content: {
41
+ type: 'string',
42
+ description:
43
+ 'The fact to remember, in prose. One or a few sentences. ' +
44
+ 'Include enough context to be useful without the surrounding conversation.',
45
+ },
46
+ fact_type: {
47
+ type: 'string',
48
+ enum: ['preference', 'entity', 'decision', 'context', 'other'],
49
+ description:
50
+ 'Category. `preference` = user always/never wants X. `entity` = ' +
51
+ 'thing/person/service (their company, their stack, their env). ' +
52
+ '`decision` = choice made this session worth remembering. ' +
53
+ '`context` = ongoing situation the agent should know next turn. ' +
54
+ '`other` = anything else.',
55
+ },
56
+ fact_id: {
57
+ type: 'string',
58
+ description:
59
+ 'Stable ID. Reuse the same id to UPDATE a prior fact (in-place ' +
60
+ 'overwrite). Auto-generated from `content` if omitted, so ' +
61
+ 'auto-generated ids create new rows every time — pass an ' +
62
+ 'explicit id for anything you might want to update later.',
63
+ },
64
+ memory_scope: {
65
+ type: 'string',
66
+ enum: ['global', 'project'],
67
+ description:
68
+ '`global` (default) writes to ~/.bahulam/memory.md — visible ' +
69
+ 'across every session. `project` writes to ' +
70
+ '<cwd>/.bahulam/memory.md and only surfaces when running in ' +
71
+ 'this project. Use `project` for facts tied to this specific ' +
72
+ 'codebase (its architecture, its conventions, its history).',
73
+ },
74
+ confidence: {
75
+ type: 'number',
76
+ minimum: 0,
77
+ maximum: 1,
78
+ description:
79
+ '0..1. How sure are you this fact is durably true? Use 0.9+ ' +
80
+ 'when the user stated it explicitly. Use 0.5-0.7 for inferred ' +
81
+ 'facts. Below 0.5 usually isn\'t worth saving.',
82
+ },
83
+ tags: {
84
+ type: 'array',
85
+ items: { type: 'string' },
86
+ description:
87
+ 'Short lowercase labels (e.g. ["performance", "cache"]). ' +
88
+ 'Help future turns retrieve related facts.',
89
+ },
90
+ },
91
+ required: ['content'],
92
+ },
93
+
94
+ validateInput(input) {
95
+ const errs = [];
96
+ if (!input || typeof input.content !== 'string' || !input.content.trim()) {
97
+ errs.push('content is required and must be a non-empty string');
98
+ }
99
+ if (input.confidence != null) {
100
+ const n = Number(input.confidence);
101
+ if (!Number.isFinite(n) || n < 0 || n > 1) errs.push('confidence must be a number in [0, 1]');
102
+ }
103
+ return errs;
104
+ },
105
+
106
+ async call(input, _options = {}) {
107
+ const now = new Date().toISOString();
108
+ const scope = input.memory_scope === 'project' ? 'project' : 'global';
109
+ const fact = {
110
+ fact_id: (input.fact_id || _makeFactId(input.content)).trim(),
111
+ content: String(input.content).trim(),
112
+ fact_type: input.fact_type || 'other',
113
+ confidence: typeof input.confidence === 'number' ? input.confidence : 0.8,
114
+ source: 'agent',
115
+ tags: Array.isArray(input.tags) ? input.tags.map(String) : [],
116
+ metadata: {},
117
+ project_id: null,
118
+ memory_scope: scope,
119
+ created_at: now,
120
+ updated_at: now,
121
+ };
122
+
123
+ // Preserve created_at if we're updating an existing fact.
124
+ const existing = loadDiskMemory().find(f => f.fact_id === fact.fact_id);
125
+ if (existing?.created_at) fact.created_at = existing.created_at;
126
+
127
+ try {
128
+ // Belt-and-braces: upsertFacts also mkdir's, but a stale cwd on
129
+ // a project write could still miss the parent — do it explicitly.
130
+ ensureBahulamDir(scope);
131
+ upsertFacts([fact]);
132
+ } catch (err) {
133
+ return {
134
+ success: false,
135
+ output: `remember failed: ${err?.message || err}`,
136
+ _tool: 'remember',
137
+ };
138
+ }
139
+ return {
140
+ success: true,
141
+ output: `Saved ${scope} fact '${fact.fact_id}' (${fact.content.length} chars).`,
142
+ _tool: 'remember',
143
+ _fact_id: fact.fact_id,
144
+ _scope: scope,
145
+ };
146
+ },
147
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Search Files Tool — regex-based file content search (matches Python schema).
3
+ */
4
+ import { spawnSync } from 'child_process';
5
+ import * as path from 'node:path';
6
+
7
+ export const SearchFilesTool = {
8
+ name: 'search_files',
9
+ description: 'Search file contents with regex',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ pattern: { type: 'string', description: 'Regex pattern' },
14
+ path: { type: 'string', description: 'Directory' },
15
+ },
16
+ required: ['pattern'],
17
+ },
18
+ validateInput(input) {
19
+ return input.pattern ? [] : ['pattern required'];
20
+ },
21
+ async call(input) {
22
+ const dir = path.resolve(input.path || '.');
23
+ try {
24
+ const args = ['-rn', '--max-count', '10', '--max-filesize', '500K'];
25
+ args.push('-e', input.pattern);
26
+ args.push(dir);
27
+ const result = spawnSync('rg', args, {
28
+ encoding: 'utf-8',
29
+ timeout: 15_000,
30
+ stdio: 'pipe',
31
+ });
32
+ if (result.status === 0 || result.status === 1) {
33
+ const output = (result.stdout || '').trim();
34
+ return output || `No matches for "${input.pattern}" in ${dir}`;
35
+ }
36
+ return `grep error (exit ${result.status}): ${result.stderr?.trim() || 'unknown'}`;
37
+ } catch (err) {
38
+ return `Error: ${err.message}`;
39
+ }
40
+ },
41
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Write Project Tool — batch write multiple files at once (matches Python schema).
3
+ */
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { hasBeenRead, markRead } from './read.mjs';
7
+
8
+ export const WriteProjectTool = {
9
+ name: 'write_project',
10
+ description: 'Write multiple files at once',
11
+ inputSchema: {
12
+ type: 'object',
13
+ properties: {
14
+ files: {
15
+ type: 'array',
16
+ items: {
17
+ type: 'object',
18
+ properties: {
19
+ path: { type: 'string', description: 'File path' },
20
+ content: { type: 'string', description: 'File content' },
21
+ },
22
+ required: ['path', 'content'],
23
+ },
24
+ description: 'Files to write',
25
+ },
26
+ },
27
+ required: ['files'],
28
+ },
29
+ validateInput(input) {
30
+ const errors = [];
31
+ if (!Array.isArray(input.files) || input.files.length === 0) errors.push('files array required');
32
+ return errors;
33
+ },
34
+ async call(input) {
35
+ const results = [];
36
+ const errors = [];
37
+ for (const file of input.files) {
38
+ const filePath = path.resolve(file.path || file.file_path);
39
+ try {
40
+ const dir = path.dirname(filePath);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ if (fs.existsSync(filePath) && !hasBeenRead(filePath)) {
43
+ // Read first for overwrites
44
+ const { ReadTool } = await import('./read.mjs');
45
+ await ReadTool.call({ file_path: filePath, limit: 1 });
46
+ }
47
+ fs.writeFileSync(filePath, file.content || '', 'utf-8');
48
+ markRead(filePath);
49
+ results.push(filePath);
50
+ } catch (err) {
51
+ errors.push(`${filePath}: ${err.message}`);
52
+ }
53
+ }
54
+ const output = results.length > 0
55
+ ? `Created ${results.length} file(s):\n${results.map(f => ` ✓ ${f}`).join('\n')}`
56
+ : 'No files written';
57
+ if (errors.length > 0) {
58
+ return `${output}\n\nErrors:\n${errors.map(e => ` ✗ ${e}`).join('\n')}`;
59
+ }
60
+ return output;
61
+ },
62
+ };
@@ -11,7 +11,7 @@ import path from 'path';
11
11
  import { hasBeenRead, markRead } from './read.mjs';
12
12
 
13
13
  export const WriteTool = {
14
- name: 'Write',
14
+ name: 'write_file',
15
15
  description: 'Write content to a file. Creates parent dirs if needed.',
16
16
  inputSchema: {
17
17
  type: 'object',
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { paint } from './palette.mjs';
21
21
  import { icons } from './icons.mjs';
22
+ import { formatSeconds } from '../terminal/ansi.mjs';
22
23
 
23
24
  const SUB_ICONS = {
24
25
  explore: '🔭',
@@ -122,9 +123,14 @@ export function renderSubAgentClose({
122
123
  const parts = [];
123
124
  if (toolCalls > 0) parts.push(`${toolCalls} tools`);
124
125
  if (iterations > 0) parts.push(`${iterations} iter`);
125
- if (tokens > 0) parts.push(`${formatTokens(tokens)} tok`);
126
+ // Tokens: pass the OUTPUT (generation) count only. Summing input+output
127
+ // across a multi-iteration sub-agent double-counts the context that is
128
+ // re-shipped each iteration, and the resulting number reads huge and
129
+ // misleading (e.g. 632.8k for a 16-iter run whose actual generation was
130
+ // a fraction of that). Output tokens are the honest "work done" number.
131
+ if (tokens > 0) parts.push(`${formatTokens(tokens)} gen`);
126
132
  if (typeof costUsd === 'number' && costUsd > 0) parts.push(formatCost(costUsd));
127
- if (durationS != null) parts.push(`${Number(durationS).toFixed(1)}s`);
133
+ if (durationS != null) parts.push(formatSeconds(durationS));
128
134
  const detail = parts.length ? paint.text.dim(' · ' + parts.join(' · ')) : '';
129
135
 
130
136
  const body = summary