@axplusb/kepler 1.0.9 → 2.0.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.
@@ -16,13 +16,21 @@
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
- import { calculateCost, formatCostValue, formatTokens } from '../core/pricing.mjs';
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 { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
28
+ import {
29
+ getVerbosity,
30
+ setVerbosity,
31
+ label as verbosityLabel,
32
+ MODES as V_MODES,
33
+ } from '../state/verbosity.mjs';
26
34
  import { persistProjectArtifacts } from '../core/project-artifacts.mjs';
27
35
  import { TarangAuth } from '../auth/tarang-auth.mjs';
28
36
  import { ApprovalManager } from '../core/approval.mjs';
@@ -30,7 +38,27 @@ import { resolveBackendUrl } from '../core/backend-url.mjs';
30
38
  import { BUILTIN_AGENTS, runAgent } from './agents.mjs';
31
39
  import { SessionManager } from '../core/session-manager.mjs';
32
40
  import { parseArgs } from '../config/cli-args.mjs';
33
- import { formatShellCommand, toolDisplayLabel, toolDisplaySummary } from './tool-display.mjs';
41
+ import { toolDisplayLabel } from './tool-display.mjs';
42
+ import { createOrbit } from '../state/orbit.mjs';
43
+ import { attachOrbit, unmount as unmountStatusBar } from '../ui/status-bar.mjs';
44
+ import { term } from '../ui/term.mjs';
45
+ import {
46
+ formatCardHead,
47
+ summarizeResult,
48
+ recordCard,
49
+ lastCard,
50
+ getCard,
51
+ allCards,
52
+ } from '../ui/tool-card.mjs';
53
+ import { detailFor } from '../ui/tool-details.mjs';
54
+ import { paint } from '../ui/palette.mjs';
55
+ import {
56
+ renderSubAgentOpen,
57
+ renderSubAgentClose,
58
+ subAgentIndent,
59
+ inSubAgent as inSubAgentBlock,
60
+ resetSubAgents,
61
+ } from '../ui/sub-agent.mjs';
34
62
 
35
63
  import { createRequire } from 'node:module';
36
64
  const __require = createRequire(import.meta.url);
@@ -63,6 +91,7 @@ function safeCwd() {
63
91
  // ── Session State ──
64
92
 
65
93
  let _sessionMgr = null; // Set in startTerminalRepl, used by renderEvent
94
+ let _orbit = null; // Mission Control orbit state machine; set in startTerminalRepl
66
95
 
67
96
  const session = {
68
97
  id: null, // set by backend on first turn via session_info event
@@ -82,6 +111,13 @@ const session = {
82
111
  inSubAgent: false, // true while a sub-agent is running (for indented tool display)
83
112
  filesChanged: [], // files modified this session
84
113
  lastTurnDuration: 0,
114
+ toolCounts: {}, // per-tool histogram (mission report)
115
+ subAgentCounts: {}, // per-sub-agent histogram (mission report)
116
+ savedUsd: 0, // total sub-agent cost (for "saved by routing")
117
+ lastTask: '', // most recent user prompt (mission report title)
118
+ lastReasoning: '', // captured from agent for /why
119
+ budgetUsd: null, // /budget cap, null = unlimited
120
+ budgetExceeded: false,
85
121
  costBreakdown: [], // per-model usage: [{ model, role, input_tokens, output_tokens, cost }]
86
122
  totalCost: 0, // accumulated session cost (USD)
87
123
  costAccurate: false, // true if backend provides per-model breakdown
@@ -100,6 +136,19 @@ const COMMANDS = {
100
136
  '/diff': 'Git diff',
101
137
  '/cost': 'Show session cost',
102
138
  '/history': 'Show conversation',
139
+ '/last': 'Expand last tool output',
140
+ '/expand': 'Expand tool output by index (or "all")',
141
+ '/fold': 'Hide previously expanded tool output',
142
+ '/checkpoint':'List recent file checkpoints',
143
+ '/undo': 'Restore the last file checkpoint',
144
+ '/preflight':'Re-run the onboarding diagnostic',
145
+ '/report': 'Save the mission report as markdown',
146
+ '/why': 'Print the agent reasoning for the last decision',
147
+ '/map': 'Show the registered project tree',
148
+ '/budget': 'Set / clear a hard session cost cap',
149
+ '/quiet': 'Verbosity: hide sub-agent inner tools',
150
+ '/verbose': 'Verbosity: show sub-agent inner tools',
151
+ '/surgical': 'Verbosity: show everything (reasoning, expanded tools)',
103
152
  '/compact': 'Compact conversation context',
104
153
  '/agents': 'List available agents',
105
154
  '/explore': 'Code explorer agent',
@@ -163,13 +212,12 @@ function printBanner(auth) {
163
212
  */
164
213
  function buildContextStrip() {
165
214
  const totalTokens = session.inputTokens + session.outputTokens;
166
- const cost = formatCostValue(session.totalCost);
215
+ const credits = formatCredits(costToCredits(session.totalCost));
167
216
  const elapsed = formatElapsed(session.startTime);
168
217
 
169
- // Right side — always shown
170
218
  const right = [
171
219
  c.dim(`${formatTokens(totalTokens)} tok`),
172
- c.dim(cost),
220
+ c.dim(credits),
173
221
  c.dim(elapsed),
174
222
  ].join(c.dim(' · '));
175
223
 
@@ -196,11 +244,27 @@ function printPromptBlock() {
196
244
  * Print a turn summary after a response completes.
197
245
  * Shows only when there's something meaningful to report.
198
246
  */
247
+ /**
248
+ * Pull blocker bullet points from the completion payload — used by the
249
+ * failure variant of the mission report.
250
+ */
251
+ function extractBlockers(data) {
252
+ const out = [];
253
+ if (data?.error) out.push(String(data.error).slice(0, 160));
254
+ if (Array.isArray(data?.failed_tests)) {
255
+ for (const t of data.failed_tests.slice(0, 6)) {
256
+ if (typeof t === 'string') out.push(t);
257
+ else if (t?.name) out.push(`${t.name}${t.message ? ': ' + t.message : ''}`);
258
+ }
259
+ }
260
+ return out;
261
+ }
262
+
199
263
  function printTurnSummary(toolCount, durationS, turnCost) {
200
264
  const parts = [];
201
265
  if (toolCount > 0) parts.push(`${toolCount} tools`);
202
266
  if (durationS) parts.push(`${Number(durationS).toFixed(1)}s`);
203
- if (turnCost > 0) parts.push(formatCostValue(turnCost));
267
+ if (turnCost > 0) parts.push(formatCredits(costToCredits(turnCost)));
204
268
  if (parts.length > 0) {
205
269
  process.stderr.write(`\n ${c.green('✓')} ${c.dim(parts.join(' · '))}\n`);
206
270
  }
@@ -213,31 +277,25 @@ function updateStatusBar() {
213
277
  // ── Tool Display Renderer ──
214
278
 
215
279
  /**
216
- * Render a tool call in a transparent, informational way.
217
- * Shows tool name + key args on one line, no box borders for reads.
280
+ * Render a tool call as the head of a Mission Control card — icon + label +
281
+ * args. The result arrives later via `renderToolResult` and is appended as a
282
+ * gutter line. Sub-agent calls are indented per session.inSubAgent.
218
283
  */
219
284
  function renderToolCall(data) {
220
285
  const tool = data?.tool || 'unknown';
221
- const label = toolDisplayLabel(tool);
222
286
  const args = data?.args || {};
223
- const indent = session.inSubAgent ? ' ' : ' ';
287
+ const indent = subAgentIndent();
288
+ const callId = data?.call_id || data?._callId || `${tool}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
224
289
 
225
- const summary = toolDisplaySummary(tool, args, { cwd: safeCwd() });
290
+ const head = formatCardHead(tool, args, {
291
+ cwd: safeCwd(),
292
+ columns: process.stderr.columns || 120,
293
+ indent,
294
+ });
226
295
 
227
- // Render: Human-readable action(summary)
228
- // Use terminal width minus label and padding, minimum 60
229
- const cols = process.stderr.columns || 120;
230
- const maxSummary = Math.max(60, cols - label.length - 10);
231
- let displaySummary = summary || '';
232
- if (displaySummary.length > maxSummary) {
233
- displaySummary = '...' + displaySummary.slice(-(maxSummary - 3));
234
- }
235
- const summaryStr = displaySummary
236
- ? c.gray('(') + (tool === 'shell'
237
- ? formatShellCommand(displaySummary, c)
238
- : c.white(displaySummary)) + c.gray(')')
239
- : '';
240
- process.stderr.write(`\n${indent}${c.brand('⏺')} ${c.bold(label)}${summaryStr}\n`);
296
+ recordCard({ id: callId, tool, args, head, startedAt: Date.now() });
297
+ session.toolCounts[tool] = (session.toolCounts[tool] || 0) + 1;
298
+ process.stderr.write(`\n${head}\n`);
241
299
  }
242
300
 
243
301
  /**
@@ -251,77 +309,71 @@ function formatToolDuration(data) {
251
309
  return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
252
310
  }
253
311
 
254
- function firstOutputLine(data) {
255
- const output = data?.output_preview || data?.output || data?.message || '';
256
- return String(output).split('\n').map(line => line.trim()).find(Boolean) || '';
257
- }
258
-
259
- function fileTypeLabel(filePath) {
260
- const ext = path.extname(filePath || '').toLowerCase();
261
- if (ext === '.md' || ext === '.mdx') return 'Markdown';
262
- if (ext === '.json' || ext === '.jsonl') return 'JSON';
263
- if (ext === '.yaml' || ext === '.yml') return 'YAML';
264
- if (ext === '.toml') return 'TOML';
265
- if (ext === '.csv' || ext === '.tsv') return 'tabular data';
266
- if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.rb', '.php', '.swift'].includes(ext)) {
267
- return 'source';
268
- }
269
- return ext ? `${ext.slice(1).toUpperCase()} file` : 'file';
270
- }
271
-
272
312
  function renderToolResult(data, eventType = 'tool_result') {
273
313
  if (!data) return;
274
- const indent = session.inSubAgent ? ' ' : ' ';
275
- const gutter = `${indent}${c.dim('⎿')} `;
314
+ const indent = subAgentIndent();
315
+ const gutter = `${indent}${paint.text.dim('⎿')} `;
276
316
  const callId = data.call_id || data._callId;
277
317
  if (eventType === 'tool_done' && callId && _renderedToolResults.has(callId)) return;
278
318
  if (callId) _renderedToolResults.add(callId);
319
+
320
+ const tool = data.tool || data._tool || '';
321
+ const durationMs = data?.duration_ms ?? (data?.duration_s != null ? data.duration_s * 1000 : null);
322
+
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;
279
334
  const duration = formatToolDuration(data);
280
- const suffix = duration ? c.dim(` · ${duration}`) : '';
335
+ const tail = duration ? paint.text.dim(` · ${duration}`) : '';
336
+ process.stderr.write(`${gutter}${arrow} ${painter(text || 'done')}${tail}\n`);
281
337
 
282
- if (data._blocked) {
283
- session.blockedOps++;
284
- process.stderr.write(`${gutter}${c.red(firstOutputLine(data) || 'Blocked by safety guardrails')}${suffix}\n`);
285
- return;
338
+ // Lint warnings stay visible alongside writes.
339
+ if ((tool === 'write_file' || tool === 'edit_file') && data.lint) {
340
+ process.stderr.write(`${gutter}${paint.state.warn('⚠ ' + String(data.lint).split('\n')[0].slice(0, 80))}\n`);
286
341
  }
342
+ }
287
343
 
288
- if (data.success === false) {
289
- const msg = (data.error || firstOutputLine(data) || 'Failed').slice(0, 140);
290
- process.stderr.write(`${gutter}${c.red(msg)}${suffix}\n`);
344
+ // ── Expand handler — `d`, `/last`, `/expand` ───────────────────────────
345
+ //
346
+ // All three call into the same renderer so output is consistent across
347
+ // keypress and slash-command paths. `expandLast` and `expandIndex` write
348
+ // directly to stderr.
349
+
350
+ function expandLast() {
351
+ const card = lastCard();
352
+ if (!card) {
353
+ process.stderr.write(` ${paint.text.dim('(no tool to expand yet)')}\n`);
291
354
  return;
292
355
  }
356
+ process.stderr.write('\n' + detailFor(card) + '\n\n');
357
+ }
293
358
 
294
- const tool = data.tool || data._tool || '';
295
- let summary = 'Completed';
296
- if (tool === 'read_file') {
297
- const lines = data._total_lines || String(data.output || '').split('\n').length;
298
- const filePath = data.args?.file_path || data.args?.path || '';
299
- summary = `Read ${fileTypeLabel(filePath)} · ${lines} line${lines === 1 ? '' : 's'}`;
300
- } else if (tool === 'read_files') {
301
- summary = 'Files read';
302
- } else if (tool === 'search_code' || tool === 'list_files') {
303
- const lines = String(data.output || '').split('\n').filter(line => line.trim()).length;
304
- summary = lines > 0 ? `${lines} result${lines === 1 ? '' : 's'}` : 'No results';
305
- } else if (tool === 'write_file' || tool === 'edit_file' || tool === 'write_project') {
306
- summary = 'Updated';
307
- } else if (tool === 'delete_file') {
308
- summary = 'Deleted';
309
- } else if (data.server_side) {
310
- summary = firstOutputLine(data).slice(0, 100) || 'Completed server-side';
311
- } else if (tool === 'shell') {
312
- summary = firstOutputLine(data).slice(0, 100) || 'Command completed';
313
- }
314
-
315
- const renderedSummary = tool === 'shell' ? c.green(summary) : c.white(summary);
316
- process.stderr.write(`${gutter}${renderedSummary}${suffix}\n`);
317
-
318
- // For writes, show lint warnings
319
- if (tool === 'write_file' || tool === 'edit_file') {
320
- const lint = data.lint;
321
- if (lint) {
322
- process.stderr.write(`${gutter}${c.yellow('⚠ ' + lint.split('\n')[0].slice(0, 80))}\n`);
359
+ function expandIndex(idxOrAll) {
360
+ if (idxOrAll === 'all') {
361
+ const cards = allCards();
362
+ if (!cards.length) {
363
+ process.stderr.write(` ${paint.text.dim('(no tools to expand yet)')}\n`);
364
+ return;
323
365
  }
366
+ process.stderr.write('\n');
367
+ for (const c of cards) process.stderr.write(detailFor(c) + '\n');
368
+ process.stderr.write('\n');
369
+ return;
324
370
  }
371
+ const card = getCard(idxOrAll);
372
+ if (!card) {
373
+ process.stderr.write(` ${paint.text.dim('(no card at index ' + idxOrAll + ')')}\n`);
374
+ return;
375
+ }
376
+ process.stderr.write('\n' + detailFor(card) + '\n\n');
325
377
  }
326
378
 
327
379
  /**
@@ -410,6 +462,11 @@ function flushContent() {
410
462
  function renderEvent(event) {
411
463
  const { type, data } = event;
412
464
 
465
+ // Push every event into the orbit state machine before rendering so the
466
+ // bottom status bar reflects what is happening this very moment. The orbit
467
+ // module is a no-op when status-bar is not mounted (non-TTY, --headless).
468
+ if (_orbit) _orbit.onEvent(event);
469
+
413
470
  switch (type) {
414
471
  case 'status': {
415
472
  const msg = data?.message || '';
@@ -422,6 +479,8 @@ function renderEvent(event) {
422
479
  const text = data?.message || data?.text || '';
423
480
  if (text && !text.startsWith('Processing')) {
424
481
  startSpinner(text.slice(0, 80));
482
+ // Capture reasoning so /why can replay it.
483
+ session.lastReasoning = text;
425
484
  }
426
485
  break;
427
486
  }
@@ -483,7 +542,7 @@ function renderEvent(event) {
483
542
  case 'approval_denied': {
484
543
  const reason = data?.reason || 'User denied';
485
544
  const toolName = data?.tool || '';
486
- const indent = session.inSubAgent ? ' ' : ' ';
545
+ const indent = subAgentIndent();
487
546
  process.stderr.write(`${indent}${c.red('✗')} ${c.dim(`Denied ${toolName}: ${reason}`)}\n`);
488
547
  break;
489
548
  }
@@ -577,21 +636,19 @@ function renderEvent(event) {
577
636
 
578
637
  case 'sub_agent_start': {
579
638
  stopSpinner();
580
- session.inSubAgent = true;
581
639
  const agentType = data?.type || 'sub-agent';
582
640
  const model = data?.model || '';
583
641
  const query = data?.query || '';
584
- const icon = agentType === 'explore' ? '🔭' : agentType === 'plan' ? '📐' : '🤖';
585
- process.stderr.write(`\n ${icon} ${c.bold(c.brand(`${agentType} agent`))} ${c.dim('started')}\n`);
586
- if (model) process.stderr.write(` ${c.gray('model:')} ${c.dim(model)}\n`);
587
- if (query) process.stderr.write(` ${c.gray('query:')} ${c.dim(query)}\n`);
642
+ process.stderr.write(renderSubAgentOpen({ type: agentType, model, query }) + '\n');
643
+ session.inSubAgent = inSubAgentBlock(); // kept for legacy readers
644
+ session.subAgentCounts[agentType] = (session.subAgentCounts[agentType] || 0) + 1;
588
645
  startSpinner(`${agentType}: working...`);
589
646
  break;
590
647
  }
591
648
 
592
649
  case 'sub_agent_tool': {
593
- // No separate display the regular tool_call event shows full detail
594
- // indented under the sub-agent block. Just update the spinner text.
650
+ // The regular tool_call event renders the card, indented by the
651
+ // sub-agent stack depth. Just update the spinner text here.
595
652
  const agentType = data?.type || 'sub-agent';
596
653
  const tool = data?.tool || '';
597
654
  if (tool) updateSpinner(`${agentType} → ${tool}`);
@@ -600,24 +657,25 @@ function renderEvent(event) {
600
657
 
601
658
  case 'sub_agent_complete': {
602
659
  stopSpinner();
603
- session.inSubAgent = false;
604
660
  const agentType = data?.type || 'sub-agent';
605
- const model = data?.model || '';
606
- const resultLen = data?.result_length || 0;
607
661
  const usage = data?.usage || {};
608
662
  const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
609
- const parts = [];
610
- if (data?.tool_calls > 0) parts.push(`${data.tool_calls} tools`);
611
- if (data?.iterations > 0) parts.push(`${data.iterations} iterations`);
612
- if (resultLen > 0) parts.push(`${resultLen} chars`);
613
- if (tokens > 0) parts.push(`${formatTokens(tokens)} tok`);
614
- if (data?.duration_s != null) parts.push(`${Number(data.duration_s).toFixed(1)}s`);
615
- const icon = agentType === 'explore' ? '🔭' : agentType === 'plan' ? '📐' : '🤖';
616
- const marker = data?.success === false ? c.red('✗') : c.green('✓');
617
- const label = data?.success === false ? `${agentType} agent failed` : `${agentType} agent complete`;
618
- process.stderr.write(` ${icon} ${marker} ${c.dim(label)}${parts.length ? ' ' + c.dim(parts.join(' · ')) : ''}\n`);
619
- if (data?.error) process.stderr.write(` ${c.red(String(data.error).slice(0, 140))}\n`);
620
- process.stderr.write('\n');
663
+ const costUsd = usage.cost_usd ?? usage.total_cost_usd ?? data?.cost_usd ?? null;
664
+ if (typeof costUsd === 'number') session.savedUsd += costUsd;
665
+ const summary = data?.result_summary
666
+ || (data?.result_length > 0 ? `${agentType} returned ${data.result_length} chars` : '');
667
+ process.stderr.write(renderSubAgentClose({
668
+ type: agentType,
669
+ success: data?.success !== false,
670
+ summary,
671
+ costUsd,
672
+ tokens,
673
+ durationS: data?.duration_s,
674
+ toolCalls: data?.tool_calls,
675
+ iterations: data?.iterations,
676
+ error: data?.error,
677
+ }) + '\n\n');
678
+ session.inSubAgent = inSubAgentBlock();
621
679
  break;
622
680
  }
623
681
 
@@ -654,6 +712,8 @@ function renderEvent(event) {
654
712
  case 'complete': {
655
713
  stopSpinner();
656
714
  flushContent();
715
+ resetSubAgents();
716
+ session.inSubAgent = false;
657
717
 
658
718
  const summary = data?.summary || '';
659
719
  if (summary && !_renderedContentThisTurn) {
@@ -696,9 +756,38 @@ function renderEvent(event) {
696
756
 
697
757
  session.lastTurnDuration = data?.duration_s || 0;
698
758
 
759
+ // Sync cumulative session cost into the orbit (status bar shows it).
760
+ if (_orbit) _orbit.onCost(session.totalCost);
761
+
699
762
  // Compact turn summary
700
763
  const tools = data?.tool_calls || session.toolCalls || 0;
701
- printTurnSummary(tools, data?.duration_s, turnCost);
764
+
765
+ // Mission report — replaces the trailing "Done" when the turn did real
766
+ // work (touched files or invoked tools). Plain chat turns keep the
767
+ // tight printTurnSummary so the report does not feel ceremonial.
768
+ const didRealWork = tools > 0 || session.filesChanged.length > 0;
769
+ if (didRealWork) {
770
+ const successOverall = data?.success !== false;
771
+ const report = renderMissionReport({
772
+ task: session.lastTask,
773
+ success: successOverall,
774
+ filesChanged: session.filesChanged,
775
+ toolCounts: session.toolCounts,
776
+ subAgents: { ...session.subAgentCounts, savedUsd: session.savedUsd },
777
+ costUsd: turnCost || session.totalCost,
778
+ durationS: data?.duration_s,
779
+ testsPass: data?.tests_passed != null
780
+ ? { passed: data.tests_passed, total: data.tests_total || data.tests_passed }
781
+ : null,
782
+ blockers: !successOverall ? (data?.blockers || extractBlockers(data)) : null,
783
+ nextActions: successOverall
784
+ ? ['/commit', '/pr', '/undo', '/report']
785
+ : ['/why', '/undo', '/re-plan'],
786
+ });
787
+ process.stderr.write(report + '\n');
788
+ } else {
789
+ printTurnSummary(tools, data?.duration_s, turnCost);
790
+ }
702
791
  break;
703
792
  }
704
793
 
@@ -737,7 +826,8 @@ async function handleCommand(input, ctx) {
737
826
  process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}\n`);
738
827
  }
739
828
  process.stderr.write(`\n ${c.bold('Keyboard')}\n`);
740
- process.stderr.write(` ${c.gray('Ctrl+C')} exit ${c.gray('↑↓')} history ${c.gray('Tab')} autocomplete\n\n`);
829
+ process.stderr.write(` ${c.gray('Ctrl+C')} exit ${c.gray('↑↓')} history ${c.gray('Tab')} autocomplete\n`);
830
+ process.stderr.write(` ${c.gray('d')} expand last tool ${c.gray('Space')} pause/resume ${c.gray('Esc')} interrupt\n\n`);
741
831
  return;
742
832
 
743
833
  case '/login':
@@ -783,7 +873,7 @@ async function handleCommand(input, ctx) {
783
873
  process.stderr.write(` ${c.dim('Turns')} ${session.turns}\n`);
784
874
  process.stderr.write(` ${c.dim('Tools')} ${session.totalToolCalls} total, ${session.toolCalls} last turn\n`);
785
875
  process.stderr.write(` ${c.dim('Duration')} ${formatElapsed(session.startTime)}\n`);
786
- process.stderr.write(` ${c.dim('Cost')} ${formatCostValue(session.totalCost)}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
876
+ process.stderr.write(` ${c.dim('Credits')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
787
877
  process.stderr.write(` ${c.dim('CWD')} ${safeCwd()}\n`);
788
878
 
789
879
  // Permissions
@@ -851,29 +941,29 @@ async function handleCommand(input, ctx) {
851
941
  process.stderr.write(` ${c.gray('Turns:')} ${session.turns}\n`);
852
942
  process.stderr.write(` ${c.gray('Tools:')} ${session.toolCalls}\n`);
853
943
  process.stderr.write(` ${c.gray('Blocked:')} ${session.blockedOps}\n`);
854
- process.stderr.write(` ${c.gray('Cost:')} ${formatCostValue(session.totalCost)}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
944
+ process.stderr.write(` ${c.gray('Credits:')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
855
945
  process.stderr.write(` ${c.gray('Elapsed:')} ${formatElapsed(session.startTime)}\n\n`);
856
946
  return;
857
947
  }
858
948
 
859
949
  case '/cost': {
860
- process.stderr.write(`\n ${c.bold('Session Cost')}`);
950
+ process.stderr.write(`\n ${c.bold('Session Credits')} ${c.brand(formatCredits(costToCredits(session.totalCost)))}`);
861
951
  if (!session.costAccurate) {
862
- process.stderr.write(` ${c.yellow('(estimated — backend not sending model breakdown)')}`);
952
+ process.stderr.write(` ${c.yellow('(estimated)')}`);
863
953
  }
864
954
  process.stderr.write('\n');
865
955
  process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
866
956
 
867
957
  if (session.costBreakdown.length > 0) {
868
958
  // Header
869
- process.stderr.write(` ${c.dim('Model'.padEnd(36))}${c.dim('Input'.padStart(10))}${c.dim('Output'.padStart(10))}${c.dim('Cache'.padStart(10))}${c.dim('Cost'.padStart(10))}\n`);
959
+ process.stderr.write(` ${c.dim('Model'.padEnd(36))}${c.dim('Input'.padStart(10))}${c.dim('Output'.padStart(10))}${c.dim('Cache'.padStart(10))}${c.dim('Credits'.padStart(10))}\n`);
870
960
  process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
871
961
 
872
962
  for (const b of session.costBreakdown) {
873
963
  const modelLabel = b.model === 'unknown' ? c.yellow('unknown model') : b.model;
874
964
  const roleTag = b.role && b.role !== 'unknown' ? ` ${c.dim(`(${b.role})`)}` : '';
875
965
  const cacheTokens = (b.cache_read_tokens || 0) + (b.cache_creation_tokens || 0);
876
- const costStr = b.free ? c.green('free') : formatCostValue(b.cost);
966
+ const costStr = b.free ? c.green('free') : formatCredits(costToCredits(b.cost));
877
967
 
878
968
  process.stderr.write(
879
969
  ` ${(modelLabel + roleTag).padEnd(36)}` +
@@ -892,9 +982,9 @@ async function handleCommand(input, ctx) {
892
982
  `${formatTokens(session.inputTokens).padStart(10)}` +
893
983
  `${formatTokens(session.outputTokens).padStart(10)}` +
894
984
  `${''.padStart(10)}` +
895
- `${formatCostValue(session.totalCost).padStart(10)}\n`
985
+ `${formatCredits(costToCredits(session.totalCost)).padStart(10)}\n`
896
986
  );
897
- process.stderr.write(` ${c.dim(`Turns: ${session.turns} Duration: ${formatElapsed(session.startTime)}`)}\n\n`);
987
+ process.stderr.write(` ${c.dim(`Turns: ${session.turns} Duration: ${formatElapsed(session.startTime)} Provider: ${formatCostValue(session.totalCost)}`)}\n\n`);
898
988
  return;
899
989
  }
900
990
 
@@ -909,6 +999,143 @@ async function handleCommand(input, ctx) {
909
999
  process.stderr.write('\n');
910
1000
  return;
911
1001
 
1002
+ case '/last':
1003
+ expandLast();
1004
+ return;
1005
+
1006
+ case '/expand': {
1007
+ const arg = rest.trim();
1008
+ if (!arg) { expandLast(); return; }
1009
+ if (arg === 'all') { expandIndex('all'); return; }
1010
+ const n = Number(arg);
1011
+ if (!Number.isFinite(n)) {
1012
+ process.stderr.write(` ${c.gray('Usage: /expand [n|all] — n is the 1-based index from the start of the session')}\n`);
1013
+ return;
1014
+ }
1015
+ // Users pass 1-based; getCard accepts negative (-1 = last) or positive index.
1016
+ expandIndex(n > 0 ? n - 1 : n);
1017
+ return;
1018
+ }
1019
+
1020
+ case '/fold':
1021
+ process.stderr.write(` ${c.gray('Output is folded by default — there is nothing to hide. Use /last or d to expand.')}\n`);
1022
+ return;
1023
+
1024
+ case '/undo': {
1025
+ const result = ctx.checkpoints?.undo();
1026
+ if (!result) {
1027
+ process.stderr.write(` ${c.gray('No checkpoints to undo.')}\n`);
1028
+ return;
1029
+ }
1030
+ if (result.restored) {
1031
+ process.stderr.write(` ${c.green('↩')} ${c.dim('Restored')} ${result.filePath}\n`);
1032
+ } else {
1033
+ process.stderr.write(` ${c.red('✗')} ${c.dim('Undo failed: ' + (result.error || 'unknown error'))}\n`);
1034
+ }
1035
+ return;
1036
+ }
1037
+
1038
+ case '/checkpoint': {
1039
+ const list = ctx.checkpoints?.list(10) || [];
1040
+ if (!list.length) {
1041
+ process.stderr.write(` ${c.gray('No checkpoints recorded yet — they are taken automatically before each edit.')}\n`);
1042
+ return;
1043
+ }
1044
+ process.stderr.write(`\n ${c.bold('Recent checkpoints')}\n ${c.gray('─'.repeat(40))}\n`);
1045
+ for (const ckpt of list) {
1046
+ const when = String(ckpt.timestamp).slice(11, 19);
1047
+ process.stderr.write(` ${c.gray(when)} ${c.white(ckpt.file)} ${c.gray(formatTokens(ckpt.size) + ' bytes')}\n`);
1048
+ }
1049
+ process.stderr.write(`\n ${c.gray('/undo restores the most recent one')}\n\n`);
1050
+ return;
1051
+ }
1052
+
1053
+ case '/preflight': {
1054
+ await runPreflight({ auth: ctx.auth, cwd: safeCwd(), version: VERSION });
1055
+ return;
1056
+ }
1057
+
1058
+ case '/report': {
1059
+ if (Object.keys(session.toolCounts).length === 0 && session.filesChanged.length === 0) {
1060
+ process.stderr.write(` ${c.gray('Nothing to report yet — run a task first.')}\n`);
1061
+ return;
1062
+ }
1063
+ const state = {
1064
+ task: session.lastTask,
1065
+ success: true,
1066
+ filesChanged: session.filesChanged,
1067
+ toolCounts: session.toolCounts,
1068
+ subAgents: { ...session.subAgentCounts, savedUsd: session.savedUsd },
1069
+ costUsd: session.totalCost,
1070
+ durationS: (Date.now() - session.startTime) / 1000,
1071
+ nextActions: ['/commit', '/pr', '/undo'],
1072
+ };
1073
+ const out = saveReport(state, { cwd: safeCwd() });
1074
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Saved')} ${out}\n`);
1075
+ return;
1076
+ }
1077
+
1078
+ case '/why': {
1079
+ if (!session.lastReasoning) {
1080
+ process.stderr.write(` ${c.gray('No reasoning captured yet for this session.')}\n`);
1081
+ return;
1082
+ }
1083
+ process.stderr.write(`\n ${c.bold('Last reasoning')}\n ${c.gray('─'.repeat(40))}\n`);
1084
+ for (const line of String(session.lastReasoning).split('\n')) {
1085
+ process.stderr.write(` ${c.dim(line)}\n`);
1086
+ }
1087
+ process.stderr.write('\n');
1088
+ return;
1089
+ }
1090
+
1091
+ case '/map': {
1092
+ try {
1093
+ const resources = ctx.toolExecutor?.getProjectResources?.() || [];
1094
+ if (!resources.length) {
1095
+ process.stderr.write(` ${c.gray('No project resources registered yet. Use get_project_overview to register one.')}\n`);
1096
+ return;
1097
+ }
1098
+ process.stderr.write(`\n ${c.bold('Registered projects')}\n ${c.gray('─'.repeat(40))}\n`);
1099
+ for (const r of resources) {
1100
+ process.stderr.write(` ${c.brand('•')} ${c.white(r.id || r.name || '?')} ${c.dim(r.root || r.path || '')}\n`);
1101
+ }
1102
+ process.stderr.write('\n');
1103
+ } catch (err) {
1104
+ process.stderr.write(` ${c.red('/map failed: ' + err.message)}\n`);
1105
+ }
1106
+ return;
1107
+ }
1108
+
1109
+ case '/budget': {
1110
+ const arg = rest.trim();
1111
+ if (!arg || arg === 'clear' || arg === 'off') {
1112
+ session.budgetUsd = null;
1113
+ session.budgetExceeded = false;
1114
+ process.stderr.write(` ${c.gray('Budget cap cleared.')}\n`);
1115
+ return;
1116
+ }
1117
+ const n = Number(arg.replace(/^\$/, ''));
1118
+ if (!Number.isFinite(n) || n <= 0) {
1119
+ process.stderr.write(` ${c.gray('Usage: /budget <amount in USD> or /budget clear')}\n`);
1120
+ return;
1121
+ }
1122
+ session.budgetUsd = n;
1123
+ session.budgetExceeded = false;
1124
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Budget set: ')} $${n.toFixed(2)}\n`);
1125
+ return;
1126
+ }
1127
+
1128
+ case '/quiet':
1129
+ case '/verbose':
1130
+ case '/surgical': {
1131
+ const mode = cmd === '/quiet' ? V_MODES.QUIET
1132
+ : cmd === '/verbose' ? V_MODES.VERBOSE
1133
+ : V_MODES.SURGICAL;
1134
+ setVerbosity(mode);
1135
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Verbosity: ')} ${c.brand(verbosityLabel(mode))}\n`);
1136
+ return;
1137
+ }
1138
+
912
1139
  case '/compact': {
913
1140
  const before = session.history.length;
914
1141
  if (before <= 4) { process.stderr.write(` ${c.gray('Nothing to compact.')}\n`); return; }
@@ -1133,7 +1360,9 @@ export async function startTerminalRepl() {
1133
1360
  const auth = new TarangAuth();
1134
1361
 
1135
1362
  // Projects are registered and indexed on demand through get_project_overview.
1136
- const toolExecutor = createToolExecutor();
1363
+ // CheckpointManager records per-file snapshots before edits so /undo works.
1364
+ const checkpoints = new CheckpointManager(safeCwd());
1365
+ const toolExecutor = createToolExecutor({ checkpoints });
1137
1366
  const skipPerms = cliArgs.freeswim;
1138
1367
  const approval = new ApprovalManager({ autoApprove: skipPerms });
1139
1368
 
@@ -1147,10 +1376,30 @@ export async function startTerminalRepl() {
1147
1376
  // Persistent stream client — session_id captured from backend on first turn
1148
1377
  let streamClient = null;
1149
1378
 
1150
- const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr };
1379
+ const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints };
1380
+
1381
+ // ── Mission Control orbit + status bar ──
1382
+ // Opt-out via KEPLER_STATUS_BAR=0 (debugging) or KEPLER_PLAIN=1 (PRD-055).
1383
+ // status-bar.mjs already no-ops when stdout is not a TTY, but the explicit
1384
+ // env opt-out is useful for debugging escape-sequence noise.
1385
+ const statusBarEnabled = process.env.KEPLER_STATUS_BAR !== '0' && term().isTTY && !term().plain;
1386
+ if (statusBarEnabled) {
1387
+ _orbit = createOrbit();
1388
+ attachOrbit(_orbit);
1389
+ // Always unmount before exit so the terminal scroll region is restored.
1390
+ process.on('beforeExit', unmountStatusBar);
1391
+ process.on('exit', unmountStatusBar);
1392
+ }
1151
1393
 
1152
1394
  printBanner(auth);
1153
1395
 
1396
+ // Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
1397
+ // KEPLER_NO_PREFLIGHT=1 (used by tests / scripted runs).
1398
+ if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.freeswim) {
1399
+ try { await runPreflight({ auth, cwd: safeCwd(), version: VERSION }); }
1400
+ catch { /* preflight is best-effort */ }
1401
+ }
1402
+
1154
1403
  // ── Initialization ──
1155
1404
  process.stderr.write(` ${c.brand('⠋')} ${c.dim('Initializing...')}\r`);
1156
1405
  await fetchUser(ctx);
@@ -1231,10 +1480,27 @@ export async function startTerminalRepl() {
1231
1480
  return;
1232
1481
  }
1233
1482
 
1483
+ // Budget cap (PRD-055 §10). Stop before the next paid call when exceeded.
1484
+ if (session.budgetUsd && session.totalCost >= session.budgetUsd) {
1485
+ session.budgetExceeded = true;
1486
+ process.stderr.write(` ${c.yellow('⏹')} ${c.dim(`Budget reached ($${session.totalCost.toFixed(2)} of $${session.budgetUsd.toFixed(2)}). Use /budget clear to continue.`)}\n`);
1487
+ showPrompt();
1488
+ return;
1489
+ }
1490
+
1234
1491
  // Regular prompt
1235
1492
  session.history.push({ role: 'user', content: input });
1236
1493
  session.turns++;
1237
1494
  session.toolCalls = 0;
1495
+ session.lastTask = input;
1496
+ // Reset per-turn counts so the mission report reflects this turn only.
1497
+ session.toolCounts = {};
1498
+ session.subAgentCounts = {};
1499
+ session.savedUsd = 0;
1500
+
1501
+ // Tell the orbit a new turn started — switches to DISCOVERY and updates
1502
+ // task / turn counters in the status bar.
1503
+ if (_orbit) _orbit.onUserInput(input);
1238
1504
 
1239
1505
  // Start session tracking on first turn
1240
1506
  if (session.turns === 1) {
@@ -1273,6 +1539,7 @@ export async function startTerminalRepl() {
1273
1539
  let executionPaused = false;
1274
1540
  let keypressCleanup = null;
1275
1541
  let execListenerActive = false;
1542
+ let lastCtrlCAt = 0; // PRD-055 §8.4: first Ctrl+C cancels, second exits
1276
1543
 
1277
1544
  if (process.stdin.isTTY) {
1278
1545
  rl.pause();
@@ -1299,20 +1566,39 @@ export async function startTerminalRepl() {
1299
1566
  executionPaused = false;
1300
1567
  process.stderr.write(` ${c.green('▶')} ${c.dim('Resumed')}\n`);
1301
1568
  client.resume();
1569
+ if (_orbit) _orbit.onResume();
1302
1570
  } else {
1303
1571
  executionPaused = true;
1304
1572
  stopSpinner();
1305
1573
  process.stderr.write(` ${c.yellow('⏸')} ${c.dim('Paused — press Space to resume, Esc to cancel')}\n`);
1306
1574
  client.pause();
1575
+ if (_orbit) _orbit.onPause();
1307
1576
  }
1308
1577
  return;
1309
1578
  }
1310
1579
 
1311
- // Ctrl+C during execution
1580
+ // Ctrl+C during execution — PRD-055 §8.4 two-step semantics:
1581
+ // first press → cancel current backend run, stay in REPL
1582
+ // second press within 2s → exit the CLI
1312
1583
  if (bytes[0] === 0x03) {
1313
1584
  stopSpinner();
1314
- client.cancel();
1315
- process.exit(0);
1585
+ const now = Date.now();
1586
+ if (lastCtrlCAt && (now - lastCtrlCAt) < 2000) {
1587
+ process.stderr.write(`\n ${c.dim('exiting…')}\n`);
1588
+ try { client.cancel(); } catch {}
1589
+ process.exit(0);
1590
+ }
1591
+ lastCtrlCAt = now;
1592
+ process.stderr.write(`\n ${c.yellow('⏹')} ${c.dim('Cancelled. Press Ctrl+C again within 2s to exit.')}\n`);
1593
+ try { client.cancel(); } catch {}
1594
+ return;
1595
+ }
1596
+
1597
+ // `d` — expand last tool card (Mission Control §6.2)
1598
+ if (bytes.length === 1 && (bytes[0] === 0x64 || bytes[0] === 0x44)) {
1599
+ stopSpinner();
1600
+ expandLast();
1601
+ return;
1316
1602
  }
1317
1603
  };
1318
1604