@axplusb/kepler 1.0.10 → 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
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',
@@ -195,6 +244,22 @@ function printPromptBlock() {
195
244
  * Print a turn summary after a response completes.
196
245
  * Shows only when there's something meaningful to report.
197
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
+
198
263
  function printTurnSummary(toolCount, durationS, turnCost) {
199
264
  const parts = [];
200
265
  if (toolCount > 0) parts.push(`${toolCount} tools`);
@@ -212,31 +277,25 @@ function updateStatusBar() {
212
277
  // ── Tool Display Renderer ──
213
278
 
214
279
  /**
215
- * Render a tool call in a transparent, informational way.
216
- * 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.
217
283
  */
218
284
  function renderToolCall(data) {
219
285
  const tool = data?.tool || 'unknown';
220
- const label = toolDisplayLabel(tool);
221
286
  const args = data?.args || {};
222
- 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)}`;
223
289
 
224
- 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
+ });
225
295
 
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`);
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`);
240
299
  }
241
300
 
242
301
  /**
@@ -250,77 +309,71 @@ 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);
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;
278
334
  const duration = formatToolDuration(data);
279
- 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`);
280
337
 
281
- if (data._blocked) {
282
- session.blockedOps++;
283
- process.stderr.write(`${gutter}${c.red(firstOutputLine(data) || 'Blocked by safety guardrails')}${suffix}\n`);
284
- 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`);
285
341
  }
342
+ }
286
343
 
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`);
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`);
290
354
  return;
291
355
  }
356
+ process.stderr.write('\n' + detailFor(card) + '\n\n');
357
+ }
292
358
 
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';
312
- }
313
-
314
- const renderedSummary = tool === 'shell' ? c.green(summary) : c.white(summary);
315
- process.stderr.write(`${gutter}${renderedSummary}${suffix}\n`);
316
-
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`);
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;
322
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;
323
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');
324
377
  }
325
378
 
326
379
  /**
@@ -409,6 +462,11 @@ function flushContent() {
409
462
  function renderEvent(event) {
410
463
  const { type, data } = event;
411
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
+
412
470
  switch (type) {
413
471
  case 'status': {
414
472
  const msg = data?.message || '';
@@ -421,6 +479,8 @@ function renderEvent(event) {
421
479
  const text = data?.message || data?.text || '';
422
480
  if (text && !text.startsWith('Processing')) {
423
481
  startSpinner(text.slice(0, 80));
482
+ // Capture reasoning so /why can replay it.
483
+ session.lastReasoning = text;
424
484
  }
425
485
  break;
426
486
  }
@@ -482,7 +542,7 @@ function renderEvent(event) {
482
542
  case 'approval_denied': {
483
543
  const reason = data?.reason || 'User denied';
484
544
  const toolName = data?.tool || '';
485
- const indent = session.inSubAgent ? ' ' : ' ';
545
+ const indent = subAgentIndent();
486
546
  process.stderr.write(`${indent}${c.red('✗')} ${c.dim(`Denied ${toolName}: ${reason}`)}\n`);
487
547
  break;
488
548
  }
@@ -576,21 +636,19 @@ function renderEvent(event) {
576
636
 
577
637
  case 'sub_agent_start': {
578
638
  stopSpinner();
579
- session.inSubAgent = true;
580
639
  const agentType = data?.type || 'sub-agent';
581
640
  const model = data?.model || '';
582
641
  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`);
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;
587
645
  startSpinner(`${agentType}: working...`);
588
646
  break;
589
647
  }
590
648
 
591
649
  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.
650
+ // The regular tool_call event renders the card, indented by the
651
+ // sub-agent stack depth. Just update the spinner text here.
594
652
  const agentType = data?.type || 'sub-agent';
595
653
  const tool = data?.tool || '';
596
654
  if (tool) updateSpinner(`${agentType} → ${tool}`);
@@ -599,24 +657,25 @@ function renderEvent(event) {
599
657
 
600
658
  case 'sub_agent_complete': {
601
659
  stopSpinner();
602
- session.inSubAgent = false;
603
660
  const agentType = data?.type || 'sub-agent';
604
- const model = data?.model || '';
605
- const resultLen = data?.result_length || 0;
606
661
  const usage = data?.usage || {};
607
662
  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');
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();
620
679
  break;
621
680
  }
622
681
 
@@ -653,6 +712,8 @@ function renderEvent(event) {
653
712
  case 'complete': {
654
713
  stopSpinner();
655
714
  flushContent();
715
+ resetSubAgents();
716
+ session.inSubAgent = false;
656
717
 
657
718
  const summary = data?.summary || '';
658
719
  if (summary && !_renderedContentThisTurn) {
@@ -695,9 +756,38 @@ function renderEvent(event) {
695
756
 
696
757
  session.lastTurnDuration = data?.duration_s || 0;
697
758
 
759
+ // Sync cumulative session cost into the orbit (status bar shows it).
760
+ if (_orbit) _orbit.onCost(session.totalCost);
761
+
698
762
  // Compact turn summary
699
763
  const tools = data?.tool_calls || session.toolCalls || 0;
700
- 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
+ }
701
791
  break;
702
792
  }
703
793
 
@@ -736,7 +826,8 @@ async function handleCommand(input, ctx) {
736
826
  process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}\n`);
737
827
  }
738
828
  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`);
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`);
740
831
  return;
741
832
 
742
833
  case '/login':
@@ -908,6 +999,143 @@ async function handleCommand(input, ctx) {
908
999
  process.stderr.write('\n');
909
1000
  return;
910
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
+
911
1139
  case '/compact': {
912
1140
  const before = session.history.length;
913
1141
  if (before <= 4) { process.stderr.write(` ${c.gray('Nothing to compact.')}\n`); return; }
@@ -1132,7 +1360,9 @@ export async function startTerminalRepl() {
1132
1360
  const auth = new TarangAuth();
1133
1361
 
1134
1362
  // Projects are registered and indexed on demand through get_project_overview.
1135
- 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 });
1136
1366
  const skipPerms = cliArgs.freeswim;
1137
1367
  const approval = new ApprovalManager({ autoApprove: skipPerms });
1138
1368
 
@@ -1146,10 +1376,30 @@ export async function startTerminalRepl() {
1146
1376
  // Persistent stream client — session_id captured from backend on first turn
1147
1377
  let streamClient = null;
1148
1378
 
1149
- 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
+ }
1150
1393
 
1151
1394
  printBanner(auth);
1152
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
+
1153
1403
  // ── Initialization ──
1154
1404
  process.stderr.write(` ${c.brand('⠋')} ${c.dim('Initializing...')}\r`);
1155
1405
  await fetchUser(ctx);
@@ -1230,10 +1480,27 @@ export async function startTerminalRepl() {
1230
1480
  return;
1231
1481
  }
1232
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
+
1233
1491
  // Regular prompt
1234
1492
  session.history.push({ role: 'user', content: input });
1235
1493
  session.turns++;
1236
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);
1237
1504
 
1238
1505
  // Start session tracking on first turn
1239
1506
  if (session.turns === 1) {
@@ -1272,6 +1539,7 @@ export async function startTerminalRepl() {
1272
1539
  let executionPaused = false;
1273
1540
  let keypressCleanup = null;
1274
1541
  let execListenerActive = false;
1542
+ let lastCtrlCAt = 0; // PRD-055 §8.4: first Ctrl+C cancels, second exits
1275
1543
 
1276
1544
  if (process.stdin.isTTY) {
1277
1545
  rl.pause();
@@ -1298,20 +1566,39 @@ export async function startTerminalRepl() {
1298
1566
  executionPaused = false;
1299
1567
  process.stderr.write(` ${c.green('▶')} ${c.dim('Resumed')}\n`);
1300
1568
  client.resume();
1569
+ if (_orbit) _orbit.onResume();
1301
1570
  } else {
1302
1571
  executionPaused = true;
1303
1572
  stopSpinner();
1304
1573
  process.stderr.write(` ${c.yellow('⏸')} ${c.dim('Paused — press Space to resume, Esc to cancel')}\n`);
1305
1574
  client.pause();
1575
+ if (_orbit) _orbit.onPause();
1306
1576
  }
1307
1577
  return;
1308
1578
  }
1309
1579
 
1310
- // 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
1311
1583
  if (bytes[0] === 0x03) {
1312
1584
  stopSpinner();
1313
- client.cancel();
1314
- 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;
1315
1602
  }
1316
1603
  };
1317
1604