@axplusb/kepler 2.0.7 → 2.2.0

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.
@@ -17,6 +17,7 @@ function stripAnsi(str) {
17
17
  }
18
18
 
19
19
  const MAX_OUTPUT_BYTES = 1024 * 1024; // 1MB
20
+ const TIMEOUT_TAIL_BYTES = 64 * 1024;
20
21
 
21
22
  export const BashTool = {
22
23
  name: 'Bash',
@@ -47,6 +48,8 @@ export const BashTool = {
47
48
  return new Promise((resolve) => {
48
49
  let stdout = '';
49
50
  let stderr = '';
51
+ let stdoutTail = '';
52
+ let stderrTail = '';
50
53
  let killed = false;
51
54
  let exitCode = null;
52
55
 
@@ -54,27 +57,28 @@ export const BashTool = {
54
57
  cwd: input.cwd,
55
58
  env: { ...process.env },
56
59
  stdio: ['pipe', 'pipe', 'pipe'],
60
+ detached: process.platform !== 'win32',
57
61
  timeout: 0, // we handle timeout ourselves
58
62
  });
59
63
 
60
64
  proc.stdout.on('data', (chunk) => {
61
- if (stdout.length < MAX_OUTPUT_BYTES) {
62
- stdout += chunk.toString();
63
- }
65
+ const text = chunk.toString();
66
+ stdout = appendHead(stdout, text, MAX_OUTPUT_BYTES);
67
+ stdoutTail = appendTail(stdoutTail, text, TIMEOUT_TAIL_BYTES);
64
68
  });
65
69
 
66
70
  proc.stderr.on('data', (chunk) => {
67
- if (stderr.length < MAX_OUTPUT_BYTES) {
68
- stderr += chunk.toString();
69
- }
71
+ const text = chunk.toString();
72
+ stderr = appendHead(stderr, text, MAX_OUTPUT_BYTES);
73
+ stderrTail = appendTail(stderrTail, text, TIMEOUT_TAIL_BYTES);
70
74
  });
71
75
 
72
76
  // Timeout: SIGTERM first, then SIGKILL after 5s
73
77
  const timer = setTimeout(() => {
74
78
  killed = true;
75
- proc.kill('SIGTERM');
79
+ killProcess(proc, 'SIGTERM');
76
80
  setTimeout(() => {
77
- try { proc.kill('SIGKILL'); } catch { /* already dead */ }
81
+ killProcess(proc, 'SIGKILL');
78
82
  }, 5000);
79
83
  }, timeout);
80
84
 
@@ -95,7 +99,8 @@ export const BashTool = {
95
99
  stderr = stripAnsi(stderr);
96
100
 
97
101
  if (killed) {
98
- resolve(`Error: Command timed out after ${timeout}ms\n${stdout}\n${stderr}`.trim());
102
+ const tail = formatTimeoutTail(stdoutTail, stderrTail);
103
+ resolve(`Error: Command timed out after ${timeout}ms\n${tail}`.trim());
99
104
  return;
100
105
  }
101
106
 
@@ -118,6 +123,49 @@ export const BashTool = {
118
123
  },
119
124
  };
120
125
 
126
+ function appendHead(current, chunk, maxBytes) {
127
+ if (current.length >= maxBytes) return current;
128
+ const next = current + chunk;
129
+ return next.length > maxBytes ? next.slice(0, maxBytes) : next;
130
+ }
131
+
132
+ function appendTail(current, chunk, maxBytes) {
133
+ const next = current + chunk;
134
+ return next.length > maxBytes ? next.slice(next.length - maxBytes) : next;
135
+ }
136
+
137
+ function formatTimeoutTail(stdoutTail, stderrTail) {
138
+ const out = stripAnsi(stdoutTail || '').trim();
139
+ const err = stripAnsi(stderrTail || '').trim();
140
+ const lines = [];
141
+ if (out) {
142
+ lines.push('[stdout tail]');
143
+ lines.push(tailLines(out, 80));
144
+ }
145
+ if (err) {
146
+ if (lines.length) lines.push('');
147
+ lines.push('[stderr tail]');
148
+ lines.push(tailLines(err, 80));
149
+ }
150
+ return lines.length ? lines.join('\n') : '(no output captured before timeout)';
151
+ }
152
+
153
+ function tailLines(text, maxLines) {
154
+ const lines = String(text || '').split('\n');
155
+ return lines.slice(-maxLines).join('\n');
156
+ }
157
+
158
+ function killProcess(proc, signal) {
159
+ if (!proc?.pid) return;
160
+ try {
161
+ if (process.platform !== 'win32') {
162
+ process.kill(-proc.pid, signal);
163
+ return;
164
+ }
165
+ } catch { /* fall through to direct process kill */ }
166
+ try { proc.kill(signal); } catch { /* already exited */ }
167
+ }
168
+
121
169
  // Background jobs store
122
170
  const backgroundJobs = new Map();
123
171
  let bgJobId = 0;
@@ -64,6 +64,19 @@ function isWithin(root, candidate) {
64
64
  return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
65
65
  }
66
66
 
67
+ function uniqueValues(values) {
68
+ return [...new Set(values.filter(Boolean))];
69
+ }
70
+
71
+ function canonicalRoot(rootPath) {
72
+ const resolved = path.resolve(normalizePathInput(rootPath));
73
+ try {
74
+ return fs.realpathSync(resolved);
75
+ } catch {
76
+ return resolved;
77
+ }
78
+ }
79
+
67
80
  function canonicalizeCandidate(candidate) {
68
81
  if (fs.existsSync(candidate)) return fs.realpathSync(candidate);
69
82
 
@@ -233,6 +246,9 @@ function formatResource(resource) {
233
246
  if (resource.project_context) {
234
247
  lines.push('', '--- Project Context ---', resource.project_context);
235
248
  }
249
+ if (resource.style) {
250
+ lines.push('', '--- Project Style ---', resource.style);
251
+ }
236
252
  if (resource.goal) {
237
253
  lines.push('', '--- Current Goal ---', resource.goal);
238
254
  }
@@ -261,27 +277,53 @@ function _scanSkills(keplerDir) {
261
277
  const skillsDir = path.join(keplerDir, 'skills');
262
278
  if (!fs.existsSync(skillsDir)) return [];
263
279
  try {
264
- return fs.readdirSync(skillsDir)
265
- .filter(f => f.endsWith('.md'))
266
- .map(f => {
267
- const content = fs.readFileSync(path.join(skillsDir, f), 'utf-8');
280
+ return fs.readdirSync(skillsDir, { withFileTypes: true })
281
+ .map(entry => {
282
+ const file = entry.isDirectory()
283
+ ? path.join(skillsDir, entry.name, 'SKILL.md')
284
+ : path.join(skillsDir, entry.name);
285
+ if (!fs.existsSync(file) || !file.endsWith('.md')) return null;
286
+ const content = fs.readFileSync(file, 'utf-8');
268
287
  const descMatch = content.match(/^#\s+.*\n+(.+)/);
269
288
  return {
270
- name: f.replace('.md', ''),
271
- description: descMatch ? descMatch[1].slice(0, 100) : f.replace('.md', ''),
289
+ name: entry.isDirectory() ? entry.name : entry.name.replace('.md', ''),
290
+ description: content.match(/^description:\s*(.+)$/mi)?.[1]?.trim()
291
+ || (descMatch ? descMatch[1].slice(0, 100) : entry.name.replace('.md', '')),
272
292
  };
273
- });
293
+ })
294
+ .filter(Boolean);
274
295
  } catch { return []; }
275
296
  }
276
297
 
298
+ function defaultScratchRoots() {
299
+ return uniqueValues([
300
+ '/tmp',
301
+ '/private/tmp',
302
+ os.tmpdir(),
303
+ process.env.TMPDIR,
304
+ ...(process.env.KEPLER_SCRATCH_ROOTS || '')
305
+ .split(path.delimiter)
306
+ .map(s => s.trim())
307
+ .filter(Boolean),
308
+ ]).map(canonicalRoot);
309
+ }
310
+
277
311
  export class ProjectRegistry {
278
312
  constructor() {
279
313
  this.projects = new Map();
314
+ this.scratchRoots = new Set(defaultScratchRoots());
280
315
  this._globalIdentity = null;
281
316
  this._globalPreferences = null;
282
317
  this._globalSkills = null;
283
318
  }
284
319
 
320
+ addScratchRoot(rawPath) {
321
+ if (!rawPath) return null;
322
+ const root = canonicalRoot(rawPath);
323
+ this.scratchRoots.add(root);
324
+ return root;
325
+ }
326
+
285
327
  /**
286
328
  * Load global context from ~/.kepler/ (once per session).
287
329
  */
@@ -382,10 +424,13 @@ export class ProjectRegistry {
382
424
  fs.writeFileSync(resourcePath, JSON.stringify(resource));
383
425
  }
384
426
 
385
- // Read project-level context files (.kepler/project.md, goal.md, skills/)
427
+ // Read project-level context files (.kepler/KEPLER.md, project.md, goal.md, plan.md, style.md, skills/)
386
428
  const keplerDir = path.join(root, '.kepler');
387
429
  resource.environment = detectEnvironment();
388
- resource.project_context = _readIfExists(keplerDir, 'project.md', 8000);
430
+ resource.project_context = _readIfExists(keplerDir, 'KEPLER.md', 10000) ||
431
+ _readIfExists(root, 'KEPLER.md', 10000) ||
432
+ _readIfExists(keplerDir, 'project.md', 8000);
433
+ resource.style = _readIfExists(keplerDir, 'style.md', 4000);
389
434
  resource.goal = _readIfExists(keplerDir, 'goal.md', 2000);
390
435
  resource.plan = _readIfExists(keplerDir, 'plan.md', 6000);
391
436
  resource.skills_index = _scanSkills(keplerDir);
@@ -410,6 +455,23 @@ export class ProjectRegistry {
410
455
  return this.projects.get(projectIdValue) || null;
411
456
  }
412
457
 
458
+ projectScratchRoots() {
459
+ return this.resources().map(resource => path.join(resource.root, '.kepler', 'tmp'));
460
+ }
461
+
462
+ allowedScratchRoots() {
463
+ return uniqueValues([
464
+ ...this.scratchRoots,
465
+ ...this.projectScratchRoots(),
466
+ ]).map(canonicalRoot);
467
+ }
468
+
469
+ isAllowedScratchPath(filePath) {
470
+ const normalized = normalizePathInput(filePath);
471
+ const candidate = canonicalizeCandidate(path.resolve(normalized));
472
+ return this.allowedScratchRoots().some(root => isWithin(root, candidate));
473
+ }
474
+
413
475
  resolvePath(rawPath, projectIdValue, { allowMissing = false } = {}) {
414
476
  let root = null;
415
477
  if (projectIdValue) {
@@ -453,8 +515,12 @@ export class ProjectRegistry {
453
515
  const findContaining = (cand) => [...this.projects.values()].find(({ resource }) =>
454
516
  isWithin(resource.root, cand)
455
517
  );
518
+ const findScratchRoot = (cand) => this.allowedScratchRoots().find(scratchRoot =>
519
+ isWithin(scratchRoot, cand)
520
+ );
456
521
 
457
522
  let containingProject = findContaining(candidate);
523
+ let containingScratchRoot = containingProject ? null : findScratchRoot(candidate);
458
524
 
459
525
  // Two reasons to try the unescaped variant:
460
526
  // (1) candidate is outside every project root (literal "Tarang\ Orca"
@@ -473,12 +539,19 @@ export class ProjectRegistry {
473
539
  if (altProject && (allowMissing || fs.existsSync(altCandidate))) {
474
540
  candidate = altCandidate;
475
541
  containingProject = altProject;
542
+ containingScratchRoot = null;
543
+ } else {
544
+ const altScratchRoot = findScratchRoot(altCandidate);
545
+ if (altScratchRoot && (allowMissing || fs.existsSync(altCandidate))) {
546
+ candidate = altCandidate;
547
+ containingScratchRoot = altScratchRoot;
548
+ }
476
549
  }
477
550
  } catch { /* fall through to the original error */ }
478
551
  }
479
552
  }
480
553
 
481
- if (!containingProject) {
554
+ if (!containingProject && !containingScratchRoot) {
482
555
  throw new Error(`Path is outside registered project roots: ${rawPath}`);
483
556
  }
484
557
  if (!allowMissing && !fs.existsSync(candidate)) {
@@ -34,20 +34,19 @@ const PAD_X = 3;
34
34
  * hint — secondary description shown to the right of the label
35
35
  */
36
36
  export function defaultOptions(tier) {
37
+ const shared = [
38
+ { key: 'y', label: 'approve once', value: 'approve', hint: 'run this call' },
39
+ { key: 'r', label: 're-plan with note...', value: 'replan', hint: 'steer the agent' },
40
+ { key: 'n', label: 'stop', value: 'reject', hint: 'do not run' },
41
+ { key: '?', label: 'why', value: 'why', hint: 'show reasoning' },
42
+ ];
37
43
  if (requiresExplicitApproval(tier)) {
38
- return [
39
- { key: 'y', label: 'approve', value: 'approve', hint: 'run this once' },
40
- { key: 'e', label: 'edit', value: 'edit', hint: 'tweak the args before running' },
41
- { key: 'r', label: 're-plan', value: 'replan', hint: 'send back to the agent' },
42
- { key: 'n', label: 'reject', value: 'reject', hint: 'do not run' },
43
- { key: '?', label: 'why', value: 'why', hint: 'show reasoning' },
44
- ];
44
+ return shared;
45
45
  }
46
46
  return [
47
- { key: 'y', label: 'approve', value: 'approve', hint: 'run this once' },
47
+ shared[0],
48
48
  { key: 't', label: 'always allow', value: 'allow-type', hint: 'auto-approve future calls to this tool' },
49
- { key: 'n', label: 'reject', value: 'reject', hint: 'do not run' },
50
- { key: '?', label: 'why', value: 'why', hint: 'show reasoning' },
49
+ ...shared.slice(1),
51
50
  ];
52
51
  }
53
52
 
@@ -88,22 +87,30 @@ export function renderApprovalPrompt({
88
87
  const explicit = requiresExplicitApproval(tier);
89
88
  const accent = explicit ? paint.brand.accent : paint.brand.data;
90
89
  const opts = options || defaultOptions(tier);
91
-
92
- const summary = toolDisplaySummary(tool, args, {});
93
- const toolLine = `${icon(tool)} ${paint.text.primary(toolDisplayLabel(tool))} ${paint.text.muted(summary ? `"${truncate(summary, cols - 20)}"` : '')}`;
90
+ const available = cols - 2 - PAD_X * 2;
91
+ const title = `${riskIcon(tier)} ${approvalTitle(tier)} · ${tierLabel(tier)} · ${tool || 'tool'}`;
94
92
 
95
93
  const lines = [
96
94
  bar(cols, accent),
97
- fill(' AWAITING APPROVAL', cols, accent, paint.bold(accent('AWAITING APPROVAL'))),
95
+ fill(' ' + ' '.repeat(PAD_X - 1) + paint.bold(accent(title)), cols, accent),
98
96
  fill('', cols, accent),
99
- fill(' ' + ' '.repeat(PAD_X - 1) + toolLine, cols, accent),
97
+ ...subjectRows(tool, args, cols, accent),
100
98
  fill('', cols, accent),
101
- fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim('Tier: ') + accent(tierLabel(tier)), cols, accent),
102
99
  ];
103
100
  if (why) {
104
- lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim('Why: ') + paint.text.primary(truncate(why, cols - PAD_X - 12)), cols, accent));
101
+ const whyLines = wrapText(why, Math.max(20, available - 6));
102
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim('Why ') + paint.text.primary(whyLines[0]), cols, accent));
103
+ for (const line of whyLines.slice(1, 4)) {
104
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim(' ') + paint.text.primary(line), cols, accent));
105
+ }
106
+ if (whyLines.length > 4) {
107
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim(' ...'), cols, accent));
108
+ }
109
+ } else {
110
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim('Why ') + paint.text.muted('No additional reason provided'), cols, accent));
105
111
  }
106
112
  lines.push(fill('', cols, accent));
113
+ lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + paint.text.dim('Decision:'), cols, accent));
107
114
 
108
115
  for (let i = 0; i < opts.length; i++) {
109
116
  const o = opts[i];
@@ -111,7 +118,7 @@ export function renderApprovalPrompt({
111
118
  const cursor = isSel ? accent('▸ ') : paint.text.dim(' ');
112
119
  const keyTag = paint.text.dim('[') + (isSel ? accent(o.key) : paint.brand.data(o.key)) + paint.text.dim('] ');
113
120
  const label = isSel ? paint.bold(accent(o.label)) : paint.text.primary(o.label);
114
- const hint = o.hint ? ' ' + paint.text.muted(o.hint) : '';
121
+ const hint = o.hint ? ' ' + paint.text.muted(o.hint) : '';
115
122
  lines.push(fill(' ' + ' '.repeat(PAD_X - 1) + cursor + keyTag + label + hint, cols, accent));
116
123
  }
117
124
 
@@ -119,12 +126,20 @@ export function renderApprovalPrompt({
119
126
  lines.push(fill(' ' + ' '.repeat(PAD_X - 1) +
120
127
  paint.text.dim('↑↓ ') + paint.brand.data('move') +
121
128
  paint.text.dim(' · Enter ') + paint.brand.data('pick') +
122
- paint.text.dim(' · or press a letter'), cols, accent));
129
+ paint.text.dim(' · letter shortcut · Esc stop'), cols, accent));
123
130
  lines.push(bar(cols, accent));
124
131
 
125
132
  return '\n' + lines.join('\n');
126
133
  }
127
134
 
135
+ export function renderTrustedApproval({ tool, args = {}, scope = 'session', ruleId = '', delaySeconds = 0 } = {}) {
136
+ const summary = toolDisplaySummary(tool, args, {});
137
+ const subject = `${tool || 'tool'}${summary ? ` "${truncate(summary, 80)}"` : ''}`;
138
+ const rule = ruleId ? ` · rule ${ruleId}` : '';
139
+ const delay = delaySeconds > 0 ? ` · auto-approved after ${delaySeconds}s` : '';
140
+ return ` ${paint.state.success('✓')} ${paint.text.primary(subject)} ${paint.text.dim(`· pre-approved (${String(scope || 'session').toLowerCase()}${rule})${delay}`)}\n`;
141
+ }
142
+
128
143
  function bar(width, painter) {
129
144
  // Top / bottom rule: a magenta vertical-stack line spanning the full width.
130
145
  return painter('▔'.repeat(width));
@@ -142,26 +157,130 @@ function truncate(text, n) {
142
157
  return s.slice(0, Math.max(0, n - 1)) + '…';
143
158
  }
144
159
 
145
- // ── Inline (safe-default) prompt for shell-medium / network ────────────
160
+ // ── Compatibility wrapper ──────────────────────────────────────────────
146
161
 
147
162
  /**
148
- * Render the compact one-line prompt used for safe-default tiers.
149
- * The bordered block is reserved for explicit-approval tiers.
150
- *
151
- * ? Run `npm install lodash`? Tier: SHELL-MEDIUM [Enter=yes n=no ?=why]
163
+ * Render the unified approval prompt. Kept as a named export for older call
164
+ * sites/tests that imported the previous inline renderer.
152
165
  */
153
166
  export function renderInlinePrompt({ tool, args = {}, tier, why = '' } = {}) {
154
- const summary = toolDisplaySummary(tool, args, {});
155
- const label = toolDisplayLabel(tool);
156
- const lines = [];
157
- const head = `${paint.brand.data('?')} ${paint.text.primary(label)} ${paint.text.muted(summary ? `"${truncate(summary, 80)}"` : '')}`;
158
- lines.push(' ' + head);
159
- lines.push(' ' + paint.text.dim('Tier ') + paint.brand.data(tierLabel(tier)) +
160
- (why ? ' ' + paint.text.dim('Why ') + paint.text.muted(truncate(why, 60)) : ''));
161
- lines.push(' ' + paint.text.dim('[') + paint.brand.data('Enter') + paint.text.dim('=yes ') +
162
- paint.brand.data('n') + paint.text.dim('=no ') +
163
- paint.brand.data('?') + paint.text.dim('=why]'));
164
- return '\n' + lines.join('\n');
167
+ return renderApprovalPrompt({ tool, args, tier, why });
165
168
  }
166
169
 
167
170
  export { TIERS };
171
+
172
+ function riskIcon(tier) {
173
+ switch (tier) {
174
+ case TIERS.SENSITIVE_READ:
175
+ return '🔐';
176
+ case TIERS.SHELL_DANGEROUS:
177
+ case TIERS.DESTRUCTIVE:
178
+ return '🔴';
179
+ case TIERS.SHELL_MEDIUM:
180
+ return '⚠';
181
+ case TIERS.NETWORK:
182
+ return '🌐';
183
+ case TIERS.LOCAL_EDIT:
184
+ return '✎';
185
+ default:
186
+ return '◈';
187
+ }
188
+ }
189
+
190
+ function tierTitle(tier) {
191
+ switch (tier) {
192
+ case TIERS.SENSITIVE_READ: return 'SENSITIVE';
193
+ case TIERS.SHELL_DANGEROUS: return 'DANGEROUS';
194
+ case TIERS.DESTRUCTIVE: return 'DESTRUCTIVE';
195
+ case TIERS.SHELL_MEDIUM: return 'MEDIUM';
196
+ case TIERS.NETWORK: return 'NETWORK';
197
+ case TIERS.LOCAL_EDIT: return 'EDIT';
198
+ case TIERS.SHELL_SAFE: return 'SAFE';
199
+ case TIERS.READ: return 'READ';
200
+ default: return tierLabel(tier);
201
+ }
202
+ }
203
+
204
+ function approvalTitle(tier) {
205
+ switch (tier) {
206
+ case TIERS.SENSITIVE_READ:
207
+ case TIERS.SHELL_DANGEROUS:
208
+ case TIERS.DESTRUCTIVE:
209
+ return tierTitle(tier);
210
+ default:
211
+ return 'APPROVAL';
212
+ }
213
+ }
214
+
215
+ function subjectRows(tool, args, cols, accent) {
216
+ const available = cols - 2 - PAD_X * 2;
217
+ const prefix = ' ' + ' '.repeat(PAD_X - 1);
218
+ const rows = [];
219
+ const summary = toolDisplaySummary(tool, args, {});
220
+ const label = `${icon(tool)} ${paint.text.primary(toolDisplayLabel(tool))}`;
221
+ rows.push(fill(prefix + label, cols, accent));
222
+
223
+ for (const line of subjectDetails(tool, args, summary, available)) {
224
+ rows.push(fill(prefix + paint.text.primary(line), cols, accent));
225
+ }
226
+ return rows;
227
+ }
228
+
229
+ function subjectDetails(tool, args = {}, summary = '', available = 72) {
230
+ if (tool === 'shell') {
231
+ const command = args.command || args.cmd || summary || '';
232
+ const lines = wrapText(command, available);
233
+ return lines.length ? lines : ['(empty command)'];
234
+ }
235
+ if (tool === 'write_file') {
236
+ const file = args.file_path || args.path || summary || '';
237
+ const lineCount = typeof args.content === 'string' ? args.content.split('\n').length : null;
238
+ return [`${file}${lineCount ? ` · ${lineCount} lines` : ''}`];
239
+ }
240
+ if (tool === 'edit_file') {
241
+ const file = args.file_path || args.path || '';
242
+ const search = String(args.search || args.old_string || '').trim();
243
+ const replacement = String(args.replace || args.new_string || '').trim();
244
+ const details = [`${file || summary}`];
245
+ if (search) details.push(`match: ${truncate(search, available - 7)}`);
246
+ if (replacement) details.push(`replace: ${truncate(replacement, available - 9)}`);
247
+ return details.slice(0, 4);
248
+ }
249
+ if (tool === 'delete_file') {
250
+ return [args.file_path || args.path || summary || ''];
251
+ }
252
+ if (tool === 'WebFetch' || tool === 'fetch_url') {
253
+ const url = args.url || summary || '';
254
+ return [`Target ${hostFromUrl(url) || url}`];
255
+ }
256
+ return wrapText(summary || JSON.stringify(args || {}), available).slice(0, 4);
257
+ }
258
+
259
+ function hostFromUrl(url) {
260
+ try {
261
+ return new URL(String(url)).host;
262
+ } catch {
263
+ return '';
264
+ }
265
+ }
266
+
267
+ function wrapText(text, width) {
268
+ const words = String(text || '').split(/\s+/).filter(Boolean);
269
+ if (!words.length) return [''];
270
+ const lines = [];
271
+ let line = '';
272
+ for (const word of words) {
273
+ if (!line) {
274
+ line = word;
275
+ continue;
276
+ }
277
+ if ((line + ' ' + word).length <= width) {
278
+ line += ' ' + word;
279
+ } else {
280
+ lines.push(line);
281
+ line = word;
282
+ }
283
+ }
284
+ if (line) lines.push(line);
285
+ return lines;
286
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { toolDisplayLabel, toolDisplaySummary } from '../terminal/tool-display.mjs';
12
+ import { formatMessageWindow } from '../core/rate-limit-display.mjs';
12
13
 
13
14
  const RESET = '\x1b[0m';
14
15
  const BOLD = '\x1b[1m';
@@ -65,6 +66,9 @@ export class EventFormatter {
65
66
  this._toolDone(data);
66
67
  return true;
67
68
  case 'complete':
69
+ if (data?.rate_limit) {
70
+ this.sessionInfo = { ...(this.sessionInfo || {}), rate_limit: data.rate_limit };
71
+ }
68
72
  this._complete(data);
69
73
  return true;
70
74
  case 'error':
@@ -121,12 +125,11 @@ export class EventFormatter {
121
125
 
122
126
  _status(data) {
123
127
  const msg = data?.message || '';
124
- // Skip noisy statuses
128
+ // Skip noisy per-turn statuses. Backend emits "Creating agent..." and
129
+ // "Task type: ..." on every SSE turn (v3_sse.py:566), not just the first —
130
+ // repeating them clutters the transcript.
125
131
  if (!msg || msg === 'Agent started') return;
126
- if (msg.startsWith('Creating agent') || msg.startsWith('Task type:')) {
127
- process.stderr.write(`${DIM} ${msg}${RESET}\n`);
128
- return;
129
- }
132
+ if (msg.startsWith('Creating agent') || msg.startsWith('Task type:')) return;
130
133
  process.stderr.write(` ${this._spinner()} ${CYAN}${msg}${RESET}\n`);
131
134
  }
132
135
 
@@ -183,6 +186,14 @@ export class EventFormatter {
183
186
 
184
187
  const label = toolDisplayLabel(tool);
185
188
  const summary = toolDisplaySummary(tool, args);
189
+ if (tool === 'shell' && summary) {
190
+ process.stderr.write(` ${this._spinner()} [${this.toolCount}] ${CYAN}${label}${RESET}\n`);
191
+ for (const line of wrapShellSummary(summary, 100)) {
192
+ process.stderr.write(` ${DIM} ${line}${RESET}\n`);
193
+ }
194
+ this.toolCalls.push({ name: tool, callId, startTime: Date.now() });
195
+ return;
196
+ }
186
197
  const detail = summary ? `${DIM}${summary}${RESET}` : '';
187
198
  process.stderr.write(` ${this._spinner()} [${this.toolCount}] ${CYAN}${label}${RESET}${detail ? ` ${detail}` : ''}\n`);
188
199
 
@@ -334,6 +345,11 @@ export class EventFormatter {
334
345
  process.stderr.write(`\n ${GREEN}✓ Done${stats}${RESET}\n`);
335
346
  }
336
347
 
348
+ const rateLimitLine = formatMessageWindow(data?.rate_limit || this.sessionInfo?.rate_limit);
349
+ if (rateLimitLine) {
350
+ process.stderr.write(` ${DIM}Messages: ${rateLimitLine}${RESET}\n`);
351
+ }
352
+
337
353
  // Token usage if available
338
354
  const usage = data?.usage;
339
355
  if (usage && (usage.input_tokens || usage.total_tokens)) {
@@ -343,3 +359,21 @@ export class EventFormatter {
343
359
  }
344
360
  }
345
361
  }
362
+
363
+ function wrapShellSummary(command, width = 100) {
364
+ const max = Math.max(32, Number(width) || 100);
365
+ const text = String(command || '');
366
+ const lines = [];
367
+ let line = '';
368
+ for (const token of text.match(/\S+\s*/g) || [text]) {
369
+ const next = line + token;
370
+ if (line && next.trimEnd().length > max) {
371
+ lines.push(line.trimEnd());
372
+ line = token;
373
+ continue;
374
+ }
375
+ line = next;
376
+ }
377
+ if (line.trimEnd()) lines.push(line.trimEnd());
378
+ return lines.length ? lines : ['(empty command)'];
379
+ }