@axplusb/kepler 0.0.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +82 -0
  2. package/package.json +36 -4
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/costs/route.ts +88 -0
  6. package/pulse/app/api/export/route.ts +77 -0
  7. package/pulse/app/api/history/route.ts +11 -0
  8. package/pulse/app/api/import/route.ts +31 -0
  9. package/pulse/app/api/memory/route.ts +52 -0
  10. package/pulse/app/api/plans/route.ts +9 -0
  11. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  12. package/pulse/app/api/projects/route.ts +121 -0
  13. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  14. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  15. package/pulse/app/api/sessions/route.ts +112 -0
  16. package/pulse/app/api/settings/route.ts +14 -0
  17. package/pulse/app/api/stats/route.ts +143 -0
  18. package/pulse/app/api/todos/route.ts +9 -0
  19. package/pulse/app/api/tools/route.ts +160 -0
  20. package/pulse/app/costs/page.tsx +179 -0
  21. package/pulse/app/export/page.tsx +465 -0
  22. package/pulse/app/favicon.ico +0 -0
  23. package/pulse/app/globals.css +263 -0
  24. package/pulse/app/help/page.tsx +142 -0
  25. package/pulse/app/history/page.tsx +157 -0
  26. package/pulse/app/layout.tsx +46 -0
  27. package/pulse/app/memory/page.tsx +365 -0
  28. package/pulse/app/overview-client.tsx +393 -0
  29. package/pulse/app/page.tsx +14 -0
  30. package/pulse/app/plans/page.tsx +308 -0
  31. package/pulse/app/projects/[slug]/page.tsx +390 -0
  32. package/pulse/app/projects/page.tsx +110 -0
  33. package/pulse/app/sessions/[id]/page.tsx +243 -0
  34. package/pulse/app/sessions/page.tsx +39 -0
  35. package/pulse/app/settings/page.tsx +188 -0
  36. package/pulse/app/todos/page.tsx +211 -0
  37. package/pulse/app/tools/page.tsx +249 -0
  38. package/pulse/cli.js +159 -0
  39. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  40. package/pulse/components/activity/streak-card.tsx +36 -0
  41. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  42. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  43. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  44. package/pulse/components/costs/model-token-table.tsx +60 -0
  45. package/pulse/components/global-search.tsx +193 -0
  46. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  47. package/pulse/components/layout/bottom-nav.tsx +52 -0
  48. package/pulse/components/layout/client-layout.tsx +31 -0
  49. package/pulse/components/layout/sidebar-context.tsx +50 -0
  50. package/pulse/components/layout/sidebar.tsx +182 -0
  51. package/pulse/components/layout/top-bar.tsx +121 -0
  52. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  53. package/pulse/components/overview/conversation-table.tsx +148 -0
  54. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  55. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  56. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  57. package/pulse/components/overview/stat-card.tsx +102 -0
  58. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  59. package/pulse/components/projects/project-card.tsx +175 -0
  60. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  61. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  62. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  63. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  64. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  65. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  66. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  67. package/pulse/components/sessions/session-badges.tsx +49 -0
  68. package/pulse/components/sessions/session-table.tsx +299 -0
  69. package/pulse/components/theme-provider.tsx +44 -0
  70. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  71. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  72. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  73. package/pulse/components/tools/version-history-table.tsx +32 -0
  74. package/pulse/components/ui/alert.tsx +66 -0
  75. package/pulse/components/ui/badge.tsx +48 -0
  76. package/pulse/components/ui/breadcrumb.tsx +109 -0
  77. package/pulse/components/ui/button.tsx +64 -0
  78. package/pulse/components/ui/calendar.tsx +220 -0
  79. package/pulse/components/ui/card.tsx +92 -0
  80. package/pulse/components/ui/command.tsx +158 -0
  81. package/pulse/components/ui/dialog.tsx +158 -0
  82. package/pulse/components/ui/input.tsx +21 -0
  83. package/pulse/components/ui/popover.tsx +89 -0
  84. package/pulse/components/ui/progress.tsx +31 -0
  85. package/pulse/components/ui/select.tsx +190 -0
  86. package/pulse/components/ui/separator.tsx +28 -0
  87. package/pulse/components/ui/sheet.tsx +143 -0
  88. package/pulse/components/ui/skeleton.tsx +13 -0
  89. package/pulse/components/ui/table.tsx +116 -0
  90. package/pulse/components/ui/tabs.tsx +91 -0
  91. package/pulse/components/ui/tooltip.tsx +57 -0
  92. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  93. package/pulse/components.json +23 -0
  94. package/pulse/eslint.config.mjs +18 -0
  95. package/pulse/lib/claude-reader.ts +594 -0
  96. package/pulse/lib/decode.ts +129 -0
  97. package/pulse/lib/pricing.ts +102 -0
  98. package/pulse/lib/replay-parser.ts +165 -0
  99. package/pulse/lib/tool-categories.ts +127 -0
  100. package/pulse/lib/utils.ts +6 -0
  101. package/pulse/next-env.d.ts +6 -0
  102. package/pulse/next.config.ts +16 -0
  103. package/pulse/package.json +45 -0
  104. package/pulse/postcss.config.mjs +7 -0
  105. package/pulse/public/activity.png +0 -0
  106. package/pulse/public/cc-lens.png +0 -0
  107. package/pulse/public/command-k.png +0 -0
  108. package/pulse/public/costs.png +0 -0
  109. package/pulse/public/dashboard-dark.png +0 -0
  110. package/pulse/public/dashboard-white.png +0 -0
  111. package/pulse/public/export.png +0 -0
  112. package/pulse/public/file.svg +1 -0
  113. package/pulse/public/globe.svg +1 -0
  114. package/pulse/public/next.svg +1 -0
  115. package/pulse/public/projects.png +0 -0
  116. package/pulse/public/session-chat.png +0 -0
  117. package/pulse/public/todos.png +0 -0
  118. package/pulse/public/tools.png +0 -0
  119. package/pulse/public/vercel.svg +1 -0
  120. package/pulse/public/window.svg +1 -0
  121. package/pulse/tsconfig.json +34 -0
  122. package/pulse/types/claude.ts +294 -0
  123. package/src/agents/loader.mjs +89 -0
  124. package/src/agents/parser.mjs +98 -0
  125. package/src/agents/teams.mjs +123 -0
  126. package/src/auth/oauth.mjs +220 -0
  127. package/src/auth/tarang-auth.mjs +277 -0
  128. package/src/config/cli-args.mjs +173 -0
  129. package/src/config/env.mjs +263 -0
  130. package/src/config/settings.mjs +132 -0
  131. package/src/context/ast-parser.mjs +298 -0
  132. package/src/context/bm25.mjs +85 -0
  133. package/src/context/retriever.mjs +270 -0
  134. package/src/context/skeleton.mjs +134 -0
  135. package/src/core/agent-loop.mjs +480 -0
  136. package/src/core/approval.mjs +273 -0
  137. package/src/core/backend-url.mjs +57 -0
  138. package/src/core/cache.mjs +105 -0
  139. package/src/core/callback-client.mjs +149 -0
  140. package/src/core/checkpoints.mjs +142 -0
  141. package/src/core/context-manager.mjs +198 -0
  142. package/src/core/headless.mjs +168 -0
  143. package/src/core/hooks-manager.mjs +87 -0
  144. package/src/core/jsonl-writer.mjs +351 -0
  145. package/src/core/local-agent.mjs +429 -0
  146. package/src/core/local-store.mjs +325 -0
  147. package/src/core/mode-selector.mjs +51 -0
  148. package/src/core/output-filter.mjs +177 -0
  149. package/src/core/paths.mjs +101 -0
  150. package/src/core/pricing.mjs +314 -0
  151. package/src/core/providers.mjs +219 -0
  152. package/src/core/rate-limiter.mjs +119 -0
  153. package/src/core/safety.mjs +200 -0
  154. package/src/core/scheduler.mjs +173 -0
  155. package/src/core/session-manager.mjs +317 -0
  156. package/src/core/session.mjs +143 -0
  157. package/src/core/settings-sync.mjs +85 -0
  158. package/src/core/stagnation.mjs +57 -0
  159. package/src/core/stream-client.mjs +367 -0
  160. package/src/core/streaming.mjs +182 -0
  161. package/src/core/system-prompt.mjs +135 -0
  162. package/src/core/tool-executor.mjs +725 -0
  163. package/src/hooks/engine.mjs +162 -0
  164. package/src/index.mjs +370 -0
  165. package/src/mcp/client.mjs +253 -0
  166. package/src/mcp/transport-shttp.mjs +130 -0
  167. package/src/mcp/transport-sse.mjs +131 -0
  168. package/src/mcp/transport-ws.mjs +134 -0
  169. package/src/permissions/checker.mjs +57 -0
  170. package/src/permissions/command-classifier.mjs +573 -0
  171. package/src/permissions/injection-check.mjs +60 -0
  172. package/src/permissions/path-check.mjs +102 -0
  173. package/src/permissions/prompt.mjs +73 -0
  174. package/src/permissions/sandbox.mjs +112 -0
  175. package/src/plugins/loader.mjs +138 -0
  176. package/src/skills/loader.mjs +147 -0
  177. package/src/skills/runner.mjs +55 -0
  178. package/src/telemetry/index.mjs +96 -0
  179. package/src/terminal/agents.mjs +177 -0
  180. package/src/terminal/analytics.mjs +292 -0
  181. package/src/terminal/ansi.mjs +421 -0
  182. package/src/terminal/main.mjs +150 -0
  183. package/src/terminal/repl.mjs +1484 -0
  184. package/src/terminal/tool-display.mjs +58 -0
  185. package/src/tools/agent.mjs +137 -0
  186. package/src/tools/ask-user.mjs +61 -0
  187. package/src/tools/bash.mjs +148 -0
  188. package/src/tools/cron-create.mjs +120 -0
  189. package/src/tools/cron-delete.mjs +49 -0
  190. package/src/tools/cron-list.mjs +37 -0
  191. package/src/tools/edit.mjs +82 -0
  192. package/src/tools/enter-worktree.mjs +69 -0
  193. package/src/tools/exit-worktree.mjs +57 -0
  194. package/src/tools/glob.mjs +117 -0
  195. package/src/tools/grep.mjs +129 -0
  196. package/src/tools/lint.mjs +71 -0
  197. package/src/tools/ls.mjs +58 -0
  198. package/src/tools/lsp.mjs +115 -0
  199. package/src/tools/multi-edit.mjs +94 -0
  200. package/src/tools/notebook-edit.mjs +96 -0
  201. package/src/tools/read-mcp-resource.mjs +57 -0
  202. package/src/tools/read.mjs +138 -0
  203. package/src/tools/registry.mjs +132 -0
  204. package/src/tools/remote-trigger.mjs +84 -0
  205. package/src/tools/send-message.mjs +64 -0
  206. package/src/tools/skill.mjs +52 -0
  207. package/src/tools/test-runner.mjs +49 -0
  208. package/src/tools/todo-write.mjs +68 -0
  209. package/src/tools/tool-search.mjs +77 -0
  210. package/src/tools/web-fetch.mjs +65 -0
  211. package/src/tools/web-search.mjs +89 -0
  212. package/src/tools/write.mjs +55 -0
  213. package/src/ui/banner.mjs +237 -0
  214. package/src/ui/commands.mjs +499 -0
  215. package/src/ui/formatter.mjs +379 -0
  216. package/src/ui/markdown.mjs +278 -0
  217. package/src/ui/slash-commands.mjs +258 -0
  218. package/index.js +0 -1
@@ -0,0 +1,1484 @@
1
+ /**
2
+ * Kepler REPL — Full Claude-like terminal UX.
3
+ *
4
+ * Pure ANSI. No React. No Ink. No flickering.
5
+ *
6
+ * Features:
7
+ * - Persistent status bar (model, cost, context, elapsed)
8
+ * - Streaming content with live partial updates
9
+ * - Tool execution display (transparent, collapsible)
10
+ * - File diff display with +/- highlighting
11
+ * - Phase/worker progress indicators
12
+ * - Built-in agents (explore, review, architect)
13
+ * - Permission prompts (Y/n/a/t)
14
+ * - Input history & Tab autocomplete
15
+ * - Safety guardrails on all tool execution
16
+ */
17
+
18
+ import * as readline from 'node:readline';
19
+ import * as path from 'node:path';
20
+ import { c, progressBar, spinner, inPlace, renderMarkdown, renderDiff, formatElapsed, formatCost, stripAnsi } from './ansi.mjs';
21
+ import { calculateCost, formatCostValue, formatTokens } from '../core/pricing.mjs';
22
+ import { TarangStreamClient, EVENT_TYPES } from '../core/stream-client.mjs';
23
+ import { JsonlWriter } from '../core/jsonl-writer.mjs';
24
+ import { createToolExecutor } from '../core/tool-executor.mjs';
25
+ import { TarangAuth } from '../auth/tarang-auth.mjs';
26
+ import { ApprovalManager } from '../core/approval.mjs';
27
+ import { resolveBackendUrl } from '../core/backend-url.mjs';
28
+ import { BUILTIN_AGENTS, runAgent } from './agents.mjs';
29
+ import { ContextRetriever } from '../context/retriever.mjs';
30
+ import { buildProjectSkeleton } from '../context/skeleton.mjs';
31
+ import { SessionManager } from '../core/session-manager.mjs';
32
+ import { parseArgs } from '../config/cli-args.mjs';
33
+ import { formatShellCommand, toolDisplayLabel } from './tool-display.mjs';
34
+
35
+ import { createRequire } from 'node:module';
36
+ const __require = createRequire(import.meta.url);
37
+ const VERSION = __require('../../package.json').version;
38
+
39
+ // ── Safe CWD ──
40
+ // If the working directory gets deleted (by a rogue tool call),
41
+ // process.cwd() throws ENOENT. Detect and recover.
42
+
43
+ let _cachedCwd = null;
44
+
45
+ function safeCwd() {
46
+ try {
47
+ _cachedCwd = process.cwd();
48
+ return _cachedCwd;
49
+ } catch {
50
+ // CWD deleted — try to recover
51
+ const fallback = _cachedCwd || process.env.HOME || '/tmp';
52
+ try {
53
+ process.chdir(fallback);
54
+ process.stderr.write(` ${c.yellow('Working directory was deleted. Recovered to: ' + fallback)}\n`);
55
+ _cachedCwd = fallback;
56
+ return fallback;
57
+ } catch {
58
+ return process.env.HOME || '/tmp';
59
+ }
60
+ }
61
+ }
62
+
63
+ // ── Session State ──
64
+
65
+ let _sessionMgr = null; // Set in startTerminalRepl, used by renderEvent
66
+
67
+ const session = {
68
+ id: null, // set by backend on first turn via session_info event
69
+ startTime: Date.now(),
70
+ inputTokens: 0,
71
+ outputTokens: 0,
72
+ toolCalls: 0,
73
+ totalToolCalls: 0, // across all turns
74
+ turns: 0,
75
+ history: [], // conversation messages
76
+ inputHistory: [], // previous prompts (for Up/Down)
77
+ user: null, // { github_username, email, role }
78
+ model: null, // from backend user profile
79
+ blockedOps: 0, // safety guardrail blocks
80
+ delegations: [], // agent delegation events: { from, to, time }
81
+ phases: [], // phase history: { name, time }
82
+ inSubAgent: false, // true while a sub-agent is running (for indented tool display)
83
+ filesChanged: [], // files modified this session
84
+ lastTurnDuration: 0,
85
+ costBreakdown: [], // per-model usage: [{ model, role, input_tokens, output_tokens, cost }]
86
+ totalCost: 0, // accumulated session cost (USD)
87
+ costAccurate: false, // true if backend provides per-model breakdown
88
+ };
89
+
90
+ // ── Commands ──
91
+
92
+ const COMMANDS = {
93
+ '/help': 'Show commands',
94
+ '/login': 'Sign in via browser',
95
+ '/whoami': 'Show logged-in user',
96
+ '/status': 'Session status & system info',
97
+ '/stats': 'Progress bars & metrics',
98
+ '/clear': 'Clear conversation',
99
+ '/git': 'Git status',
100
+ '/diff': 'Git diff',
101
+ '/cost': 'Show session cost',
102
+ '/history': 'Show conversation',
103
+ '/compact': 'Compact conversation context',
104
+ '/agents': 'List available agents',
105
+ '/explore': 'Code explorer agent',
106
+ '/review': 'Code review agent',
107
+ '/architect':'Feature architect agent',
108
+ '/safety': 'Show safety guardrail status',
109
+ '/revoke': 'Revoke auto-approvals',
110
+ '/resume': 'Resume a previous session',
111
+ '/sessions': 'List resumable sessions',
112
+ '/logout': 'Sign out and clear credentials',
113
+ '/exit': 'Exit CLI',
114
+ };
115
+
116
+ // ── Banner ──
117
+
118
+ function printBanner(auth) {
119
+ const creds = auth.loadCredentials();
120
+ const env = process.env.TARANG_ENV || 'production';
121
+ const authStatus = creds.token ? c.green('authenticated') : c.red('/login to start');
122
+
123
+ const art = [
124
+ ' ██████╗ ██████╗ ██████╗ █████╗',
125
+ ' ██╔═══██╗██╔══██╗██╔════╝██╔══██╗',
126
+ ' ██║ ██║██████╔╝██║ ███████║',
127
+ ' ██║ ██║██╔══██╗██║ ██╔══██║',
128
+ ' ╚██████╔╝██║ ██║╚██████╗██║ ██║',
129
+ ' ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝',
130
+ ];
131
+ process.stderr.write('\n');
132
+ for (const line of art) {
133
+ process.stderr.write(c.brand(line) + '\n');
134
+ }
135
+ process.stderr.write('\n');
136
+ process.stderr.write(` ${c.gray('v' + VERSION)} ${c.dim(env)} ${authStatus}\n`);
137
+ process.stderr.write('\n');
138
+ }
139
+
140
+ // ── Prompt Chrome ──
141
+ //
142
+ // Design: let the content breathe. The prompt area is a thin contextual
143
+ // strip — only shows what changed since last turn. No heavy borders.
144
+ //
145
+ // Layout after a response:
146
+ //
147
+ // <assistant content>
148
+ //
149
+ // ✓ 3 tools · 1.2s · $0.02 ctx 21% · 42k tok
150
+ // ╶─────────────────────────────────────────────────────────────────╴
151
+ // kepler ›
152
+ //
153
+ // Layout on first prompt (no stats yet):
154
+ //
155
+ // ╶─────────────────────────────────────────────────────────────────╴
156
+ // kepler ›
157
+
158
+ /**
159
+ * Build the contextual status strip — compact, one line.
160
+ * Left side: last-turn summary (tools, time, cost)
161
+ * Right side: session totals (ctx%, tokens)
162
+ */
163
+ function buildContextStrip() {
164
+ const totalTokens = session.inputTokens + session.outputTokens;
165
+ const cost = formatCostValue(session.totalCost);
166
+ const elapsed = formatElapsed(session.startTime);
167
+
168
+ // Right side — always shown
169
+ const right = [
170
+ c.dim(`${formatTokens(totalTokens)} tok`),
171
+ c.dim(cost),
172
+ c.dim(elapsed),
173
+ ].join(c.dim(' · '));
174
+
175
+ return right;
176
+ }
177
+
178
+ /**
179
+ * Print the prompt separator + prompt label.
180
+ * Minimal horizontal rule with contextual info.
181
+ */
182
+ function printPromptBlock() {
183
+ const w = process.stdout.columns || 80;
184
+ const strip = buildContextStrip();
185
+ const stripPlain = stripAnsi(strip);
186
+
187
+ // Rule with context strip right-aligned
188
+ const ruleLen = Math.max(0, w - stripPlain.length - 4);
189
+ process.stderr.write(
190
+ c.dim('╶') + c.dim('─'.repeat(ruleLen)) + ' ' + strip + ' ' + c.dim('╴') + '\n'
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Print a turn summary after a response completes.
196
+ * Shows only when there's something meaningful to report.
197
+ */
198
+ function printTurnSummary(toolCount, durationS, turnCost) {
199
+ const parts = [];
200
+ if (toolCount > 0) parts.push(`${toolCount} tools`);
201
+ if (durationS) parts.push(`${Number(durationS).toFixed(1)}s`);
202
+ if (turnCost > 0) parts.push(formatCostValue(turnCost));
203
+ if (parts.length > 0) {
204
+ process.stderr.write(`\n ${c.green('✓')} ${c.dim(parts.join(' · '))}\n`);
205
+ }
206
+ }
207
+
208
+ function updateStatusBar() {
209
+ // No-op: status is printed inline via printPromptBlock before each prompt
210
+ }
211
+
212
+ // ── Tool Display Renderer ──
213
+
214
+ /**
215
+ * Render a tool call in a transparent, informational way.
216
+ * Shows tool name + key args on one line, no box borders for reads.
217
+ */
218
+ function renderToolCall(data) {
219
+ const tool = data?.tool || 'unknown';
220
+ const label = toolDisplayLabel(tool);
221
+ const args = data?.args || {};
222
+ const indent = session.inSubAgent ? ' ' : ' ';
223
+
224
+ // Build summary string (what the tool will do)
225
+ let summary;
226
+ switch (tool) {
227
+ case 'read_file': {
228
+ const fp = shortPath(args.file_path || args.path || '');
229
+ const range = args.start_line && args.end_line
230
+ ? ` lines ${args.start_line}-${args.end_line}`
231
+ : args.start_line ? ` from line ${args.start_line}` : '';
232
+ summary = `${fp}${range}`;
233
+ break;
234
+ }
235
+ case 'write_file': {
236
+ const fp = shortPath(args.file_path || args.path || '');
237
+ const lines = args.content ? `, ${args.content.split('\n').length} lines` : '';
238
+ summary = `${fp}${lines}`;
239
+ break;
240
+ }
241
+ case 'edit_file': {
242
+ const fp = shortPath(args.file_path || args.path || '');
243
+ const search = args.search ? `, "${(args.search || '').slice(0, 30)}..."` : '';
244
+ summary = `${fp}${search}`;
245
+ break;
246
+ }
247
+ case 'shell':
248
+ summary = args.command || '';
249
+ break;
250
+ case 'search_code':
251
+ summary = `"${args.query || args.pattern || ''}"`;
252
+ break;
253
+ case 'list_files':
254
+ summary = `${args.pattern || '*'}${args.path ? ` in ${shortPath(args.path)}` : ''}`;
255
+ break;
256
+ case 'delete_file':
257
+ summary = shortPath(args.file_path || args.path || '');
258
+ break;
259
+ case 'read_files':
260
+ summary = (args.file_paths || args.paths || []).map(shortPath).join(', ');
261
+ break;
262
+ case 'write_project': {
263
+ const files = (args.files || []).map(f => shortPath(f.path || f.file_path || ''));
264
+ summary = files.length > 0 ? files.join(', ') : '';
265
+ break;
266
+ }
267
+ default:
268
+ summary = Object.values(args || {}).filter(v => typeof v === 'string').join(', ').slice(0, 60);
269
+ }
270
+
271
+ // Render: ⏺ Human-readable action(summary)
272
+ // Use terminal width minus label and padding, minimum 60
273
+ const cols = process.stderr.columns || 120;
274
+ const maxSummary = Math.max(60, cols - label.length - 10);
275
+ let displaySummary = summary || '';
276
+ if (displaySummary.length > maxSummary) {
277
+ displaySummary = '...' + displaySummary.slice(-(maxSummary - 3));
278
+ }
279
+ const summaryStr = displaySummary
280
+ ? c.gray('(') + (tool === 'shell'
281
+ ? formatShellCommand(displaySummary, c)
282
+ : c.white(displaySummary)) + c.gray(')')
283
+ : '';
284
+ process.stderr.write(`\n${indent}${c.brand('⏺')} ${c.bold(label)}${summaryStr}\n`);
285
+ }
286
+
287
+ /**
288
+ * Render a tool result (success/failure, output snippet).
289
+ */
290
+ const _renderedToolResults = new Set();
291
+
292
+ function formatToolDuration(data) {
293
+ const ms = data?.duration_ms ?? (data?.duration_s != null ? data.duration_s * 1000 : null);
294
+ if (ms == null) return '';
295
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
296
+ }
297
+
298
+ function firstOutputLine(data) {
299
+ const output = data?.output_preview || data?.output || data?.message || '';
300
+ return String(output).split('\n').map(line => line.trim()).find(Boolean) || '';
301
+ }
302
+
303
+ function fileTypeLabel(filePath) {
304
+ const ext = path.extname(filePath || '').toLowerCase();
305
+ if (ext === '.md' || ext === '.mdx') return 'Markdown';
306
+ if (ext === '.json' || ext === '.jsonl') return 'JSON';
307
+ if (ext === '.yaml' || ext === '.yml') return 'YAML';
308
+ if (ext === '.toml') return 'TOML';
309
+ if (ext === '.csv' || ext === '.tsv') return 'tabular data';
310
+ if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.rb', '.php', '.swift'].includes(ext)) {
311
+ return 'source';
312
+ }
313
+ return ext ? `${ext.slice(1).toUpperCase()} file` : 'file';
314
+ }
315
+
316
+ function renderToolResult(data, eventType = 'tool_result') {
317
+ if (!data) return;
318
+ const indent = session.inSubAgent ? ' ' : ' ';
319
+ const gutter = `${indent}${c.dim('⎿')} `;
320
+ const callId = data.call_id || data._callId;
321
+ if (eventType === 'tool_done' && callId && _renderedToolResults.has(callId)) return;
322
+ if (callId) _renderedToolResults.add(callId);
323
+ const duration = formatToolDuration(data);
324
+ const suffix = duration ? c.dim(` · ${duration}`) : '';
325
+
326
+ if (data._blocked) {
327
+ session.blockedOps++;
328
+ process.stderr.write(`${gutter}${c.red(firstOutputLine(data) || 'Blocked by safety guardrails')}${suffix}\n`);
329
+ return;
330
+ }
331
+
332
+ if (data.success === false) {
333
+ const msg = (data.error || firstOutputLine(data) || 'Failed').slice(0, 140);
334
+ process.stderr.write(`${gutter}${c.red(msg)}${suffix}\n`);
335
+ return;
336
+ }
337
+
338
+ const tool = data.tool || data._tool || '';
339
+ let summary = 'Completed';
340
+ if (tool === 'read_file') {
341
+ const lines = data._total_lines || String(data.output || '').split('\n').length;
342
+ const filePath = data.args?.file_path || data.args?.path || '';
343
+ summary = `Read ${fileTypeLabel(filePath)} · ${lines} line${lines === 1 ? '' : 's'}`;
344
+ } else if (tool === 'read_files') {
345
+ summary = 'Files read';
346
+ } else if (tool === 'search_code' || tool === 'list_files') {
347
+ const lines = String(data.output || '').split('\n').filter(line => line.trim()).length;
348
+ summary = lines > 0 ? `${lines} result${lines === 1 ? '' : 's'}` : 'No results';
349
+ } else if (tool === 'write_file' || tool === 'edit_file' || tool === 'write_project') {
350
+ summary = 'Updated';
351
+ } else if (tool === 'delete_file') {
352
+ summary = 'Deleted';
353
+ } else if (data.server_side) {
354
+ summary = firstOutputLine(data).slice(0, 100) || 'Completed server-side';
355
+ } else if (tool === 'shell') {
356
+ summary = firstOutputLine(data).slice(0, 100) || 'Command completed';
357
+ }
358
+
359
+ const renderedSummary = tool === 'shell' ? c.green(summary) : c.white(summary);
360
+ process.stderr.write(`${gutter}${renderedSummary}${suffix}\n`);
361
+
362
+ // For writes, show lint warnings
363
+ if (tool === 'write_file' || tool === 'edit_file') {
364
+ const lint = data.lint;
365
+ if (lint) {
366
+ process.stderr.write(`${gutter}${c.yellow('⚠ ' + lint.split('\n')[0].slice(0, 80))}\n`);
367
+ }
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Shorten a file path for display: /Users/sree/Sites/project/src/foo.mjs → src/foo.mjs
373
+ */
374
+ function shortPath(p) {
375
+ if (!p) return '';
376
+ const cwd = safeCwd();
377
+ if (p.startsWith(cwd)) return p.slice(cwd.length + 1);
378
+ // Show last 2 segments
379
+ const parts = p.split('/');
380
+ return parts.length > 2 ? parts.slice(-2).join('/') : p;
381
+ }
382
+
383
+ // ── Live Spinner ──
384
+ // A real animated spinner that ticks on an interval, not just per-call.
385
+ // Shows what's happening right now — thinking, tool executing, etc.
386
+
387
+ const SPIN_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
388
+ let _spinInterval = null;
389
+ let _spinFrame = 0;
390
+ let _spinText = '';
391
+
392
+ function startSpinner(text) {
393
+ _spinText = text;
394
+ _spinFrame = 0;
395
+ if (_spinInterval) return; // already running
396
+ _spinInterval = setInterval(() => {
397
+ if (!_spinText) return;
398
+ const frame = SPIN_FRAMES[_spinFrame % SPIN_FRAMES.length];
399
+ _spinFrame++;
400
+ inPlace(` ${c.brand(frame)} ${c.dim(_spinText)}`);
401
+ }, 80);
402
+ }
403
+
404
+ function updateSpinner(text) {
405
+ _spinText = text;
406
+ }
407
+
408
+ function stopSpinner() {
409
+ if (_spinInterval) { clearInterval(_spinInterval); _spinInterval = null; }
410
+ _spinText = '';
411
+ inPlace('');
412
+ }
413
+
414
+ // ── Content Streaming Display ──
415
+
416
+ let _streamBuffer = '';
417
+ let _streamedPartialText = '';
418
+ let _streamTimer = null;
419
+ let _renderedContentThisTurn = false;
420
+
421
+ function startContentStream() {
422
+ _streamBuffer = '';
423
+ _streamedPartialText = '';
424
+ _renderedToolResults.clear();
425
+ _renderedContentThisTurn = false;
426
+ stopSpinner();
427
+ }
428
+
429
+ function appendContent(text) {
430
+ if (!text) return;
431
+ _streamBuffer += text;
432
+ _streamedPartialText += text;
433
+
434
+ // Debounce rendering to avoid flicker on rapid partial updates
435
+ if (_streamTimer) clearTimeout(_streamTimer);
436
+ _streamTimer = setTimeout(() => flushContent(), 50);
437
+ }
438
+
439
+ function flushContent() {
440
+ if (_streamTimer) { clearTimeout(_streamTimer); _streamTimer = null; }
441
+ if (!_streamBuffer) return;
442
+
443
+ stopSpinner();
444
+ const rendered = renderMarkdown(_streamBuffer);
445
+ for (const line of rendered.split('\n')) {
446
+ process.stdout.write(` ${line}\n`);
447
+ }
448
+ _streamBuffer = '';
449
+ _renderedContentThisTurn = true;
450
+ }
451
+
452
+ // ── Event Renderer ──
453
+
454
+ function renderEvent(event) {
455
+ const { type, data } = event;
456
+
457
+ switch (type) {
458
+ case 'status': {
459
+ const msg = data?.message || '';
460
+ if (!msg || msg === 'Agent started') return;
461
+ startSpinner(msg);
462
+ break;
463
+ }
464
+
465
+ case 'thinking': {
466
+ const text = data?.message || data?.text || '';
467
+ if (text && !text.startsWith('Processing')) {
468
+ startSpinner(text.slice(0, 80));
469
+ }
470
+ break;
471
+ }
472
+
473
+ case 'content': {
474
+ let text = data?.text || '';
475
+ if (text) {
476
+ flushContent();
477
+ stopSpinner();
478
+ if (_streamedPartialText && text.startsWith(_streamedPartialText)) {
479
+ text = text.slice(_streamedPartialText.length);
480
+ } else if (_streamedPartialText.includes(text)) {
481
+ text = '';
482
+ }
483
+ }
484
+ if (text) {
485
+ const rendered = renderMarkdown(text);
486
+ for (const line of rendered.split('\n')) {
487
+ process.stdout.write(` ${line}\n`);
488
+ }
489
+ _renderedContentThisTurn = true;
490
+ }
491
+ break;
492
+ }
493
+
494
+ case 'content_partial': {
495
+ const text = data?.text || '';
496
+ if (text) {
497
+ stopSpinner();
498
+ appendContent(text);
499
+ }
500
+ break;
501
+ }
502
+
503
+ case 'tool_call':
504
+ case 'tool_request': {
505
+ session.toolCalls++;
506
+ session.totalToolCalls++;
507
+ stopSpinner();
508
+ flushContent();
509
+ renderToolCall(data);
510
+ break;
511
+ }
512
+
513
+ // ── HITL: Framework-level approval events ──
514
+
515
+ case 'approval_required': {
516
+ stopSpinner();
517
+ flushContent();
518
+ break;
519
+ }
520
+
521
+ case 'approval_granted': {
522
+ // approval.mjs _prompt already rendered the result line for human approvals.
523
+ // Nothing extra needed here — avoid duplicate output.
524
+ break;
525
+ }
526
+
527
+ case 'approval_denied': {
528
+ const reason = data?.reason || 'User denied';
529
+ const toolName = data?.tool || '';
530
+ const indent = session.inSubAgent ? ' ' : ' ';
531
+ process.stderr.write(`${indent}${c.red('✗')} ${c.dim(`Denied ${toolName}: ${reason}`)}\n`);
532
+ break;
533
+ }
534
+
535
+ case 'tool_result':
536
+ case 'tool_done': {
537
+ stopSpinner();
538
+ renderToolResult(data, type);
539
+ break;
540
+ }
541
+
542
+ case 'plan': {
543
+ stopSpinner();
544
+ flushContent();
545
+ const milestones = data?.milestones || data?.steps || [];
546
+ const title = data?.title || 'Plan';
547
+ process.stderr.write(`\n ${c.brand('▸')} ${c.bold(title)}\n`);
548
+ for (const [index, milestone] of milestones.entries()) {
549
+ const label = typeof milestone === 'string'
550
+ ? milestone
551
+ : milestone.name || milestone.title || milestone.description || `Step ${index + 1}`;
552
+ const status = typeof milestone === 'object' ? milestone.status : '';
553
+ const marker = status === 'complete' || status === 'completed' ? c.green('✓') : c.dim(`${index + 1}.`);
554
+ process.stderr.write(` ${marker} ${label}\n`);
555
+ }
556
+ break;
557
+ }
558
+
559
+ case 'change': {
560
+ stopSpinner();
561
+ const changeType = data?.type || 'modify';
562
+ const filePath = shortPath(data?.path || '');
563
+ const icon = changeType === 'create' ? c.green('+') :
564
+ changeType === 'delete' ? c.red('-') : c.yellow('~');
565
+ process.stderr.write(` ${icon} ${c.dim(filePath)}\n`);
566
+ // Track changed files
567
+ if (filePath && !session.filesChanged.includes(filePath)) {
568
+ session.filesChanged.push(filePath);
569
+ }
570
+ break;
571
+ }
572
+
573
+ case 'phase_start':
574
+ case 'phase_update': {
575
+ const phase = data?.phase || data?.stage_name || '';
576
+ if (phase) {
577
+ stopSpinner();
578
+ session.phases.push({ name: phase, time: Date.now() });
579
+ process.stderr.write(`\n ${c.brand('▸')} ${c.bold(phase)}\n`);
580
+ }
581
+ break;
582
+ }
583
+
584
+ case 'phase_summary': {
585
+ const summary = data?.summary || '';
586
+ if (summary) {
587
+ process.stderr.write(` ${c.dim(summary.slice(0, 120))}\n`);
588
+ }
589
+ break;
590
+ }
591
+
592
+ case 'worker_start':
593
+ case 'worker_update': {
594
+ const worker = data?.worker || data?.name || '';
595
+ const status = data?.status || data?.message || 'working';
596
+ if (worker) startSpinner(`${worker}: ${status}`);
597
+ break;
598
+ }
599
+
600
+ case 'worker_done': {
601
+ stopSpinner();
602
+ const worker = data?.worker || data?.name || '';
603
+ if (worker) process.stderr.write(` ${c.green('✓')} ${c.dim(worker)}\n`);
604
+ break;
605
+ }
606
+
607
+ case 'delegation': {
608
+ stopSpinner();
609
+ const from = data?.from || '';
610
+ const to = data?.to || '';
611
+ session.delegations.push({ from, to, time: Date.now() });
612
+ process.stderr.write(`\n ${c.brand('↳')} ${c.dim(from)} ${c.brand('→')} ${c.bold(to)}`);
613
+ if (data?.instruction) {
614
+ process.stderr.write(` ${c.dim(data.instruction.slice(0, 50))}`);
615
+ }
616
+ process.stderr.write('\n');
617
+ break;
618
+ }
619
+
620
+ // ── Sub-Agent Activity ──
621
+
622
+ case 'sub_agent_start': {
623
+ stopSpinner();
624
+ session.inSubAgent = true;
625
+ const agentType = data?.type || 'sub-agent';
626
+ const model = data?.model || '';
627
+ const query = data?.query || '';
628
+ const icon = agentType === 'explore' ? '🔭' : agentType === 'plan' ? '📐' : '🤖';
629
+ process.stderr.write(`\n ${icon} ${c.bold(c.brand(`${agentType} agent`))} ${c.dim('started')}\n`);
630
+ if (model) process.stderr.write(` ${c.gray('model:')} ${c.dim(model)}\n`);
631
+ if (query) process.stderr.write(` ${c.gray('query:')} ${c.dim(query)}\n`);
632
+ startSpinner(`${agentType}: working...`);
633
+ break;
634
+ }
635
+
636
+ case 'sub_agent_tool': {
637
+ // No separate display — the regular tool_call event shows full detail
638
+ // indented under the sub-agent block. Just update the spinner text.
639
+ const agentType = data?.type || 'sub-agent';
640
+ const tool = data?.tool || '';
641
+ if (tool) updateSpinner(`${agentType} → ${tool}`);
642
+ break;
643
+ }
644
+
645
+ case 'sub_agent_complete': {
646
+ stopSpinner();
647
+ session.inSubAgent = false;
648
+ const agentType = data?.type || 'sub-agent';
649
+ const model = data?.model || '';
650
+ const resultLen = data?.result_length || 0;
651
+ const usage = data?.usage || {};
652
+ const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
653
+ const parts = [];
654
+ if (data?.tool_calls > 0) parts.push(`${data.tool_calls} tools`);
655
+ if (data?.iterations > 0) parts.push(`${data.iterations} iterations`);
656
+ if (resultLen > 0) parts.push(`${resultLen} chars`);
657
+ if (tokens > 0) parts.push(`${formatTokens(tokens)} tok`);
658
+ if (data?.duration_s != null) parts.push(`${Number(data.duration_s).toFixed(1)}s`);
659
+ const icon = agentType === 'explore' ? '🔭' : agentType === 'plan' ? '📐' : '🤖';
660
+ const marker = data?.success === false ? c.red('✗') : c.green('✓');
661
+ const label = data?.success === false ? `${agentType} agent failed` : `${agentType} agent complete`;
662
+ process.stderr.write(` ${icon} ${marker} ${c.dim(label)}${parts.length ? ' ' + c.dim(parts.join(' · ')) : ''}\n`);
663
+ if (data?.error) process.stderr.write(` ${c.red(String(data.error).slice(0, 140))}\n`);
664
+ process.stderr.write('\n');
665
+ break;
666
+ }
667
+
668
+ case 'session_info': {
669
+ if (data?.session_id) {
670
+ session.id = data.session_id;
671
+ // Track in session manager so conversations save to the right file
672
+ if (_sessionMgr) _sessionMgr.setSessionInfo({ session_id: data.session_id });
673
+ }
674
+ if (data?.model) session.model = data.model;
675
+ if (data?.user) session.user = { ...session.user, ...data.user };
676
+ break;
677
+ }
678
+
679
+ case 'error':
680
+ stopSpinner();
681
+ flushContent();
682
+ process.stderr.write(`\n ${c.red('✗')} ${data?.message || 'Unknown error'}\n`);
683
+ if ((data?.message || '').includes('Authentication')) {
684
+ process.stderr.write(` ${c.dim('Run /login to re-authenticate')}\n`);
685
+ }
686
+ break;
687
+
688
+ case 'complete': {
689
+ stopSpinner();
690
+ flushContent();
691
+
692
+ const summary = data?.summary || '';
693
+ if (summary && !_renderedContentThisTurn) {
694
+ const rendered = renderMarkdown(summary);
695
+ for (const line of rendered.split('\n')) {
696
+ process.stdout.write(` ${line}\n`);
697
+ }
698
+ _renderedContentThisTurn = true;
699
+ }
700
+
701
+ // Update session token counts
702
+ const usage = data?.usage;
703
+ let turnCost = 0;
704
+ if (usage) {
705
+ const inp = usage.total_input_tokens || usage.input_tokens || 0;
706
+ const out = usage.total_output_tokens || usage.output_tokens || 0;
707
+ session.inputTokens += inp;
708
+ session.outputTokens += out;
709
+
710
+ // Model-aware cost calculation
711
+ const costResult = calculateCost(usage);
712
+ turnCost = costResult.total;
713
+ session.totalCost += costResult.total;
714
+ session.costAccurate = costResult.accurate;
715
+
716
+ // Accumulate per-model breakdown
717
+ for (const entry of costResult.breakdown) {
718
+ const existing = session.costBreakdown.find(b => b.model === entry.model);
719
+ if (existing) {
720
+ existing.input_tokens += entry.input_tokens;
721
+ existing.output_tokens += entry.output_tokens;
722
+ existing.cache_read_tokens += entry.cache_read_tokens || 0;
723
+ existing.cache_creation_tokens += entry.cache_creation_tokens || 0;
724
+ existing.cost += entry.cost;
725
+ } else {
726
+ session.costBreakdown.push({ ...entry });
727
+ }
728
+ }
729
+ }
730
+
731
+ session.lastTurnDuration = data?.duration_s || 0;
732
+
733
+ // Compact turn summary
734
+ const tools = data?.tool_calls || session.toolCalls || 0;
735
+ printTurnSummary(tools, data?.duration_s, turnCost);
736
+ break;
737
+ }
738
+
739
+ case 'cancelled':
740
+ stopSpinner();
741
+ flushContent();
742
+ process.stderr.write(`\n ${c.yellow('⏹')} Cancelled${data?.reason ? ': ' + c.dim(data.reason) : ''}\n`);
743
+ break;
744
+
745
+ case 'paused':
746
+ stopSpinner();
747
+ process.stderr.write(` ${c.yellow('⏸')} Paused${data?.reason ? ' ' + c.dim(data.reason) : ''}\n`);
748
+ break;
749
+
750
+ case 'resumed':
751
+ process.stderr.write(` ${c.green('▶')} Resumed\n`);
752
+ break;
753
+
754
+ default:
755
+ break;
756
+ }
757
+ }
758
+
759
+ // ── Slash Commands ──
760
+
761
+ async function handleCommand(input, ctx) {
762
+ const parts = input.split(/\s+/);
763
+ const cmd = parts[0].toLowerCase();
764
+ const rest = parts.slice(1).join(' ');
765
+
766
+ switch (cmd) {
767
+ case '/help':
768
+ process.stderr.write(`\n ${c.bold('Kepler Commands')}\n`);
769
+ process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
770
+ for (const [name, desc] of Object.entries(COMMANDS)) {
771
+ process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}\n`);
772
+ }
773
+ process.stderr.write(`\n ${c.bold('Keyboard')}\n`);
774
+ process.stderr.write(` ${c.gray('Ctrl+C')} exit ${c.gray('↑↓')} history ${c.gray('Tab')} autocomplete\n\n`);
775
+ return;
776
+
777
+ case '/login':
778
+ process.stderr.write(`${c.brand('Starting login flow...')}\n`);
779
+ try {
780
+ await ctx.auth.login();
781
+ process.stderr.write(`${c.green('✓ Login successful!')}\n`);
782
+ await fetchUser(ctx);
783
+ } catch (err) {
784
+ process.stderr.write(`${c.red('✗ Login failed: ' + err.message)}\n`);
785
+ }
786
+ return;
787
+
788
+ case '/whoami': {
789
+ if (!session.user) await fetchUser(ctx);
790
+ if (session.user) {
791
+ process.stderr.write(`\n ${c.green('✓')} ${session.user.github_username}\n`);
792
+ process.stderr.write(` ${c.gray('Email:')} ${session.user.email || 'n/a'}\n`);
793
+ process.stderr.write(` ${c.gray('User ID:')} ${session.user.id}\n`);
794
+ process.stderr.write(` ${c.gray('Role:')} ${session.user.role || 'user'}\n\n`);
795
+ } else {
796
+ process.stderr.write(` ${c.red('Not logged in. Run /login.')}\n`);
797
+ }
798
+ return;
799
+ }
800
+
801
+ case '/status': {
802
+ const creds = ctx.auth.loadCredentials();
803
+ const env = process.env.TARANG_ENV || 'production';
804
+ const os = await import('node:os');
805
+ const mem = process.memoryUsage();
806
+ const approvalSummary = ctx.approval.getSummary();
807
+
808
+ process.stderr.write(`\n ${c.bold('Session')}\n`);
809
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
810
+ process.stderr.write(` ${c.dim('ID')} ${session.id || c.dim('(not assigned yet)')}\n`);
811
+ process.stderr.write(` ${c.dim('User')} ${session.user?.github_username || '—'}\n`);
812
+ process.stderr.write(` ${c.dim('Model')} ${session.model || 'backend default'}\n`);
813
+ if (env === 'local') {
814
+ process.stderr.write(` ${c.dim('Backend')} ${creds.backendUrl}\n`);
815
+ }
816
+ process.stderr.write(` ${c.dim('Env')} ${env}\n`);
817
+ process.stderr.write(` ${c.dim('Turns')} ${session.turns}\n`);
818
+ process.stderr.write(` ${c.dim('Tools')} ${session.totalToolCalls} total, ${session.toolCalls} last turn\n`);
819
+ process.stderr.write(` ${c.dim('Duration')} ${formatElapsed(session.startTime)}\n`);
820
+ process.stderr.write(` ${c.dim('Cost')} ${formatCostValue(session.totalCost)}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
821
+ process.stderr.write(` ${c.dim('CWD')} ${safeCwd()}\n`);
822
+
823
+ // Permissions
824
+ process.stderr.write(`\n ${c.bold('Permissions')}\n`);
825
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
826
+ process.stderr.write(` ${c.dim('Approved')} ${approvalSummary.approved} ${c.dim('Denied')} ${approvalSummary.denied}\n`);
827
+ if (approvalSummary.autoApproveAll) {
828
+ process.stderr.write(` ${c.dim('Mode')} ${c.yellow('approve-all active')}\n`);
829
+ }
830
+ if (approvalSummary.autoApprovedTypes.length > 0) {
831
+ process.stderr.write(` ${c.dim('Auto-types')} ${approvalSummary.autoApprovedTypes.join(', ')}\n`);
832
+ }
833
+ process.stderr.write(` ${c.dim('Blocked')} ${session.blockedOps} by safety guardrails\n`);
834
+
835
+ // Orchestration
836
+ if (session.delegations.length > 0 || session.phases.length > 0) {
837
+ process.stderr.write(`\n ${c.bold('Orchestration')}\n`);
838
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
839
+ if (session.delegations.length > 0) {
840
+ process.stderr.write(` ${c.dim('Delegations')} ${session.delegations.length}\n`);
841
+ for (const d of session.delegations.slice(-5)) {
842
+ process.stderr.write(` ${c.dim(d.from)} ${c.brand('→')} ${d.to}\n`);
843
+ }
844
+ }
845
+ if (session.phases.length > 0) {
846
+ process.stderr.write(` ${c.dim('Phases')} ${session.phases.map(p => p.name).join(' → ')}\n`);
847
+ }
848
+ }
849
+
850
+ // Files changed
851
+ if (session.filesChanged.length > 0) {
852
+ process.stderr.write(`\n ${c.bold('Files Changed')} ${c.dim(`(${session.filesChanged.length})`)}\n`);
853
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
854
+ for (const f of session.filesChanged.slice(-10)) {
855
+ process.stderr.write(` ${c.dim('~')} ${f}\n`);
856
+ }
857
+ if (session.filesChanged.length > 10) {
858
+ process.stderr.write(` ${c.dim(` ...and ${session.filesChanged.length - 10} more`)}\n`);
859
+ }
860
+ }
861
+
862
+ // System
863
+ process.stderr.write(`\n ${c.bold('System')}\n`);
864
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
865
+ process.stderr.write(` ${c.dim('Node')} ${process.version}\n`);
866
+ process.stderr.write(` ${c.dim('Platform')} ${process.platform} ${os.arch()}\n`);
867
+ process.stderr.write(` ${c.dim('Heap')} ${(mem.heapUsed / 1024 / 1024).toFixed(0)} MB\n`);
868
+ process.stderr.write(` ${c.dim('Memory')} ${((os.totalmem() - os.freemem()) / 1024 / 1024 / 1024).toFixed(1)}G / ${(os.totalmem() / 1024 / 1024 / 1024).toFixed(1)}G\n\n`);
869
+ return;
870
+ }
871
+
872
+ case '/stats': {
873
+ const os = await import('node:os');
874
+ const mem = process.memoryUsage();
875
+ const totalMem = os.totalmem();
876
+ const usedMem = totalMem - os.freemem();
877
+ const totalTokens = session.inputTokens + session.outputTokens;
878
+ const ctxPct = Math.min(100, (totalTokens / 200000) * 100);
879
+
880
+ process.stderr.write(`\n ${c.bold('Metrics')}\n`);
881
+ process.stderr.write(` ${c.gray('─'.repeat(40))}\n`);
882
+ process.stderr.write(` ${progressBar(ctxPct, 15, 'Context')} ${(totalTokens / 1000).toFixed(1)}k tok\n`);
883
+ process.stderr.write(` ${progressBar(Math.round((usedMem / totalMem) * 100), 15, 'Memory')} ${(usedMem / 1024 / 1024 / 1024).toFixed(1)}G\n`);
884
+ process.stderr.write(` ${progressBar(Math.round((mem.heapUsed / mem.heapTotal) * 100), 15, 'Heap')} ${(mem.heapUsed / 1024 / 1024).toFixed(0)}M\n`);
885
+ process.stderr.write(` ${c.gray('Turns:')} ${session.turns}\n`);
886
+ process.stderr.write(` ${c.gray('Tools:')} ${session.toolCalls}\n`);
887
+ process.stderr.write(` ${c.gray('Blocked:')} ${session.blockedOps}\n`);
888
+ process.stderr.write(` ${c.gray('Cost:')} ${formatCostValue(session.totalCost)}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
889
+ process.stderr.write(` ${c.gray('Elapsed:')} ${formatElapsed(session.startTime)}\n\n`);
890
+ return;
891
+ }
892
+
893
+ case '/cost': {
894
+ process.stderr.write(`\n ${c.bold('Session Cost')}`);
895
+ if (!session.costAccurate) {
896
+ process.stderr.write(` ${c.yellow('(estimated — backend not sending model breakdown)')}`);
897
+ }
898
+ process.stderr.write('\n');
899
+ process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
900
+
901
+ if (session.costBreakdown.length > 0) {
902
+ // Header
903
+ 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`);
904
+ process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
905
+
906
+ for (const b of session.costBreakdown) {
907
+ const modelLabel = b.model === 'unknown' ? c.yellow('unknown model') : b.model;
908
+ const roleTag = b.role && b.role !== 'unknown' ? ` ${c.dim(`(${b.role})`)}` : '';
909
+ const cacheTokens = (b.cache_read_tokens || 0) + (b.cache_creation_tokens || 0);
910
+ const costStr = b.free ? c.green('free') : formatCostValue(b.cost);
911
+
912
+ process.stderr.write(
913
+ ` ${(modelLabel + roleTag).padEnd(36)}` +
914
+ `${formatTokens(b.input_tokens).padStart(10)}` +
915
+ `${formatTokens(b.output_tokens).padStart(10)}` +
916
+ `${(cacheTokens > 0 ? formatTokens(cacheTokens) : '—').padStart(10)}` +
917
+ `${costStr.padStart(10)}\n`
918
+ );
919
+ }
920
+
921
+ process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
922
+ }
923
+
924
+ process.stderr.write(
925
+ ` ${c.bold('Total'.padEnd(36))}` +
926
+ `${formatTokens(session.inputTokens).padStart(10)}` +
927
+ `${formatTokens(session.outputTokens).padStart(10)}` +
928
+ `${''.padStart(10)}` +
929
+ `${formatCostValue(session.totalCost).padStart(10)}\n`
930
+ );
931
+ process.stderr.write(` ${c.dim(`Turns: ${session.turns} Duration: ${formatElapsed(session.startTime)}`)}\n\n`);
932
+ return;
933
+ }
934
+
935
+ case '/history':
936
+ if (session.history.length === 0) { process.stderr.write(` ${c.gray('No conversation yet.')}\n`); return; }
937
+ process.stderr.write(`\n ${c.bold('Conversation')} (${session.history.length} messages)\n`);
938
+ process.stderr.write(` ${c.gray('─'.repeat(40))}\n`);
939
+ for (const msg of session.history.slice(-20)) {
940
+ const role = msg.role === 'user' ? c.white('You') : c.brand('Kepler');
941
+ process.stderr.write(` ${role}: ${msg.content.slice(0, 80)}${msg.content.length > 80 ? '...' : ''}\n`);
942
+ }
943
+ process.stderr.write('\n');
944
+ return;
945
+
946
+ case '/compact': {
947
+ const before = session.history.length;
948
+ if (before <= 4) { process.stderr.write(` ${c.gray('Nothing to compact.')}\n`); return; }
949
+ session.history.splice(2, session.history.length - 6);
950
+ process.stderr.write(` ${c.gray(`Compacted: ${before} → ${session.history.length} messages`)}\n`);
951
+ return;
952
+ }
953
+
954
+ case '/clear':
955
+ session.history.length = 0;
956
+ session.toolCalls = 0;
957
+ process.stderr.write(` ${c.gray('Conversation cleared.')}\n`);
958
+ return;
959
+
960
+ case '/git': {
961
+ const { execSync } = await import('node:child_process');
962
+ try { process.stdout.write(execSync('git status --short --branch', { encoding: 'utf-8' }) + '\n'); }
963
+ catch (e) { process.stderr.write(` ${c.red(e.message)}\n`); }
964
+ return;
965
+ }
966
+
967
+ case '/diff': {
968
+ const { execSync } = await import('node:child_process');
969
+ try {
970
+ const diff = execSync('git diff --no-ext-diff --unified=3', {
971
+ encoding: 'utf-8',
972
+ maxBuffer: 2 * 1024 * 1024,
973
+ });
974
+ process.stdout.write(diff ? renderDiff(diff) + '\n' : c.dim('(no changes)') + '\n');
975
+ }
976
+ catch (e) { process.stderr.write(` ${c.red(e.message)}\n`); }
977
+ return;
978
+ }
979
+
980
+ case '/safety': {
981
+ const { getSafetyRules } = await import('../core/safety.mjs');
982
+ const rules = getSafetyRules();
983
+ const summary = ctx.approval.getSummary();
984
+ process.stderr.write(`\n ${c.bold('Safety Guardrails')} ${c.green('ACTIVE')}\n`);
985
+ process.stderr.write(` ${c.gray('─'.repeat(40))}\n`);
986
+ process.stderr.write(` ${c.gray('Approval mode:')} ${ctx.approval.getModeLabel()}\n`);
987
+ process.stderr.write(` ${c.gray('Approved:')} ${summary.approved} ${c.gray('Denied:')} ${summary.denied}\n`);
988
+ process.stderr.write(` ${c.gray('Protected files:')} ${rules.protectedNames.join(', ')}\n`);
989
+ process.stderr.write(` ${c.gray('Source dirs:')} ${rules.sourceDirs.join(', ')}\n`);
990
+ process.stderr.write(` ${c.gray('Blocked patterns:')} ${rules.blockedPatterns}\n`);
991
+ process.stderr.write(` ${c.gray('High-risk patterns:')} ${rules.highRiskPatterns}\n`);
992
+ process.stderr.write(` ${c.gray('Ops blocked:')} ${session.blockedOps}\n\n`);
993
+ return;
994
+ }
995
+
996
+ case '/revoke': {
997
+ const wasActive = ctx.approval.revoke();
998
+ if (wasActive) {
999
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Auto-approvals revoked. All tool calls will prompt again.')}\n`);
1000
+ } else {
1001
+ process.stderr.write(` ${c.gray('No auto-approvals were active.')}\n`);
1002
+ }
1003
+ return;
1004
+ }
1005
+
1006
+ case '/sessions': {
1007
+ const resumable = ctx.sessionMgr.listResumable(10);
1008
+ if (resumable.length === 0) {
1009
+ process.stderr.write(` ${c.gray('No resumable sessions found.')}\n`);
1010
+ return;
1011
+ }
1012
+ process.stderr.write(`\n ${c.bold('Resumable Sessions')}\n`);
1013
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
1014
+ for (const s of resumable) {
1015
+ const date = s.startedAt ? new Date(s.startedAt).toLocaleDateString() : '?';
1016
+ const instr = s.instruction ? s.instruction.slice(0, 40) : '(no instruction)';
1017
+ process.stderr.write(` ${c.brand(s.sessionId)} ${c.dim(date)} ${s.messageCount} msgs ${c.dim(instr)}\n`);
1018
+ }
1019
+ process.stderr.write(`\n ${c.dim('Resume with:')} kepler --resume <sessionId>\n`);
1020
+ return;
1021
+ }
1022
+
1023
+ case '/resume': {
1024
+ const parts = input.split(/\s+/);
1025
+ const targetId = parts[1]; // /resume <sessionId>
1026
+
1027
+ if (targetId) {
1028
+ // Direct resume by ID
1029
+ const messages = ctx.sessionMgr.loadMessages(targetId);
1030
+ if (messages.length === 0) {
1031
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('No conversation found for session ' + targetId)}\n`);
1032
+ return;
1033
+ }
1034
+ session.history = messages;
1035
+ session.id = targetId;
1036
+ session.turns = Math.floor(messages.length / 2);
1037
+ process.stderr.write(` ${c.green('↺')} ${c.dim(`Resumed: ${messages.length} messages`)}\n`);
1038
+ return;
1039
+ }
1040
+
1041
+ // No ID given — list sessions and let user pick by number
1042
+ const resumable = ctx.sessionMgr.listResumable(10);
1043
+ if (resumable.length === 0) {
1044
+ process.stderr.write(` ${c.gray('No resumable sessions found.')}\n`);
1045
+ return;
1046
+ }
1047
+
1048
+ process.stderr.write(`\n ${c.bold('Pick a session to resume:')}\n\n`);
1049
+ for (let i = 0; i < resumable.length; i++) {
1050
+ const s = resumable[i];
1051
+ const date = s.startedAt ? new Date(s.startedAt).toLocaleString() : '?';
1052
+ const instr = (s.instruction || '(no instruction)').slice(0, 45);
1053
+ const proj = s.project ? c.brand(s.project) + ' ' : '';
1054
+ const num = `[${i + 1}]`;
1055
+ process.stderr.write(` ${c.brand(num)} ${proj}${c.dim(date)} ${s.messageCount} msgs\n`);
1056
+ process.stderr.write(` ${c.dim(instr)}\n`);
1057
+ }
1058
+ process.stderr.write(`\n ${c.dim('Enter number (or Esc to cancel):')} `);
1059
+
1060
+ // Read single key for selection
1061
+ const rl = ctx._rl || null;
1062
+ if (rl) rl.pause();
1063
+ const choice = await new Promise((resolve) => {
1064
+ if (!process.stdin.isTTY) { resolve(null); return; }
1065
+ const wasRaw = process.stdin.isRaw;
1066
+ process.stdin.setRawMode(true);
1067
+ process.stdin.resume();
1068
+ process.stdin.once('data', (data) => {
1069
+ process.stdin.setRawMode(wasRaw || false);
1070
+ if (rl) rl.resume();
1071
+ const bytes = [...data];
1072
+ if (bytes[0] === 0x1b || bytes[0] === 0x03) { resolve(null); return; }
1073
+ const num = parseInt(data.toString(), 10);
1074
+ resolve(isNaN(num) ? null : num);
1075
+ });
1076
+ });
1077
+
1078
+ if (!choice || choice < 1 || choice > resumable.length) {
1079
+ process.stderr.write(`\n ${c.dim('Cancelled.')}\n`);
1080
+ return;
1081
+ }
1082
+
1083
+ const picked = resumable[choice - 1];
1084
+ const messages = ctx.sessionMgr.loadMessages(picked.sessionId);
1085
+ if (messages.length === 0) {
1086
+ process.stderr.write(`\n ${c.yellow('!')} ${c.dim('No messages in that session.')}\n`);
1087
+ return;
1088
+ }
1089
+
1090
+ session.history = messages;
1091
+ session.id = picked.sessionId;
1092
+ session.turns = Math.floor(messages.length / 2);
1093
+ process.stderr.write(`\n ${c.green('↺')} ${c.dim(`Resumed: ${messages.length} messages`)}`);
1094
+ if (picked.instruction) {
1095
+ process.stderr.write(` ${c.dim('—')} ${c.dim(picked.instruction.slice(0, 50))}`);
1096
+ }
1097
+ process.stderr.write('\n');
1098
+ return;
1099
+ }
1100
+
1101
+ case '/agents':
1102
+ process.stderr.write(`\n ${c.bold('Built-in Agents')}\n`);
1103
+ process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
1104
+ for (const agent of BUILTIN_AGENTS) {
1105
+ process.stderr.write(` ${c.brand(('/' + agent.command).padEnd(14))} ${agent.description}\n`);
1106
+ process.stderr.write(` ${' '.repeat(14)} ${c.gray(agent.detail)}\n`);
1107
+ }
1108
+ process.stderr.write(`\n ${c.gray('Usage: /<agent> <instruction>')}\n`);
1109
+ process.stderr.write(` ${c.gray('Example: /explore how does the auth flow work?')}\n\n`);
1110
+ return;
1111
+
1112
+ case '/explore':
1113
+ case '/review':
1114
+ case '/architect': {
1115
+ if (!rest) {
1116
+ process.stderr.write(` ${c.yellow('Usage:')} ${cmd} <instruction>\n`);
1117
+ process.stderr.write(` ${c.gray(`Example: ${cmd} ${cmd === '/explore' ? 'how does authentication work?' : cmd === '/review' ? 'check src/core/ for bugs' : 'design a caching layer'}`)}\n`);
1118
+ return;
1119
+ }
1120
+ return await runAgent(cmd.slice(1), rest, ctx, session, renderEvent);
1121
+ }
1122
+
1123
+ case '/logout': {
1124
+ const success = ctx.auth.logout();
1125
+ if (success) {
1126
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Signed out. Credentials cleared from ~/.kepler/config.json')}\n`);
1127
+ process.stderr.write(` ${c.dim('Run /login to sign in again.')}\n`);
1128
+ } else {
1129
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('No credentials to clear.')}\n`);
1130
+ }
1131
+ return;
1132
+ }
1133
+
1134
+ case '/exit':
1135
+ case '/quit':
1136
+ process.stderr.write(`\n ${c.brand('Goodbye!')}\n\n`);
1137
+ process.exit(0);
1138
+
1139
+ default:
1140
+ process.stderr.write(` ${c.gray(`Unknown: ${cmd}. Type /help.`)}\n`);
1141
+ }
1142
+ }
1143
+
1144
+ // ── Fetch User Profile ──
1145
+
1146
+ async function fetchUser(ctx) {
1147
+ const creds = ctx.auth.loadCredentials();
1148
+ if (!creds.token) return;
1149
+ try {
1150
+ const resp = await fetch(`${creds.backendUrl}/api/user/me`, {
1151
+ headers: { 'Authorization': `Bearer ${creds.token}` },
1152
+ });
1153
+ if (resp.ok) {
1154
+ session.user = await resp.json();
1155
+ session.model = session.user.default_reasoning_model || session.user.default_orchestrator_model || null;
1156
+ }
1157
+ } catch {}
1158
+ }
1159
+
1160
+ // ── Main REPL ──
1161
+ // Cache CWD at startup so safeCwd() has a fallback if the dir gets deleted
1162
+
1163
+ export async function startTerminalRepl() {
1164
+ _cachedCwd = process.cwd(); // Cache startup CWD for recovery
1165
+
1166
+ const cliArgs = parseArgs(process.argv.slice(2));
1167
+ const auth = new TarangAuth();
1168
+
1169
+ // BM25 retriever — indexes project files for search_code tool
1170
+ const retriever = new ContextRetriever(safeCwd());
1171
+ const toolExecutor = createToolExecutor({ retriever });
1172
+ const skipPerms = cliArgs.freeswim;
1173
+ const approval = new ApprovalManager({ autoApprove: skipPerms });
1174
+
1175
+ // Session manager — persists conversation messages to .kepler/conversations/
1176
+ const sessionMgr = new SessionManager(safeCwd());
1177
+ _sessionMgr = sessionMgr; // expose to renderEvent
1178
+
1179
+ // Local JSONL writer — writes cc-lens compatible session data to ~/.kepler/
1180
+ const jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
1181
+
1182
+ // Persistent stream client — session_id captured from backend on first turn
1183
+ let streamClient = null;
1184
+
1185
+ const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr };
1186
+
1187
+ printBanner(auth);
1188
+
1189
+ // ── Initialization with progress ──
1190
+ // BM25 indexing is CPU-bound and blocks the event loop, so setInterval
1191
+ // spinners won't tick during it. Instead, show a static "Initializing..."
1192
+ // message, then yield to the event loop between phases so the spinner runs.
1193
+ let projectSkeleton = '';
1194
+
1195
+ // Phase 1: Show immediate feedback
1196
+ process.stderr.write(` ${c.brand('⠋')} ${c.dim('Initializing...')}\r`);
1197
+
1198
+ // Fetch user in parallel (network I/O, won't block event loop)
1199
+ const userPromise = fetchUser(ctx);
1200
+
1201
+ // Phase 2: BM25 index — CPU-bound, blocks event loop.
1202
+ // Wrap in a microtask break so the initial message renders first.
1203
+ const indexResult = await new Promise((resolve) => {
1204
+ // Let the event loop flush stderr before blocking
1205
+ setImmediate(async () => {
1206
+ try {
1207
+ process.stderr.write(`\r ${c.brand('⠹')} ${c.dim('Indexing project files...')}${' '.repeat(20)}\r`);
1208
+ const result = await retriever.buildIndex();
1209
+ resolve(result);
1210
+ } catch {
1211
+ resolve({ fileCount: 0, chunkCount: 0 });
1212
+ }
1213
+ });
1214
+ });
1215
+
1216
+ // Phase 3: Build skeleton (fast, synchronous)
1217
+ process.stderr.write(`\r ${c.brand('⠼')} ${c.dim('Building project skeleton...')}${' '.repeat(20)}\r`);
1218
+ await new Promise(r => setImmediate(r)); // yield so message renders
1219
+ projectSkeleton = buildProjectSkeleton(safeCwd());
1220
+
1221
+ // Wait for user fetch
1222
+ await userPromise;
1223
+
1224
+ // Clear the spinner line
1225
+ process.stderr.write(`\r${' '.repeat(60)}\r`);
1226
+
1227
+ // Show init summary
1228
+ if (indexResult.fileCount > 0) {
1229
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Indexed ${indexResult.fileCount} files (${indexResult.chunkCount} chunks)`)}\n`);
1230
+ }
1231
+ if (projectSkeleton) {
1232
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Project skeleton ready')}\n`);
1233
+ }
1234
+ if (session.user) {
1235
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Logged in as ${session.user.github_username || session.user.email || 'user'}`)}\n`);
1236
+ }
1237
+ // ── Resume previous session ──
1238
+ if (cliArgs.resume) {
1239
+ const lastSession = cliArgs.resumeSessionId
1240
+ ? { sessionId: cliArgs.resumeSessionId }
1241
+ : sessionMgr.getLastSession();
1242
+
1243
+ if (lastSession) {
1244
+ const messages = sessionMgr.loadMessages(lastSession.sessionId);
1245
+ if (messages.length > 0) {
1246
+ session.history = messages;
1247
+ session.id = lastSession.sessionId;
1248
+ session.turns = Math.floor(messages.length / 2);
1249
+ process.stderr.write(` ${c.green('↺')} ${c.dim(`Resumed session: ${messages.length} messages`)}`);
1250
+ if (lastSession.instruction) {
1251
+ process.stderr.write(` ${c.dim('—')} ${c.dim(lastSession.instruction.slice(0, 50))}`);
1252
+ }
1253
+ process.stderr.write('\n');
1254
+ } else {
1255
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('No conversation found for session ' + lastSession.sessionId)}\n`);
1256
+ }
1257
+ } else {
1258
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('No previous session to resume')}\n`);
1259
+ }
1260
+ }
1261
+
1262
+ process.stderr.write(`\n ${c.dim('Press')} ${c.brand('Enter')} ${c.dim('to start, or type a prompt below.')}\n`);
1263
+
1264
+ const PROMPT = `${c.brand('kepler')} ${c.dim('›')} `;
1265
+
1266
+ const rl = readline.createInterface({
1267
+ input: process.stdin,
1268
+ output: process.stderr,
1269
+ prompt: PROMPT,
1270
+ completer: (line) => {
1271
+ if (line.startsWith('/')) {
1272
+ const hits = Object.keys(COMMANDS).filter(cmd => cmd.startsWith(line));
1273
+ return [hits.length ? hits : Object.keys(COMMANDS), line];
1274
+ }
1275
+ return [[], line];
1276
+ },
1277
+ historySize: 100,
1278
+ });
1279
+
1280
+ // Give approval manager access to readline for pause/resume
1281
+ approval.setReadline(rl);
1282
+ ctx._rl = rl; // expose to /resume command for readline pause
1283
+
1284
+ // Helper: show prompt with separator + vertical breathing room
1285
+ function showPrompt() {
1286
+ printPromptBlock();
1287
+ process.stderr.write('\n'); // half-inch vertical gap above input line
1288
+ rl.prompt();
1289
+ }
1290
+
1291
+ showPrompt();
1292
+
1293
+ rl.on('line', async (line) => {
1294
+ const input = line.trim();
1295
+ if (!input) { rl.prompt(); return; }
1296
+
1297
+ // Save to input history
1298
+ session.inputHistory.push(input);
1299
+
1300
+ // Slash commands
1301
+ if (input.startsWith('/')) {
1302
+ await handleCommand(input, ctx);
1303
+ showPrompt();
1304
+ return;
1305
+ }
1306
+
1307
+ // Regular prompt
1308
+ session.history.push({ role: 'user', content: input });
1309
+ session.turns++;
1310
+ session.toolCalls = 0;
1311
+
1312
+ // Start session tracking on first turn
1313
+ if (session.turns === 1) {
1314
+ sessionMgr.start(input);
1315
+ }
1316
+ sessionMgr.saveMessage('user', input);
1317
+
1318
+ // Local JSONL: write user turn + history
1319
+ jsonlWriter.writeUserTurn(input);
1320
+ jsonlWriter.writeHistory(input);
1321
+
1322
+ const creds = auth.loadCredentials();
1323
+ if (!creds.token) {
1324
+ process.stderr.write(` ${c.red('Not logged in. Run /login first.')}\n`);
1325
+ showPrompt();
1326
+ return;
1327
+ }
1328
+
1329
+ // Kepler response label
1330
+ process.stderr.write(`\n${c.bold(c.brand('kepler'))}\n`);
1331
+
1332
+ // Create or reuse stream client — sessionId persists across turns
1333
+ if (!streamClient || streamClient.baseUrl !== creds.backendUrl || streamClient.token !== creds.token) {
1334
+ streamClient = new TarangStreamClient({
1335
+ baseUrl: creds.backendUrl,
1336
+ token: creds.token,
1337
+ toolExecutor,
1338
+ approvalManager: approval,
1339
+ });
1340
+ }
1341
+ const client = streamClient;
1342
+
1343
+ let assistantContent = '';
1344
+
1345
+ // ── Execution keypress listener (Esc = cancel, Space = pause/resume) ──
1346
+ let executionPaused = false;
1347
+ let keypressCleanup = null;
1348
+ let execListenerActive = false;
1349
+
1350
+ if (process.stdin.isTTY) {
1351
+ rl.pause();
1352
+ const wasRaw = process.stdin.isRaw;
1353
+ process.stdin.setRawMode(true);
1354
+ process.stdin.resume();
1355
+ execListenerActive = true;
1356
+
1357
+ const onData = (data) => {
1358
+ if (!execListenerActive) return; // paused for approval menu
1359
+ const bytes = [...data];
1360
+
1361
+ // Esc key (single byte 0x1b, not part of arrow sequence)
1362
+ if (bytes.length === 1 && bytes[0] === 0x1b) {
1363
+ stopSpinner();
1364
+ process.stderr.write(`\n ${c.yellow('⏹')} ${c.dim('Cancelling...')}\n`);
1365
+ client.cancel();
1366
+ return;
1367
+ }
1368
+
1369
+ // Space — toggle pause/resume
1370
+ if (bytes.length === 1 && bytes[0] === 0x20) {
1371
+ if (executionPaused) {
1372
+ executionPaused = false;
1373
+ process.stderr.write(` ${c.green('▶')} ${c.dim('Resumed')}\n`);
1374
+ client.resume();
1375
+ } else {
1376
+ executionPaused = true;
1377
+ stopSpinner();
1378
+ process.stderr.write(` ${c.yellow('⏸')} ${c.dim('Paused — press Space to resume, Esc to cancel')}\n`);
1379
+ client.pause();
1380
+ }
1381
+ return;
1382
+ }
1383
+
1384
+ // Ctrl+C during execution
1385
+ if (bytes[0] === 0x03) {
1386
+ stopSpinner();
1387
+ client.cancel();
1388
+ process.exit(0);
1389
+ }
1390
+ };
1391
+
1392
+ process.stdin.on('data', onData);
1393
+
1394
+ // Let approval manager pause/resume this listener
1395
+ approval.setExecutionHooks({
1396
+ onPause: () => { execListenerActive = false; },
1397
+ onResume: () => { execListenerActive = true; },
1398
+ });
1399
+
1400
+ keypressCleanup = () => {
1401
+ process.stdin.removeListener('data', onData);
1402
+ process.stdin.setRawMode(wasRaw || false);
1403
+ execListenerActive = false;
1404
+ approval.setExecutionHooks({}); // clear hooks
1405
+ rl.resume();
1406
+ };
1407
+ }
1408
+
1409
+ try {
1410
+ startContentStream();
1411
+
1412
+ const execContext = { cwd: safeCwd() };
1413
+ if (skipPerms) execContext.freeswim = true;
1414
+ if (projectSkeleton) execContext.project_skeleton = projectSkeleton;
1415
+
1416
+ for await (const event of client.execute(input, execContext, session.history)) {
1417
+ renderEvent(event);
1418
+
1419
+ if (event.type === 'content_partial') {
1420
+ const text = event.data?.text || '';
1421
+ assistantContent += text;
1422
+ jsonlWriter.accumulateContent(text);
1423
+ } else if (event.type === 'content') {
1424
+ const text = event.data?.text || '';
1425
+ const newText = assistantContent && text.startsWith(assistantContent)
1426
+ ? text.slice(assistantContent.length)
1427
+ : text === assistantContent ? '' : text;
1428
+ if (text) {
1429
+ assistantContent = assistantContent && !text.startsWith(assistantContent)
1430
+ ? assistantContent + text
1431
+ : text;
1432
+ }
1433
+ if (newText) jsonlWriter.accumulateContent(newText);
1434
+ }
1435
+
1436
+ // Local JSONL: capture session ID from backend
1437
+ if (event.type === 'session_info' && event.data?.session_id) {
1438
+ jsonlWriter.setSessionId(event.data.session_id);
1439
+ }
1440
+
1441
+ // Local JSONL: accumulate tool calls
1442
+ if (event.type === 'tool_call' || event.type === 'tool_request') {
1443
+ const d = event.data || {};
1444
+ jsonlWriter.accumulateToolCall(d.call_id || d.request_id, d.tool, d.args);
1445
+ }
1446
+
1447
+ // Local JSONL: record tool results
1448
+ if (event.type === 'tool_done' || event.type === 'tool_result') {
1449
+ const d = event.data || {};
1450
+ jsonlWriter.recordToolResult(d.call_id || d._callId, d.output, d.success === false);
1451
+ }
1452
+
1453
+ // Local JSONL: flush assistant turn on complete
1454
+ if (event.type === 'complete') {
1455
+ jsonlWriter.setTurnUsage(event.data?.usage, session.model);
1456
+ jsonlWriter.flushAssistantTurn();
1457
+ }
1458
+ }
1459
+
1460
+ flushContent();
1461
+ } catch (err) {
1462
+ inPlace('');
1463
+ flushContent();
1464
+ process.stderr.write(` ${c.red('Error: ' + err.message)}\n`);
1465
+ } finally {
1466
+ // Clean up execution keypress listener
1467
+ if (keypressCleanup) keypressCleanup();
1468
+ }
1469
+
1470
+ if (assistantContent) {
1471
+ session.history.push({ role: 'assistant', content: assistantContent });
1472
+ sessionMgr.saveMessage('assistant', assistantContent);
1473
+ }
1474
+
1475
+ showPrompt();
1476
+ });
1477
+
1478
+ rl.on('close', async () => {
1479
+ stopSpinner();
1480
+ await jsonlWriter.close();
1481
+ process.stderr.write(`\n ${c.dim('session ended')}\n\n`);
1482
+ process.exit(0);
1483
+ });
1484
+ }