@axplusb/kepler 1.0.10 → 2.0.2

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.
@@ -16,13 +16,22 @@
16
16
  */
17
17
 
18
18
  import * as readline from 'node:readline';
19
- import * as path from 'node:path';
20
19
  import * as fs from 'node:fs';
21
20
  import { c, progressBar, spinner, inPlace, renderMarkdown, renderDiff, formatElapsed, formatCost, stripAnsi } from './ansi.mjs';
22
21
  import { calculateCost, formatCostValue, formatTokens, costToCredits, formatCredits } from '../core/pricing.mjs';
23
22
  import { TarangStreamClient, EVENT_TYPES } from '../core/stream-client.mjs';
24
23
  import { JsonlWriter } from '../core/jsonl-writer.mjs';
25
24
  import { createToolExecutor } from '../core/tool-executor.mjs';
25
+ import { CheckpointManager } from '../core/checkpoints.mjs';
26
+ import { runPreflight } from '../onboarding/preflight.mjs';
27
+ import { printBanner as printBrandedBanner } from '../ui/banner.mjs';
28
+ import { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
29
+ import {
30
+ getVerbosity,
31
+ setVerbosity,
32
+ label as verbosityLabel,
33
+ MODES as V_MODES,
34
+ } from '../state/verbosity.mjs';
26
35
  import { persistProjectArtifacts } from '../core/project-artifacts.mjs';
27
36
  import { TarangAuth } from '../auth/tarang-auth.mjs';
28
37
  import { ApprovalManager } from '../core/approval.mjs';
@@ -30,7 +39,27 @@ import { resolveBackendUrl } from '../core/backend-url.mjs';
30
39
  import { BUILTIN_AGENTS, runAgent } from './agents.mjs';
31
40
  import { SessionManager } from '../core/session-manager.mjs';
32
41
  import { parseArgs } from '../config/cli-args.mjs';
33
- import { formatShellCommand, toolDisplayLabel, toolDisplaySummary } from './tool-display.mjs';
42
+ import { toolDisplayLabel } from './tool-display.mjs';
43
+ import { createOrbit } from '../state/orbit.mjs';
44
+ import { attachOrbit, unmount as unmountStatusBar } from '../ui/status-bar.mjs';
45
+ import { term } from '../ui/term.mjs';
46
+ import {
47
+ formatCardHead,
48
+ summarizeResult,
49
+ recordCard,
50
+ lastCard,
51
+ getCard,
52
+ allCards,
53
+ } from '../ui/tool-card.mjs';
54
+ import { detailFor } from '../ui/tool-details.mjs';
55
+ import { paint } from '../ui/palette.mjs';
56
+ import {
57
+ renderSubAgentOpen,
58
+ renderSubAgentClose,
59
+ subAgentIndent,
60
+ inSubAgent as inSubAgentBlock,
61
+ resetSubAgents,
62
+ } from '../ui/sub-agent.mjs';
34
63
 
35
64
  import { createRequire } from 'node:module';
36
65
  const __require = createRequire(import.meta.url);
@@ -63,6 +92,7 @@ function safeCwd() {
63
92
  // ── Session State ──
64
93
 
65
94
  let _sessionMgr = null; // Set in startTerminalRepl, used by renderEvent
95
+ let _orbit = null; // Mission Control orbit state machine; set in startTerminalRepl
66
96
 
67
97
  const session = {
68
98
  id: null, // set by backend on first turn via session_info event
@@ -82,9 +112,17 @@ const session = {
82
112
  inSubAgent: false, // true while a sub-agent is running (for indented tool display)
83
113
  filesChanged: [], // files modified this session
84
114
  lastTurnDuration: 0,
115
+ toolCounts: {}, // per-tool histogram (mission report)
116
+ subAgentCounts: {}, // per-sub-agent histogram (mission report)
117
+ savedUsd: 0, // total sub-agent cost (for "saved by routing")
118
+ lastTask: '', // most recent user prompt (mission report title)
119
+ lastReasoning: '', // captured from agent for /why
120
+ budgetUsd: null, // /budget cap, null = unlimited
121
+ budgetExceeded: false,
85
122
  costBreakdown: [], // per-model usage: [{ model, role, input_tokens, output_tokens, cost }]
86
123
  totalCost: 0, // accumulated session cost (USD)
87
124
  costAccurate: false, // true if backend provides per-model breakdown
125
+ isByok: false, // set from session_info; hides cost + credits when true
88
126
  };
89
127
 
90
128
  // ── Commands ──
@@ -100,6 +138,19 @@ const COMMANDS = {
100
138
  '/diff': 'Git diff',
101
139
  '/cost': 'Show session cost',
102
140
  '/history': 'Show conversation',
141
+ '/last': 'Expand last tool output',
142
+ '/expand': 'Expand tool output by index (or "all")',
143
+ '/fold': 'Hide previously expanded tool output',
144
+ '/checkpoint':'List recent file checkpoints',
145
+ '/undo': 'Restore the last file checkpoint',
146
+ '/preflight':'Re-run the onboarding diagnostic',
147
+ '/report': 'Save the mission report as markdown',
148
+ '/why': 'Print the agent reasoning for the last decision',
149
+ '/map': 'Show the registered project tree',
150
+ '/budget': 'Set / clear a hard session cost cap',
151
+ '/quiet': 'Verbosity: hide sub-agent inner tools',
152
+ '/verbose': 'Verbosity: show sub-agent inner tools',
153
+ '/surgical': 'Verbosity: show everything (reasoning, expanded tools)',
103
154
  '/compact': 'Compact conversation context',
104
155
  '/agents': 'List available agents',
105
156
  '/explore': 'Code explorer agent',
@@ -116,26 +167,15 @@ const COMMANDS = {
116
167
  // ── Banner ──
117
168
 
118
169
  function printBanner(auth) {
170
+ // Delegate the visual block to the branded banner module (PRD-055 §4.3,
171
+ // gradient KEPLER letters in Deep Space Purple → Stellar Magenta → Neon
172
+ // Cyan). The trailing status line stays here because it needs `auth`.
173
+ printBrandedBanner();
174
+
119
175
  const creds = auth.loadCredentials();
120
176
  const env = process.env.TARANG_ENV || 'production';
121
177
  const authStatus = creds.token ? c.green('authenticated') : c.red('/login to start');
122
-
123
- const CYAN = '\x1b[36m';
124
- const DIM = '\x1b[2m';
125
- const BOLD = '\x1b[1m';
126
- const YELLOW = '\x1b[33m';
127
- const RST = '\x1b[0m';
128
-
129
- process.stderr.write('\n');
130
- process.stderr.write(`${DIM} ✦${RST}\n`);
131
- process.stderr.write(`${DIM} ╭──────────────────────────╮${RST}\n`);
132
- process.stderr.write(`${DIM} │${RST} ${BOLD}${CYAN}K · E · P · L · E · R${RST} ${DIM}│${RST}\n`);
133
- process.stderr.write(`${DIM} ╰──────── ${YELLOW}◯${RST}${DIM} ───────────────╯${RST}\n`);
134
- process.stderr.write(`${DIM} ╱ ╲${RST}\n`);
135
- process.stderr.write(`${DIM} the agentic os${RST}\n`);
136
- process.stderr.write('\n');
137
- process.stderr.write(` ${c.gray('v' + VERSION)} ${c.dim(env)} ${authStatus}\n`);
138
- process.stderr.write('\n');
178
+ process.stderr.write(` ${c.gray('v' + VERSION)} ${c.dim(env)} ${authStatus}\n\n`);
139
179
  }
140
180
 
141
181
  // ── Prompt Chrome ──
@@ -163,12 +203,12 @@ function printBanner(auth) {
163
203
  */
164
204
  function buildContextStrip() {
165
205
  const totalTokens = session.inputTokens + session.outputTokens;
166
- const credits = formatCredits(costToCredits(session.totalCost));
167
206
  const elapsed = formatElapsed(session.startTime);
168
207
 
208
+ // BYOK: user pays the provider directly, suppress credits entirely.
169
209
  const right = [
170
210
  c.dim(`${formatTokens(totalTokens)} tok`),
171
- c.dim(credits),
211
+ ...(session.isByok ? [] : [c.dim(formatCredits(costToCredits(session.totalCost)))]),
172
212
  c.dim(elapsed),
173
213
  ].join(c.dim(' · '));
174
214
 
@@ -195,11 +235,27 @@ function printPromptBlock() {
195
235
  * Print a turn summary after a response completes.
196
236
  * Shows only when there's something meaningful to report.
197
237
  */
238
+ /**
239
+ * Pull blocker bullet points from the completion payload — used by the
240
+ * failure variant of the mission report.
241
+ */
242
+ function extractBlockers(data) {
243
+ const out = [];
244
+ if (data?.error) out.push(String(data.error).slice(0, 160));
245
+ if (Array.isArray(data?.failed_tests)) {
246
+ for (const t of data.failed_tests.slice(0, 6)) {
247
+ if (typeof t === 'string') out.push(t);
248
+ else if (t?.name) out.push(`${t.name}${t.message ? ': ' + t.message : ''}`);
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
198
254
  function printTurnSummary(toolCount, durationS, turnCost) {
199
255
  const parts = [];
200
256
  if (toolCount > 0) parts.push(`${toolCount} tools`);
201
257
  if (durationS) parts.push(`${Number(durationS).toFixed(1)}s`);
202
- if (turnCost > 0) parts.push(formatCredits(costToCredits(turnCost)));
258
+ if (turnCost > 0 && !session.isByok) parts.push(formatCredits(costToCredits(turnCost)));
203
259
  if (parts.length > 0) {
204
260
  process.stderr.write(`\n ${c.green('✓')} ${c.dim(parts.join(' · '))}\n`);
205
261
  }
@@ -212,31 +268,34 @@ function updateStatusBar() {
212
268
  // ── Tool Display Renderer ──
213
269
 
214
270
  /**
215
- * Render a tool call in a transparent, informational way.
216
- * Shows tool name + key args on one line, no box borders for reads.
271
+ * Render a tool call as the head of a Mission Control card — icon + label +
272
+ * args. The result arrives later via `renderToolResult` and is appended as a
273
+ * gutter line. Sub-agent calls are indented per session.inSubAgent.
217
274
  */
275
+ // Set by renderToolCall, consumed by renderToolResult so we can collapse the
276
+ // "head\n ⎿ → outcome\n" two-line shape into a single line whenever nothing
277
+ // else printed in between. Cleared by any handler that writes interleaving
278
+ // content (content/thinking/sub_agent_*/delegation/etc).
279
+ let _pendingHead = null; // { callId, head }
280
+
281
+ function clearPendingHead() { _pendingHead = null; }
282
+
218
283
  function renderToolCall(data) {
219
284
  const tool = data?.tool || 'unknown';
220
- const label = toolDisplayLabel(tool);
221
285
  const args = data?.args || {};
222
- const indent = session.inSubAgent ? ' ' : ' ';
286
+ const indent = subAgentIndent();
287
+ const callId = data?.call_id || data?._callId || `${tool}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
223
288
 
224
- const summary = toolDisplaySummary(tool, args, { cwd: safeCwd() });
289
+ const head = formatCardHead(tool, args, {
290
+ cwd: safeCwd(),
291
+ columns: process.stderr.columns || 120,
292
+ indent,
293
+ });
225
294
 
226
- // Render: Human-readable action(summary)
227
- // Use terminal width minus label and padding, minimum 60
228
- const cols = process.stderr.columns || 120;
229
- const maxSummary = Math.max(60, cols - label.length - 10);
230
- let displaySummary = summary || '';
231
- if (displaySummary.length > maxSummary) {
232
- displaySummary = '...' + displaySummary.slice(-(maxSummary - 3));
233
- }
234
- const summaryStr = displaySummary
235
- ? c.gray('(') + (tool === 'shell'
236
- ? formatShellCommand(displaySummary, c)
237
- : c.white(displaySummary)) + c.gray(')')
238
- : '';
239
- process.stderr.write(`\n${indent}${c.brand('⏺')} ${c.bold(label)}${summaryStr}\n`);
295
+ recordCard({ id: callId, tool, args, head, startedAt: Date.now() });
296
+ session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
297
+ process.stderr.write(`\n${head}\n`);
298
+ _pendingHead = { callId, head };
240
299
  }
241
300
 
242
301
  /**
@@ -250,77 +309,93 @@ function formatToolDuration(data) {
250
309
  return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
251
310
  }
252
311
 
253
- function firstOutputLine(data) {
254
- const output = data?.output_preview || data?.output || data?.message || '';
255
- return String(output).split('\n').map(line => line.trim()).find(Boolean) || '';
256
- }
257
-
258
- function fileTypeLabel(filePath) {
259
- const ext = path.extname(filePath || '').toLowerCase();
260
- if (ext === '.md' || ext === '.mdx') return 'Markdown';
261
- if (ext === '.json' || ext === '.jsonl') return 'JSON';
262
- if (ext === '.yaml' || ext === '.yml') return 'YAML';
263
- if (ext === '.toml') return 'TOML';
264
- if (ext === '.csv' || ext === '.tsv') return 'tabular data';
265
- if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.rb', '.php', '.swift'].includes(ext)) {
266
- return 'source';
267
- }
268
- return ext ? `${ext.slice(1).toUpperCase()} file` : 'file';
269
- }
270
-
271
312
  function renderToolResult(data, eventType = 'tool_result') {
272
313
  if (!data) return;
273
- const indent = session.inSubAgent ? ' ' : ' ';
274
- const gutter = `${indent}${c.dim('⎿')} `;
314
+ const indent = subAgentIndent();
315
+ const gutter = `${indent}${paint.text.dim('⎿')} `;
275
316
  const callId = data.call_id || data._callId;
276
317
  if (eventType === 'tool_done' && callId && _renderedToolResults.has(callId)) return;
277
318
  if (callId) _renderedToolResults.add(callId);
278
- const duration = formatToolDuration(data);
279
- const suffix = duration ? c.dim(` · ${duration}`) : '';
280
319
 
281
- if (data._blocked) {
282
- session.blockedOps++;
283
- process.stderr.write(`${gutter}${c.red(firstOutputLine(data) || 'Blocked by safety guardrails')}${suffix}\n`);
284
- return;
285
- }
320
+ const tool = data.tool || data._tool || '';
321
+ const durationMs = data?.duration_ms ?? (data?.duration_s != null ? data.duration_s * 1000 : null);
286
322
 
287
- if (data.success === false) {
288
- const msg = (data.error || firstOutputLine(data) || 'Failed').slice(0, 140);
289
- process.stderr.write(`${gutter}${c.red(msg)}${suffix}\n`);
290
- return;
323
+ // Update the card buffer so /last and `d` can find it.
324
+ if (callId) recordCard({ id: callId, tool, args: data.args, result: data, durationMs });
325
+
326
+ if (data._blocked) session.blockedOps++;
327
+
328
+ const { text, tone: t } = summarizeResult(tool, data);
329
+ const arrow = paint.text.dim('→');
330
+ const painter = t === 'success' ? paint.state.success
331
+ : t === 'warn' ? paint.state.warn
332
+ : t === 'danger' ? paint.state.danger
333
+ : paint.text.dim;
334
+ const duration = formatToolDuration(data);
335
+ const tail = duration ? paint.text.dim(` · ${duration}`) : '';
336
+ const outcome = `${arrow} ${painter(text || 'done')}${tail}`;
337
+
338
+ // ── Single-line collapse ──
339
+ // If nothing has interleaved between renderToolCall and this result, rewrite
340
+ // the head line in-place as "<head> → outcome · duration" — saves a full
341
+ // row per tool call. Falls back to the two-line gutter form when the head
342
+ // is gone (something scrolled it away) or the combined line would not fit.
343
+ const hasLint = (tool === 'write_file' || tool === 'edit_file') && data.lint;
344
+ if (_pendingHead && _pendingHead.callId === callId && !hasLint) {
345
+ const cols = process.stderr.columns || 120;
346
+ const combined = `${_pendingHead.head} ${outcome}`;
347
+ if (stripAnsi(combined).length <= cols) {
348
+ // Move up one line, clear it, rewrite as one line. No leading newline
349
+ // because the cursor is already at the start of the (now-cleared) line.
350
+ process.stderr.write(`\x1b[1A\x1b[2K\r${combined}\n`);
351
+ _pendingHead = null;
352
+ return;
353
+ }
291
354
  }
355
+ _pendingHead = null;
292
356
 
293
- const tool = data.tool || data._tool || '';
294
- let summary = 'Completed';
295
- if (tool === 'read_file') {
296
- const lines = data._total_lines || String(data.output || '').split('\n').length;
297
- const filePath = data.args?.file_path || data.args?.path || '';
298
- summary = `Read ${fileTypeLabel(filePath)} · ${lines} line${lines === 1 ? '' : 's'}`;
299
- } else if (tool === 'read_files') {
300
- summary = 'Files read';
301
- } else if (tool === 'search_code' || tool === 'list_files') {
302
- const lines = String(data.output || '').split('\n').filter(line => line.trim()).length;
303
- summary = lines > 0 ? `${lines} result${lines === 1 ? '' : 's'}` : 'No results';
304
- } else if (tool === 'write_file' || tool === 'edit_file' || tool === 'write_project') {
305
- summary = 'Updated';
306
- } else if (tool === 'delete_file') {
307
- summary = 'Deleted';
308
- } else if (data.server_side) {
309
- summary = firstOutputLine(data).slice(0, 100) || 'Completed server-side';
310
- } else if (tool === 'shell') {
311
- summary = firstOutputLine(data).slice(0, 100) || 'Command completed';
357
+ // Default two-line shape.
358
+ process.stderr.write(`${gutter}${outcome}\n`);
359
+
360
+ // Lint warnings stay visible alongside writes.
361
+ if (hasLint) {
362
+ process.stderr.write(`${gutter}${paint.state.warn('⚠ ' + String(data.lint).split('\n')[0].slice(0, 80))}\n`);
312
363
  }
364
+ }
313
365
 
314
- const renderedSummary = tool === 'shell' ? c.green(summary) : c.white(summary);
315
- process.stderr.write(`${gutter}${renderedSummary}${suffix}\n`);
366
+ // ── Expand handler `d`, `/last`, `/expand` ───────────────────────────
367
+ //
368
+ // All three call into the same renderer so output is consistent across
369
+ // keypress and slash-command paths. `expandLast` and `expandIndex` write
370
+ // directly to stderr.
371
+
372
+ function expandLast() {
373
+ const card = lastCard();
374
+ if (!card) {
375
+ process.stderr.write(` ${paint.text.dim('(no tool to expand yet)')}\n`);
376
+ return;
377
+ }
378
+ process.stderr.write('\n' + detailFor(card) + '\n\n');
379
+ }
316
380
 
317
- // For writes, show lint warnings
318
- if (tool === 'write_file' || tool === 'edit_file') {
319
- const lint = data.lint;
320
- if (lint) {
321
- process.stderr.write(`${gutter}${c.yellow(' ' + lint.split('\n')[0].slice(0, 80))}\n`);
381
+ function expandIndex(idxOrAll) {
382
+ if (idxOrAll === 'all') {
383
+ const cards = allCards();
384
+ if (!cards.length) {
385
+ process.stderr.write(` ${paint.text.dim('(no tools to expand yet)')}\n`);
386
+ return;
322
387
  }
388
+ process.stderr.write('\n');
389
+ for (const c of cards) process.stderr.write(detailFor(c) + '\n');
390
+ process.stderr.write('\n');
391
+ return;
392
+ }
393
+ const card = getCard(idxOrAll);
394
+ if (!card) {
395
+ process.stderr.write(` ${paint.text.dim('(no card at index ' + idxOrAll + ')')}\n`);
396
+ return;
323
397
  }
398
+ process.stderr.write('\n' + detailFor(card) + '\n\n');
324
399
  }
325
400
 
326
401
  /**
@@ -383,6 +458,9 @@ function startContentStream() {
383
458
 
384
459
  function appendContent(text) {
385
460
  if (!text) return;
461
+ // Any streamed content between renderToolCall and renderToolResult would
462
+ // scroll the head off "the line above", breaking the in-place collapse.
463
+ clearPendingHead();
386
464
  _streamBuffer += text;
387
465
  _streamedPartialText += text;
388
466
 
@@ -409,6 +487,11 @@ function flushContent() {
409
487
  function renderEvent(event) {
410
488
  const { type, data } = event;
411
489
 
490
+ // Push every event into the orbit state machine before rendering so the
491
+ // bottom status bar reflects what is happening this very moment. The orbit
492
+ // module is a no-op when status-bar is not mounted (non-TTY, --headless).
493
+ if (_orbit) _orbit.onEvent(event);
494
+
412
495
  switch (type) {
413
496
  case 'status': {
414
497
  const msg = data?.message || '';
@@ -421,6 +504,8 @@ function renderEvent(event) {
421
504
  const text = data?.message || data?.text || '';
422
505
  if (text && !text.startsWith('Processing')) {
423
506
  startSpinner(text.slice(0, 80));
507
+ // Capture reasoning so /why can replay it.
508
+ session.lastReasoning = text;
424
509
  }
425
510
  break;
426
511
  }
@@ -482,7 +567,7 @@ function renderEvent(event) {
482
567
  case 'approval_denied': {
483
568
  const reason = data?.reason || 'User denied';
484
569
  const toolName = data?.tool || '';
485
- const indent = session.inSubAgent ? ' ' : ' ';
570
+ const indent = subAgentIndent();
486
571
  process.stderr.write(`${indent}${c.red('✗')} ${c.dim(`Denied ${toolName}: ${reason}`)}\n`);
487
572
  break;
488
573
  }
@@ -561,6 +646,7 @@ function renderEvent(event) {
561
646
 
562
647
  case 'delegation': {
563
648
  stopSpinner();
649
+ clearPendingHead();
564
650
  const from = data?.from || '';
565
651
  const to = data?.to || '';
566
652
  session.delegations.push({ from, to, time: Date.now() });
@@ -576,21 +662,20 @@ function renderEvent(event) {
576
662
 
577
663
  case 'sub_agent_start': {
578
664
  stopSpinner();
579
- session.inSubAgent = true;
665
+ clearPendingHead();
580
666
  const agentType = data?.type || 'sub-agent';
581
667
  const model = data?.model || '';
582
668
  const query = data?.query || '';
583
- const icon = agentType === 'explore' ? '🔭' : agentType === 'plan' ? '📐' : '🤖';
584
- process.stderr.write(`\n ${icon} ${c.bold(c.brand(`${agentType} agent`))} ${c.dim('started')}\n`);
585
- if (model) process.stderr.write(` ${c.gray('model:')} ${c.dim(model)}\n`);
586
- if (query) process.stderr.write(` ${c.gray('query:')} ${c.dim(query)}\n`);
669
+ process.stderr.write(renderSubAgentOpen({ type: agentType, model, query }) + '\n');
670
+ session.inSubAgent = inSubAgentBlock(); // kept for legacy readers
671
+ session.subAgentCounts[agentType] = (session.subAgentCounts[agentType] || 0) + 1;
587
672
  startSpinner(`${agentType}: working...`);
588
673
  break;
589
674
  }
590
675
 
591
676
  case 'sub_agent_tool': {
592
- // No separate display the regular tool_call event shows full detail
593
- // indented under the sub-agent block. Just update the spinner text.
677
+ // The regular tool_call event renders the card, indented by the
678
+ // sub-agent stack depth. Just update the spinner text here.
594
679
  const agentType = data?.type || 'sub-agent';
595
680
  const tool = data?.tool || '';
596
681
  if (tool) updateSpinner(`${agentType} → ${tool}`);
@@ -599,24 +684,26 @@ function renderEvent(event) {
599
684
 
600
685
  case 'sub_agent_complete': {
601
686
  stopSpinner();
602
- session.inSubAgent = false;
687
+ clearPendingHead();
603
688
  const agentType = data?.type || 'sub-agent';
604
- const model = data?.model || '';
605
- const resultLen = data?.result_length || 0;
606
689
  const usage = data?.usage || {};
607
690
  const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
608
- const parts = [];
609
- if (data?.tool_calls > 0) parts.push(`${data.tool_calls} tools`);
610
- if (data?.iterations > 0) parts.push(`${data.iterations} iterations`);
611
- if (resultLen > 0) parts.push(`${resultLen} chars`);
612
- if (tokens > 0) parts.push(`${formatTokens(tokens)} tok`);
613
- if (data?.duration_s != null) parts.push(`${Number(data.duration_s).toFixed(1)}s`);
614
- const icon = agentType === 'explore' ? '🔭' : agentType === 'plan' ? '📐' : '🤖';
615
- const marker = data?.success === false ? c.red('✗') : c.green('✓');
616
- const label = data?.success === false ? `${agentType} agent failed` : `${agentType} agent complete`;
617
- process.stderr.write(` ${icon} ${marker} ${c.dim(label)}${parts.length ? ' ' + c.dim(parts.join(' · ')) : ''}\n`);
618
- if (data?.error) process.stderr.write(` ${c.red(String(data.error).slice(0, 140))}\n`);
619
- process.stderr.write('\n');
691
+ const costUsd = usage.cost_usd ?? usage.total_cost_usd ?? data?.cost_usd ?? null;
692
+ if (typeof costUsd === 'number') session.savedUsd += costUsd;
693
+ const summary = data?.result_summary
694
+ || (data?.result_length > 0 ? `${agentType} returned ${data.result_length} chars` : '');
695
+ process.stderr.write(renderSubAgentClose({
696
+ type: agentType,
697
+ success: data?.success !== false,
698
+ summary,
699
+ costUsd,
700
+ tokens,
701
+ durationS: data?.duration_s,
702
+ toolCalls: data?.tool_calls,
703
+ iterations: data?.iterations,
704
+ error: data?.error,
705
+ }) + '\n\n');
706
+ session.inSubAgent = inSubAgentBlock();
620
707
  break;
621
708
  }
622
709
 
@@ -638,6 +725,9 @@ function renderEvent(event) {
638
725
  }
639
726
  if (data?.model) session.model = data.model;
640
727
  if (data?.user) session.user = { ...session.user, ...data.user };
728
+ // BYOK users pay their model provider directly; the platform does not
729
+ // charge them credits. Hide cost + credits when this flag is set.
730
+ if (typeof data?.is_byok === 'boolean') session.isByok = data.is_byok;
641
731
  break;
642
732
  }
643
733
 
@@ -653,6 +743,8 @@ function renderEvent(event) {
653
743
  case 'complete': {
654
744
  stopSpinner();
655
745
  flushContent();
746
+ resetSubAgents();
747
+ session.inSubAgent = false;
656
748
 
657
749
  const summary = data?.summary || '';
658
750
  if (summary && !_renderedContentThisTurn) {
@@ -695,9 +787,39 @@ function renderEvent(event) {
695
787
 
696
788
  session.lastTurnDuration = data?.duration_s || 0;
697
789
 
790
+ // Sync cumulative session cost into the orbit (status bar shows it).
791
+ if (_orbit) _orbit.onCost(session.totalCost);
792
+
698
793
  // Compact turn summary
699
794
  const tools = data?.tool_calls || session.toolCalls || 0;
700
- printTurnSummary(tools, data?.duration_s, turnCost);
795
+
796
+ // Mission report — replaces the trailing "Done" when the turn did real
797
+ // work (touched files or invoked tools). Plain chat turns keep the
798
+ // tight printTurnSummary so the report does not feel ceremonial.
799
+ const didRealWork = tools > 0 || session.filesChanged.length > 0;
800
+ if (didRealWork) {
801
+ const successOverall = data?.success !== false;
802
+ const report = renderMissionReport({
803
+ task: session.lastTask,
804
+ success: successOverall,
805
+ filesChanged: session.filesChanged,
806
+ toolCounts: session.toolCounts,
807
+ subAgents: { ...session.subAgentCounts, savedUsd: session.isByok ? 0 : session.savedUsd },
808
+ // BYOK users pay their provider directly; suppress cost in the report.
809
+ costUsd: session.isByok ? null : (turnCost || session.totalCost),
810
+ durationS: data?.duration_s,
811
+ testsPass: data?.tests_passed != null
812
+ ? { passed: data.tests_passed, total: data.tests_total || data.tests_passed }
813
+ : null,
814
+ blockers: !successOverall ? (data?.blockers || extractBlockers(data)) : null,
815
+ nextActions: successOverall
816
+ ? ['/commit', '/pr', '/undo', '/report']
817
+ : ['/why', '/undo', '/re-plan'],
818
+ });
819
+ process.stderr.write(report + '\n');
820
+ } else {
821
+ printTurnSummary(tools, data?.duration_s, turnCost);
822
+ }
701
823
  break;
702
824
  }
703
825
 
@@ -736,7 +858,8 @@ async function handleCommand(input, ctx) {
736
858
  process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}\n`);
737
859
  }
738
860
  process.stderr.write(`\n ${c.bold('Keyboard')}\n`);
739
- process.stderr.write(` ${c.gray('Ctrl+C')} exit ${c.gray('↑↓')} history ${c.gray('Tab')} autocomplete\n\n`);
861
+ process.stderr.write(` ${c.gray('Ctrl+C')} exit ${c.gray('↑↓')} history ${c.gray('Tab')} autocomplete\n`);
862
+ process.stderr.write(` ${c.gray('d')} expand last tool ${c.gray('Space')} pause/resume ${c.gray('Esc')} interrupt\n\n`);
740
863
  return;
741
864
 
742
865
  case '/login':
@@ -782,7 +905,11 @@ async function handleCommand(input, ctx) {
782
905
  process.stderr.write(` ${c.dim('Turns')} ${session.turns}\n`);
783
906
  process.stderr.write(` ${c.dim('Tools')} ${session.totalToolCalls} total, ${session.toolCalls} last turn\n`);
784
907
  process.stderr.write(` ${c.dim('Duration')} ${formatElapsed(session.startTime)}\n`);
785
- process.stderr.write(` ${c.dim('Credits')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
908
+ if (session.isByok) {
909
+ process.stderr.write(` ${c.dim('Billing')} ${c.green('BYOK')} ${c.dim('(provider-billed)')}\n`);
910
+ } else {
911
+ process.stderr.write(` ${c.dim('Credits')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
912
+ }
786
913
  process.stderr.write(` ${c.dim('CWD')} ${safeCwd()}\n`);
787
914
 
788
915
  // Permissions
@@ -850,12 +977,20 @@ async function handleCommand(input, ctx) {
850
977
  process.stderr.write(` ${c.gray('Turns:')} ${session.turns}\n`);
851
978
  process.stderr.write(` ${c.gray('Tools:')} ${session.toolCalls}\n`);
852
979
  process.stderr.write(` ${c.gray('Blocked:')} ${session.blockedOps}\n`);
853
- process.stderr.write(` ${c.gray('Credits:')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
980
+ if (session.isByok) {
981
+ process.stderr.write(` ${c.gray('Billing:')} ${c.green('BYOK')} ${c.dim('(provider-billed)')}\n`);
982
+ } else {
983
+ process.stderr.write(` ${c.gray('Credits:')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
984
+ }
854
985
  process.stderr.write(` ${c.gray('Elapsed:')} ${formatElapsed(session.startTime)}\n\n`);
855
986
  return;
856
987
  }
857
988
 
858
989
  case '/cost': {
990
+ if (session.isByok) {
991
+ process.stderr.write(`\n ${c.bold('Billing')} ${c.green('BYOK')} ${c.dim('— you pay your model provider directly. Kepler does not charge credits for BYOK usage.')}\n\n`);
992
+ return;
993
+ }
859
994
  process.stderr.write(`\n ${c.bold('Session Credits')} ${c.brand(formatCredits(costToCredits(session.totalCost)))}`);
860
995
  if (!session.costAccurate) {
861
996
  process.stderr.write(` ${c.yellow('(estimated)')}`);
@@ -908,6 +1043,143 @@ async function handleCommand(input, ctx) {
908
1043
  process.stderr.write('\n');
909
1044
  return;
910
1045
 
1046
+ case '/last':
1047
+ expandLast();
1048
+ return;
1049
+
1050
+ case '/expand': {
1051
+ const arg = rest.trim();
1052
+ if (!arg) { expandLast(); return; }
1053
+ if (arg === 'all') { expandIndex('all'); return; }
1054
+ const n = Number(arg);
1055
+ if (!Number.isFinite(n)) {
1056
+ process.stderr.write(` ${c.gray('Usage: /expand [n|all] — n is the 1-based index from the start of the session')}\n`);
1057
+ return;
1058
+ }
1059
+ // Users pass 1-based; getCard accepts negative (-1 = last) or positive index.
1060
+ expandIndex(n > 0 ? n - 1 : n);
1061
+ return;
1062
+ }
1063
+
1064
+ case '/fold':
1065
+ process.stderr.write(` ${c.gray('Output is folded by default — there is nothing to hide. Use /last or d to expand.')}\n`);
1066
+ return;
1067
+
1068
+ case '/undo': {
1069
+ const result = ctx.checkpoints?.undo();
1070
+ if (!result) {
1071
+ process.stderr.write(` ${c.gray('No checkpoints to undo.')}\n`);
1072
+ return;
1073
+ }
1074
+ if (result.restored) {
1075
+ process.stderr.write(` ${c.green('↩')} ${c.dim('Restored')} ${result.filePath}\n`);
1076
+ } else {
1077
+ process.stderr.write(` ${c.red('✗')} ${c.dim('Undo failed: ' + (result.error || 'unknown error'))}\n`);
1078
+ }
1079
+ return;
1080
+ }
1081
+
1082
+ case '/checkpoint': {
1083
+ const list = ctx.checkpoints?.list(10) || [];
1084
+ if (!list.length) {
1085
+ process.stderr.write(` ${c.gray('No checkpoints recorded yet — they are taken automatically before each edit.')}\n`);
1086
+ return;
1087
+ }
1088
+ process.stderr.write(`\n ${c.bold('Recent checkpoints')}\n ${c.gray('─'.repeat(40))}\n`);
1089
+ for (const ckpt of list) {
1090
+ const when = String(ckpt.timestamp).slice(11, 19);
1091
+ process.stderr.write(` ${c.gray(when)} ${c.white(ckpt.file)} ${c.gray(formatTokens(ckpt.size) + ' bytes')}\n`);
1092
+ }
1093
+ process.stderr.write(`\n ${c.gray('/undo restores the most recent one')}\n\n`);
1094
+ return;
1095
+ }
1096
+
1097
+ case '/preflight': {
1098
+ await runPreflight({ auth: ctx.auth, cwd: safeCwd(), version: VERSION });
1099
+ return;
1100
+ }
1101
+
1102
+ case '/report': {
1103
+ if (Object.keys(session.toolCounts).length === 0 && session.filesChanged.length === 0) {
1104
+ process.stderr.write(` ${c.gray('Nothing to report yet — run a task first.')}\n`);
1105
+ return;
1106
+ }
1107
+ const state = {
1108
+ task: session.lastTask,
1109
+ success: true,
1110
+ filesChanged: session.filesChanged,
1111
+ toolCounts: session.toolCounts,
1112
+ subAgents: { ...session.subAgentCounts, savedUsd: session.isByok ? 0 : session.savedUsd },
1113
+ costUsd: session.isByok ? null : session.totalCost,
1114
+ durationS: (Date.now() - session.startTime) / 1000,
1115
+ nextActions: ['/commit', '/pr', '/undo'],
1116
+ };
1117
+ const out = saveReport(state, { cwd: safeCwd() });
1118
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Saved')} ${out}\n`);
1119
+ return;
1120
+ }
1121
+
1122
+ case '/why': {
1123
+ if (!session.lastReasoning) {
1124
+ process.stderr.write(` ${c.gray('No reasoning captured yet for this session.')}\n`);
1125
+ return;
1126
+ }
1127
+ process.stderr.write(`\n ${c.bold('Last reasoning')}\n ${c.gray('─'.repeat(40))}\n`);
1128
+ for (const line of String(session.lastReasoning).split('\n')) {
1129
+ process.stderr.write(` ${c.dim(line)}\n`);
1130
+ }
1131
+ process.stderr.write('\n');
1132
+ return;
1133
+ }
1134
+
1135
+ case '/map': {
1136
+ try {
1137
+ const resources = ctx.toolExecutor?.getProjectResources?.() || [];
1138
+ if (!resources.length) {
1139
+ process.stderr.write(` ${c.gray('No project resources registered yet. Use get_project_overview to register one.')}\n`);
1140
+ return;
1141
+ }
1142
+ process.stderr.write(`\n ${c.bold('Registered projects')}\n ${c.gray('─'.repeat(40))}\n`);
1143
+ for (const r of resources) {
1144
+ process.stderr.write(` ${c.brand('•')} ${c.white(r.id || r.name || '?')} ${c.dim(r.root || r.path || '')}\n`);
1145
+ }
1146
+ process.stderr.write('\n');
1147
+ } catch (err) {
1148
+ process.stderr.write(` ${c.red('/map failed: ' + err.message)}\n`);
1149
+ }
1150
+ return;
1151
+ }
1152
+
1153
+ case '/budget': {
1154
+ const arg = rest.trim();
1155
+ if (!arg || arg === 'clear' || arg === 'off') {
1156
+ session.budgetUsd = null;
1157
+ session.budgetExceeded = false;
1158
+ process.stderr.write(` ${c.gray('Budget cap cleared.')}\n`);
1159
+ return;
1160
+ }
1161
+ const n = Number(arg.replace(/^\$/, ''));
1162
+ if (!Number.isFinite(n) || n <= 0) {
1163
+ process.stderr.write(` ${c.gray('Usage: /budget <amount in USD> or /budget clear')}\n`);
1164
+ return;
1165
+ }
1166
+ session.budgetUsd = n;
1167
+ session.budgetExceeded = false;
1168
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Budget set: ')} $${n.toFixed(2)}\n`);
1169
+ return;
1170
+ }
1171
+
1172
+ case '/quiet':
1173
+ case '/verbose':
1174
+ case '/surgical': {
1175
+ const mode = cmd === '/quiet' ? V_MODES.QUIET
1176
+ : cmd === '/verbose' ? V_MODES.VERBOSE
1177
+ : V_MODES.SURGICAL;
1178
+ setVerbosity(mode);
1179
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Verbosity: ')} ${c.brand(verbosityLabel(mode))}\n`);
1180
+ return;
1181
+ }
1182
+
911
1183
  case '/compact': {
912
1184
  const before = session.history.length;
913
1185
  if (before <= 4) { process.stderr.write(` ${c.gray('Nothing to compact.')}\n`); return; }
@@ -1132,7 +1404,9 @@ export async function startTerminalRepl() {
1132
1404
  const auth = new TarangAuth();
1133
1405
 
1134
1406
  // Projects are registered and indexed on demand through get_project_overview.
1135
- const toolExecutor = createToolExecutor();
1407
+ // CheckpointManager records per-file snapshots before edits so /undo works.
1408
+ const checkpoints = new CheckpointManager(safeCwd());
1409
+ const toolExecutor = createToolExecutor({ checkpoints });
1136
1410
  const skipPerms = cliArgs.freeswim;
1137
1411
  const approval = new ApprovalManager({ autoApprove: skipPerms });
1138
1412
 
@@ -1146,10 +1420,20 @@ export async function startTerminalRepl() {
1146
1420
  // Persistent stream client — session_id captured from backend on first turn
1147
1421
  let streamClient = null;
1148
1422
 
1149
- const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr };
1423
+ const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints };
1150
1424
 
1425
+ // ── Print banner + preflight + init BEFORE mounting the status bar ──
1426
+ // The status bar shrinks the scroll region; if it mounts first, the
1427
+ // banner scrolls off-screen before the user ever sees it.
1151
1428
  printBanner(auth);
1152
1429
 
1430
+ // Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
1431
+ // KEPLER_NO_PREFLIGHT=1 (used by tests / scripted runs).
1432
+ if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.freeswim) {
1433
+ try { await runPreflight({ auth, cwd: safeCwd(), version: VERSION }); }
1434
+ catch { /* preflight is best-effort */ }
1435
+ }
1436
+
1153
1437
  // ── Initialization ──
1154
1438
  process.stderr.write(` ${c.brand('⠋')} ${c.dim('Initializing...')}\r`);
1155
1439
  await fetchUser(ctx);
@@ -1187,12 +1471,41 @@ export async function startTerminalRepl() {
1187
1471
 
1188
1472
  process.stderr.write(`\n ${c.dim('Press')} ${c.brand('Enter')} ${c.dim('to start, or type a prompt below.')}\n`);
1189
1473
 
1190
- const PROMPT = `${c.brand('kepler')} ${c.dim('›')} `;
1474
+ // Mission Control status bar is OPT-IN as of v2.0.1.
1475
+ // Set KEPLER_STATUS_BAR=1 (or KEPLER_MISSION=1) to enable the persistent
1476
+ // bottom-anchored ORBIT bar. Default off because the DECSTBM scroll
1477
+ // region was eating the prompt visibility on some terminals (issue
1478
+ // observed during v2.0.0 testing). The orbit state machine and tool
1479
+ // cards still work without the bar — the bar is just the rendering.
1480
+ const statusBarEnabled = (
1481
+ process.env.KEPLER_STATUS_BAR === '1' || process.env.KEPLER_MISSION === '1'
1482
+ ) && term().isTTY && !term().plain;
1483
+ if (statusBarEnabled) {
1484
+ _orbit = createOrbit();
1485
+ attachOrbit(_orbit);
1486
+ process.on('beforeExit', unmountStatusBar);
1487
+ process.on('exit', unmountStatusBar);
1488
+ }
1489
+
1490
+ // The prompt label is the USER speaking, not the agent. Use the signed-in
1491
+ // GitHub handle if known, otherwise fall back to "You".
1492
+ //
1493
+ // readline counts every byte of the prompt as a visible column when it
1494
+ // computes cursor position for line-wrapping; ANSI color codes throw the
1495
+ // math off and produce duplicated text on wrap. Wrap each escape sequence
1496
+ // in SOH (\x01) ... STX (\x02) so readline skips it when measuring width.
1497
+ function rlSafe(s) {
1498
+ return String(s || '').replace(/\x1b\[[0-9;]*m/g, '\x01$&\x02');
1499
+ }
1500
+ function userPrompt() {
1501
+ const who = session.user?.github_username || session.user?.email?.split('@')[0] || 'You';
1502
+ return rlSafe(`${c.brand(who)} ${c.dim('›')} `);
1503
+ }
1191
1504
 
1192
1505
  const rl = readline.createInterface({
1193
1506
  input: process.stdin,
1194
1507
  output: process.stderr,
1195
- prompt: PROMPT,
1508
+ prompt: userPrompt(),
1196
1509
  completer: (line) => {
1197
1510
  if (line.startsWith('/')) {
1198
1511
  const hits = Object.keys(COMMANDS).filter(cmd => cmd.startsWith(line));
@@ -1211,6 +1524,7 @@ export async function startTerminalRepl() {
1211
1524
  function showPrompt() {
1212
1525
  printPromptBlock();
1213
1526
  process.stderr.write('\n'); // half-inch vertical gap above input line
1527
+ rl.setPrompt(userPrompt()); // refresh label in case session.user resolved
1214
1528
  rl.prompt();
1215
1529
  }
1216
1530
 
@@ -1230,10 +1544,27 @@ export async function startTerminalRepl() {
1230
1544
  return;
1231
1545
  }
1232
1546
 
1547
+ // Budget cap (PRD-055 §10). Stop before the next paid call when exceeded.
1548
+ if (session.budgetUsd && session.totalCost >= session.budgetUsd) {
1549
+ session.budgetExceeded = true;
1550
+ process.stderr.write(` ${c.yellow('⏹')} ${c.dim(`Budget reached ($${session.totalCost.toFixed(2)} of $${session.budgetUsd.toFixed(2)}). Use /budget clear to continue.`)}\n`);
1551
+ showPrompt();
1552
+ return;
1553
+ }
1554
+
1233
1555
  // Regular prompt
1234
1556
  session.history.push({ role: 'user', content: input });
1235
1557
  session.turns++;
1236
1558
  session.toolCalls = 0;
1559
+ session.lastTask = input;
1560
+ // Reset per-turn counts so the mission report reflects this turn only.
1561
+ session.toolCounts = {};
1562
+ session.subAgentCounts = {};
1563
+ session.savedUsd = 0;
1564
+
1565
+ // Tell the orbit a new turn started — switches to DISCOVERY and updates
1566
+ // task / turn counters in the status bar.
1567
+ if (_orbit) _orbit.onUserInput(input);
1237
1568
 
1238
1569
  // Start session tracking on first turn
1239
1570
  if (session.turns === 1) {
@@ -1272,6 +1603,7 @@ export async function startTerminalRepl() {
1272
1603
  let executionPaused = false;
1273
1604
  let keypressCleanup = null;
1274
1605
  let execListenerActive = false;
1606
+ let lastCtrlCAt = 0; // PRD-055 §8.4: first Ctrl+C cancels, second exits
1275
1607
 
1276
1608
  if (process.stdin.isTTY) {
1277
1609
  rl.pause();
@@ -1287,7 +1619,10 @@ export async function startTerminalRepl() {
1287
1619
  // Esc key (single byte 0x1b, not part of arrow sequence)
1288
1620
  if (bytes.length === 1 && bytes[0] === 0x1b) {
1289
1621
  stopSpinner();
1290
- process.stderr.write(`\n ${c.yellow('⏹')} ${c.dim('Cancelling...')}\n`);
1622
+ process.stderr.write(`\n ${c.yellow('⏹')} ${c.dim('Cancelled.')}\n`);
1623
+ // cancel() now aborts the in-flight SSE reader; the for-await loop
1624
+ // wakes up immediately and the prompt returns. No more "stuck"
1625
+ // Cancelling… message.
1291
1626
  client.cancel();
1292
1627
  return;
1293
1628
  }
@@ -1298,20 +1633,39 @@ export async function startTerminalRepl() {
1298
1633
  executionPaused = false;
1299
1634
  process.stderr.write(` ${c.green('▶')} ${c.dim('Resumed')}\n`);
1300
1635
  client.resume();
1636
+ if (_orbit) _orbit.onResume();
1301
1637
  } else {
1302
1638
  executionPaused = true;
1303
1639
  stopSpinner();
1304
1640
  process.stderr.write(` ${c.yellow('⏸')} ${c.dim('Paused — press Space to resume, Esc to cancel')}\n`);
1305
1641
  client.pause();
1642
+ if (_orbit) _orbit.onPause();
1306
1643
  }
1307
1644
  return;
1308
1645
  }
1309
1646
 
1310
- // Ctrl+C during execution
1647
+ // Ctrl+C during execution — PRD-055 §8.4 two-step semantics:
1648
+ // first press → cancel current backend run, stay in REPL
1649
+ // second press within 2s → exit the CLI
1311
1650
  if (bytes[0] === 0x03) {
1312
1651
  stopSpinner();
1313
- client.cancel();
1314
- process.exit(0);
1652
+ const now = Date.now();
1653
+ if (lastCtrlCAt && (now - lastCtrlCAt) < 2000) {
1654
+ process.stderr.write(`\n ${c.dim('exiting…')}\n`);
1655
+ try { client.cancel(); } catch {}
1656
+ process.exit(0);
1657
+ }
1658
+ lastCtrlCAt = now;
1659
+ process.stderr.write(`\n ${c.yellow('⏹')} ${c.dim('Cancelled. Press Ctrl+C again within 2s to exit.')}\n`);
1660
+ try { client.cancel(); } catch {}
1661
+ return;
1662
+ }
1663
+
1664
+ // `d` — expand last tool card (Mission Control §6.2)
1665
+ if (bytes.length === 1 && (bytes[0] === 0x64 || bytes[0] === 0x44)) {
1666
+ stopSpinner();
1667
+ expandLast();
1668
+ return;
1315
1669
  }
1316
1670
  };
1317
1671