@bahulam/code 2.6.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.
Files changed (273) hide show
  1. package/README.md +80 -0
  2. package/package.json +49 -0
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/benchmark/route.ts +113 -0
  6. package/pulse/app/api/benchmarks/route.ts +195 -0
  7. package/pulse/app/api/costs/route.ts +88 -0
  8. package/pulse/app/api/export/route.ts +77 -0
  9. package/pulse/app/api/history/route.ts +11 -0
  10. package/pulse/app/api/import/route.ts +31 -0
  11. package/pulse/app/api/memory/route.ts +50 -0
  12. package/pulse/app/api/plans/route.ts +9 -0
  13. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  14. package/pulse/app/api/projects/route.ts +121 -0
  15. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  16. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  17. package/pulse/app/api/sessions/route.ts +112 -0
  18. package/pulse/app/api/settings/route.ts +14 -0
  19. package/pulse/app/api/stats/route.ts +143 -0
  20. package/pulse/app/api/todos/route.ts +9 -0
  21. package/pulse/app/api/tools/route.ts +160 -0
  22. package/pulse/app/benchmarks/page.tsx +224 -0
  23. package/pulse/app/costs/page.tsx +179 -0
  24. package/pulse/app/export/page.tsx +465 -0
  25. package/pulse/app/favicon.ico +0 -0
  26. package/pulse/app/globals.css +263 -0
  27. package/pulse/app/help/page.tsx +143 -0
  28. package/pulse/app/history/page.tsx +157 -0
  29. package/pulse/app/layout.tsx +46 -0
  30. package/pulse/app/memory/page.tsx +365 -0
  31. package/pulse/app/overview-client.tsx +393 -0
  32. package/pulse/app/page.tsx +14 -0
  33. package/pulse/app/plans/page.tsx +308 -0
  34. package/pulse/app/projects/[slug]/page.tsx +390 -0
  35. package/pulse/app/projects/page.tsx +110 -0
  36. package/pulse/app/sessions/[id]/page.tsx +243 -0
  37. package/pulse/app/sessions/page.tsx +39 -0
  38. package/pulse/app/settings/page.tsx +188 -0
  39. package/pulse/app/todos/page.tsx +211 -0
  40. package/pulse/app/tools/page.tsx +249 -0
  41. package/pulse/cli.js +164 -0
  42. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  43. package/pulse/components/activity/streak-card.tsx +36 -0
  44. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  45. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  46. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  47. package/pulse/components/costs/model-token-table.tsx +60 -0
  48. package/pulse/components/global-search.tsx +193 -0
  49. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  50. package/pulse/components/layout/bottom-nav.tsx +53 -0
  51. package/pulse/components/layout/client-layout.tsx +31 -0
  52. package/pulse/components/layout/sidebar-context.tsx +50 -0
  53. package/pulse/components/layout/sidebar.tsx +183 -0
  54. package/pulse/components/layout/top-bar.tsx +121 -0
  55. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  56. package/pulse/components/overview/conversation-table.tsx +148 -0
  57. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  58. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  59. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  60. package/pulse/components/overview/stat-card.tsx +102 -0
  61. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  62. package/pulse/components/projects/project-card.tsx +175 -0
  63. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  64. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  65. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  66. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  67. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  68. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  69. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  70. package/pulse/components/sessions/session-badges.tsx +49 -0
  71. package/pulse/components/sessions/session-table.tsx +299 -0
  72. package/pulse/components/theme-provider.tsx +44 -0
  73. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  74. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  75. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  76. package/pulse/components/tools/version-history-table.tsx +32 -0
  77. package/pulse/components/ui/alert.tsx +66 -0
  78. package/pulse/components/ui/badge.tsx +48 -0
  79. package/pulse/components/ui/breadcrumb.tsx +109 -0
  80. package/pulse/components/ui/button.tsx +64 -0
  81. package/pulse/components/ui/calendar.tsx +220 -0
  82. package/pulse/components/ui/card.tsx +92 -0
  83. package/pulse/components/ui/command.tsx +158 -0
  84. package/pulse/components/ui/dialog.tsx +158 -0
  85. package/pulse/components/ui/input.tsx +21 -0
  86. package/pulse/components/ui/popover.tsx +89 -0
  87. package/pulse/components/ui/progress.tsx +31 -0
  88. package/pulse/components/ui/select.tsx +190 -0
  89. package/pulse/components/ui/separator.tsx +28 -0
  90. package/pulse/components/ui/sheet.tsx +143 -0
  91. package/pulse/components/ui/skeleton.tsx +13 -0
  92. package/pulse/components/ui/table.tsx +116 -0
  93. package/pulse/components/ui/tabs.tsx +91 -0
  94. package/pulse/components/ui/tooltip.tsx +57 -0
  95. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  96. package/pulse/components.json +23 -0
  97. package/pulse/eslint.config.mjs +18 -0
  98. package/pulse/lib/bahulam-paths.ts +23 -0
  99. package/pulse/lib/claude-reader.ts +592 -0
  100. package/pulse/lib/decode.ts +129 -0
  101. package/pulse/lib/pricing.ts +102 -0
  102. package/pulse/lib/replay-parser.ts +165 -0
  103. package/pulse/lib/tool-categories.ts +127 -0
  104. package/pulse/lib/utils.ts +6 -0
  105. package/pulse/next-env.d.ts +6 -0
  106. package/pulse/next.config.ts +16 -0
  107. package/pulse/package.json +45 -0
  108. package/pulse/postcss.config.mjs +7 -0
  109. package/pulse/public/activity.png +0 -0
  110. package/pulse/public/cc-lens.png +0 -0
  111. package/pulse/public/command-k.png +0 -0
  112. package/pulse/public/costs.png +0 -0
  113. package/pulse/public/dashboard-dark.png +0 -0
  114. package/pulse/public/dashboard-white.png +0 -0
  115. package/pulse/public/export.png +0 -0
  116. package/pulse/public/file.svg +1 -0
  117. package/pulse/public/globe.svg +1 -0
  118. package/pulse/public/next.svg +1 -0
  119. package/pulse/public/projects.png +0 -0
  120. package/pulse/public/session-chat.png +0 -0
  121. package/pulse/public/todos.png +0 -0
  122. package/pulse/public/tools.png +0 -0
  123. package/pulse/public/vercel.svg +1 -0
  124. package/pulse/public/window.svg +1 -0
  125. package/pulse/tsconfig.json +34 -0
  126. package/pulse/types/claude.ts +294 -0
  127. package/src/agents/loader.mjs +94 -0
  128. package/src/agents/multi_workflow_loader.mjs +330 -0
  129. package/src/agents/parser.mjs +205 -0
  130. package/src/agents/scaffold.mjs +222 -0
  131. package/src/agents/teams.mjs +123 -0
  132. package/src/agents/workflow_loader.mjs +122 -0
  133. package/src/agents/workflow_scaffold.mjs +249 -0
  134. package/src/auth/oauth.mjs +220 -0
  135. package/src/auth/tarang-auth.mjs +306 -0
  136. package/src/commands/agent.mjs +220 -0
  137. package/src/commands/workflow.mjs +581 -0
  138. package/src/config/cli-args.mjs +200 -0
  139. package/src/config/env.mjs +263 -0
  140. package/src/config/hook-runner.mjs +100 -0
  141. package/src/config/memory-loader.mjs +32 -0
  142. package/src/config/settings-loader.mjs +45 -0
  143. package/src/config/settings.mjs +132 -0
  144. package/src/context/ast-parser.mjs +298 -0
  145. package/src/context/bm25.mjs +85 -0
  146. package/src/context/retriever.mjs +308 -0
  147. package/src/context/skeleton.mjs +134 -0
  148. package/src/context/symbol-indexer.mjs +375 -0
  149. package/src/core/agent-history.mjs +111 -0
  150. package/src/core/agent-loop.mjs +486 -0
  151. package/src/core/approval-log.mjs +104 -0
  152. package/src/core/approval.mjs +476 -0
  153. package/src/core/attachments.mjs +380 -0
  154. package/src/core/backend-url.mjs +55 -0
  155. package/src/core/cache-control.mjs +92 -0
  156. package/src/core/cache.mjs +105 -0
  157. package/src/core/callback-client.mjs +180 -0
  158. package/src/core/checkpoints.mjs +142 -0
  159. package/src/core/compact-history.mjs +127 -0
  160. package/src/core/context-envelope.mjs +54 -0
  161. package/src/core/context-manager.mjs +198 -0
  162. package/src/core/error-guidance.mjs +311 -0
  163. package/src/core/file-diff.mjs +217 -0
  164. package/src/core/headless.mjs +448 -0
  165. package/src/core/hooks-manager.mjs +87 -0
  166. package/src/core/jsonl-writer.mjs +449 -0
  167. package/src/core/local-agent.mjs +537 -0
  168. package/src/core/local-store.mjs +836 -0
  169. package/src/core/mode-selector.mjs +51 -0
  170. package/src/core/output-filter.mjs +177 -0
  171. package/src/core/paths.mjs +190 -0
  172. package/src/core/policy-resolver.mjs +156 -0
  173. package/src/core/pricing.mjs +336 -0
  174. package/src/core/project-artifacts.mjs +39 -0
  175. package/src/core/project-context-loader.mjs +139 -0
  176. package/src/core/providers.mjs +219 -0
  177. package/src/core/rate-limit-display.mjs +121 -0
  178. package/src/core/rate-limiter.mjs +119 -0
  179. package/src/core/resume-mode.mjs +192 -0
  180. package/src/core/risk-tier.mjs +337 -0
  181. package/src/core/safety.mjs +203 -0
  182. package/src/core/scheduler.mjs +173 -0
  183. package/src/core/session-manager.mjs +360 -0
  184. package/src/core/session.mjs +143 -0
  185. package/src/core/settings-sync.mjs +85 -0
  186. package/src/core/stagnation.mjs +57 -0
  187. package/src/core/stream-client.mjs +829 -0
  188. package/src/core/streaming.mjs +182 -0
  189. package/src/core/system-prompt.mjs +140 -0
  190. package/src/core/tasks.mjs +196 -0
  191. package/src/core/tool-executor.mjs +1950 -0
  192. package/src/core/trust.mjs +158 -0
  193. package/src/core/work-scope.mjs +248 -0
  194. package/src/hooks/engine.mjs +162 -0
  195. package/src/index.mjs +426 -0
  196. package/src/mcp/client.mjs +253 -0
  197. package/src/mcp/transport-shttp.mjs +130 -0
  198. package/src/mcp/transport-sse.mjs +131 -0
  199. package/src/mcp/transport-ws.mjs +134 -0
  200. package/src/onboarding/preflight.mjs +360 -0
  201. package/src/permissions/checker.mjs +57 -0
  202. package/src/permissions/command-classifier.mjs +652 -0
  203. package/src/permissions/injection-check.mjs +60 -0
  204. package/src/permissions/path-check.mjs +102 -0
  205. package/src/permissions/prompt.mjs +73 -0
  206. package/src/permissions/sandbox.mjs +112 -0
  207. package/src/plugins/loader.mjs +138 -0
  208. package/src/skills/installer.mjs +188 -0
  209. package/src/skills/loader.mjs +252 -0
  210. package/src/skills/runner.mjs +55 -0
  211. package/src/state/orbit.mjs +263 -0
  212. package/src/state/verbosity.mjs +99 -0
  213. package/src/telemetry/index.mjs +96 -0
  214. package/src/terminal/agents.mjs +177 -0
  215. package/src/terminal/analytics.mjs +292 -0
  216. package/src/terminal/ansi.mjs +695 -0
  217. package/src/terminal/init.mjs +145 -0
  218. package/src/terminal/main.mjs +269 -0
  219. package/src/terminal/repl-explore.mjs +35 -0
  220. package/src/terminal/repl-format.mjs +257 -0
  221. package/src/terminal/repl-render.mjs +561 -0
  222. package/src/terminal/repl-resume.mjs +625 -0
  223. package/src/terminal/repl-state.mjs +103 -0
  224. package/src/terminal/repl-utils.mjs +34 -0
  225. package/src/terminal/repl.mjs +3832 -0
  226. package/src/terminal/skills.mjs +54 -0
  227. package/src/terminal/tool-display.mjs +240 -0
  228. package/src/tools/agent.mjs +137 -0
  229. package/src/tools/ask-user.mjs +61 -0
  230. package/src/tools/bash.mjs +231 -0
  231. package/src/tools/cron-create.mjs +120 -0
  232. package/src/tools/cron-delete.mjs +49 -0
  233. package/src/tools/cron-list.mjs +37 -0
  234. package/src/tools/edit.mjs +82 -0
  235. package/src/tools/enter-worktree.mjs +69 -0
  236. package/src/tools/exit-worktree.mjs +57 -0
  237. package/src/tools/glob.mjs +117 -0
  238. package/src/tools/grep.mjs +129 -0
  239. package/src/tools/lint.mjs +71 -0
  240. package/src/tools/ls.mjs +58 -0
  241. package/src/tools/lsp.mjs +115 -0
  242. package/src/tools/multi-edit.mjs +94 -0
  243. package/src/tools/notebook-edit.mjs +96 -0
  244. package/src/tools/project-overview.mjs +641 -0
  245. package/src/tools/read-mcp-resource.mjs +57 -0
  246. package/src/tools/read.mjs +138 -0
  247. package/src/tools/registry.mjs +116 -0
  248. package/src/tools/remote-trigger.mjs +84 -0
  249. package/src/tools/send-message.mjs +64 -0
  250. package/src/tools/skill.mjs +52 -0
  251. package/src/tools/test-runner.mjs +49 -0
  252. package/src/tools/todo-write.mjs +68 -0
  253. package/src/tools/tool-search.mjs +77 -0
  254. package/src/tools/web-fetch.mjs +65 -0
  255. package/src/tools/web-search.mjs +89 -0
  256. package/src/tools/write.mjs +55 -0
  257. package/src/ui/approval.mjs +263 -0
  258. package/src/ui/banner.mjs +235 -0
  259. package/src/ui/commands.mjs +537 -0
  260. package/src/ui/formatter.mjs +409 -0
  261. package/src/ui/icons.mjs +164 -0
  262. package/src/ui/input-dock.mjs +444 -0
  263. package/src/ui/markdown.mjs +278 -0
  264. package/src/ui/mission-report.mjs +296 -0
  265. package/src/ui/palette.mjs +189 -0
  266. package/src/ui/slash-commands.mjs +245 -0
  267. package/src/ui/spinner.mjs +116 -0
  268. package/src/ui/sub-agent.mjs +152 -0
  269. package/src/ui/term.mjs +159 -0
  270. package/src/ui/text-layout.mjs +127 -0
  271. package/src/ui/tool-card.mjs +463 -0
  272. package/src/ui/tool-details.mjs +312 -0
  273. package/src/ui/transcript-block.mjs +21 -0
@@ -0,0 +1,3832 @@
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 fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import { execSync as _execSync } from 'node:child_process';
22
+ import { c, progressBar, spinner, inPlace, renderMarkdown, renderDiff, formatElapsed, formatCost, stripAnsi } from './ansi.mjs';
23
+ import { calculateCost, formatCostValue, formatTokens, costToCredits, formatCredits } from '../core/pricing.mjs';
24
+ import { TarangStreamClient, EVENT_TYPES } from '../core/stream-client.mjs';
25
+ import { AgentHistoryTurnBuilder } from '../core/agent-history.mjs';
26
+ import { JsonlWriter } from '../core/jsonl-writer.mjs';
27
+ import { createToolExecutor } from '../core/tool-executor.mjs';
28
+ import { buildWorkScope, promptProjectRoots } from '../core/work-scope.mjs';
29
+ import { CheckpointManager } from '../core/checkpoints.mjs';
30
+ import { HookRunner } from '../config/hook-runner.mjs';
31
+ import { runPreflight } from '../onboarding/preflight.mjs';
32
+ import { printBanner as printBrandedBanner } from '../ui/banner.mjs';
33
+ import { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
34
+ import {
35
+ getVerbosity,
36
+ setVerbosity,
37
+ label as verbosityLabel,
38
+ MODES as V_MODES,
39
+ } from '../state/verbosity.mjs';
40
+ import { persistProjectArtifacts } from '../core/project-artifacts.mjs';
41
+ import { TarangAuth } from '../auth/tarang-auth.mjs';
42
+ import { ApprovalManager } from '../core/approval.mjs';
43
+ import { resolveBackendUrl } from '../core/backend-url.mjs';
44
+ import { formatMessageWindow, lowWindowStatus, messagesRemaining } from '../core/rate-limit-display.mjs';
45
+ import { formatAgentErrorGuidance } from '../core/error-guidance.mjs';
46
+ import { BUILTIN_AGENTS, runAgent } from './agents.mjs';
47
+ import { createAgentFile, isVsCodeTerminal, listLocalAgents, openAgentFile, syncAgentsToBackend } from '../agents/scaffold.mjs';
48
+ import { SessionManager } from '../core/session-manager.mjs';
49
+ import { parseArgs } from '../config/cli-args.mjs';
50
+ import { loadEffectivePolicy, formatPolicySourceRows } from '../core/policy-resolver.mjs';
51
+ import { loadProjectContext } from '../core/project-context-loader.mjs';
52
+ import { buildContextEnvelope } from '../core/context-envelope.mjs';
53
+ import { buildResumeHistory, combineResumeSummaries, getRecentSessions, getSessionDetail, getTranscriptProjectRoots } from '../core/local-store.mjs';
54
+ import { decideResumeMode, projectedTokensForChoice, formatTokens as formatCtxTokens } from '../core/resume-mode.mjs';
55
+ import { appendTask, ensureTaskFiles, loadTaskBoard, moveTask, removeTask, taskCounts, TASK_FILES, updateTask } from '../core/tasks.mjs';
56
+ import { applyCompactSummary, localCompactSummary, parseCompactTailCount, prepareCompactHistory } from '../core/compact-history.mjs';
57
+ import {
58
+ appendVisionAnalysisToInstruction,
59
+ attachmentSummaryLine,
60
+ prepareImageAttachments,
61
+ publicAttachmentMetadata,
62
+ resolveAttachmentPath,
63
+ writeClipboardImageToTemp,
64
+ } from '../core/attachments.mjs';
65
+ import { toolDisplayLabel, toolDisplaySummary } from './tool-display.mjs';
66
+ import { exploreCategory, exploreCollapseEnabled, isExploreTool } from './repl-explore.mjs';
67
+ import { session, orbitRef, sessionMgrRef, runtime } from './repl-state.mjs';
68
+ import { safeCwd } from './repl-utils.mjs';
69
+ import {
70
+ appendContent,
71
+ clearPendingHead,
72
+ clippedThinking,
73
+ expandIndex,
74
+ expandLast,
75
+ flushContent,
76
+ flushExploreRun,
77
+ flushPendingHead,
78
+ isInlineOutcomeTool,
79
+ renderBlockBoundary,
80
+ renderExploreRun,
81
+ renderStagnation,
82
+ renderToolCall,
83
+ renderToolResult,
84
+ startContentStream,
85
+ startSpinner,
86
+ stopSpinner,
87
+ thinkingPrefix,
88
+ updateSpinner,
89
+ } from './repl-render.mjs';
90
+ import {
91
+ chooseResumeHistoryMode,
92
+ chooseThresholdMode,
93
+ compactCurrentSession,
94
+ confirmCwdSwitch,
95
+ formatResumeCheckpointStatus,
96
+ formatResumeContextStatus,
97
+ listResumableSessions,
98
+ pickResumableSession,
99
+ previewResumeSession,
100
+ renderResumePreview,
101
+ summarizeResumeTranscript,
102
+ } from './repl-resume.mjs';
103
+ import {
104
+ endStatusMarker,
105
+ filterResumeReplayEvents,
106
+ fitAnsiLine,
107
+ formatRelativeTime,
108
+ formatSessionCost,
109
+ historyRoleLabel,
110
+ mergeResumeReplayItems,
111
+ messageCountLabel,
112
+ normalizeResumableSession,
113
+ oneLineInstruction,
114
+ renderHistoryEntries,
115
+ replayStartOrderForMode,
116
+ resumeModeLabel,
117
+ resumeProgressBar,
118
+ resumeTailTurnCount,
119
+ sessionListTimestamp,
120
+ startResumeProgress,
121
+ } from './repl-format.mjs';
122
+ import {
123
+ COMMANDS,
124
+ HELP_GROUPS,
125
+ HELP_GROUP_ALIASES,
126
+ LEGACY_COMMAND_HINTS,
127
+ NAMESPACED_COMMANDS,
128
+ normalizeCommandInput,
129
+ } from '../ui/slash-commands.mjs';
130
+ import { createOrbit } from '../state/orbit.mjs';
131
+ import {
132
+ clearInputPrompt,
133
+ focusDockInput,
134
+ isInputDockMounted,
135
+ mountInputDock,
136
+ moveToContent,
137
+ prepareInputPrompt,
138
+ redrawDockFrame,
139
+ renderDockInput,
140
+ unmountInputDock,
141
+ } from '../ui/input-dock.mjs';
142
+ import { term } from '../ui/term.mjs';
143
+ import { transcriptHeader, transcriptLine } from '../ui/transcript-block.mjs';
144
+ import {
145
+ formatCardHead,
146
+ formatCompactFileDiff,
147
+ summarizeResult,
148
+ recordCard,
149
+ lastCard,
150
+ getCard,
151
+ allCards,
152
+ clearCards,
153
+ } from '../ui/tool-card.mjs';
154
+ import { detailFor } from '../ui/tool-details.mjs';
155
+ import { paint } from '../ui/palette.mjs';
156
+ import {
157
+ renderSubAgentOpen,
158
+ renderSubAgentClose,
159
+ subAgentIndent,
160
+ inSubAgent as inSubAgentBlock,
161
+ resetSubAgents,
162
+ } from '../ui/sub-agent.mjs';
163
+
164
+ import { createRequire } from 'node:module';
165
+ const __require = createRequire(import.meta.url);
166
+ const VERSION = __require('../../package.json').version;
167
+
168
+ // ── Safe CWD ──
169
+ // If the working directory gets deleted (by a rogue tool call),
170
+ // process.cwd() throws ENOENT. Detect and recover.
171
+
172
+ // safeCwd() moved to ./repl-utils.mjs.
173
+
174
+ // messageCountLabel, sessionListTimestamp, oneLineInstruction, fitAnsiLine
175
+ // moved to ./repl-format.mjs. Imported at the top of this file.
176
+
177
+ // normalizeResumableSession moved to ./repl-format.mjs.
178
+
179
+
180
+ // resumeTailTurnCount, replayStartOrderForMode, filterResumeReplayEvents,
181
+ // mergeResumeReplayItems, resumeProgressBar moved to ./repl-format.mjs.
182
+
183
+ // startResumeProgress moved to ./repl-format.mjs.
184
+
185
+ // ── Session State ──
186
+
187
+ // sessionMgrRef.current + orbitRef.current live in ./repl-state.mjs;
188
+ // assigned below at startup by startTerminalRepl().
189
+
190
+ // The `session` object lives in repl-state.mjs so other repl-* modules
191
+ // (resume helpers, tool renderers, streaming, etc.) can import it during
192
+ // the ongoing split. Everything below still references `session.<field>`
193
+ // directly; only the declaration site moved.
194
+
195
+ // ── Commands ──
196
+
197
+ // COMMANDS + HELP_GROUPS + HELP_GROUP_ALIASES + LEGACY_COMMAND_HINTS +
198
+ // NAMESPACED_COMMANDS + normalizeCommandInput are now the canonical
199
+ // catalog in ui/slash-commands.mjs. See imports at the top of this file.
200
+
201
+
202
+ function renderHelp(topic = '') {
203
+ const key = String(topic || '').trim().toLowerCase();
204
+ if (!key) {
205
+ process.stderr.write(`\n ${c.bold('Bahulam Code Commands')}\n`);
206
+ process.stderr.write(` ${c.gray('─'.repeat(52))}\n`);
207
+ const top = [
208
+ ['/help', 'Grouped command help'],
209
+ ['/status', 'Session snapshot'],
210
+ ['/plan', 'Task list and plan'],
211
+ ['/tasks', 'Project task files'],
212
+ ['/history', 'Transcript, approvals, undo'],
213
+ ['/settings', 'Policy, auth, verbosity'],
214
+ ['/why', 'Explain last reasoning'],
215
+ ];
216
+ for (const [name, desc] of top) {
217
+ process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}\n`);
218
+ }
219
+ process.stderr.write(`\n ${c.bold('Categories')}\n`);
220
+ for (const group of HELP_GROUPS) {
221
+ process.stderr.write(` ${c.brand(('/help ' + group.key).padEnd(20))} ${c.dim(group.summary)}\n`);
222
+ }
223
+ process.stderr.write(`\n ${c.dim('Use /help all for legacy command aliases.')}\n`);
224
+ renderKeyboardHelp();
225
+ return;
226
+ }
227
+
228
+ if (key === 'all' || key === 'commands') {
229
+ process.stderr.write(`\n ${c.bold('All Commands')}\n`);
230
+ process.stderr.write(` ${c.gray('─'.repeat(52))}\n`);
231
+ for (const [name, desc] of Object.entries(COMMANDS)) {
232
+ const alias = LEGACY_COMMAND_HINTS[name] ? c.dim(` alias for ${LEGACY_COMMAND_HINTS[name]}`) : '';
233
+ process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}${alias}\n`);
234
+ }
235
+ process.stderr.write('\n');
236
+ return;
237
+ }
238
+
239
+ const group = HELP_GROUP_ALIASES.get(key);
240
+ if (!group) {
241
+ process.stderr.write(` ${c.gray(`Unknown help category: ${key}. Use /help.`)}\n`);
242
+ return;
243
+ }
244
+
245
+ process.stderr.write(`\n ${c.bold(group.title)} ${c.dim(group.summary)}\n`);
246
+ process.stderr.write(` ${c.gray('─'.repeat(52))}\n`);
247
+ for (const [name, desc] of group.commands) {
248
+ process.stderr.write(` ${c.brand(name.padEnd(30))} ${desc}\n`);
249
+ }
250
+ process.stderr.write('\n');
251
+ }
252
+
253
+ function renderKeyboardHelp() {
254
+ process.stderr.write(`\n ${c.bold('Keyboard')}\n`);
255
+ process.stderr.write(` ${c.gray('Ctrl+C')} exit ${c.gray('↑↓')} history ${c.gray('Tab')} autocomplete\n`);
256
+ process.stderr.write(` ${c.gray('Ctrl+D')} expand last tool ${c.gray('Space')} pause/resume ${c.gray('Esc')} interrupt\n\n`);
257
+ }
258
+
259
+ const MODEL_ROLE_ALIASES = new Map([
260
+ ['reasoning', 'reasoning'],
261
+ ['main', 'reasoning'],
262
+ ['coder', 'reasoning'],
263
+ ['coding', 'reasoning'],
264
+ ['smart', 'reasoning'],
265
+ ['fast', 'fast'],
266
+ ['explorer', 'fast'],
267
+ ['orchestrator', 'orchestrator'],
268
+ ['planner', 'orchestrator'],
269
+ ['local', 'local'],
270
+ ['worker', 'worker'],
271
+ ['explore', 'explore'],
272
+ ['plan', 'plan'],
273
+ ['verify', 'verify'],
274
+ ['debug', 'debug'],
275
+ ['refactor', 'refactor'],
276
+ ]);
277
+
278
+ const MODEL_ROLE_LABELS = {
279
+ reasoning: 'coding',
280
+ fast: 'fast',
281
+ orchestrator: 'orchestrator',
282
+ local: 'local',
283
+ worker: 'worker',
284
+ explore: 'explore',
285
+ plan: 'plan',
286
+ verify: 'verify',
287
+ debug: 'debug',
288
+ refactor: 'refactor',
289
+ };
290
+
291
+ const MODEL_ROLE_ORDER = [
292
+ 'reasoning',
293
+ 'fast',
294
+ 'orchestrator',
295
+ 'local',
296
+ 'worker',
297
+ 'explore',
298
+ 'plan',
299
+ 'verify',
300
+ 'debug',
301
+ 'refactor',
302
+ ];
303
+
304
+ function normalizeModelRole(value) {
305
+ return MODEL_ROLE_ALIASES.get(String(value || '').trim().toLowerCase()) || null;
306
+ }
307
+
308
+ function sessionModelOverrideEntries() {
309
+ return Object.entries(session.modelOverrides || {})
310
+ .filter(([, model]) => typeof model === 'string' && model.trim())
311
+ .sort(([a], [b]) => MODEL_ROLE_ORDER.indexOf(a) - MODEL_ROLE_ORDER.indexOf(b));
312
+ }
313
+
314
+ function printModelCommandUsage() {
315
+ process.stderr.write(` ${c.gray('Usage:')} /model [model]\n`);
316
+ process.stderr.write(` /model <role> <model>\n`);
317
+ process.stderr.write(` /model clear [role]\n`);
318
+ process.stderr.write(` ${c.gray('Roles:')} ${MODEL_ROLE_ORDER.map(role => MODEL_ROLE_LABELS[role]).join(', ')}\n`);
319
+ }
320
+
321
+ function printModelStatus() {
322
+ process.stderr.write(`\n ${c.bold('Models')}\n`);
323
+ process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
324
+ process.stderr.write(` ${c.gray('Active coding')} ${session.model || 'backend default'}\n`);
325
+
326
+ const limits = session.modelLimits || {};
327
+ const rows = [
328
+ ['coder', limits.coder?.model],
329
+ ['explorer', limits.explorer?.model],
330
+ ['orchestrator', limits.orchestrator?.model],
331
+ ].filter(([, model]) => model);
332
+ if (rows.length) {
333
+ process.stderr.write(`\n ${c.bold('Backend roles')}\n`);
334
+ for (const [role, model] of rows) {
335
+ process.stderr.write(` ${c.brand(role.padEnd(14))} ${c.dim(model)}\n`);
336
+ }
337
+ }
338
+
339
+ const overrides = sessionModelOverrideEntries();
340
+ process.stderr.write(`\n ${c.bold('Session overrides')}\n`);
341
+ if (!overrides.length) {
342
+ process.stderr.write(` ${c.dim('(none)')}\n`);
343
+ } else {
344
+ for (const [role, model] of overrides) {
345
+ process.stderr.write(` ${c.brand((MODEL_ROLE_LABELS[role] || role).padEnd(14))} ${model}\n`);
346
+ }
347
+ }
348
+ process.stderr.write('\n');
349
+ printModelCommandUsage();
350
+ }
351
+
352
+ function handleModelCommand(rest = '') {
353
+ const parts = String(rest || '').trim().split(/\s+/).filter(Boolean);
354
+ if (parts.length === 0) {
355
+ printModelStatus();
356
+ return;
357
+ }
358
+
359
+ if (parts[0] === 'clear' || parts[0] === 'reset') {
360
+ if (parts.length === 1) {
361
+ session.modelOverrides = {};
362
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Cleared all session model overrides.')}\n`);
363
+ return;
364
+ }
365
+ const role = normalizeModelRole(parts[1]);
366
+ if (!role) {
367
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`Unknown model role: ${parts[1]}`)}\n`);
368
+ printModelCommandUsage();
369
+ return;
370
+ }
371
+ delete session.modelOverrides[role];
372
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Cleared ${MODEL_ROLE_LABELS[role] || role} model override.`)}\n`);
373
+ return;
374
+ }
375
+
376
+ let role = 'reasoning';
377
+ let model = parts.join(' ');
378
+ const maybeRole = normalizeModelRole(parts[0]);
379
+ if (maybeRole && parts.length >= 2) {
380
+ role = maybeRole;
381
+ model = parts.slice(1).join(' ');
382
+ }
383
+
384
+ if (!model || normalizeModelRole(model)) {
385
+ printModelCommandUsage();
386
+ return;
387
+ }
388
+
389
+ session.modelOverrides = { ...(session.modelOverrides || {}), [role]: model };
390
+ if (role === 'reasoning') session.model = model;
391
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Session ${MODEL_ROLE_LABELS[role] || role} model override:`)} ${c.brand(model)}\n`);
392
+ process.stderr.write(` ${c.dim('Use /model clear or /model clear <role> to return to backend settings.')}\n`);
393
+ }
394
+
395
+ function stripPathQuotes(value) {
396
+ const text = String(value || '').trim();
397
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
398
+ return text.slice(1, -1);
399
+ }
400
+ return text;
401
+ }
402
+
403
+ function pendingVisionPaths(ctx) {
404
+ if (!Array.isArray(ctx.pendingVisionPaths)) ctx.pendingVisionPaths = [];
405
+ return ctx.pendingVisionPaths;
406
+ }
407
+
408
+ function printPendingAttachments(ctx) {
409
+ const pending = pendingVisionPaths(ctx);
410
+ if (!pending.length) {
411
+ process.stderr.write(` ${c.gray('No pending image attachments.')}\n`);
412
+ return;
413
+ }
414
+ process.stderr.write(`\n ${c.bold('Pending Attachments')}\n`);
415
+ for (const filePath of pending) {
416
+ process.stderr.write(` ${c.brand('◇')} ${filePath}\n`);
417
+ }
418
+ process.stderr.write(` ${c.dim('They will be sent for vision analysis with your next prompt.')}\n`);
419
+ }
420
+
421
+ function handleAttachCommand(rest = '', ctx) {
422
+ const pending = pendingVisionPaths(ctx);
423
+ const value = stripPathQuotes(rest);
424
+ if (!value) {
425
+ process.stderr.write(` ${c.yellow('Usage:')} /attach <image-path> ${c.dim('or')} /attach clipboard\n`);
426
+ return;
427
+ }
428
+ if (value === 'clear') {
429
+ pending.length = 0;
430
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Cleared pending image attachments.')}\n`);
431
+ return;
432
+ }
433
+ if (['clipboard', '--clipboard', 'paste', '--paste'].includes(value.toLowerCase())) {
434
+ try {
435
+ const filePath = writeClipboardImageToTemp();
436
+ pending.push(filePath);
437
+ process.stderr.write(` ${c.green('✓')} ${c.dim('attached clipboard image for next prompt:')} ${c.brand(path.basename(filePath))}\n`);
438
+ } catch (err) {
439
+ process.stderr.write(` ${c.red('✗')} ${c.dim(err.message || String(err))}\n`);
440
+ }
441
+ return;
442
+ }
443
+ const resolved = resolveAttachmentPath(value, safeCwd());
444
+ pending.push(resolved);
445
+ process.stderr.write(` ${c.green('✓')} ${c.dim('attached for next prompt:')} ${c.brand(path.basename(resolved))}\n`);
446
+ }
447
+
448
+ function handleAttachmentsCommand(rest = '', ctx) {
449
+ const action = String(rest || '').trim().toLowerCase();
450
+ if (action === 'clear') {
451
+ pendingVisionPaths(ctx).length = 0;
452
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Cleared pending image attachments.')}\n`);
453
+ return;
454
+ }
455
+ printPendingAttachments(ctx);
456
+ }
457
+
458
+ async function confirmVisionUpload(ctx, attachments, { skip = false } = {}) {
459
+ if (skip || process.env.KEPLER_VISION_CONFIRM === '0' || process.env.KEPLER_VISION_CONFIRM === 'false') {
460
+ return true;
461
+ }
462
+ if (!ctx?._rl || !process.stdin.isTTY) return false;
463
+ const names = attachments.map(a => a.name).join(', ');
464
+ return await new Promise(resolve => {
465
+ ctx._rl.question(` ${c.yellow('Upload image for vision analysis?')} ${c.dim(names)} ${c.dim('[y/N]')} `, answer => {
466
+ resolve(/^y(?:es)?$/i.test(String(answer || '').trim()));
467
+ });
468
+ });
469
+ }
470
+
471
+ function parseSimpleFlags(parts) {
472
+ const flags = {};
473
+ const positional = [];
474
+ for (let i = 0; i < parts.length; i++) {
475
+ const part = parts[i];
476
+ if (part.startsWith('--')) {
477
+ const key = part.slice(2);
478
+ const next = parts[i + 1];
479
+ if (!next || next.startsWith('--')) {
480
+ flags[key] = true;
481
+ } else {
482
+ flags[key] = next;
483
+ i++;
484
+ }
485
+ } else {
486
+ positional.push(part);
487
+ }
488
+ }
489
+ return { flags, positional };
490
+ }
491
+
492
+ function printAgentsUsage() {
493
+ process.stderr.write(` ${c.gray('Usage:')} /agents\n`);
494
+ process.stderr.write(` /agents create <name> [--description text] [--role specialist] [--model id] [--tools a,b] [--open|--no-open]\n`);
495
+ process.stderr.write(` /agents edit <name>\n`);
496
+ process.stderr.write(` /agents sync [name]\n`);
497
+ }
498
+
499
+ function printAgentsList() {
500
+ const local = listLocalAgents(safeCwd());
501
+ process.stderr.write(`\n ${c.bold('Built-in Agents')}\n`);
502
+ process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
503
+ for (const agent of BUILTIN_AGENTS) {
504
+ process.stderr.write(` ${c.brand(('/' + agent.command).padEnd(14))} ${agent.description}\n`);
505
+ }
506
+
507
+ process.stderr.write(`\n ${c.bold('Local Agents')} ${c.dim('.bahulam/agents + ~/.bahulam/agents')}\n`);
508
+ process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
509
+ if (!local.length) {
510
+ process.stderr.write(` ${c.dim('(none)')}\n`);
511
+ } else {
512
+ for (const agent of local) {
513
+ const scope = agent.source_scope === 'project' ? c.green('project') : c.dim(agent.source_scope);
514
+ const model = agent.model ? c.dim(` · ${agent.model}`) : '';
515
+ process.stderr.write(` ${c.brand(agent.slug.padEnd(18))} ${scope} ${agent.description || ''}${model}\n`);
516
+ }
517
+ }
518
+ process.stderr.write('\n');
519
+ printAgentsUsage();
520
+ }
521
+
522
+ async function handleAgentsCommand(rest = '', ctx) {
523
+ const parts = String(rest || '').trim().split(/\s+/).filter(Boolean);
524
+ const action = (parts.shift() || 'list').toLowerCase();
525
+
526
+ if (action === 'list' || action === 'ls') {
527
+ printAgentsList();
528
+ return;
529
+ }
530
+
531
+ if (action === 'create' || action === 'new') {
532
+ const { flags, positional } = parseSimpleFlags(parts);
533
+ const name = positional[0];
534
+ if (!name) {
535
+ printAgentsUsage();
536
+ return;
537
+ }
538
+ try {
539
+ const created = createAgentFile({
540
+ cwd: safeCwd(),
541
+ name,
542
+ description: flags.description || flags.desc || '',
543
+ role: flags.role || 'specialist',
544
+ model: flags.model || '',
545
+ tools: flags.tools || 'read_file,search_code,list_files',
546
+ force: Boolean(flags.force),
547
+ });
548
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Created local agent:')} ${created.filePath}\n`);
549
+ const shouldOpen = !flags['no-open'] && (Boolean(flags.open) || isVsCodeTerminal());
550
+ if (shouldOpen) {
551
+ const opened = openAgentFile(created.filePath, {
552
+ allowConfiguredEditor: Boolean(flags.open),
553
+ });
554
+ if (opened.opened) {
555
+ process.stderr.write(` ${c.dim('Opened in:')} ${opened.editor}\n`);
556
+ } else {
557
+ process.stderr.write(` ${c.dim(opened.reason)}\n`);
558
+ }
559
+ }
560
+ process.stderr.write(` ${c.dim('Sync explicitly with:')} /agents sync ${created.slug}\n`);
561
+ } catch (err) {
562
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
563
+ }
564
+ return;
565
+ }
566
+
567
+ if (action === 'edit' || action === 'open') {
568
+ const target = parts.find(p => !p.startsWith('--'));
569
+ if (!target) {
570
+ printAgentsUsage();
571
+ return;
572
+ }
573
+ const local = listLocalAgents(safeCwd());
574
+ const agent = local.find(item => item.slug === target || item.name === target);
575
+ if (!agent?.source) {
576
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`No local agent found: ${target}`)}\n`);
577
+ return;
578
+ }
579
+ const opened = openAgentFile(agent.source, { allowConfiguredEditor: true });
580
+ if (opened.opened) {
581
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Opened ${agent.slug} in ${opened.editor}.`)}\n`);
582
+ } else {
583
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(opened.reason)}\n`);
584
+ process.stderr.write(` ${c.dim('Agent file:')} ${agent.source}\n`);
585
+ }
586
+ process.stderr.write(` ${c.dim('Sync after editing:')} /agents sync ${agent.slug}\n`);
587
+ return;
588
+ }
589
+
590
+ if (action === 'sync') {
591
+ const target = parts.find(p => !p.startsWith('--'));
592
+ const local = listLocalAgents(safeCwd());
593
+ const selected = target
594
+ ? local.filter(agent => agent.slug === target || agent.name === target)
595
+ : local;
596
+ if (!selected.length) {
597
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(target ? `No local agent found: ${target}` : 'No local agents to sync.')}\n`);
598
+ return;
599
+ }
600
+ try {
601
+ const creds = ctx.auth.loadCredentials();
602
+ const result = await syncAgentsToBackend({
603
+ backendUrl: creds.backendUrl,
604
+ token: creds.token,
605
+ agents: selected,
606
+ });
607
+ const synced = result.synced ?? selected.length;
608
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Synced ${synced} agent${synced === 1 ? '' : 's'} to Supabase.`)}\n`);
609
+ } catch (err) {
610
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
611
+ }
612
+ return;
613
+ }
614
+
615
+ printAgentsUsage();
616
+ }
617
+
618
+ function commandCompletions(line) {
619
+ if (line.startsWith('/help ')) {
620
+ const topic = line.slice('/help '.length).toLowerCase();
621
+ const categories = ['all', ...HELP_GROUPS.map(g => g.key)];
622
+ const hits = categories.map(c => `/help ${c}`).filter(cmd => cmd.startsWith(`/help ${topic}`));
623
+ return hits.length ? hits : categories.map(c => `/help ${c}`);
624
+ }
625
+ const top = ['/help', '/status', '/plan', '/tasks', '/history', '/settings', '/why'];
626
+ const namespaced = HELP_GROUPS.flatMap(g => g.commands.map(([name]) => name.split(/\s+/)[0]));
627
+ const all = [...new Set([...top, ...namespaced, ...Object.keys(COMMANDS), '/quit'])].sort();
628
+ const hits = all.filter(cmd => cmd.startsWith(line));
629
+ return hits.length ? hits : all;
630
+ }
631
+
632
+ function slashCommandSuggestions(line, limit = 5) {
633
+ const text = String(line || '').trimStart();
634
+ if (!text.startsWith('/')) return [];
635
+ const partial = text.split(/\s+/)[0] || '/';
636
+ return commandCompletions(partial)
637
+ .filter(cmd => cmd.startsWith('/'))
638
+ .slice(0, limit)
639
+ .map(cmd => ({
640
+ command: cmd,
641
+ description: COMMANDS[cmd] || (cmd === '/quit' ? 'Exit CLI' : ''),
642
+ }));
643
+ }
644
+
645
+ // ── Banner ──
646
+
647
+ function printBanner(auth) {
648
+ // The visual block itself lives in the branded banner module. The trailing
649
+ // status line stays here because it needs `auth`.
650
+ printBrandedBanner(VERSION);
651
+
652
+ const creds = auth.loadCredentials();
653
+ const env = process.env.TARANG_ENV || 'production';
654
+ const authStatus = creds.token ? c.green('authenticated') : c.red('/login to start');
655
+ process.stderr.write(` ${c.dim(env)} ${authStatus}\n\n`);
656
+ }
657
+
658
+ // ── Prompt Chrome ──
659
+ //
660
+ // Design: let the content breathe. The prompt area is a thin contextual
661
+ // strip — only shows what changed since last turn. No heavy borders.
662
+ //
663
+ // Layout after a response:
664
+ //
665
+ // <assistant content>
666
+ //
667
+ // ✓ 3 tools · 1.2s · $0.02 ctx 21% · 42k tok
668
+ // ╶─────────────────────────────────────────────────────────────────╴
669
+ // kepler ›
670
+ //
671
+ // Layout on first prompt (no stats yet):
672
+ //
673
+ // ╶─────────────────────────────────────────────────────────────────╴
674
+ // kepler ›
675
+
676
+ /**
677
+ * Build the contextual status strip — compact, one line.
678
+ * Left side: last-turn summary (tools, time, cost)
679
+ * Right side: session totals (ctx%, tokens)
680
+ */
681
+ function computeCacheTotals() {
682
+ let read = 0;
683
+ let write = 0;
684
+ for (const b of session.costBreakdown) {
685
+ read += b.cache_read_tokens || 0;
686
+ write += b.cache_creation_tokens || 0;
687
+ }
688
+ // OpenRouter/Anthropic/DeepSeek return `total_input_tokens` INCLUSIVE of
689
+ // cache-read tokens. session.inputTokens is that sum, so the denominator is
690
+ // just session.inputTokens (do NOT add `read` — would double-count).
691
+ const hitRate = session.inputTokens > 0
692
+ ? Math.round((read / session.inputTokens) * 100)
693
+ : 0;
694
+ return { read, write, hitRate };
695
+ }
696
+
697
+ function buildContextStrip() {
698
+ const totalTokens = session.inputTokens + session.outputTokens;
699
+ const elapsed = formatElapsed(session.startTime);
700
+ // Cache hit % lives under /cache — keep the always-on strip focused on
701
+ // volume + elapsed. Historical rate calc was double-counting the cache tokens
702
+ // vs OpenRouter's convention (see computeCacheTotals) which was misleading.
703
+ const right = [
704
+ c.dim(`${formatTokens(totalTokens)} tok`),
705
+ c.dim(elapsed),
706
+ ].join(c.dim(' · '));
707
+
708
+ return right;
709
+ }
710
+
711
+ // ── Dock meta line (model · cwd ⎇ branch · turn N) ─────────────────────
712
+ //
713
+ // The dock's meta row shows durable session context. Git branch is cached
714
+ // so we don't shell out on every keystroke; refreshed at most every 5s.
715
+
716
+ const _dockGitCache = { branch: null, at: 0, cwd: null };
717
+
718
+ function _probeGitBranch(cwd) {
719
+ const now = Date.now();
720
+ if (_dockGitCache.cwd === cwd && (now - _dockGitCache.at) < 5000) {
721
+ return _dockGitCache.branch;
722
+ }
723
+ let branch = null;
724
+ try {
725
+ // Synchronous but bounded: git head lookup is a single file read.
726
+ branch = _execSync('git branch --show-current', {
727
+ cwd, encoding: 'utf-8', timeout: 500, stdio: ['pipe', 'pipe', 'pipe'],
728
+ }).trim() || null;
729
+ } catch { branch = null; }
730
+ _dockGitCache.cwd = cwd;
731
+ _dockGitCache.at = now;
732
+ _dockGitCache.branch = branch;
733
+ return branch;
734
+ }
735
+
736
+ function buildDockMeta() {
737
+ const parts = [];
738
+
739
+ const cwd = process.cwd();
740
+ const projectName = path.basename(cwd);
741
+ const branch = _probeGitBranch(cwd);
742
+ parts.push(branch ? `${projectName} ⎇ ${branch}` : projectName);
743
+
744
+ if (session.turns > 0) {
745
+ parts.push(`turn ${session.turns}`);
746
+ }
747
+
748
+ const totalTokens = session.inputTokens + session.outputTokens;
749
+ if (totalTokens > 0) {
750
+ parts.push(`${formatTokens(totalTokens)} tok`);
751
+ }
752
+
753
+ return parts.join(' · ');
754
+ }
755
+
756
+ /**
757
+ * Print the prompt separator + prompt label.
758
+ * Minimal horizontal rule with contextual info.
759
+ */
760
+ function printPromptBlock() {
761
+ const w = process.stdout.columns || 80;
762
+ const strip = buildContextStrip();
763
+ const stripPlain = stripAnsi(strip);
764
+
765
+ // Rule with context strip right-aligned
766
+ const ruleLen = Math.max(0, w - stripPlain.length - 4);
767
+ process.stderr.write(
768
+ c.dim('╶') + c.dim('─'.repeat(ruleLen)) + ' ' + strip + ' ' + c.dim('╴') + '\n'
769
+ );
770
+ }
771
+
772
+ /**
773
+ * Print a turn summary after a response completes.
774
+ * Shows only when there's something meaningful to report.
775
+ */
776
+ /**
777
+ * Pull blocker bullet points from the completion payload — used by the
778
+ * failure variant of the mission report.
779
+ */
780
+ function extractBlockers(data) {
781
+ const out = [];
782
+ if (data?.error) out.push(String(data.error).slice(0, 160));
783
+ if (Array.isArray(data?.failed_tests)) {
784
+ for (const t of data.failed_tests.slice(0, 6)) {
785
+ if (typeof t === 'string') out.push(t);
786
+ else if (t?.name) out.push(`${t.name}${t.message ? ': ' + t.message : ''}`);
787
+ }
788
+ }
789
+ return out;
790
+ }
791
+
792
+ function printTurnSummary(toolCount, durationS, turnCost) {
793
+ const parts = [];
794
+ if (toolCount > 0) parts.push(`${toolCount} tools`);
795
+ if (durationS) parts.push(`${Number(durationS).toFixed(1)}s`);
796
+ if (parts.length > 0) {
797
+ renderBlockBoundary('status', { compactSame: true });
798
+ process.stderr.write(` ${c.green('✓')} ${c.dim(parts.join(' · '))}\n`);
799
+ runtime.lastRenderedBlock = 'status';
800
+ }
801
+ }
802
+
803
+ function formatMessageChip(rateLimit) {
804
+ const remaining = messagesRemaining(rateLimit);
805
+ if (remaining === Infinity) return 'unlimited messages';
806
+ const limit = Number(rateLimit?.msgs_per_window);
807
+ if (typeof remaining === 'number' && Number.isFinite(limit)) {
808
+ return `${remaining}/${limit} messages`;
809
+ }
810
+ return 'messages';
811
+ }
812
+
813
+ function updateStatusBar() {
814
+ // No-op: status is printed inline via printPromptBlock before each prompt
815
+ }
816
+
817
+ // ── Tool Display Renderer ──
818
+
819
+ /**
820
+ * Render a tool call as the head of a Mission Control card — icon + label +
821
+ * args. The result arrives later via `renderToolResult` and is appended as a
822
+ * gutter line. Sub-agent calls are indented per session.inSubAgent.
823
+ */
824
+ // Deferred-head strategy: we DON'T print the tool head when tool_call fires.
825
+ // Instead we buffer it and let renderToolResult emit one combined line
826
+ // "head → outcome · duration\n". A spinner shows what's running in the
827
+ // meantime so the user still has feedback during slow tools.
828
+ //
829
+ // If something else needs to print before the result arrives (a streamed
830
+ // content event, a sub-agent open, an error, completion), we flush the
831
+ // buffered head as a regular two-line shape first so the interleaving
832
+ // content lands below it.
833
+ // (declaration moved to repl-state.mjs runtime.*)
834
+ // (declaration moved to repl-state.mjs runtime.*)
835
+ // Explore-run collapse: reduces bursts of list/read/search/index tool calls
836
+ // into one in-place summary line so the user sees the agent's PROGRESS
837
+ // (12 files listed, 8 read, latest name) instead of a wall of individual
838
+ // tool cards. Any non-explore event flushes it to a static line so the
839
+ // summary survives when the transcript scrolls.
840
+ // Mutable run state stays here for now — see repl-explore.mjs for the pure
841
+ // classifier. Split TBD.
842
+ // (declaration moved to repl-state.mjs runtime.*)
843
+
844
+
845
+ // ── Event Renderer ──
846
+
847
+ function renderEvent(event) {
848
+ const { type, data } = event;
849
+
850
+ // Push every event into the orbit state machine before rendering so phase
851
+ // and cost state stay current for prompt/context surfaces.
852
+ if (orbitRef.current) orbitRef.current.onEvent(event);
853
+
854
+ // If we've been collapsing explore tools into a summary spinner, an
855
+ // incoming event that will actually WRITE to the screen ends the run
856
+ // and freezes the spinner into a static one-line summary. Transient
857
+ // metadata events (thinking, spinner-only status, sub_agent_tool,
858
+ // worker/phase updates, session_info) don't write and must NOT flush —
859
+ // otherwise every sub-agent tool call fires a `sub_agent_tool` event
860
+ // right before its `tool_call`, splitting each burst into a fresh line.
861
+ if (runtime.exploreRun.lineActive) {
862
+ const isExploreEvent =
863
+ (type === 'tool_call' || type === 'tool_request' ||
864
+ type === 'tool_result' || type === 'tool_done') &&
865
+ isExploreTool(data?.tool);
866
+ const isTransientEvent =
867
+ type === 'thinking' || // may or may not render text
868
+ type === 'status' || // usually just updates the spinner
869
+ type === 'sub_agent_tool' || // metadata for the tool_call to follow
870
+ type === 'worker_update' ||
871
+ type === 'phase_update' ||
872
+ type === 'phase_summary' ||
873
+ type === 'phase_start' ||
874
+ type === 'worker_start' ||
875
+ type === 'worker_done' ||
876
+ type === 'session_info';
877
+ if (!isExploreEvent && !isTransientEvent) flushExploreRun();
878
+ }
879
+
880
+ switch (type) {
881
+ case 'status': {
882
+ const msg = data?.message || '';
883
+ if (!msg || msg === 'Agent started') return;
884
+ if (/^Stagnation:/i.test(msg)) {
885
+ renderStagnation(data);
886
+ break;
887
+ }
888
+ startSpinner(msg);
889
+ break;
890
+ }
891
+
892
+ case 'stagnation':
893
+ case 'stagnation_detected': {
894
+ renderStagnation(data);
895
+ break;
896
+ }
897
+
898
+ case 'thinking': {
899
+ const text = data?.message || data?.text || '';
900
+ if (text && !text.startsWith('Processing')) {
901
+ // Surface substantive thinking text as visible prose so the user can
902
+ // follow the agent's reasoning, not just see a spinner blip. We
903
+ // print at most one line per distinct thought, dim italic.
904
+ if (text.length > 12 && text !== session._lastEmittedThinking) {
905
+ flushContent();
906
+ flushPendingHead();
907
+ stopSpinner();
908
+ renderBlockBoundary('thinking');
909
+ process.stderr.write(` ${c.dim(thinkingPrefix(text) + ' · ')}${c.italic(c.dim(clippedThinking(text)))}\n`);
910
+ runtime.lastRenderedBlock = 'thinking';
911
+ session._lastEmittedThinking = text;
912
+ }
913
+ startSpinner(text.slice(0, 80));
914
+ // Capture reasoning so /why can replay it.
915
+ session.lastReasoning = text;
916
+ }
917
+ break;
918
+ }
919
+
920
+ case 'content': {
921
+ let text = data?.text || '';
922
+ if (text) {
923
+ flushContent();
924
+ stopSpinner();
925
+ if (runtime.streamedPartialText && text.startsWith(runtime.streamedPartialText)) {
926
+ text = text.slice(runtime.streamedPartialText.length);
927
+ } else if (runtime.streamedPartialText.includes(text)) {
928
+ text = '';
929
+ }
930
+ }
931
+ if (text) {
932
+ renderBlockBoundary('content');
933
+ if (!runtime.contentHeaderPrinted) {
934
+ process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
935
+ runtime.contentHeaderPrinted = true;
936
+ }
937
+ const rendered = renderMarkdown(text);
938
+ for (const line of rendered.split('\n')) {
939
+ process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
940
+ }
941
+ runtime.renderedContentThisTurn = true;
942
+ runtime.lastRenderedBlock = 'content';
943
+ }
944
+ break;
945
+ }
946
+
947
+ case 'reconnecting': {
948
+ stopSpinner();
949
+ flushContent();
950
+ flushPendingHead();
951
+ renderBlockBoundary('status', { compactSame: true });
952
+ const attempt = data?.attempt ? `attempt ${data.attempt}` : 'reconnecting';
953
+ const delayMs = Number(data?.delay_ms || 0);
954
+ const wait = delayMs > 0
955
+ ? ` · retrying in ${delayMs < 1000 ? `${delayMs}ms` : `${(delayMs / 1000).toFixed(delayMs < 10_000 ? 1 : 0)}s`}`
956
+ : '';
957
+ const after = data?.after != null ? ` from event ${data.after}` : '';
958
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`connection lost; ${attempt}${wait}${after}`)}\n`);
959
+ runtime.lastRenderedBlock = 'status';
960
+ break;
961
+ }
962
+
963
+ case 'reconnected': {
964
+ stopSpinner();
965
+ flushContent();
966
+ flushPendingHead();
967
+ renderBlockBoundary('status', { compactSame: true });
968
+ const replayed = data?.replayed != null ? ` · replayed ${data.replayed} events` : '';
969
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`reconnected${replayed}`)}\n`);
970
+ runtime.lastRenderedBlock = 'status';
971
+ break;
972
+ }
973
+
974
+ case 'reconnect_failed': {
975
+ stopSpinner();
976
+ flushContent();
977
+ flushPendingHead();
978
+ renderBlockBoundary('status', { compactSame: true });
979
+ const message = data?.message || 'connection lost and reconnect failed. Use /resume to continue from saved history.';
980
+ process.stderr.write(` ${c.red('✗')} ${c.dim(message)}\n`);
981
+ runtime.lastRenderedBlock = 'status';
982
+ break;
983
+ }
984
+
985
+ case 'content_partial': {
986
+ const text = data?.text || '';
987
+ if (text) {
988
+ stopSpinner();
989
+ appendContent(text);
990
+ }
991
+ break;
992
+ }
993
+
994
+ case 'tool_call':
995
+ case 'tool_request': {
996
+ const isInternal = Boolean(data?.internal || data?.sub_agent);
997
+ if (isInternal) {
998
+ session.subAgentToolCalls++;
999
+ session.totalSubAgentToolCalls++;
1000
+ } else {
1001
+ session.toolCalls++;
1002
+ session.totalPrimaryToolCalls++;
1003
+ }
1004
+ session.totalToolCalls++;
1005
+ stopSpinner();
1006
+ flushContent();
1007
+ renderToolCall(data);
1008
+ break;
1009
+ }
1010
+
1011
+ // ── HITL: Framework-level approval events ──
1012
+
1013
+ case 'approval_required': {
1014
+ stopSpinner();
1015
+ flushContent();
1016
+ break;
1017
+ }
1018
+
1019
+ case 'approval_granted': {
1020
+ // Human approvals are rendered by approval.mjs. Auto-read grants are
1021
+ // otherwise invisible, so show one dim confirmation before the tool card.
1022
+ const scope = data?.grant_scope || data?.scope || '';
1023
+ if (scope === 'auto_read') {
1024
+ renderBlockBoundary('status', { compactSame: true });
1025
+ const toolName = data?.tool || data?.tool_name || '';
1026
+ const args = data?.args || data?.input || {};
1027
+ const summary = toolDisplaySummary(toolName, args);
1028
+ const label = toolDisplayLabel(toolName);
1029
+ const subject = summary ? `${label} ${summary}` : label;
1030
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`${subject} · auto-approved read`)}\n`);
1031
+ runtime.lastRenderedBlock = 'status';
1032
+ }
1033
+ break;
1034
+ }
1035
+
1036
+ case 'approval_denied': {
1037
+ const reason = data?.reason || 'User denied';
1038
+ const toolName = data?.tool || '';
1039
+ const indent = subAgentIndent();
1040
+ renderBlockBoundary('status', { compactSame: true });
1041
+ process.stderr.write(`${indent}${c.red('✗')} ${c.dim(`Denied ${toolName}: ${reason}`)}\n`);
1042
+ runtime.lastRenderedBlock = 'status';
1043
+ break;
1044
+ }
1045
+
1046
+ case 'tool_result':
1047
+ case 'tool_done': {
1048
+ stopSpinner();
1049
+ renderToolResult(data, type);
1050
+ break;
1051
+ }
1052
+
1053
+ case 'plan': {
1054
+ stopSpinner();
1055
+ flushContent();
1056
+ const milestones = data?.milestones || data?.steps || [];
1057
+ const title = data?.title || 'Plan';
1058
+ renderBlockBoundary('plan');
1059
+ process.stderr.write(` ${c.brand('▸')} ${c.bold(title)}\n`);
1060
+ for (const [index, milestone] of milestones.entries()) {
1061
+ const label = typeof milestone === 'string'
1062
+ ? milestone
1063
+ : milestone.name || milestone.title || milestone.description || `Step ${index + 1}`;
1064
+ const status = typeof milestone === 'object' ? milestone.status : '';
1065
+ const marker = status === 'complete' || status === 'completed' ? c.green('✓') : c.dim(`${index + 1}.`);
1066
+ process.stderr.write(` ${marker} ${label}\n`);
1067
+ }
1068
+ runtime.lastRenderedBlock = 'plan';
1069
+ break;
1070
+ }
1071
+
1072
+ case 'change': {
1073
+ stopSpinner();
1074
+ const changeType = data?.type || 'modify';
1075
+ const filePath = shortPath(data?.path || '');
1076
+ const icon = changeType === 'create' ? c.green('+') :
1077
+ changeType === 'delete' ? c.red('-') : c.yellow('~');
1078
+ renderBlockBoundary('status', { compactSame: true });
1079
+ process.stderr.write(` ${icon} ${c.dim(filePath)}\n`);
1080
+ runtime.lastRenderedBlock = 'status';
1081
+ // Track changed files
1082
+ if (filePath && !session.filesChanged.includes(filePath)) {
1083
+ session.filesChanged.push(filePath);
1084
+ }
1085
+ break;
1086
+ }
1087
+
1088
+ case 'phase_start':
1089
+ case 'phase_update': {
1090
+ const phase = data?.phase || data?.stage_name || '';
1091
+ if (phase) {
1092
+ stopSpinner();
1093
+ session.phases.push({ name: phase, time: Date.now() });
1094
+ renderBlockBoundary('plan');
1095
+ process.stderr.write(` ${c.brand('▸')} ${c.bold(phase)}\n`);
1096
+ runtime.lastRenderedBlock = 'plan';
1097
+ }
1098
+ break;
1099
+ }
1100
+
1101
+ case 'phase_summary': {
1102
+ const summary = data?.summary || '';
1103
+ if (summary) {
1104
+ renderBlockBoundary('status', { compactSame: true });
1105
+ process.stderr.write(` ${c.dim(summary.slice(0, 120))}\n`);
1106
+ runtime.lastRenderedBlock = 'status';
1107
+ }
1108
+ break;
1109
+ }
1110
+
1111
+ case 'worker_start':
1112
+ case 'worker_update': {
1113
+ const worker = data?.worker || data?.name || '';
1114
+ const status = data?.status || data?.message || 'working';
1115
+ if (worker) startSpinner(`${worker}: ${status}`);
1116
+ break;
1117
+ }
1118
+
1119
+ case 'worker_done': {
1120
+ stopSpinner();
1121
+ const worker = data?.worker || data?.name || '';
1122
+ if (worker) {
1123
+ renderBlockBoundary('status', { compactSame: true });
1124
+ process.stderr.write(` ${c.green('✓')} ${c.dim(worker)}\n`);
1125
+ runtime.lastRenderedBlock = 'status';
1126
+ }
1127
+ break;
1128
+ }
1129
+
1130
+ case 'delegation': {
1131
+ stopSpinner();
1132
+ clearPendingHead();
1133
+ const from = data?.from || '';
1134
+ const to = data?.to || '';
1135
+ session.delegations.push({ from, to, time: Date.now() });
1136
+ renderBlockBoundary('status', { compactSame: true });
1137
+ process.stderr.write(` ${c.brand('↳')} ${c.dim(from)} ${c.brand('→')} ${c.bold(to)}`);
1138
+ if (data?.instruction) {
1139
+ process.stderr.write(` ${c.dim(data.instruction.slice(0, 50))}`);
1140
+ }
1141
+ process.stderr.write('\n');
1142
+ runtime.lastRenderedBlock = 'status';
1143
+ break;
1144
+ }
1145
+
1146
+ // ── Sub-Agent Activity ──
1147
+
1148
+ case 'sub_agent_start': {
1149
+ stopSpinner();
1150
+ clearPendingHead();
1151
+ const agentType = data?.type || 'sub-agent';
1152
+ const query = data?.query || '';
1153
+ renderBlockBoundary('subagent');
1154
+ process.stderr.write(renderSubAgentOpen({ type: agentType, query }).replace(/^\n/, '') + '\n');
1155
+ runtime.lastRenderedBlock = 'subagent';
1156
+ session.inSubAgent = inSubAgentBlock(); // kept for legacy readers
1157
+ session.subAgentCounts[agentType] = (session.subAgentCounts[agentType] || 0) + 1;
1158
+ startSpinner(`${agentType}: working...`);
1159
+ break;
1160
+ }
1161
+
1162
+ case 'sub_agent_tool': {
1163
+ // The regular tool_call event renders the card, indented by the
1164
+ // sub-agent stack depth. Just update the spinner text here.
1165
+ const agentType = data?.type || 'sub-agent';
1166
+ const tool = data?.tool || '';
1167
+ if (!tool) break;
1168
+ // Don't clobber an active explore-run spinner. "exploring · 5 read ·
1169
+ // 2 searched" is more informative than "explore → search_code", and
1170
+ // sub_agent_tool fires on every step of a sub-agent — otherwise the
1171
+ // spinner would flip-flop between the two texts and read as blank.
1172
+ if (runtime.exploreRun.lineActive && isExploreTool(tool)) break;
1173
+ updateSpinner(`${agentType} → ${tool}`);
1174
+ break;
1175
+ }
1176
+
1177
+ case 'sub_agent_complete': {
1178
+ stopSpinner();
1179
+ clearPendingHead();
1180
+ const agentType = data?.type || 'sub-agent';
1181
+ const usage = data?.usage || {};
1182
+ const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
1183
+ const costUsd = usage.cost_usd ?? usage.total_cost_usd ?? data?.cost_usd ?? null;
1184
+ if (typeof costUsd === 'number') session.savedUsd += costUsd;
1185
+ const summary = data?.result_summary
1186
+ || (data?.result_length > 0 ? `${agentType} returned ${data.result_length} chars` : '');
1187
+ process.stderr.write(renderSubAgentClose({
1188
+ type: agentType,
1189
+ success: data?.success !== false,
1190
+ summary,
1191
+ costUsd,
1192
+ tokens,
1193
+ durationS: data?.duration_s,
1194
+ toolCalls: data?.tool_calls,
1195
+ iterations: data?.iterations,
1196
+ error: data?.error,
1197
+ }) + '\n');
1198
+ runtime.lastRenderedBlock = 'subagent';
1199
+ session.inSubAgent = inSubAgentBlock();
1200
+ break;
1201
+ }
1202
+
1203
+ case 'plan_created': {
1204
+ renderBlockBoundary('status', { compactSame: true });
1205
+ process.stderr.write(` ${c.dim('project plan prepared')}\n`);
1206
+ runtime.lastRenderedBlock = 'status';
1207
+ break;
1208
+ }
1209
+
1210
+ case 'goal_created': {
1211
+ renderBlockBoundary('status', { compactSame: true });
1212
+ process.stderr.write(` ${c.dim('project goal prepared')}\n`);
1213
+ runtime.lastRenderedBlock = 'status';
1214
+ break;
1215
+ }
1216
+
1217
+ case 'session_info': {
1218
+ if (data?.session_id) {
1219
+ session.id = data.session_id;
1220
+ // Track in session manager so conversations save to the right file
1221
+ if (sessionMgrRef.current) sessionMgrRef.current.setSessionInfo({ session_id: data.session_id });
1222
+ }
1223
+ if (data?.model) session.model = data.model;
1224
+ if (data?.models?.coder) session.model = data.models.coder;
1225
+ if (data?.model_limits && typeof data.model_limits === 'object') {
1226
+ session.modelLimits = data.model_limits;
1227
+ }
1228
+ if (data?.user) session.user = { ...session.user, ...data.user };
1229
+ // BYOK users pay their model provider directly; the platform does not
1230
+ // charge them credits. Hide cost + credits when this flag is set.
1231
+ if (typeof data?.is_byok === 'boolean') session.isByok = data.is_byok;
1232
+ // Subscription tier + credit balance — backend is authoritative.
1233
+ if (data?.subscription_tier) session.subscriptionTier = data.subscription_tier;
1234
+ if (typeof data?.credits_included_limit === 'number') session.creditsLimit = data.credits_included_limit;
1235
+ const bal = data?.credits_balance;
1236
+ if (bal && typeof bal === 'object') {
1237
+ if (typeof bal.total === 'number') session.creditsTotal = bal.total;
1238
+ if (typeof bal.included === 'number') session.creditsIncluded = bal.included;
1239
+ if (typeof bal.purchased === 'number') session.creditsPurchased = bal.purchased;
1240
+ }
1241
+ if (data?.rate_limit) session.rateLimit = data.rate_limit;
1242
+ break;
1243
+ }
1244
+
1245
+ case 'error':
1246
+ stopSpinner();
1247
+ flushContent();
1248
+ {
1249
+ const guidance = formatAgentErrorGuidance(data || {});
1250
+ renderBlockBoundary('status', { compactSame: true });
1251
+ process.stderr.write(` ${c.red('✗')} ${guidance.title}\n`);
1252
+ for (const line of guidance.lines) {
1253
+ process.stderr.write(` ${c.dim(line)}\n`);
1254
+ }
1255
+ if (guidance.meta.length) {
1256
+ process.stderr.write(` ${c.dim(guidance.meta.join(' · '))}\n`);
1257
+ }
1258
+ runtime.lastRenderedBlock = 'status';
1259
+ }
1260
+ break;
1261
+
1262
+ case 'complete': {
1263
+ stopSpinner();
1264
+ flushContent();
1265
+ resetSubAgents();
1266
+ session.inSubAgent = false;
1267
+
1268
+ const summary = data?.summary || '';
1269
+ if (summary && !runtime.renderedContentThisTurn) {
1270
+ renderBlockBoundary('content');
1271
+ if (!runtime.contentHeaderPrinted) {
1272
+ process.stdout.write(`${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
1273
+ runtime.contentHeaderPrinted = true;
1274
+ }
1275
+ const rendered = renderMarkdown(summary);
1276
+ for (const line of rendered.split('\n')) {
1277
+ process.stdout.write(`${transcriptLine(line, { tone: 'assistant' })}\n`);
1278
+ }
1279
+ runtime.renderedContentThisTurn = true;
1280
+ runtime.lastRenderedBlock = 'content';
1281
+ }
1282
+
1283
+ // Update session token counts
1284
+ const usage = data?.usage;
1285
+ let turnCost = 0;
1286
+ if (usage) {
1287
+ const inp = usage.total_input_tokens || usage.input_tokens || 0;
1288
+ const out = usage.total_output_tokens || usage.output_tokens || 0;
1289
+ session.inputTokens += inp;
1290
+ session.outputTokens += out;
1291
+
1292
+ // Model-aware cost calculation
1293
+ const costResult = calculateCost(usage);
1294
+ turnCost = costResult.total;
1295
+ session.totalCost += costResult.total;
1296
+ session.costAccurate = costResult.accurate;
1297
+
1298
+ // Accumulate per-model breakdown
1299
+ for (const entry of costResult.breakdown) {
1300
+ const existing = session.costBreakdown.find(b => b.model === entry.model);
1301
+ if (existing) {
1302
+ existing.input_tokens += entry.input_tokens;
1303
+ existing.output_tokens += entry.output_tokens;
1304
+ existing.cache_read_tokens += entry.cache_read_tokens || 0;
1305
+ existing.cache_creation_tokens += entry.cache_creation_tokens || 0;
1306
+ existing.cost += entry.cost;
1307
+ } else {
1308
+ session.costBreakdown.push({ ...entry });
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ session.lastTurnDuration = data?.duration_s || 0;
1314
+ if (data?.rate_limit) session.rateLimit = data.rate_limit;
1315
+
1316
+ // ── Server-authoritative credits ──
1317
+ // Backend sends usage.credits_charged (this turn) + balance (remaining)
1318
+ // in the complete event. CLI uses these instead of the local
1319
+ // costToCredits estimate so /status and /cost match the dashboard.
1320
+ if (!session.isByok) {
1321
+ const msgStatus = lowWindowStatus(session.rateLimit);
1322
+ if (!session.msgsLowWarned && msgStatus !== 'ok') {
1323
+ const windowLine = formatMessageWindow(session.rateLimit);
1324
+ renderBlockBoundary('status', { compactSame: true });
1325
+ if (msgStatus === 'exhausted') {
1326
+ process.stderr.write(` ${c.red('✗')} ${c.dim(`${windowLine}. Wait for the window to reset or upgrade at bahulam.ai/pricing.`)}\n`);
1327
+ } else {
1328
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`${windowLine}. Message window is running low.`)}\n`);
1329
+ }
1330
+ runtime.lastRenderedBlock = 'status';
1331
+ session.msgsLowWarned = true;
1332
+ }
1333
+
1334
+ const charged = data?.usage?.credits_charged;
1335
+ if (typeof charged === 'number') session.creditsCharged += charged;
1336
+ const bal = data?.balance;
1337
+ if (bal && typeof bal === 'object') {
1338
+ if (typeof bal.total === 'number') session.creditsTotal = bal.total;
1339
+ if (typeof bal.included === 'number') session.creditsIncluded = bal.included;
1340
+ if (typeof bal.purchased === 'number') session.creditsPurchased = bal.purchased;
1341
+ }
1342
+ // Warn once per turn when the remaining credits drop below 20% of the
1343
+ // tier's included limit (or below 10 absolute for tiny tiers). Credits
1344
+ // stay out of the always-on prompt strip; this warning is the exception.
1345
+ if (!session.creditsLowWarned && typeof session.creditsTotal === 'number' && session.creditsLimit) {
1346
+ const threshold = Math.max(10, Math.floor(session.creditsLimit * 0.2));
1347
+ if (session.creditsTotal <= threshold && session.creditsTotal > 0) {
1348
+ renderBlockBoundary('status', { compactSame: true });
1349
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`${session.creditsTotal} of ${session.creditsLimit} credits remaining on the ${session.subscriptionTier || 'free'} plan. Upgrade or top up at bahulam.ai/pricing.`)}\n`);
1350
+ runtime.lastRenderedBlock = 'status';
1351
+ session.creditsLowWarned = true;
1352
+ } else if (session.creditsTotal <= 0) {
1353
+ renderBlockBoundary('status', { compactSame: true });
1354
+ process.stderr.write(` ${c.red('✗')} ${c.yellow(`Credit balance exhausted on the ${session.subscriptionTier || 'free'} plan. Purchase credits at bahulam.ai/pricing or switch to BYOK.`)}\n`);
1355
+ runtime.lastRenderedBlock = 'status';
1356
+ session.creditsLowWarned = true;
1357
+ }
1358
+ }
1359
+ }
1360
+
1361
+ // Sync cumulative session cost into the orbit (status bar shows it).
1362
+ if (orbitRef.current) orbitRef.current.onCost(session.totalCost);
1363
+
1364
+ // Compact turn summary. Backend's tool_calls is authoritative and
1365
+ // includes primary + sub-agent internals for billing/credit rollups.
1366
+ const observedPrimaryTools = session.toolCalls;
1367
+ const observedSubAgentTools = session.subAgentToolCalls;
1368
+ const observedTurnTools = observedPrimaryTools + observedSubAgentTools;
1369
+ if (Number.isFinite(data?.primary_tool_calls)) {
1370
+ session.toolCalls = data.primary_tool_calls;
1371
+ const delta = data.primary_tool_calls - observedPrimaryTools;
1372
+ if (delta > 0) session.totalPrimaryToolCalls += delta;
1373
+ }
1374
+ if (Number.isFinite(data?.sub_agent_tool_calls)) {
1375
+ session.subAgentToolCalls = data.sub_agent_tool_calls;
1376
+ const delta = data.sub_agent_tool_calls - observedSubAgentTools;
1377
+ if (delta > 0) session.totalSubAgentToolCalls += delta;
1378
+ }
1379
+ if (Number.isFinite(data?.tool_calls)) {
1380
+ const delta = data.tool_calls - observedTurnTools;
1381
+ if (delta > 0) session.totalToolCalls += delta;
1382
+ }
1383
+ const tools = Number.isFinite(data?.tool_calls)
1384
+ ? data.tool_calls
1385
+ : (session.toolCalls + session.subAgentToolCalls);
1386
+
1387
+ // Mission report — replaces the trailing "Done" when the turn did real
1388
+ // work (touched files or invoked tools). Plain chat turns keep the
1389
+ // tight printTurnSummary so the report does not feel ceremonial.
1390
+ const didRealWork = tools > 0 || session.filesChanged.length > 0;
1391
+ if (didRealWork) {
1392
+ const successOverall = data?.success !== false;
1393
+ const report = renderMissionReport({
1394
+ task: session.lastTask,
1395
+ success: successOverall,
1396
+ filesChanged: session.filesChanged,
1397
+ filesRead: session.filesRead,
1398
+ toolCounts: session.toolCounts,
1399
+ subAgents: { ...session.subAgentCounts, savedUsd: 0 },
1400
+ costUsd: null,
1401
+ durationS: data?.duration_s,
1402
+ testsPass: data?.tests_passed != null
1403
+ ? { passed: data.tests_passed, total: data.tests_total || data.tests_passed }
1404
+ : null,
1405
+ blockers: !successOverall ? (data?.blockers || extractBlockers(data)) : null,
1406
+ nextActions: [],
1407
+ cwd: safeCwd(),
1408
+ });
1409
+ renderBlockBoundary('status', { compactSame: true });
1410
+ process.stderr.write(report.replace(/^\n/, '') + '\n');
1411
+ runtime.lastRenderedBlock = 'status';
1412
+ } else {
1413
+ printTurnSummary(tools, data?.duration_s, turnCost);
1414
+ }
1415
+ break;
1416
+ }
1417
+
1418
+ case 'cancelled':
1419
+ stopSpinner();
1420
+ flushContent();
1421
+ renderBlockBoundary('status', { compactSame: true });
1422
+ process.stderr.write(` ${c.yellow('⏹')} Cancelled${data?.reason ? ': ' + c.dim(data.reason) : ''}\n`);
1423
+ runtime.lastRenderedBlock = 'status';
1424
+ break;
1425
+
1426
+ case 'paused':
1427
+ stopSpinner();
1428
+ flushPendingHead();
1429
+ renderBlockBoundary('status', { compactSame: true });
1430
+ process.stderr.write(` ${c.yellow('⏸')} Paused${data?.reason ? ' ' + c.dim(data.reason) : ''}\n`);
1431
+ runtime.lastRenderedBlock = 'status';
1432
+ break;
1433
+
1434
+ case 'resumed':
1435
+ renderBlockBoundary('status', { compactSame: true });
1436
+ process.stderr.write(` ${c.green('▶')} Resumed\n`);
1437
+ runtime.lastRenderedBlock = 'status';
1438
+ break;
1439
+
1440
+ default:
1441
+ break;
1442
+ }
1443
+ }
1444
+
1445
+ // ── Slash Commands ──
1446
+
1447
+ function taskListLabel(list) {
1448
+ return {
1449
+ active: 'Active',
1450
+ backlog: 'Backlog',
1451
+ blocked: 'Blocked',
1452
+ done: 'Done',
1453
+ }[list] || list;
1454
+ }
1455
+
1456
+ function firstMeaningfulLines(content, limit = 6) {
1457
+ return String(content || '')
1458
+ .split(/\r?\n/)
1459
+ .map(line => line.trim())
1460
+ .filter(line => line && !line.startsWith('#'))
1461
+ .slice(0, limit);
1462
+ }
1463
+
1464
+ function renderTaskBoard(board, { showDone = false } = {}) {
1465
+ const order = showDone
1466
+ ? ['active', 'blocked', 'backlog', 'done']
1467
+ : ['active', 'blocked', 'backlog'];
1468
+ let any = false;
1469
+ for (const list of order) {
1470
+ const tasks = board.lists[list]?.tasks || [];
1471
+ if (!tasks.length) continue;
1472
+ any = true;
1473
+ process.stderr.write(`\n ${c.bold(taskListLabel(list))} ${c.dim(board.lists[list].fileName)}\n`);
1474
+ for (const task of tasks.slice(0, 12)) {
1475
+ const marker = task.checked ? c.green('[x]') : c.dim('[ ]');
1476
+ const section = task.section && task.section !== taskListLabel(list) ? c.dim(` · ${task.section}`) : '';
1477
+ process.stderr.write(` ${marker} ${task.text}${section}\n`);
1478
+ }
1479
+ if (tasks.length > 12) {
1480
+ process.stderr.write(` ${c.dim(`+${tasks.length - 12} more`)}\n`);
1481
+ }
1482
+ }
1483
+ if (!any) {
1484
+ process.stderr.write(` ${c.dim('No project tasks yet. Add one with /tasks add <text>.')}\n`);
1485
+ }
1486
+ }
1487
+
1488
+ function renderPlanOverview({ ctx, mode = 'overview' } = {}) {
1489
+ const cwd = safeCwd();
1490
+ ensureTaskFiles({ cwd });
1491
+ const board = loadTaskBoard({ cwd });
1492
+ const counts = taskCounts(board);
1493
+ const planLines = firstMeaningfulLines(board.plan.content, 8);
1494
+ const goalLines = firstMeaningfulLines(board.goal.content, 3);
1495
+ const effective = ctx.effectivePolicy || loadEffectivePolicy({ cwd });
1496
+ const owner = effective.policy?.planning?.owner || 'auto';
1497
+
1498
+ process.stderr.write(`\n ${c.bold('Plan')}\n`);
1499
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
1500
+ process.stderr.write(` ${c.dim('Owner')} ${c.brand(owner)}\n`);
1501
+ process.stderr.write(` ${c.dim('Tasks')} ${counts.active} active, ${counts.blocked} blocked, ${counts.backlog} backlog, ${counts.done} done\n`);
1502
+ if (mode === 'status') {
1503
+ process.stderr.write(` ${c.dim('Plan file')} ${board.plan.exists ? board.plan.path : c.dim('(none)')}\n`);
1504
+ process.stderr.write(` ${c.dim('Tasks dir')} ${board.dir}\n`);
1505
+ }
1506
+
1507
+ if (goalLines.length) {
1508
+ process.stderr.write(`\n ${c.bold('Goal')}\n`);
1509
+ for (const line of goalLines) process.stderr.write(` ${line}\n`);
1510
+ }
1511
+ if (planLines.length) {
1512
+ process.stderr.write(`\n ${c.bold('Current Plan')}\n`);
1513
+ for (const line of planLines) process.stderr.write(` ${line}\n`);
1514
+ }
1515
+
1516
+ renderTaskBoard(board, { showDone: mode === 'status' });
1517
+ process.stderr.write(`\n ${c.dim('Update: /tasks add <text> · /tasks move active 1 done · /tasks edit active 1 <text>')}\n\n`);
1518
+ }
1519
+
1520
+ function refreshTaskContext(ctx) {
1521
+ try {
1522
+ const previous = ctx.latestProjectContext || null;
1523
+ ctx.latestProjectContext = loadProjectContext({ cwd: safeCwd(), previous });
1524
+ ctx.latestEnvelope = null;
1525
+ } catch { /* best effort */ }
1526
+ }
1527
+
1528
+ function handleTasksCommand(rest, ctx) {
1529
+ const raw = String(rest || '').trim();
1530
+ ensureTaskFiles({ cwd: safeCwd() });
1531
+ if (!raw || raw === 'list') {
1532
+ const board = loadTaskBoard({ cwd: safeCwd() });
1533
+ process.stderr.write(`\n ${c.bold('Tasks')}\n`);
1534
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
1535
+ renderTaskBoard(board, { showDone: true });
1536
+ process.stderr.write(`\n ${c.dim('Update: /tasks add <text> · /tasks move active 1 done · /tasks edit active 1 <text>')}\n\n`);
1537
+ return;
1538
+ }
1539
+
1540
+ if (raw === 'help') {
1541
+ renderHelp('plan');
1542
+ return;
1543
+ }
1544
+
1545
+ const parts = raw.split(/\s+/);
1546
+ let verb = (parts.shift() || '').toLowerCase();
1547
+
1548
+ if (verb === 'move') {
1549
+ try {
1550
+ const [from, index, to, ...textParts] = parts;
1551
+ const result = moveTask({ cwd: safeCwd(), from, index, to, text: textParts.join(' ') || undefined });
1552
+ refreshTaskContext(ctx);
1553
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`moved ${result.from} #${result.index} → ${result.to}`)} ${result.text}\n`);
1554
+ } catch (err) {
1555
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1556
+ process.stderr.write(` ${c.gray('Usage: /tasks move <active|backlog|blocked|done> <number> <active|backlog|blocked|done> [new text]')}\n`);
1557
+ }
1558
+ return;
1559
+ }
1560
+
1561
+ if (verb === 'edit' || verb === 'rename') {
1562
+ try {
1563
+ const [list, index, ...textParts] = parts;
1564
+ const result = updateTask({ cwd: safeCwd(), list, index, text: textParts.join(' ') });
1565
+ refreshTaskContext(ctx);
1566
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`updated ${result.list} #${result.index}`)} ${result.text}\n`);
1567
+ } catch (err) {
1568
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1569
+ process.stderr.write(` ${c.gray('Usage: /tasks edit <active|backlog|blocked|done> <number> <new text>')}\n`);
1570
+ }
1571
+ return;
1572
+ }
1573
+
1574
+ if (verb === 'remove' || verb === 'rm' || verb === 'delete') {
1575
+ try {
1576
+ const [list, index] = parts;
1577
+ const result = removeTask({ cwd: safeCwd(), list, index });
1578
+ refreshTaskContext(ctx);
1579
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`removed ${result.list} #${result.index}`)} ${result.task.text}\n`);
1580
+ } catch (err) {
1581
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1582
+ process.stderr.write(` ${c.gray('Usage: /tasks remove <active|backlog|blocked|done> <number>')}\n`);
1583
+ }
1584
+ return;
1585
+ }
1586
+
1587
+ if (verb === 'finish' || verb === 'complete' || verb === 'block' || verb === 'unblock') {
1588
+ try {
1589
+ const [from, index, ...textParts] = parts;
1590
+ const to = verb === 'block' ? 'blocked' : verb === 'unblock' ? 'active' : 'done';
1591
+ const result = moveTask({ cwd: safeCwd(), from, index, to, text: textParts.join(' ') || undefined });
1592
+ refreshTaskContext(ctx);
1593
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`moved ${result.from} #${result.index} → ${result.to}`)} ${result.text}\n`);
1594
+ } catch (err) {
1595
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1596
+ process.stderr.write(` ${c.gray(`Usage: /tasks ${verb} <active|backlog|blocked|done> <number> [new text]`)}\n`);
1597
+ }
1598
+ return;
1599
+ }
1600
+
1601
+ let list = 'backlog';
1602
+ if (verb === 'add' || verb === 'new') {
1603
+ const maybeList = (parts[0] || '').toLowerCase();
1604
+ if (maybeList in TASK_FILES || ['todo', 'pending', 'current', 'doing', 'complete', 'completed'].includes(maybeList)) {
1605
+ list = parts.shift();
1606
+ }
1607
+ } else if (verb in TASK_FILES || ['todo', 'pending', 'current', 'doing', 'complete', 'completed'].includes(verb)) {
1608
+ list = verb;
1609
+ } else {
1610
+ parts.unshift(verb);
1611
+ verb = 'add';
1612
+ }
1613
+
1614
+ try {
1615
+ const result = appendTask({ cwd: safeCwd(), list, text: parts.join(' ') });
1616
+ refreshTaskContext(ctx);
1617
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`added to ${result.list}`)} ${result.text}\n`);
1618
+ } catch (err) {
1619
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
1620
+ }
1621
+ }
1622
+
1623
+ async function handleCommand(input, ctx) {
1624
+ const { cmd, rest, aliasTarget } = normalizeCommandInput(input);
1625
+ if (aliasTarget) {
1626
+ process.stderr.write(` ${c.dim(`Legacy alias: use ${aliasTarget}`)}\n`);
1627
+ }
1628
+
1629
+ switch (cmd) {
1630
+ case '/help': {
1631
+ renderHelp(rest);
1632
+ return;
1633
+ }
1634
+
1635
+ case '/plan': {
1636
+ const mode = rest.trim().toLowerCase();
1637
+ if (mode === 'help') {
1638
+ renderHelp('plan');
1639
+ return;
1640
+ }
1641
+ if (mode === 'edit') {
1642
+ ensureTaskFiles({ cwd: safeCwd() });
1643
+ const board = loadTaskBoard({ cwd: safeCwd() });
1644
+ process.stderr.write(`\n ${c.bold('Editable Plan Files')}\n`);
1645
+ process.stderr.write(` ${c.dim('Plan')} ${board.plan.path}\n`);
1646
+ process.stderr.write(` ${c.dim('Active')} ${board.lists.active.path}\n`);
1647
+ process.stderr.write(` ${c.dim('Backlog')} ${board.lists.backlog.path}\n`);
1648
+ process.stderr.write(` ${c.dim('Blocked')} ${board.lists.blocked.path}\n`);
1649
+ process.stderr.write(` ${c.dim('Done')} ${board.lists.done.path}\n\n`);
1650
+ return;
1651
+ }
1652
+ renderPlanOverview({ ctx, mode: mode === 'status' ? 'status' : 'overview' });
1653
+ return;
1654
+ }
1655
+
1656
+ case '/tasks':
1657
+ handleTasksCommand(rest, ctx);
1658
+ return;
1659
+
1660
+ case '/attach':
1661
+ handleAttachCommand(rest, ctx);
1662
+ return;
1663
+
1664
+ case '/attachments':
1665
+ handleAttachmentsCommand(rest, ctx);
1666
+ return;
1667
+
1668
+ case '/history': {
1669
+ if (rest.trim() === 'fold') {
1670
+ process.stderr.write(` ${c.gray('Output is folded by default — there is nothing to hide. Use /history last or d to expand.')}\n`);
1671
+ return;
1672
+ }
1673
+ if (rest.trim() === 'help') {
1674
+ renderHelp('history');
1675
+ return;
1676
+ }
1677
+ if (rest.trim() === 'approvals') {
1678
+ const entries = ctx.approval?.approvalLog?.readRecent?.(20) || [];
1679
+ if (!entries.length) {
1680
+ process.stderr.write(` ${c.gray('No approval log entries yet.')}\n`);
1681
+ return;
1682
+ }
1683
+ process.stderr.write(`\n ${c.bold('Approval History')}\n`);
1684
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
1685
+ for (const e of entries) {
1686
+ const when = e.ts ? String(e.ts).slice(0, 19).replace('T', ' ') : '';
1687
+ const decision = e.decision?.includes('reject') || e.decision?.includes('deny') ? c.red(e.decision) : c.green(e.decision);
1688
+ process.stderr.write(` ${c.dim(when)} ${decision} ${c.brand(e.tool || '?')} ${c.dim(e.scope || 'once')} ${c.dim(e.args || '')}\n`);
1689
+ }
1690
+ process.stderr.write('\n');
1691
+ return;
1692
+ }
1693
+ if (session.history.length === 0) { process.stderr.write(` ${c.gray('No conversation yet.')}\n`); return; }
1694
+ renderHistoryEntries(session.history, { limit: 20, maxChars: 120 });
1695
+ return;
1696
+ }
1697
+
1698
+ case '/login':
1699
+ process.stderr.write(`${c.brand('Starting login flow...')}\n`);
1700
+ try {
1701
+ await ctx.auth.login();
1702
+ process.stderr.write(`${c.green('✓ Login successful!')}\n`);
1703
+ await fetchUser(ctx);
1704
+ } catch (err) {
1705
+ process.stderr.write(`${c.red('✗ Login failed: ' + err.message)}\n`);
1706
+ }
1707
+ return;
1708
+
1709
+ case '/whoami': {
1710
+ if (!session.user) await fetchUser(ctx);
1711
+ if (session.user) {
1712
+ process.stderr.write(`\n ${c.green('✓')} ${session.user.github_username}\n`);
1713
+ process.stderr.write(` ${c.gray('Email:')} ${session.user.email || 'n/a'}\n`);
1714
+ process.stderr.write(` ${c.gray('User ID:')} ${session.user.id}\n`);
1715
+ process.stderr.write(` ${c.gray('Role:')} ${session.user.role || 'user'}\n\n`);
1716
+ } else {
1717
+ process.stderr.write(` ${c.red('Not logged in. Run /login.')}\n`);
1718
+ }
1719
+ return;
1720
+ }
1721
+
1722
+ case '/status': {
1723
+ if (rest.trim() === 'help') {
1724
+ renderHelp('status');
1725
+ return;
1726
+ }
1727
+ if (rest.trim() === 'context') {
1728
+ const current = ctx.latestProjectContext || loadProjectContext({ cwd: safeCwd() });
1729
+ const envelope = ctx.latestEnvelope || buildContextEnvelope({
1730
+ cwd: safeCwd(),
1731
+ effectivePolicy: ctx.effectivePolicy,
1732
+ projectContext: current,
1733
+ projectResources: ctx.toolExecutor?.getProjectResources?.() || [],
1734
+ agentContext: ctx.toolExecutor?.getAgentContext?.() || {},
1735
+ });
1736
+ process.stderr.write(`\n ${c.bold('Context')}\n`);
1737
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
1738
+ const loaded = current.loaded || [];
1739
+ if (!loaded.length) {
1740
+ process.stderr.write(` ${c.dim('No .bahulam context files loaded yet.')}\n`);
1741
+ } else {
1742
+ for (const file of loaded) {
1743
+ const changed = file.changed ? ` ${c.yellow('updated')}` : '';
1744
+ process.stderr.write(` ${c.brand(file.label.padEnd(18))} ${c.dim(file.hash)} ${c.dim(file.path)}${changed}\n`);
1745
+ }
1746
+ }
1747
+ process.stderr.write(`\n ${c.bold('Command Context')}\n`);
1748
+ process.stderr.write(` ${c.dim('Active')} ${envelope.command_context.active_command || c.dim('(none)')}\n`);
1749
+ process.stderr.write(` ${c.dim('Source')} ${envelope.command_context.source}\n`);
1750
+ process.stderr.write(` ${c.dim('Timeout')} ${envelope.command_context.runtime_limits.command_timeout_seconds}s command, ${envelope.command_context.runtime_limits.tool_timeout_seconds}s tool\n`);
1751
+ process.stderr.write(` ${c.dim('Plan owner')} ${envelope.effective_options.plan_owner}\n`);
1752
+ process.stderr.write(` ${c.dim('HITL scope')} ${envelope.effective_options.hitl_default_scope} ${c.dim(`(reask ${envelope.effective_options.reask_after_minutes}m)`)}\n`);
1753
+ if (envelope.available_skills.length) {
1754
+ process.stderr.write(`\n ${c.bold('Skills')}\n`);
1755
+ for (const skill of envelope.available_skills.slice(0, 12)) {
1756
+ process.stderr.write(` ${c.brand(skill.name)} ${c.dim(skill.scope)} ${c.dim(skill.description || '')}\n`);
1757
+ }
1758
+ }
1759
+ process.stderr.write('\n');
1760
+ return;
1761
+ }
1762
+
1763
+ const creds = ctx.auth.loadCredentials();
1764
+ const env = process.env.TARANG_ENV || 'production';
1765
+ const os = await import('node:os');
1766
+ const mem = process.memoryUsage();
1767
+ const approvalSummary = ctx.approval.getSummary();
1768
+
1769
+ process.stderr.write(`\n ${c.bold('Session')}\n`);
1770
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
1771
+ process.stderr.write(` ${c.dim('ID')} ${session.id || c.dim('(not assigned yet)')}\n`);
1772
+ process.stderr.write(` ${c.dim('User')} ${session.user?.github_username || '—'}\n`);
1773
+ process.stderr.write(` ${c.dim('Model')} ${session.model || 'backend default'}\n`);
1774
+ const modelOverrides = sessionModelOverrideEntries();
1775
+ if (modelOverrides.length) {
1776
+ const summary = modelOverrides
1777
+ .slice(0, 4)
1778
+ .map(([role, model]) => `${MODEL_ROLE_LABELS[role] || role}=${model}`)
1779
+ .join(', ');
1780
+ const more = modelOverrides.length > 4 ? ` +${modelOverrides.length - 4} more` : '';
1781
+ process.stderr.write(` ${c.dim('Overrides')} ${summary}${more}\n`);
1782
+ }
1783
+ if (env === 'local') {
1784
+ process.stderr.write(` ${c.dim('Backend')} ${creds.backendUrl}\n`);
1785
+ }
1786
+ process.stderr.write(` ${c.dim('Env')} ${env}\n`);
1787
+ process.stderr.write(` ${c.dim('Turns')} ${session.turns}\n`);
1788
+ const toolSplit = session.totalSubAgentToolCalls > 0
1789
+ ? ` ${c.dim(`(${session.totalPrimaryToolCalls} primary, ${session.totalSubAgentToolCalls} sub-agent)`)}`
1790
+ : '';
1791
+ const lastTurnSplit = session.subAgentToolCalls > 0
1792
+ ? ` ${c.dim(`(${session.toolCalls} primary, ${session.subAgentToolCalls} sub-agent)`)}`
1793
+ : '';
1794
+ process.stderr.write(` ${c.dim('Tools')} ${session.totalToolCalls} total${toolSplit}, ${session.toolCalls + session.subAgentToolCalls} last turn${lastTurnSplit}\n`);
1795
+ process.stderr.write(` ${c.dim('Duration')} ${formatElapsed(session.startTime)}\n`);
1796
+ if (session.isByok) {
1797
+ process.stderr.write(` ${c.dim('Billing')} ${c.green('BYOK')} ${c.dim('(provider-billed)')}\n`);
1798
+ } else {
1799
+ // Server-authoritative remaining balance; fall back to the per-session
1800
+ // charged tally when balance hasn't been pushed yet.
1801
+ if (session.subscriptionTier) {
1802
+ process.stderr.write(` ${c.dim('Plan')} ${c.brand(session.subscriptionTier.toUpperCase())}\n`);
1803
+ }
1804
+ const messageWindow = formatMessageWindow(session.rateLimit);
1805
+ if (messageWindow) {
1806
+ process.stderr.write(` ${c.dim('Messages')} ${messageWindow}\n`);
1807
+ }
1808
+ if (typeof session.creditsTotal === 'number') {
1809
+ const limit = session.creditsLimit ? ` ${c.dim('/ ' + formatCredits(session.creditsLimit))}` : '';
1810
+ const used = session.creditsCharged ? ` ${c.dim(`(${formatCredits(session.creditsCharged)} used this session)`)}` : '';
1811
+ process.stderr.write(` ${c.dim('Credits')} ${formatCredits(session.creditsTotal)}${limit}${used}\n`);
1812
+ } else if (session.creditsCharged) {
1813
+ process.stderr.write(` ${c.dim('Credits')} ${formatCredits(session.creditsCharged)} ${c.dim('(used this session)')}\n`);
1814
+ }
1815
+ }
1816
+ process.stderr.write(` ${c.dim('CWD')} ${safeCwd()}\n`);
1817
+
1818
+ // Cache — PRD-071 §1.2. Only surface when we have data; a fresh session
1819
+ // shows nothing rather than a misleading "0%".
1820
+ const cache = computeCacheTotals();
1821
+ if (cache.read > 0 || cache.write > 0) {
1822
+ const readLabel = formatTokens(cache.read);
1823
+ const writeLabel = formatTokens(cache.write);
1824
+ process.stderr.write(` ${c.dim('Cache')} ${cache.hitRate}% hit ${c.dim('·')} ${readLabel} read ${c.dim('·')} ${writeLabel} write\n`);
1825
+ }
1826
+
1827
+ // Permissions
1828
+ process.stderr.write(`\n ${c.bold('Permissions')}\n`);
1829
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
1830
+ process.stderr.write(` ${c.dim('Approved')} ${approvalSummary.approved} ${c.dim('Denied')} ${approvalSummary.denied}\n`);
1831
+ if (approvalSummary.autoApproveAll) {
1832
+ process.stderr.write(` ${c.dim('Mode')} ${c.yellow('approve-all active')}\n`);
1833
+ }
1834
+ if (approvalSummary.autoApprovedTypes.length > 0) {
1835
+ process.stderr.write(` ${c.dim('Auto-types')} ${approvalSummary.autoApprovedTypes.join(', ')}\n`);
1836
+ }
1837
+ if (approvalSummary.trust) {
1838
+ process.stderr.write(` ${c.dim('Trust')} ${approvalSummary.trust.sessionRules} session, ${approvalSummary.trust.projectRules} project rules\n`);
1839
+ }
1840
+ process.stderr.write(` ${c.dim('Blocked')} ${session.blockedOps} by safety guardrails\n`);
1841
+
1842
+ // Orchestration
1843
+ if (session.delegations.length > 0 || session.phases.length > 0) {
1844
+ process.stderr.write(`\n ${c.bold('Orchestration')}\n`);
1845
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
1846
+ if (session.delegations.length > 0) {
1847
+ process.stderr.write(` ${c.dim('Delegations')} ${session.delegations.length}\n`);
1848
+ for (const d of session.delegations.slice(-5)) {
1849
+ process.stderr.write(` ${c.dim(d.from)} ${c.brand('→')} ${d.to}\n`);
1850
+ }
1851
+ }
1852
+ if (session.phases.length > 0) {
1853
+ process.stderr.write(` ${c.dim('Phases')} ${session.phases.map(p => p.name).join(' → ')}\n`);
1854
+ }
1855
+ }
1856
+
1857
+ // Files changed
1858
+ if (session.filesChanged.length > 0) {
1859
+ process.stderr.write(`\n ${c.bold('Files Changed')} ${c.dim(`(${session.filesChanged.length})`)}\n`);
1860
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
1861
+ for (const f of session.filesChanged.slice(-10)) {
1862
+ process.stderr.write(` ${c.dim('~')} ${f}\n`);
1863
+ }
1864
+ if (session.filesChanged.length > 10) {
1865
+ process.stderr.write(` ${c.dim(` ...and ${session.filesChanged.length - 10} more`)}\n`);
1866
+ }
1867
+ }
1868
+
1869
+ // System
1870
+ process.stderr.write(`\n ${c.bold('System')}\n`);
1871
+ process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
1872
+ process.stderr.write(` ${c.dim('Node')} ${process.version}\n`);
1873
+ process.stderr.write(` ${c.dim('Platform')} ${process.platform} ${os.arch()}\n`);
1874
+ process.stderr.write(` ${c.dim('Heap')} ${(mem.heapUsed / 1024 / 1024).toFixed(0)} MB\n`);
1875
+ 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`);
1876
+ return;
1877
+ }
1878
+
1879
+ case '/settings': {
1880
+ const sub = rest.trim() || 'policy';
1881
+ if (sub === 'help') {
1882
+ renderHelp('settings');
1883
+ return;
1884
+ }
1885
+ if (sub !== 'policy') {
1886
+ process.stderr.write(` ${c.gray('Usage: /settings policy or /help settings')}\n`);
1887
+ return;
1888
+ }
1889
+ const effective = loadEffectivePolicy({ cwd: safeCwd() });
1890
+ ctx.effectivePolicy = effective;
1891
+ const rows = formatPolicySourceRows(effective);
1892
+ process.stderr.write(`\n ${c.bold('Effective Policy')}\n`);
1893
+ process.stderr.write(` ${c.dim('─'.repeat(86))}\n`);
1894
+ for (const row of rows.slice(0, 80)) {
1895
+ const value = typeof row.value === 'string' ? row.value : JSON.stringify(row.value);
1896
+ process.stderr.write(` ${c.brand(row.key.padEnd(38))} ${c.dim(row.source.padEnd(8))} ${String(value).slice(0, 34)}\n`);
1897
+ }
1898
+ if (rows.length > 80) process.stderr.write(` ${c.dim(`...and ${rows.length - 80} more`)}\n`);
1899
+ const projectLayer = effective.layers.find(l => l.name === 'project');
1900
+ if (projectLayer?.error) {
1901
+ process.stderr.write(`\n ${c.yellow('Project config error:')} ${projectLayer.error}\n`);
1902
+ }
1903
+ process.stderr.write('\n');
1904
+ return;
1905
+ }
1906
+
1907
+ case '/stats': {
1908
+ const os = await import('node:os');
1909
+ const mem = process.memoryUsage();
1910
+ const totalMem = os.totalmem();
1911
+ const usedMem = totalMem - os.freemem();
1912
+ const totalTokens = session.inputTokens + session.outputTokens;
1913
+ const ctxPct = Math.min(100, (totalTokens / 200000) * 100);
1914
+
1915
+ process.stderr.write(`\n ${c.bold('Metrics')}\n`);
1916
+ process.stderr.write(` ${c.gray('─'.repeat(40))}\n`);
1917
+ process.stderr.write(` ${progressBar(ctxPct, 15, 'Context')} ${(totalTokens / 1000).toFixed(1)}k tok\n`);
1918
+ process.stderr.write(` ${progressBar(Math.round((usedMem / totalMem) * 100), 15, 'Memory')} ${(usedMem / 1024 / 1024 / 1024).toFixed(1)}G\n`);
1919
+ process.stderr.write(` ${progressBar(Math.round((mem.heapUsed / mem.heapTotal) * 100), 15, 'Heap')} ${(mem.heapUsed / 1024 / 1024).toFixed(0)}M\n`);
1920
+ process.stderr.write(` ${c.gray('Turns:')} ${session.turns}\n`);
1921
+ process.stderr.write(` ${c.gray('Tools:')} ${session.toolCalls + session.subAgentToolCalls}`);
1922
+ if (session.subAgentToolCalls > 0) {
1923
+ process.stderr.write(c.dim(` (${session.toolCalls} primary, ${session.subAgentToolCalls} sub-agent)`));
1924
+ }
1925
+ process.stderr.write('\n');
1926
+ process.stderr.write(` ${c.gray('Blocked:')} ${session.blockedOps}\n`);
1927
+ if (session.isByok) {
1928
+ process.stderr.write(` ${c.gray('Billing:')} ${c.green('BYOK')} ${c.dim('(provider-billed)')}\n`);
1929
+ } else {
1930
+ process.stderr.write(` ${c.gray('Credits:')} ${formatCredits(costToCredits(session.totalCost))}${session.costAccurate ? '' : c.dim(' (est)')}\n`);
1931
+ }
1932
+ process.stderr.write(` ${c.gray('Elapsed:')} ${formatElapsed(session.startTime)}\n\n`);
1933
+ return;
1934
+ }
1935
+
1936
+ case '/cost': {
1937
+ if (session.isByok) {
1938
+ process.stderr.write(`\n ${c.bold('Billing')} ${c.green('BYOK')} ${c.dim('— you pay your model provider directly. Bahulam does not charge credits for BYOK usage.')}\n\n`);
1939
+ return;
1940
+ }
1941
+ // Prefer server-authoritative numbers when available.
1942
+ const used = session.creditsCharged || 0;
1943
+ const usedLabel = formatCredits(used);
1944
+ process.stderr.write(`\n ${c.bold('Session Credits')} ${c.brand(usedLabel)}`);
1945
+ if (used > 0 && !session.creditsCharged) process.stderr.write(` ${c.yellow('(estimated)')}`);
1946
+ process.stderr.write('\n');
1947
+ if (session.subscriptionTier && typeof session.creditsTotal === 'number') {
1948
+ const remaining = formatCredits(session.creditsTotal);
1949
+ const limit = session.creditsLimit ? ` / ${formatCredits(session.creditsLimit)}` : '';
1950
+ process.stderr.write(` ${c.dim('Plan')} ${c.brand(session.subscriptionTier.toUpperCase())} ${c.dim('· remaining')} ${c.brand(remaining)}${c.dim(limit)}\n`);
1951
+ }
1952
+ const messageWindow = formatMessageWindow(session.rateLimit);
1953
+ if (messageWindow) {
1954
+ process.stderr.write(` ${c.dim('Messages')} ${messageWindow}\n`);
1955
+ }
1956
+ process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
1957
+
1958
+ if (session.costBreakdown.length > 0) {
1959
+ // Header
1960
+ process.stderr.write(` ${c.dim('Model'.padEnd(36))}${c.dim('Input'.padStart(10))}${c.dim('Output'.padStart(10))}${c.dim('Cache'.padStart(10))}${c.dim('Credits'.padStart(10))}\n`);
1961
+ process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
1962
+
1963
+ for (const b of session.costBreakdown) {
1964
+ const modelLabel = b.model === 'unknown' ? c.yellow('unknown model') : b.model;
1965
+ const roleTag = b.role && b.role !== 'unknown' ? ` ${c.dim(`(${b.role})`)}` : '';
1966
+ const cacheTokens = (b.cache_read_tokens || 0) + (b.cache_creation_tokens || 0);
1967
+ const costStr = b.free ? c.green('free') : formatCredits(costToCredits(b.cost));
1968
+
1969
+ process.stderr.write(
1970
+ ` ${(modelLabel + roleTag).padEnd(36)}` +
1971
+ `${formatTokens(b.input_tokens).padStart(10)}` +
1972
+ `${formatTokens(b.output_tokens).padStart(10)}` +
1973
+ `${(cacheTokens > 0 ? formatTokens(cacheTokens) : '—').padStart(10)}` +
1974
+ `${costStr.padStart(10)}\n`
1975
+ );
1976
+ }
1977
+
1978
+ process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
1979
+ }
1980
+
1981
+ process.stderr.write(
1982
+ ` ${c.bold('Total'.padEnd(36))}` +
1983
+ `${formatTokens(session.inputTokens).padStart(10)}` +
1984
+ `${formatTokens(session.outputTokens).padStart(10)}` +
1985
+ `${''.padStart(10)}` +
1986
+ `${formatCredits(costToCredits(session.totalCost)).padStart(10)}\n`
1987
+ );
1988
+ process.stderr.write(` ${c.dim(`Turns: ${session.turns} Duration: ${formatElapsed(session.startTime)}`)}\n\n`);
1989
+ return;
1990
+ }
1991
+
1992
+ case '/last':
1993
+ expandLast();
1994
+ return;
1995
+
1996
+ case '/expand': {
1997
+ const arg = rest.trim();
1998
+ if (!arg) { expandLast(); return; }
1999
+ if (arg === 'all') { expandIndex('all'); return; }
2000
+ const n = Number(arg);
2001
+ if (!Number.isFinite(n)) {
2002
+ process.stderr.write(` ${c.gray('Usage: /expand [n|all] — n is the 1-based index from the start of the session')}\n`);
2003
+ return;
2004
+ }
2005
+ // Users pass 1-based; getCard accepts negative (-1 = last) or positive index.
2006
+ expandIndex(n > 0 ? n - 1 : n);
2007
+ return;
2008
+ }
2009
+
2010
+ case '/fold':
2011
+ process.stderr.write(` ${c.gray('Output is folded by default — there is nothing to hide. Use /last or d to expand.')}\n`);
2012
+ return;
2013
+
2014
+ case '/undo': {
2015
+ const result = ctx.checkpoints?.undo();
2016
+ if (!result) {
2017
+ process.stderr.write(` ${c.gray('No checkpoints to undo.')}\n`);
2018
+ return;
2019
+ }
2020
+ if (result.restored) {
2021
+ process.stderr.write(` ${c.green('↩')} ${c.dim('Restored')} ${result.filePath}\n`);
2022
+ } else {
2023
+ process.stderr.write(` ${c.red('✗')} ${c.dim('Undo failed: ' + (result.error || 'unknown error'))}\n`);
2024
+ }
2025
+ return;
2026
+ }
2027
+
2028
+ case '/checkpoint': {
2029
+ const list = ctx.checkpoints?.list(10) || [];
2030
+ if (!list.length) {
2031
+ process.stderr.write(` ${c.gray('No checkpoints recorded yet — they are taken automatically before each edit.')}\n`);
2032
+ return;
2033
+ }
2034
+ process.stderr.write(`\n ${c.bold('Recent checkpoints')}\n ${c.gray('─'.repeat(40))}\n`);
2035
+ for (const ckpt of list) {
2036
+ const when = String(ckpt.timestamp).slice(11, 19);
2037
+ process.stderr.write(` ${c.gray(when)} ${c.white(ckpt.file)} ${c.gray(formatTokens(ckpt.size) + ' bytes')}\n`);
2038
+ }
2039
+ process.stderr.write(`\n ${c.gray('/undo restores the most recent one')}\n\n`);
2040
+ return;
2041
+ }
2042
+
2043
+ case '/preflight': {
2044
+ await runPreflight({ auth: ctx.auth, cwd: safeCwd(), version: VERSION });
2045
+ return;
2046
+ }
2047
+
2048
+ case '/report': {
2049
+ if (Object.keys(session.toolCounts).length === 0 && session.filesChanged.length === 0 && session.filesRead.length === 0) {
2050
+ process.stderr.write(` ${c.gray('Nothing to report yet — run a task first.')}\n`);
2051
+ return;
2052
+ }
2053
+ const state = {
2054
+ task: session.lastTask,
2055
+ success: true,
2056
+ filesChanged: session.filesChanged,
2057
+ filesRead: session.filesRead,
2058
+ toolCounts: session.toolCounts,
2059
+ subAgents: { ...session.subAgentCounts, savedUsd: session.isByok ? 0 : session.savedUsd },
2060
+ costUsd: session.isByok ? null : session.totalCost,
2061
+ durationS: (Date.now() - session.startTime) / 1000,
2062
+ nextActions: [],
2063
+ cwd: safeCwd(),
2064
+ };
2065
+ const out = saveReport(state, { cwd: safeCwd() });
2066
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Saved')} ${out}\n`);
2067
+ return;
2068
+ }
2069
+
2070
+ case '/why': {
2071
+ if (!session.lastReasoning) {
2072
+ process.stderr.write(` ${c.gray('No reasoning captured yet for this session.')}\n`);
2073
+ return;
2074
+ }
2075
+ process.stderr.write(`\n ${c.bold('Last reasoning')}\n ${c.gray('─'.repeat(40))}\n`);
2076
+ for (const line of String(session.lastReasoning).split('\n')) {
2077
+ process.stderr.write(` ${c.dim(line)}\n`);
2078
+ }
2079
+ process.stderr.write('\n');
2080
+ return;
2081
+ }
2082
+
2083
+ case '/map': {
2084
+ try {
2085
+ const resources = ctx.toolExecutor?.getProjectResources?.() || [];
2086
+ if (!resources.length) {
2087
+ process.stderr.write(` ${c.gray('No project resources registered yet. Use get_project_overview to register one.')}\n`);
2088
+ return;
2089
+ }
2090
+ process.stderr.write(`\n ${c.bold('Registered projects')}\n ${c.gray('─'.repeat(40))}\n`);
2091
+ for (const r of resources) {
2092
+ process.stderr.write(` ${c.brand('•')} ${c.white(r.id || r.name || '?')} ${c.dim(r.root || r.path || '')}\n`);
2093
+ }
2094
+ process.stderr.write('\n');
2095
+ } catch (err) {
2096
+ process.stderr.write(` ${c.red('/map failed: ' + err.message)}\n`);
2097
+ }
2098
+ return;
2099
+ }
2100
+
2101
+ case '/budget': {
2102
+ const arg = rest.trim();
2103
+ if (!arg) {
2104
+ const current = session.budgetUsd ? `$${session.budgetUsd.toFixed(2)}` : 'not set';
2105
+ process.stderr.write(` ${c.dim('Budget cap:')} ${c.brand(current)} ${c.dim('· set with /status budget <amount> or clear with /status budget clear')}\n`);
2106
+ return;
2107
+ }
2108
+ if (arg === 'clear' || arg === 'off') {
2109
+ session.budgetUsd = null;
2110
+ session.budgetExceeded = false;
2111
+ process.stderr.write(` ${c.gray('Budget cap cleared.')}\n`);
2112
+ return;
2113
+ }
2114
+ const n = Number(arg.replace(/^\$/, ''));
2115
+ if (!Number.isFinite(n) || n <= 0) {
2116
+ process.stderr.write(` ${c.gray('Usage: /budget <amount in USD> or /budget clear')}\n`);
2117
+ return;
2118
+ }
2119
+ session.budgetUsd = n;
2120
+ session.budgetExceeded = false;
2121
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Budget set: ')} $${n.toFixed(2)}\n`);
2122
+ return;
2123
+ }
2124
+
2125
+ case '/quiet':
2126
+ case '/verbose':
2127
+ case '/surgical': {
2128
+ const mode = cmd === '/quiet' ? V_MODES.QUIET
2129
+ : cmd === '/verbose' ? V_MODES.VERBOSE
2130
+ : V_MODES.SURGICAL;
2131
+ setVerbosity(mode);
2132
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Verbosity: ')} ${c.brand(verbosityLabel(mode))}\n`);
2133
+ return;
2134
+ }
2135
+
2136
+ case '/compact': {
2137
+ await compactCurrentSession(ctx, rest);
2138
+ return;
2139
+ }
2140
+
2141
+ case '/model': {
2142
+ handleModelCommand(rest);
2143
+ return;
2144
+ }
2145
+
2146
+ case '/new':
2147
+ if (ctx.startNewSession) await ctx.startNewSession();
2148
+ else process.stderr.write(` ${c.yellow('!')} ${c.dim('New session reset is unavailable in this mode.')}\n`);
2149
+ return;
2150
+
2151
+ case '/clear':
2152
+ session.history.length = 0;
2153
+ session.agentHistory.length = 0;
2154
+ session.toolCalls = 0;
2155
+ session.subAgentToolCalls = 0;
2156
+ clearCards();
2157
+ process.stderr.write(` ${c.gray('Conversation cleared.')}\n`);
2158
+ return;
2159
+
2160
+ case '/git': {
2161
+ const { execSync } = await import('node:child_process');
2162
+ try { process.stdout.write(execSync('git status --short --branch', { encoding: 'utf-8' }) + '\n'); }
2163
+ catch (e) { process.stderr.write(` ${c.red(e.message)}\n`); }
2164
+ return;
2165
+ }
2166
+
2167
+ case '/diff': {
2168
+ const { execSync } = await import('node:child_process');
2169
+ try {
2170
+ const diff = execSync('git diff --no-ext-diff --unified=3', {
2171
+ encoding: 'utf-8',
2172
+ maxBuffer: 2 * 1024 * 1024,
2173
+ });
2174
+ process.stdout.write(diff ? renderDiff(diff) + '\n' : c.dim('(no changes)') + '\n');
2175
+ }
2176
+ catch (e) { process.stderr.write(` ${c.red(e.message)}\n`); }
2177
+ return;
2178
+ }
2179
+
2180
+ case '/safety': {
2181
+ const { getSafetyRules } = await import('../core/safety.mjs');
2182
+ const rules = getSafetyRules();
2183
+ const summary = ctx.approval.getSummary();
2184
+ process.stderr.write(`\n ${c.bold('Safety Guardrails')} ${c.green('ACTIVE')}\n`);
2185
+ process.stderr.write(` ${c.gray('─'.repeat(40))}\n`);
2186
+ process.stderr.write(` ${c.gray('Approval mode:')} ${ctx.approval.getModeLabel()}\n`);
2187
+ process.stderr.write(` ${c.gray('Approved:')} ${summary.approved} ${c.gray('Denied:')} ${summary.denied}\n`);
2188
+ process.stderr.write(` ${c.gray('Protected files:')} ${rules.protectedNames.join(', ')}\n`);
2189
+ process.stderr.write(` ${c.gray('Source dirs:')} ${rules.sourceDirs.join(', ')}\n`);
2190
+ process.stderr.write(` ${c.gray('Blocked patterns:')} ${rules.blockedPatterns}\n`);
2191
+ process.stderr.write(` ${c.gray('High-risk patterns:')} ${rules.highRiskPatterns}\n`);
2192
+ process.stderr.write(` ${c.gray('Ops blocked:')} ${session.blockedOps}\n\n`);
2193
+ return;
2194
+ }
2195
+
2196
+ case '/revoke': {
2197
+ const wasActive = ctx.approval.revoke();
2198
+ if (wasActive) {
2199
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Auto-approvals revoked. All tool calls will prompt again.')}\n`);
2200
+ } else {
2201
+ process.stderr.write(` ${c.gray('No auto-approvals were active.')}\n`);
2202
+ }
2203
+ return;
2204
+ }
2205
+
2206
+ case '/sessions': {
2207
+ const resumable = await listResumableSessions();
2208
+ if (resumable.length === 0) {
2209
+ process.stderr.write(` ${c.gray('No resumable sessions found.')}\n`);
2210
+ return;
2211
+ }
2212
+ process.stderr.write(`\n ${c.bold('Resumable Sessions')}\n`);
2213
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
2214
+ for (const s of resumable) {
2215
+ const date = sessionListTimestamp(s);
2216
+ const instr = oneLineInstruction(s.instruction, 72);
2217
+ const project = s.project || path.basename(s.projectPath || '') || '(unknown)';
2218
+ process.stderr.write(` ${c.brand(s.sessionId)} ${c.brand(project)} ${c.dim(date)} ${messageCountLabel(s.messageCount)} ${c.dim(instr)}\n`);
2219
+ }
2220
+ process.stderr.write(`\n ${c.dim('Resume with:')} bahulam-code --resume <sessionId>\n`);
2221
+ return;
2222
+ }
2223
+
2224
+ case '/resume': {
2225
+ // PRD-068 §5.14: one-prompt picker, context-length driven auto-decision,
2226
+ // direct-resume mode flags (--full, --tail10, --tail20, --summary).
2227
+ const parts = input.split(/\s+/).filter(Boolean);
2228
+ const forcedFlag = parts.find(p => /^(--full|--tail10|--tail20|--recap|--summary|-f|-1|-2|-r|-s)$/.test(p));
2229
+ const forcedMode = forcedFlag
2230
+ ? ({ '--full': 'full', '-f': 'full',
2231
+ '--tail10': 'tail-10', '-1': 'tail-10',
2232
+ '--tail20': 'tail-20', '-2': 'tail-20',
2233
+ '--recap': 'tail-20', '-r': 'tail-20',
2234
+ '--summary': 'summary', '-s': 'summary' })[forcedFlag]
2235
+ : null;
2236
+ const targetId = parts.slice(1).find(p => !p.startsWith('-'));
2237
+
2238
+ const resumable = await listResumableSessions();
2239
+ if (resumable.length === 0) {
2240
+ process.stderr.write(` ${c.gray('No resumable sessions found.')}\n`);
2241
+ return;
2242
+ }
2243
+
2244
+ // 1. Resolve which session to resume.
2245
+ let picked = null;
2246
+ if (targetId) {
2247
+ picked = resumable.find(s => s.sessionId === targetId || s.sessionId?.startsWith(targetId));
2248
+ if (!picked) {
2249
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`No session found matching id: ${targetId}`)}\n`);
2250
+ return;
2251
+ }
2252
+ } else {
2253
+ while (!picked) {
2254
+ const pickResult = await pickResumableSession(resumable, ctx);
2255
+ if (!pickResult) { process.stderr.write(`\n ${c.dim('Cancelled.')}\n`); return; }
2256
+ if (pickResult.action === 'resume') {
2257
+ if (!pickResult.session) {
2258
+ process.stderr.write(`\n ${c.yellow('!')} ${c.dim('Empty session — pick another.')}\n`);
2259
+ continue;
2260
+ }
2261
+ picked = pickResult.session;
2262
+ break;
2263
+ }
2264
+ if (pickResult.action === 'preview') {
2265
+ // Yield to the event loop so any queued keystrokes from the picker
2266
+ // don't spill into the preview's stdin listener (PRD-068 §5.14 bugfix).
2267
+ await new Promise(r => setImmediate(r));
2268
+ const previewResult = await previewResumeSession(pickResult.session, ctx);
2269
+ if (previewResult && previewResult.action === 'resume') {
2270
+ picked = pickResult.session;
2271
+ // Preview already committed to a mode — skip the threshold overlay.
2272
+ picked._presetMode = previewResult.mode;
2273
+ break;
2274
+ }
2275
+ if (previewResult === null) {
2276
+ // getSessionDetail failed — file missing, unreadable, or huge.
2277
+ // Tell the user why before looping back to the picker.
2278
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('Could not load transcript for preview — pick another session or press Enter to resume without preview.')}\n`);
2279
+ }
2280
+ // Yield again so the loop-back render doesn't collide with the
2281
+ // just-closed preview's stdin cleanup.
2282
+ await new Promise(r => setImmediate(r));
2283
+ }
2284
+ }
2285
+ }
2286
+
2287
+ // 2. Decide the mode. Force-flag > preview-preset > auto-decide > overlay.
2288
+ let mode = forcedMode || picked._presetMode || null;
2289
+ if (!mode) {
2290
+ const currentModel = picked.modelLimits?.coder?.model
2291
+ || picked.models?.[picked.models.length - 1]
2292
+ || session?.model
2293
+ || session?.user?.default_reasoning_model
2294
+ || null;
2295
+ const decision = decideResumeMode({
2296
+ transcriptTokens: picked.contextTokens,
2297
+ model: currentModel,
2298
+ contextWindow: picked.modelLimits?.coder || session?.modelLimits?.coder || null,
2299
+ settings: ctx.effectivePolicy?.policy?.resume ? { resume: ctx.effectivePolicy.policy.resume } : {},
2300
+ });
2301
+ decision.resumeSummary = picked.resumeSummary || null;
2302
+ if (decision.mode === 'full') {
2303
+ mode = 'full';
2304
+ } else {
2305
+ // Yield before attaching the overlay listener — same race guard as
2306
+ // the preview branch above. Prevents a queued Enter from the picker
2307
+ // slipping into the overlay's stdin, which otherwise looked like a
2308
+ // duplicate picker render.
2309
+ await new Promise(r => setImmediate(r));
2310
+ const chosen = await chooseThresholdMode(ctx, decision);
2311
+ if (!chosen) { process.stderr.write(`\n ${c.dim('Cancelled.')}\n`); return; }
2312
+ mode = chosen;
2313
+ }
2314
+ }
2315
+
2316
+ // 3. Activate.
2317
+ const source = targetId ? 'direct' : 'picker';
2318
+ const progress = startResumeProgress(mode);
2319
+ let resumed;
2320
+ try {
2321
+ resumed = await ctx.activateResumedSession(picked.sessionId, source, mode, picked, {
2322
+ onProgress: progress.update,
2323
+ onProgressStop: progress.stop,
2324
+ });
2325
+ } finally {
2326
+ progress.stop();
2327
+ }
2328
+ if (!resumed.ok) {
2329
+ if (resumed.reason === 'cwd-cancelled') {
2330
+ process.stderr.write(`\n ${c.dim('Cancelled.')}\n`);
2331
+ } else {
2332
+ process.stderr.write(`\n ${c.yellow('!')} ${c.dim(resumed.reason || 'No messages in that session.')}\n`);
2333
+ }
2334
+ return;
2335
+ }
2336
+
2337
+ // 4. Report — succinct honest single line, then optional hydration warning.
2338
+ const toolSummary = resumed.stats && resumed.stats.toolCalls
2339
+ ? `${resumed.stats.toolCalls} tool calls`
2340
+ : `${resumed.messages} msgs`;
2341
+ const summaryLabel = mode !== 'full' && resumed.summarySource
2342
+ ? ` · summary: ${resumed.summarySource}`
2343
+ : '';
2344
+ process.stderr.write(`\n ${c.green('↺')} ${c.dim('Resumed')} ${c.brand(picked.project || path.basename(safeCwd()))} ${c.dim(`· ${resumed.messages} msgs · ${toolSummary} · mode: ${resumeModeLabel(mode)}${summaryLabel}`)}\n`);
2345
+ if (resumed.summaryWarning) {
2346
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`backend summary unavailable — using local summary (${resumed.summaryWarning})`)}\n`);
2347
+ }
2348
+ if (resumed.hydrationFailures?.length) {
2349
+ for (const failure of resumed.hydrationFailures) {
2350
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`could not re-read project root: ${failure}`)}\n`);
2351
+ }
2352
+ }
2353
+ if (resumed.stayedInCwd) {
2354
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`resumed transcript from ${resumed.savedProjectPath} — running against ${safeCwd()}`)}\n`);
2355
+ }
2356
+
2357
+ // 5. Show continuity context. Non-summary modes use the captured
2358
+ // kepler_event stream when available so the terminal replay matches
2359
+ // the original styled interaction; older sessions fall back to
2360
+ // reconstructed text.
2361
+ if (mode === 'summary' && resumed.summary) {
2362
+ // In summary mode the agent gets only the summary block. Show it so
2363
+ // the user knows what continuity context was included.
2364
+ process.stderr.write(`\n ${c.bold('Continuity Summary')}\n`);
2365
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
2366
+ for (const line of resumed.summary.split('\n')) {
2367
+ process.stderr.write(` ${c.dim(line)}\n`);
2368
+ }
2369
+ process.stderr.write('\n');
2370
+ } else if (resumed.replayEvents?.length) {
2371
+ renderResumePreview(resumed, { renderEvent });
2372
+ } else if (resumed.history?.length) {
2373
+ // Full/tail modes feed real conversation to the agent — show
2374
+ // the tail so the user has visual context. Cap at 30 entries to avoid
2375
+ // flooding the terminal on long sessions.
2376
+ renderHistoryEntries(resumed.history, {
2377
+ limit: 30,
2378
+ maxChars: 200,
2379
+ title: mode?.startsWith('tail-') ? `Recent turns (${mode.replace('tail-', 'last ')})` : 'Conversation history (last 30 entries)',
2380
+ });
2381
+ }
2382
+ return;
2383
+ }
2384
+
2385
+ case '/agents':
2386
+ await handleAgentsCommand(rest, ctx);
2387
+ return;
2388
+
2389
+ case '/explore':
2390
+ case '/review':
2391
+ case '/architect': {
2392
+ if (!rest) {
2393
+ process.stderr.write(` ${c.yellow('Usage:')} ${cmd} <instruction>\n`);
2394
+ 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`);
2395
+ return;
2396
+ }
2397
+ return await runAgent(cmd.slice(1), rest, ctx, session, renderEvent);
2398
+ }
2399
+
2400
+ case '/logout': {
2401
+ const success = ctx.auth.logout();
2402
+ if (success) {
2403
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Signed out. Credentials cleared from ~/.bahulam/config.json')}\n`);
2404
+ process.stderr.write(` ${c.dim('Run /login to sign in again.')}\n`);
2405
+ } else {
2406
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('No credentials to clear.')}\n`);
2407
+ }
2408
+ return;
2409
+ }
2410
+
2411
+ case '/exit':
2412
+ case '/quit':
2413
+ if (isInputDockMounted()) unmountInputDock();
2414
+ process.stderr.write(`\n ${c.brand('Goodbye!')}\n\n`);
2415
+ process.exit(0);
2416
+
2417
+ default:
2418
+ process.stderr.write(` ${c.gray(`Unknown: ${cmd}. Type /help.`)}\n`);
2419
+ }
2420
+ }
2421
+
2422
+ // ── Fetch User Profile ──
2423
+
2424
+ async function fetchUser(ctx) {
2425
+ const creds = ctx.auth.loadCredentials();
2426
+ if (!creds.token) return;
2427
+ try {
2428
+ const resp = await fetch(`${creds.backendUrl}/api/user/me`, {
2429
+ headers: { 'Authorization': `Bearer ${creds.token}` },
2430
+ });
2431
+ if (resp.ok) {
2432
+ session.user = await resp.json();
2433
+ session.model = session.user.default_reasoning_model || session.user.default_orchestrator_model || null;
2434
+ }
2435
+ } catch {}
2436
+ }
2437
+
2438
+ // ── Main REPL ──
2439
+ // Cache CWD at startup so safeCwd() has a fallback if the dir gets deleted
2440
+
2441
+ export async function startTerminalRepl() {
2442
+ safeCwd(); // prime the cache in repl-utils.mjs for later recovery
2443
+
2444
+ const cliArgs = parseArgs(process.argv.slice(2));
2445
+ const auth = new TarangAuth();
2446
+
2447
+ // Projects are registered and indexed on demand through get_project_overview.
2448
+ // CheckpointManager records per-file snapshots before edits so /undo works.
2449
+ let checkpoints = new CheckpointManager(safeCwd());
2450
+ let effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
2451
+ let latestProjectContext = null;
2452
+ let latestEnvelope = null;
2453
+ let hookRunner = new HookRunner({ cwd: safeCwd() });
2454
+ let toolExecutor = createToolExecutor({ checkpoints, hookRunner });
2455
+ const skipPerms = cliArgs.freeswim;
2456
+ let approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
2457
+
2458
+ // Session manager — persists conversation messages to .bahulam/conversations/
2459
+ let sessionMgr = new SessionManager(safeCwd());
2460
+ sessionMgrRef.current = sessionMgr; // expose to renderEvent
2461
+
2462
+ // Local JSONL writer — writes cc-lens compatible session data to ~/.bahulam/
2463
+ let jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
2464
+
2465
+ // Persistent stream client — session_id captured from backend on first turn
2466
+ let streamClient = null;
2467
+
2468
+ const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints, effectivePolicy, latestProjectContext, latestEnvelope, pendingVisionPaths: [] };
2469
+
2470
+ async function startNewSession({ announce = true } = {}) {
2471
+ stopSpinner();
2472
+ flushContent();
2473
+ flushPendingHead();
2474
+ flushCompactReadRun();
2475
+ clearCards();
2476
+
2477
+ const preserved = {
2478
+ inputHistory: session.inputHistory,
2479
+ user: session.user,
2480
+ model: session.model,
2481
+ modelLimits: session.modelLimits,
2482
+ modelOverrides: session.modelOverrides,
2483
+ isByok: session.isByok,
2484
+ subscriptionTier: session.subscriptionTier,
2485
+ creditsTotal: session.creditsTotal,
2486
+ creditsIncluded: session.creditsIncluded,
2487
+ creditsPurchased: session.creditsPurchased,
2488
+ creditsLimit: session.creditsLimit,
2489
+ rateLimit: session.rateLimit,
2490
+ };
2491
+
2492
+ try { await jsonlWriter.close(); } catch {}
2493
+
2494
+ checkpoints = new CheckpointManager(safeCwd());
2495
+ effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
2496
+ hookRunner = new HookRunner({ cwd: safeCwd() });
2497
+ toolExecutor = createToolExecutor({ checkpoints, hookRunner });
2498
+ approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
2499
+ if (ctx._rl) approval.setReadline(ctx._rl);
2500
+ sessionMgr = new SessionManager(safeCwd());
2501
+ sessionMgrRef.current = sessionMgr;
2502
+ jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
2503
+ streamClient = null;
2504
+ latestProjectContext = null;
2505
+ latestEnvelope = null;
2506
+
2507
+ Object.assign(session, {
2508
+ id: null,
2509
+ startTime: Date.now(),
2510
+ inputTokens: 0,
2511
+ outputTokens: 0,
2512
+ toolCalls: 0,
2513
+ subAgentToolCalls: 0,
2514
+ totalToolCalls: 0,
2515
+ totalPrimaryToolCalls: 0,
2516
+ totalSubAgentToolCalls: 0,
2517
+ turns: 0,
2518
+ history: [],
2519
+ agentHistory: [],
2520
+ inputHistory: preserved.inputHistory,
2521
+ user: preserved.user,
2522
+ model: preserved.model,
2523
+ modelLimits: preserved.modelLimits,
2524
+ modelOverrides: preserved.modelOverrides,
2525
+ blockedOps: 0,
2526
+ delegations: [],
2527
+ phases: [],
2528
+ inSubAgent: false,
2529
+ filesChanged: [],
2530
+ filesRead: [],
2531
+ lastTurnDuration: 0,
2532
+ toolCounts: {},
2533
+ subAgentCounts: {},
2534
+ savedUsd: 0,
2535
+ lastTask: '',
2536
+ lastReasoning: '',
2537
+ budgetUsd: null,
2538
+ budgetExceeded: false,
2539
+ costBreakdown: [],
2540
+ totalCost: 0,
2541
+ costAccurate: false,
2542
+ isByok: preserved.isByok,
2543
+ subscriptionTier: preserved.subscriptionTier,
2544
+ creditsTotal: preserved.creditsTotal,
2545
+ creditsIncluded: preserved.creditsIncluded,
2546
+ creditsPurchased: preserved.creditsPurchased,
2547
+ creditsLimit: preserved.creditsLimit,
2548
+ creditsCharged: 0,
2549
+ creditsLowWarned: false,
2550
+ rateLimit: preserved.rateLimit,
2551
+ msgsLowWarned: false,
2552
+ _lastEmittedThinking: '',
2553
+ });
2554
+
2555
+ Object.assign(ctx, {
2556
+ toolExecutor,
2557
+ approval,
2558
+ jsonlWriter,
2559
+ sessionMgr,
2560
+ checkpoints,
2561
+ effectivePolicy,
2562
+ latestProjectContext,
2563
+ latestEnvelope,
2564
+ pendingVisionPaths: ctx.pendingVisionPaths || [],
2565
+ });
2566
+
2567
+ if (announce) process.stderr.write(` ${c.green('✓')} ${c.dim('New session started.')}\n`);
2568
+ }
2569
+ ctx.startNewSession = startNewSession;
2570
+
2571
+ /**
2572
+ * Activate a previously-recorded session for continuation.
2573
+ *
2574
+ * Contract (PRD-068 §5.14 and follow-up clarification):
2575
+ * 1. Keep the same sessionId. The resumed session IS the same session,
2576
+ * not a fork. Future turns are appended to the SAME .jsonl file that
2577
+ * was read here.
2578
+ * 2. Do not re-write the loaded transcript back to disk. The file already
2579
+ * contains every historical entry; the load path is read-only. Any
2580
+ * duplication would double-count tokens on the next resume.
2581
+ * 3. Fresh sessions (kepler started without /resume) get a fresh UUID
2582
+ * the first time jsonlWriter.writeUserTurn() runs — that path is
2583
+ * untouched by resume, so brand-new sessions never inherit an old id.
2584
+ * 4. In-memory history (session.history / session.agentHistory) mirrors
2585
+ * what the agent will see next turn; it is NOT written back to the
2586
+ * transcript at activation time.
2587
+ */
2588
+ async function activateResumedSession(sessionId, source = 'resume', historyMode = 'full', resumeEntry = null, options = {}) {
2589
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
2590
+ const onProgressStop = typeof options.onProgressStop === 'function' ? options.onProgressStop : () => {};
2591
+ // PRD-068 §5.14.6: JSONL is the only source. No legacy conversation fallback.
2592
+ onProgress('reading saved transcript', 14);
2593
+ const detail = await getSessionDetail(sessionId, { filePath: resumeEntry?.transcriptPath });
2594
+ if (!detail) {
2595
+ return { ok: false, reason: `No transcript found for session ${sessionId}` };
2596
+ }
2597
+ onProgress('building resume context', 28);
2598
+ const richHistory = buildResumeHistory({ ...detail, recapTailTurns: 8 }, historyMode);
2599
+ const displayHistory = richHistory.displayHistory;
2600
+ if (!displayHistory.length) {
2601
+ return { ok: false, reason: `Session ${sessionId} has no readable messages` };
2602
+ }
2603
+
2604
+ // PRD-068 §5.14.7: explicit cwd confirmation if saved path differs.
2605
+ const savedProjectPath = detail?.meta?.project || '';
2606
+ let summarySource = 'local';
2607
+ let summaryWarning = '';
2608
+ if (historyMode !== 'full' && richHistory.sourceMessages?.length) {
2609
+ onProgress('summarizing transcript', 34);
2610
+ const backendSummary = await summarizeResumeTranscript({
2611
+ auth,
2612
+ toolExecutor,
2613
+ sessionId,
2614
+ projectPath: savedProjectPath || safeCwd(),
2615
+ messages: richHistory.sourceMessages,
2616
+ });
2617
+ if (backendSummary?.summary) {
2618
+ richHistory.summary = combineResumeSummaries(richHistory.priorSummary, backendSummary.summary);
2619
+ const summaryIndex = Number.isInteger(richHistory.summaryMessageIndex)
2620
+ ? richHistory.summaryMessageIndex
2621
+ : 0;
2622
+ if (richHistory.agentHistory?.[summaryIndex]) {
2623
+ const tailTurns = resumeTailTurnCount(historyMode);
2624
+ const prefix = tailTurns
2625
+ ? `Summary of earlier turns before the retained last ${tailTurns} conversation messages:\n`
2626
+ : 'Session continuity summary:\n';
2627
+ richHistory.agentHistory[summaryIndex] = {
2628
+ ...richHistory.agentHistory[summaryIndex],
2629
+ content: `${prefix}${richHistory.summary}`,
2630
+ };
2631
+ }
2632
+ summarySource = backendSummary.source || 'backend';
2633
+ } else {
2634
+ summarySource = 'local fallback';
2635
+ summaryWarning = backendSummary?.reason || 'backend summary unavailable';
2636
+ }
2637
+ } else if (historyMode !== 'full') {
2638
+ summarySource = 'not needed';
2639
+ summaryWarning = resumeTailTurnCount(historyMode)
2640
+ ? 'retained tail covers the whole transcript'
2641
+ : 'empty transcript';
2642
+ }
2643
+ const agentHistory = richHistory.agentHistory;
2644
+ const originalCwd = safeCwd();
2645
+ let switchedProject = false;
2646
+ let projectMissing = false;
2647
+ let stayedInCwd = false;
2648
+ onProgress('checking project cwd', 40);
2649
+ if (savedProjectPath && savedProjectPath !== originalCwd) {
2650
+ if (fs.existsSync(savedProjectPath)) {
2651
+ onProgressStop();
2652
+ const choice = await confirmCwdSwitch(ctx, savedProjectPath, originalCwd);
2653
+ if (choice === 'cancel') return { ok: false, reason: 'cwd-cancelled' };
2654
+ if (choice === 'switch') {
2655
+ try {
2656
+ process.chdir(savedProjectPath);
2657
+ safeCwd(); // re-prime the cache after chdir
2658
+ switchedProject = true;
2659
+ } catch {
2660
+ projectMissing = true;
2661
+ }
2662
+ } else if (choice === 'stay') {
2663
+ stayedInCwd = true;
2664
+ }
2665
+ } else {
2666
+ projectMissing = true;
2667
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`saved project path unavailable: ${savedProjectPath} — using current cwd`)}\n`);
2668
+ }
2669
+ }
2670
+
2671
+ onProgress('rebuilding local session state', 55);
2672
+ checkpoints = new CheckpointManager(safeCwd());
2673
+ effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
2674
+ hookRunner = new HookRunner({ cwd: safeCwd(), sessionId });
2675
+ toolExecutor = createToolExecutor({ checkpoints, hookRunner });
2676
+ approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
2677
+ if (ctx._rl) approval.setReadline(ctx._rl);
2678
+ sessionMgr = new SessionManager(safeCwd());
2679
+ sessionMgr.activateSession(sessionId, {
2680
+ instruction: detail?.meta?.firstPrompt || '',
2681
+ started_at: detail?.meta?.startTime || new Date().toISOString(),
2682
+ }, displayHistory.filter(m => m.role === 'user' || m.role === 'assistant'));
2683
+ sessionMgrRef.current = sessionMgr;
2684
+
2685
+ try { await jsonlWriter.close(); } catch {}
2686
+ jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
2687
+ jsonlWriter.setSessionId(sessionId);
2688
+ if (
2689
+ historyMode !== 'full'
2690
+ && richHistory.summary
2691
+ && Number(richHistory.summaryCoveredMessageCount) > Number(richHistory.summaryCheckpointMessageCount || 0)
2692
+ ) {
2693
+ jsonlWriter.writeKeplerEvent({
2694
+ type: 'resume_summary',
2695
+ data: {
2696
+ session_id: sessionId,
2697
+ mode: historyMode,
2698
+ mode_label: resumeModeLabel(historyMode),
2699
+ summary: richHistory.summary,
2700
+ summary_source: summarySource,
2701
+ summary_warning: summaryWarning || null,
2702
+ source_message_count: richHistory.summaryCoveredMessageCount,
2703
+ previous_source_message_count: richHistory.summaryCheckpointMessageCount || 0,
2704
+ full_message_count: richHistory.fullMessageCount || 0,
2705
+ },
2706
+ });
2707
+ }
2708
+ jsonlWriter.writeKeplerEvent({
2709
+ type: 'resume_context',
2710
+ data: {
2711
+ session_id: sessionId,
2712
+ source,
2713
+ mode: historyMode,
2714
+ mode_label: resumeModeLabel(historyMode),
2715
+ messages: displayHistory.length,
2716
+ summary_source: summarySource,
2717
+ summary_injected: historyMode !== 'full' && Boolean(richHistory.agentHistory?.[richHistory.summaryMessageIndex ?? 0]?.content),
2718
+ summary_warning: summaryWarning || null,
2719
+ summary_source_message_count: richHistory.summaryCoveredMessageCount || 0,
2720
+ previous_summary_source_message_count: richHistory.summaryCheckpointMessageCount || 0,
2721
+ project_path: savedProjectPath || safeCwd(),
2722
+ },
2723
+ });
2724
+
2725
+ streamClient = null;
2726
+ latestProjectContext = loadProjectContext({ cwd: safeCwd() });
2727
+ latestEnvelope = null;
2728
+
2729
+ // PRD-068 §5.14.8: report hydration failures instead of swallowing them.
2730
+ const hydrationFailures = [];
2731
+ const resumeRoots = getTranscriptProjectRoots(detail);
2732
+ const rootsToRegister = [...new Set([safeCwd(), ...resumeRoots].filter(Boolean))];
2733
+ onProgress('hydrating project roots', 68);
2734
+ for (let i = 0; i < rootsToRegister.length; i++) {
2735
+ const root = rootsToRegister[i];
2736
+ onProgress(`hydrating project root ${i + 1}/${rootsToRegister.length}`, 68 + Math.round((i / Math.max(1, rootsToRegister.length)) * 18));
2737
+ try {
2738
+ await toolExecutor.execute('get_project_overview', { path: root });
2739
+ } catch {
2740
+ hydrationFailures.push(root);
2741
+ }
2742
+ }
2743
+
2744
+ onProgress('preparing replay', 92);
2745
+ session.history = displayHistory;
2746
+ session.agentHistory = agentHistory;
2747
+ session.id = sessionId;
2748
+ session.turns = displayHistory.filter(m => m.role === 'user').length;
2749
+ session.lastTask = detail?.meta?.firstPrompt || session.history.find(m => m.role === 'user')?.content || '';
2750
+
2751
+ Object.assign(ctx, {
2752
+ toolExecutor,
2753
+ approval,
2754
+ jsonlWriter,
2755
+ sessionMgr,
2756
+ checkpoints,
2757
+ effectivePolicy,
2758
+ latestProjectContext,
2759
+ latestEnvelope,
2760
+ pendingVisionPaths: ctx.pendingVisionPaths || [],
2761
+ });
2762
+
2763
+ return {
2764
+ ok: true,
2765
+ messages: displayHistory.length,
2766
+ projectPath: savedProjectPath || safeCwd(),
2767
+ savedProjectPath,
2768
+ switchedProject,
2769
+ projectMissing,
2770
+ stayedInCwd,
2771
+ hydrationFailures,
2772
+ instruction: detail?.meta?.firstPrompt || '',
2773
+ historyMode,
2774
+ summary: richHistory.summary || '',
2775
+ summarySource,
2776
+ summaryWarning,
2777
+ history: displayHistory,
2778
+ replayEvents: detail.replayEvents || [],
2779
+ stats: richHistory.stats,
2780
+ source,
2781
+ };
2782
+ }
2783
+ ctx.activateResumedSession = activateResumedSession;
2784
+
2785
+ // ── Print banner + preflight + init BEFORE mounting the status bar ──
2786
+ // The status bar shrinks the scroll region; if it mounts first, the
2787
+ // banner scrolls off-screen before the user ever sees it.
2788
+ printBanner(auth);
2789
+
2790
+ // Preflight diagnostic (PRD-055 §9). Non-blocking; opt-out via
2791
+ // KEPLER_NO_PREFLIGHT=1 (used by tests / scripted runs).
2792
+ if (process.env.KEPLER_NO_PREFLIGHT !== '1' && !cliArgs.freeswim) {
2793
+ try { await runPreflight({ auth, cwd: safeCwd(), version: VERSION }); }
2794
+ catch { /* preflight is best-effort */ }
2795
+ }
2796
+
2797
+ // ── Initialization ──
2798
+ process.stderr.write(` ${c.brand('⠋')} ${c.dim('Initializing...')}\r`);
2799
+ await fetchUser(ctx);
2800
+
2801
+ // Clear the spinner line
2802
+ process.stderr.write(`\r${' '.repeat(60)}\r`);
2803
+ process.stderr.write(` ${c.green('✓')} ${c.dim('Ready; projects will be indexed on demand')}\n`);
2804
+ if (session.user) {
2805
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Logged in as ${session.user.github_username || session.user.email || 'user'}`)}\n`);
2806
+ }
2807
+ // ── Resume previous session ──
2808
+ if (cliArgs.resume) {
2809
+ const lastSession = cliArgs.resumeSessionId
2810
+ ? { sessionId: cliArgs.resumeSessionId }
2811
+ : sessionMgr.getLastSession();
2812
+
2813
+ if (lastSession) {
2814
+ const resumed = await activateResumedSession(lastSession.sessionId, 'startup');
2815
+ if (resumed.ok) {
2816
+ process.stderr.write(` ${c.green('↺')} ${c.dim(`Resumed session: ${messageCountLabel(resumed.messages)}`)}`);
2817
+ process.stderr.write(` ${c.dim('· project')} ${c.brand(path.basename(safeCwd()))}`);
2818
+ process.stderr.write(` ${c.dim(`· agent ${resumed.historyMode}`)}`);
2819
+ if (resumed.switchedProject) process.stderr.write(` ${c.dim('(cwd restored)')}`);
2820
+ if (resumed.projectMissing) process.stderr.write(` ${c.yellow('(saved project path unavailable; using current cwd)')}`);
2821
+ if (resumed.instruction) process.stderr.write(` ${c.dim('—')} ${c.dim(resumed.instruction.slice(0, 50))}`);
2822
+ process.stderr.write('\n');
2823
+ renderResumePreview(resumed, { renderEvent });
2824
+ } else {
2825
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(resumed.reason || 'No conversation found for session ' + lastSession.sessionId)}\n`);
2826
+ }
2827
+ } else {
2828
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('No previous session to resume')}\n`);
2829
+ }
2830
+ }
2831
+
2832
+ process.stderr.write(`\n ${c.dim('Press')} ${c.brand('Enter')} ${c.dim('to start, or type a prompt below.')}\n`);
2833
+
2834
+ // Keep one bottom-reserved UI surface: the fixed input dock. The older
2835
+ // status bar used the same terminal scroll-region primitive, so mounting
2836
+ // both would make prompt placement unpredictable.
2837
+ orbitRef.current = createOrbit();
2838
+ const inputDockActive = mountInputDock();
2839
+ if (inputDockActive) {
2840
+ process.on('beforeExit', unmountInputDock);
2841
+ process.on('exit', unmountInputDock);
2842
+ }
2843
+
2844
+ // ── Bracketed paste (DEC private mode 2004) ──────────────────────────────
2845
+ // Ask the terminal to wrap pasted content in ESC[200~ … ESC[201~ markers so
2846
+ // we can merge a multi-line paste into a single input regardless of how
2847
+ // slowly the bytes arrive. Falls back to the legacy 35 ms debounce for
2848
+ // terminals that ignore the request.
2849
+ const PASTE_BEGIN = '\x1b[200~';
2850
+ const PASTE_END = '\x1b[201~';
2851
+ let _inBracketedPaste = false;
2852
+ let _bracketedPasteBuffer = '';
2853
+ const _pasteEndListeners = new Set();
2854
+ function onBracketedPasteEnd(cb) { _pasteEndListeners.add(cb); return () => _pasteEndListeners.delete(cb); }
2855
+ function isInBracketedPaste() { return _inBracketedPaste; }
2856
+
2857
+ if (process.stdin.isTTY) {
2858
+ try { process.stderr.write('\x1b[?2004h'); } catch {}
2859
+ const disableBracketedPaste = () => { try { process.stderr.write('\x1b[?2004l'); } catch {} };
2860
+ process.on('exit', disableBracketedPaste);
2861
+ process.once('SIGINT', disableBracketedPaste);
2862
+ process.once('SIGTERM', disableBracketedPaste);
2863
+
2864
+ // Prepend so we see raw bytes before readline consumes them.
2865
+ process.stdin.prependListener('data', (chunk) => {
2866
+ const s = chunk.toString('utf8');
2867
+ let i = 0;
2868
+ while (i < s.length) {
2869
+ if (!_inBracketedPaste) {
2870
+ const start = s.indexOf(PASTE_BEGIN, i);
2871
+ if (start === -1) return;
2872
+ _inBracketedPaste = true;
2873
+ _bracketedPasteBuffer = '';
2874
+ i = start + PASTE_BEGIN.length;
2875
+ } else {
2876
+ const end = s.indexOf(PASTE_END, i);
2877
+ if (end === -1) {
2878
+ _bracketedPasteBuffer += s.slice(i);
2879
+ return;
2880
+ }
2881
+ _bracketedPasteBuffer += s.slice(i, end);
2882
+ _inBracketedPaste = false;
2883
+ const payload = _bracketedPasteBuffer;
2884
+ _bracketedPasteBuffer = '';
2885
+ // Notify subscribers on next tick so readline finishes emitting its
2886
+ // synchronous `line` events for the buffered content first.
2887
+ const cbs = [..._pasteEndListeners];
2888
+ setImmediate(() => { for (const cb of cbs) { try { cb(payload); } catch {} } });
2889
+ i = end + PASTE_END.length;
2890
+ }
2891
+ }
2892
+ });
2893
+ }
2894
+
2895
+ // The prompt label is the USER speaking, not the agent. Use the signed-in
2896
+ // GitHub handle if known, otherwise fall back to "You".
2897
+ //
2898
+ // Modern Node readline strips ANSI escapes when calculating prompt width.
2899
+ // Bash-style SOH/STX markers confuse readline redraws on long wrapped input
2900
+ // and can make the first prompt line appear duplicated.
2901
+ function userPrompt() {
2902
+ const who = session.user?.github_username || session.user?.email?.split('@')[0] || 'You';
2903
+ if (term().plain) return `${who} > `;
2904
+ // Brand magenta handle + chevron. No inverse chip, no bold — the color
2905
+ // alone marks this row as user input.
2906
+ return `${paint.brand.primary(who)} ${paint.brand.primary('›')} `;
2907
+ }
2908
+
2909
+ function printInputBottomRule() {
2910
+ if (isInputDockMounted()) {
2911
+ clearInputPrompt();
2912
+ moveToContent();
2913
+ return;
2914
+ }
2915
+ if (term().plain) return;
2916
+ process.stderr.write('\n');
2917
+ }
2918
+
2919
+ function idleInputTips() {
2920
+ return '[Enter] send [/] commands [Tab] complete [Ctrl+D] details';
2921
+ }
2922
+
2923
+ function executionInputTips() {
2924
+ return 'type any extra context (paths, corrections, follow-ups) · [Enter] send · [Esc] cancel · [Ctrl+P] pause';
2925
+ }
2926
+
2927
+ const rl = readline.createInterface({
2928
+ input: process.stdin,
2929
+ output: process.stderr,
2930
+ prompt: userPrompt(),
2931
+ completer: (line) => {
2932
+ if (line.startsWith('/')) {
2933
+ return [commandCompletions(line), line];
2934
+ }
2935
+ return [[], line];
2936
+ },
2937
+ historySize: 100,
2938
+ });
2939
+
2940
+ // Give approval manager access to readline for pause/resume
2941
+ approval.setReadline(rl);
2942
+ ctx._rl = rl; // expose to /resume command for readline pause
2943
+ let inputActive = false;
2944
+ let slashHintVisible = false;
2945
+ let slashHintRowsVisible = 0;
2946
+ let slashHintItems = [];
2947
+ let slashHintSelected = 0;
2948
+ let slashHintLine = '';
2949
+
2950
+ function promptBottomPaddingLines() {
2951
+ if (!process.stderr.isTTY || term().plain) return 0;
2952
+ // When the dock is mounted the hint borrows the rows below the input
2953
+ // (bottom rule + tips + safety); prepareInputPrompt re-renders the
2954
+ // frame when clearSlashHint() fires, so the rule/tips reappear.
2955
+ // 3 rows fits comfortably in the dock's reservation without leaking
2956
+ // past the safety row.
2957
+ if (isInputDockMounted()) return 3;
2958
+ const raw = process.env.KEPLER_PROMPT_BOTTOM_PADDING ?? '5';
2959
+ const n = Number.parseInt(raw, 10);
2960
+ if (!Number.isFinite(n) || n <= 0) return 0;
2961
+ return Math.min(8, n);
2962
+ }
2963
+
2964
+ function truncateHintText(text, max) {
2965
+ const s = String(text || '');
2966
+ if (s.length <= max) return s;
2967
+ if (max <= 1) return '';
2968
+ return s.slice(0, max - 1) + '…';
2969
+ }
2970
+
2971
+ function promptColumns() {
2972
+ return stripAnsi(userPrompt()).length;
2973
+ }
2974
+
2975
+ function restoreReadlineCursor() {
2976
+ const col = Math.max(0, promptColumns() + Number(rl.cursor || 0));
2977
+ readline.cursorTo(process.stderr, col);
2978
+ }
2979
+
2980
+ function renderSlashHint(line = '', { preserveSelection = false } = {}) {
2981
+ if (!process.stderr.isTTY || term().plain || !inputActive || !promptBottomPaddingLines()) return;
2982
+ const rows = promptBottomPaddingLines();
2983
+ const suggestions = slashCommandSuggestions(line, Math.min(5, rows));
2984
+ const cols = process.stdout.columns || 80;
2985
+ if (!preserveSelection || line !== slashHintLine) slashHintSelected = 0;
2986
+ slashHintItems = suggestions;
2987
+ slashHintLine = line;
2988
+ if (slashHintSelected >= slashHintItems.length) slashHintSelected = Math.max(0, slashHintItems.length - 1);
2989
+
2990
+ readline.moveCursor(process.stderr, 0, 1);
2991
+ for (let i = 0; i < rows; i++) {
2992
+ readline.clearLine(process.stderr, 0);
2993
+ readline.cursorTo(process.stderr, 0);
2994
+ const item = suggestions[i];
2995
+ if (item) {
2996
+ const marker = i === slashHintSelected ? c.brand('›') : c.dim(' ');
2997
+ const command = item.command.padEnd(13);
2998
+ const maxDesc = Math.max(0, cols - 21);
2999
+ const desc = truncateHintText(item.description, maxDesc);
3000
+ process.stderr.write(` ${marker} ${c.brand(command)}${desc ? c.dim(desc) : ''}`);
3001
+ }
3002
+ if (i < rows - 1) readline.moveCursor(process.stderr, 0, 1);
3003
+ }
3004
+ readline.moveCursor(process.stderr, 0, -rows);
3005
+ restoreReadlineCursor();
3006
+ slashHintVisible = suggestions.length > 0;
3007
+ slashHintRowsVisible = rows;
3008
+ }
3009
+
3010
+ function clearSlashHint({ restoreCursor: shouldRestoreCursor = true } = {}) {
3011
+ if (!slashHintVisible || !process.stderr.isTTY || term().plain) {
3012
+ slashHintVisible = false;
3013
+ slashHintRowsVisible = 0;
3014
+ return;
3015
+ }
3016
+ const rows = slashHintRowsVisible || promptBottomPaddingLines() || 1;
3017
+ readline.moveCursor(process.stderr, 0, 1);
3018
+ for (let i = 0; i < rows; i++) {
3019
+ readline.clearLine(process.stderr, 0);
3020
+ readline.cursorTo(process.stderr, 0);
3021
+ if (i < rows - 1) readline.moveCursor(process.stderr, 0, 1);
3022
+ }
3023
+ readline.moveCursor(process.stderr, 0, -rows);
3024
+ // The dock's bottom rule + tips row live in the rows we just cleared.
3025
+ // Repaint the frame (input row untouched) so they reappear.
3026
+ if (isInputDockMounted()) redrawDockFrame();
3027
+ if (shouldRestoreCursor) restoreReadlineCursor();
3028
+ slashHintVisible = false;
3029
+ slashHintRowsVisible = 0;
3030
+ slashHintItems = [];
3031
+ slashHintSelected = 0;
3032
+ slashHintLine = '';
3033
+ }
3034
+
3035
+ function replaceReadlineLine(value) {
3036
+ const next = String(value || '');
3037
+ rl.line = next;
3038
+ rl.cursor = next.length;
3039
+ if (typeof rl._refreshLine === 'function') {
3040
+ rl._refreshLine();
3041
+ } else {
3042
+ readline.cursorTo(process.stderr, promptColumns());
3043
+ readline.clearLine(process.stderr, 1);
3044
+ process.stderr.write(next);
3045
+ }
3046
+ }
3047
+
3048
+ function acceptSlashHint() {
3049
+ const item = slashHintItems[slashHintSelected];
3050
+ if (!item) return false;
3051
+ replaceReadlineLine(item.command);
3052
+ slashHintLine = item.command;
3053
+ renderSlashHint(item.command, { preserveSelection: true });
3054
+ return true;
3055
+ }
3056
+
3057
+ function moveSlashHintSelection(delta) {
3058
+ if (!slashHintItems.length) return false;
3059
+ const count = slashHintItems.length;
3060
+ slashHintSelected = (slashHintSelected + delta + count) % count;
3061
+ replaceReadlineLine(slashHintLine);
3062
+ renderSlashHint(slashHintLine, { preserveSelection: true });
3063
+ return true;
3064
+ }
3065
+
3066
+ function selectedSlashCommandFor(line) {
3067
+ const input = String(line || '').trim();
3068
+ if (!input.startsWith('/')) return null;
3069
+ if (COMMANDS[input] || input.startsWith('/help ')) return input;
3070
+ const item = slashHintItems[slashHintSelected];
3071
+ if (!item) return input;
3072
+ return item.command;
3073
+ }
3074
+
3075
+ function reservePromptBottomPadding() {
3076
+ const lines = promptBottomPaddingLines();
3077
+ if (!lines) return;
3078
+ process.stderr.write(`${'\n'.repeat(lines)}\x1b[${lines}A\r`);
3079
+ }
3080
+
3081
+ function renderIdleDockInput() {
3082
+ if (!isInputDockMounted()) return false;
3083
+ return renderDockInput(userPrompt(), rl.line || '', {
3084
+ context: buildContextStrip(),
3085
+ meta: buildDockMeta(),
3086
+ tips: idleInputTips(),
3087
+ });
3088
+ }
3089
+
3090
+ function promptInputLine() {
3091
+ // When the fixed dock is active, readline should own only the input
3092
+ // buffer, not the visual prompt. If readline paints the prompt itself,
3093
+ // long wrapped input can leave a stale duplicate row inside the dock.
3094
+ rl.setPrompt(isInputDockMounted() ? '' : userPrompt());
3095
+ reservePromptBottomPadding();
3096
+ inputActive = true;
3097
+ rl.prompt();
3098
+ renderIdleDockInput();
3099
+ }
3100
+
3101
+ function printSubmittedInput(input) {
3102
+ if (!isInputDockMounted()) {
3103
+ printInputBottomRule();
3104
+ return;
3105
+ }
3106
+ const lines = String(input || '').split('\n');
3107
+ printInputBottomRule();
3108
+ process.stderr.write(`${transcriptHeader('you', { tone: 'user' })}\n`);
3109
+ for (const line of lines) {
3110
+ process.stderr.write(`${transcriptLine(line, { tone: 'user' })}\n`);
3111
+ }
3112
+ }
3113
+
3114
+ // Helper: show prompt with separator + vertical breathing room
3115
+ function showPrompt() {
3116
+ if (isInputDockMounted()) {
3117
+ prepareInputPrompt({ context: buildContextStrip(), meta: buildDockMeta(), tips: idleInputTips() });
3118
+ promptInputLine();
3119
+ return;
3120
+ }
3121
+ printPromptBlock();
3122
+ process.stderr.write('\n'); // half-inch vertical gap above input line
3123
+ promptInputLine();
3124
+ }
3125
+
3126
+ showPrompt();
3127
+
3128
+ if (process.stdin.isTTY) {
3129
+ readline.emitKeypressEvents(process.stdin, rl);
3130
+ process.stdin.on('keypress', (_str, key = {}) => {
3131
+ if (!inputActive) return;
3132
+ setImmediate(() => {
3133
+ if (!inputActive) return;
3134
+ if (slashHintVisible && key.name === 'tab' && acceptSlashHint()) return;
3135
+ if (slashHintVisible && key.name === 'down' && moveSlashHintSelection(1)) return;
3136
+ if (slashHintVisible && key.name === 'up' && moveSlashHintSelection(-1)) return;
3137
+ const isSlash = String(rl.line || '').trimStart().startsWith('/');
3138
+ if (!isSlash) clearSlashHint();
3139
+ // Refresh the dock input FIRST so the frame (bottom rule + tips)
3140
+ // is fresh, THEN paint the slash hint on top — otherwise the
3141
+ // dock repaint would wipe the hint we just wrote.
3142
+ renderIdleDockInput();
3143
+ if (isSlash) renderSlashHint(rl.line);
3144
+ });
3145
+ });
3146
+ }
3147
+
3148
+ // Guard against concurrent line handlers and multiline paste bursts.
3149
+ //
3150
+ // Node readline emits one `line` event per pasted newline. Two mechanisms
3151
+ // coalesce those into a single input:
3152
+ // 1. If the terminal supports bracketed paste, we hold flushing until we
3153
+ // see the ESC[201~ end marker — reliable regardless of paste latency.
3154
+ // 2. Otherwise we fall back to a short timer that merges bursts arriving
3155
+ // within KEPLER_PASTE_FLUSH_MS.
3156
+ let _lineInFlight = false;
3157
+ const _queuedLines = [];
3158
+ let _pasteLines = [];
3159
+ let _pasteFlushTimer = null;
3160
+
3161
+ function pasteFlushDelayMs() {
3162
+ const raw = Number.parseInt(process.env.KEPLER_PASTE_FLUSH_MS || '35', 10);
3163
+ return Number.isFinite(raw) && raw >= 0 ? Math.min(250, raw) : 35;
3164
+ }
3165
+
3166
+ function queueOrRunLine(line) {
3167
+ if (_lineInFlight) {
3168
+ if (line && line.trim()) _queuedLines.push(line);
3169
+ return;
3170
+ }
3171
+ _lineInFlight = true;
3172
+ Promise.resolve()
3173
+ .then(() => _handleLine(line))
3174
+ .finally(() => {
3175
+ _lineInFlight = false;
3176
+ if (_queuedLines.length) {
3177
+ const next = _queuedLines.shift();
3178
+ setImmediate(() => queueOrRunLine(next));
3179
+ }
3180
+ });
3181
+ }
3182
+
3183
+ function flushPastedLines() {
3184
+ if (_pasteFlushTimer) {
3185
+ clearTimeout(_pasteFlushTimer);
3186
+ _pasteFlushTimer = null;
3187
+ }
3188
+ if (!_pasteLines.length) return;
3189
+ const line = _pasteLines.join('\n');
3190
+ _pasteLines = [];
3191
+ queueOrRunLine(line);
3192
+ }
3193
+
3194
+ // If we're mid-paste when readline fires `line`, cancel the debounce timer;
3195
+ // the paste-end listener below will flush once the terminal closes the
3196
+ // bracket. If we're NOT in a paste (either the terminal doesn't support it,
3197
+ // or the user pressed Enter normally), the debounce falls back to old
3198
+ // behavior — a single Enter flushes almost instantly.
3199
+ rl.on('line', async (line) => {
3200
+ _pasteLines.push(line);
3201
+ if (_pasteFlushTimer) clearTimeout(_pasteFlushTimer);
3202
+ if (isInBracketedPaste()) {
3203
+ _pasteFlushTimer = null;
3204
+ } else {
3205
+ _pasteFlushTimer = setTimeout(flushPastedLines, pasteFlushDelayMs());
3206
+ }
3207
+ });
3208
+
3209
+ onBracketedPasteEnd(() => {
3210
+ // Readline has finished emitting synchronous line events for the pasted
3211
+ // content by the time this fires (setImmediate in the pre-listener).
3212
+ flushPastedLines();
3213
+ });
3214
+
3215
+ async function _handleLine(line) {
3216
+ let input = line.trim();
3217
+ const selectedSlashCommand = selectedSlashCommandFor(input);
3218
+ inputActive = false;
3219
+ clearSlashHint();
3220
+ if (selectedSlashCommand) input = selectedSlashCommand;
3221
+ if (!input) {
3222
+ // Empty Enter leaves readline's phantom prompt line ("ravia.sapbpc ›")
3223
+ // committed above the cursor. Wipe it so repeated blank Enters don't
3224
+ // stack into a ladder of empty prompts in the transcript.
3225
+ if (process.stderr.isTTY && !term().plain) {
3226
+ // The paste debounce may have batched N Enters into one flush. The
3227
+ // joined `line` string has one '\n' per additional Enter, so
3228
+ // split('\n').length gives the phantom count.
3229
+ const phantoms = Math.max(1, String(line).split('\n').length);
3230
+ for (let i = 0; i < phantoms; i++) {
3231
+ process.stderr.write('\x1b[A\x1b[2K\r');
3232
+ }
3233
+ }
3234
+ showPrompt();
3235
+ return;
3236
+ }
3237
+ printSubmittedInput(input);
3238
+
3239
+ // Save to input history
3240
+ session.inputHistory.push(input);
3241
+
3242
+ // Slash commands
3243
+ if (input.startsWith('/')) {
3244
+ await handleCommand(input, ctx);
3245
+ showPrompt();
3246
+ return;
3247
+ }
3248
+
3249
+ // Budget cap (PRD-055 §10). Stop before the next paid call when exceeded.
3250
+ if (session.budgetUsd && session.totalCost >= session.budgetUsd) {
3251
+ session.budgetExceeded = true;
3252
+ process.stderr.write(` ${c.yellow('⏹')} ${c.dim(`Budget reached ($${session.totalCost.toFixed(2)} of $${session.budgetUsd.toFixed(2)}). Use /budget clear to continue.`)}\n`);
3253
+ showPrompt();
3254
+ return;
3255
+ }
3256
+
3257
+ const originalInput = input;
3258
+ const creds = auth.loadCredentials();
3259
+ if (!creds.token) {
3260
+ process.stderr.write(` ${c.red('Not logged in. Run /login first.')}\n`);
3261
+ showPrompt();
3262
+ return;
3263
+ }
3264
+
3265
+ // Create or reuse stream client — sessionId persists across turns.
3266
+ // The same client also owns the authenticated vision-analysis preflight.
3267
+ if (!streamClient || streamClient.baseUrl !== creds.backendUrl || streamClient.token !== creds.token) {
3268
+ streamClient = new TarangStreamClient({
3269
+ baseUrl: creds.backendUrl,
3270
+ token: creds.token,
3271
+ toolExecutor,
3272
+ approvalManager: approval,
3273
+ });
3274
+ }
3275
+ const client = streamClient;
3276
+ if (session.id && !client.sessionId) {
3277
+ client.sessionId = session.id;
3278
+ }
3279
+
3280
+ try {
3281
+ const pending = pendingVisionPaths(ctx);
3282
+ const prepared = prepareImageAttachments(originalInput, {
3283
+ cwd: safeCwd(),
3284
+ extraPaths: pending,
3285
+ });
3286
+ if (prepared.attachments.length) {
3287
+ process.stderr.write(` ${c.brand('◇')} ${c.dim(`attached ${prepared.attachments.length} image${prepared.attachments.length === 1 ? '' : 's'}:`)} ${prepared.attachments.map(attachmentSummaryLine).join(c.dim(' · '))}\n`);
3288
+ jsonlWriter.writeKeplerEvent({
3289
+ type: 'attachments',
3290
+ data: { attachments: prepared.attachments.map(publicAttachmentMetadata) },
3291
+ });
3292
+ const approved = await confirmVisionUpload(ctx, prepared.attachments, { skip: skipPerms });
3293
+ pending.length = 0;
3294
+ if (!approved) {
3295
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('Vision upload skipped; continuing without image analysis.')}\n`);
3296
+ input = prepared.instruction || originalInput;
3297
+ } else {
3298
+ process.stderr.write(` ${c.brand('⠋')} ${c.dim('Analyzing image...')}\r`);
3299
+ const analysis = await client.analyzeVision({
3300
+ instruction: prepared.instruction,
3301
+ attachments: prepared.attachments,
3302
+ });
3303
+ process.stderr.write(`\r${' '.repeat(80)}\r`);
3304
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`Vision analysis completed for ${prepared.attachments.length} image${prepared.attachments.length === 1 ? '' : 's'}`)}${analysis.model ? c.dim(` · ${analysis.model}`) : ''}\n`);
3305
+ jsonlWriter.writeKeplerEvent({
3306
+ type: 'vision_analysis',
3307
+ data: {
3308
+ model: analysis.model || '',
3309
+ summary_chars: String(analysis.summary || '').length,
3310
+ attachments: analysis.attachments || prepared.metadata,
3311
+ },
3312
+ });
3313
+ input = appendVisionAnalysisToInstruction(prepared.instruction, analysis);
3314
+ }
3315
+ } else {
3316
+ input = prepared.instruction || originalInput;
3317
+ }
3318
+ } catch (err) {
3319
+ process.stderr.write(` ${c.red('Vision error: ' + (err.message || String(err)))}\n`);
3320
+ showPrompt();
3321
+ return;
3322
+ }
3323
+
3324
+ // Regular prompt
3325
+ const userMessage = { role: 'user', content: input };
3326
+ session.history.push(userMessage);
3327
+ session.agentHistory.push(userMessage);
3328
+ session.turns++;
3329
+ session.toolCalls = 0;
3330
+ session.subAgentToolCalls = 0;
3331
+ session.lastTask = originalInput;
3332
+ // Reset per-turn counts so the mission report reflects this turn only.
3333
+ session.toolCounts = {};
3334
+ session.subAgentCounts = {};
3335
+ session.filesRead = [];
3336
+ session.savedUsd = 0;
3337
+ session._lastEmittedThinking = '';
3338
+ session.creditsLowWarned = false;
3339
+ session.msgsLowWarned = false;
3340
+
3341
+ // Tell the orbit a new turn started — switches to DISCOVERY and updates
3342
+ // task / turn counters in the status bar.
3343
+ if (orbitRef.current) orbitRef.current.onUserInput(originalInput);
3344
+
3345
+ // Start session tracking on first turn
3346
+ if (session.turns === 1) {
3347
+ sessionMgr.start(originalInput);
3348
+ }
3349
+ let userTurnWritten = false;
3350
+ const writeCurrentUserTurn = () => {
3351
+ if (userTurnWritten) return;
3352
+ jsonlWriter.writeUserTurn(input);
3353
+ jsonlWriter.writeHistory(input);
3354
+ userTurnWritten = true;
3355
+ };
3356
+ if (session.id) writeCurrentUserTurn();
3357
+
3358
+ let assistantContent = '';
3359
+ const agentTurnHistory = new AgentHistoryTurnBuilder();
3360
+
3361
+ // ── Execution keypress listener (Esc = cancel, Space = pause/resume) ──
3362
+ let executionPaused = false;
3363
+ let keypressCleanup = null;
3364
+ let execListenerActive = false;
3365
+ let lastCtrlCAt = 0; // PRD-055 §8.4: first Ctrl+C cancels, second exits
3366
+ let executionInputBuffer = '';
3367
+ let executionInputVisible = false;
3368
+
3369
+ function executionInputPrefix() {
3370
+ // Inviting prompt: brand '+' + hint that this accepts any extra
3371
+ // context (paths, corrections, more instructions). Visible even when
3372
+ // the buffer is empty so users know they can type mid-run.
3373
+ return `${paint.brand.data('+')} ${paint.dim('add instruction')} ${paint.dim('›')} `;
3374
+ }
3375
+
3376
+ function redrawExecutionInput() {
3377
+ if (isInputDockMounted()) {
3378
+ renderDockInput(executionInputPrefix(), executionInputBuffer, {
3379
+ context: buildContextStrip(),
3380
+ meta: buildDockMeta(),
3381
+ tips: executionInputTips(),
3382
+ });
3383
+ executionInputVisible = true;
3384
+ return;
3385
+ }
3386
+ if (!executionInputVisible) {
3387
+ stopSpinner();
3388
+ process.stderr.write(`\n${executionInputPrefix()}`);
3389
+ executionInputVisible = true;
3390
+ }
3391
+ readline.clearLine(process.stderr, 0);
3392
+ readline.cursorTo(process.stderr, 0);
3393
+ process.stderr.write(`${executionInputPrefix()}${executionInputBuffer}`);
3394
+ }
3395
+
3396
+ function focusExecutionInput() {
3397
+ if (!isInputDockMounted()) return;
3398
+ focusDockInput(executionInputPrefix(), executionInputBuffer);
3399
+ }
3400
+ runtime.afterContentFlush = focusExecutionInput;
3401
+
3402
+ async function submitExecutionInstruction() {
3403
+ const instruction = executionInputBuffer.trim();
3404
+ executionInputBuffer = '';
3405
+ if (!instruction) {
3406
+ if (isInputDockMounted()) {
3407
+ clearInputPrompt();
3408
+ renderDockInput(executionInputPrefix(), '', {
3409
+ context: buildContextStrip(),
3410
+ meta: buildDockMeta(),
3411
+ tips: executionInputTips(),
3412
+ });
3413
+ moveToContent();
3414
+ } else if (executionInputVisible) {
3415
+ process.stderr.write('\n');
3416
+ }
3417
+ executionInputVisible = false;
3418
+ return;
3419
+ }
3420
+ if (isInputDockMounted()) {
3421
+ clearInputPrompt();
3422
+ moveToContent();
3423
+ process.stderr.write(`${executionInputPrefix()}${instruction}\n`);
3424
+ renderDockInput(executionInputPrefix(), '', {
3425
+ context: buildContextStrip(),
3426
+ meta: buildDockMeta(),
3427
+ tips: executionInputTips(),
3428
+ });
3429
+ moveToContent();
3430
+ } else if (executionInputVisible) {
3431
+ process.stderr.write('\n');
3432
+ }
3433
+ executionInputVisible = false;
3434
+ try {
3435
+ await client.resume(instruction);
3436
+ jsonlWriter.writeKeplerEvent({
3437
+ type: 'user_intervention',
3438
+ data: { instruction, task_id: client.currentTaskId || null },
3439
+ });
3440
+ process.stderr.write(` ${c.green('↳')} ${c.dim('sent follow-up to running agent')}\n`);
3441
+ } catch {
3442
+ _queuedLines.push(instruction);
3443
+ process.stderr.write(` ${c.yellow('↳')} ${c.dim('queued follow-up for the next turn')}\n`);
3444
+ }
3445
+ }
3446
+
3447
+ function appendExecutionInput(text) {
3448
+ if (!text) return;
3449
+ executionInputBuffer += text;
3450
+ redrawExecutionInput();
3451
+ }
3452
+
3453
+ function backspaceExecutionInput() {
3454
+ if (!executionInputBuffer) return false;
3455
+ executionInputBuffer = executionInputBuffer.slice(0, -1);
3456
+ redrawExecutionInput();
3457
+ return true;
3458
+ }
3459
+
3460
+ if (process.stdin.isTTY) {
3461
+ rl.pause();
3462
+ const wasRaw = process.stdin.isRaw;
3463
+ process.stdin.setRawMode(true);
3464
+ process.stdin.resume();
3465
+ execListenerActive = true;
3466
+
3467
+ // Bracketed-paste state for follow-up input during execution.
3468
+ let execPasteActive = false;
3469
+ let execPasteBuffer = '';
3470
+
3471
+ // Accept any character that isn't a bare C0 control (except tab) and
3472
+ // isn't part of an ESC sequence. Unicode, emoji, and tabs all pass.
3473
+ const isSafeFollowUpText = (s) => {
3474
+ if (!s) return false;
3475
+ if (s.includes('\x1b')) return false; // escape / arrow / meta keys
3476
+ for (const ch of s) {
3477
+ const code = ch.codePointAt(0);
3478
+ if (code === 0x09) continue; // tab ok
3479
+ if (code < 0x20 || code === 0x7f) return false;
3480
+ }
3481
+ return true;
3482
+ };
3483
+
3484
+ const onData = (data) => {
3485
+ if (!execListenerActive) return; // paused for approval menu
3486
+ const bytes = [...data];
3487
+ const text = data.toString('utf8');
3488
+
3489
+ // ── Bracketed paste passthrough ────────────────────────────────────
3490
+ // Strip ESC[200~/ESC[201~ markers and treat the content between them
3491
+ // as a single append. Handles pastes that straddle chunk boundaries.
3492
+ if (execPasteActive || text.includes(PASTE_BEGIN)) {
3493
+ let s = text;
3494
+ while (s.length) {
3495
+ if (!execPasteActive) {
3496
+ const start = s.indexOf(PASTE_BEGIN);
3497
+ if (start === -1) break;
3498
+ // Anything before the start marker is normal keystrokes;
3499
+ // let it fall through by re-invoking with just that slice.
3500
+ if (start > 0) {
3501
+ const pre = s.slice(0, start);
3502
+ // Recurse via a synthetic buffer so normal handlers process
3503
+ // the pre-paste characters below.
3504
+ onData(Buffer.from(pre, 'utf8'));
3505
+ }
3506
+ execPasteActive = true;
3507
+ execPasteBuffer = '';
3508
+ s = s.slice(start + PASTE_BEGIN.length);
3509
+ continue;
3510
+ }
3511
+ const end = s.indexOf(PASTE_END);
3512
+ if (end === -1) { execPasteBuffer += s; return; }
3513
+ execPasteBuffer += s.slice(0, end);
3514
+ execPasteActive = false;
3515
+ const payload = execPasteBuffer;
3516
+ execPasteBuffer = '';
3517
+ if (payload) appendExecutionInput(payload);
3518
+ s = s.slice(end + PASTE_END.length);
3519
+ }
3520
+ if (!s.length) return;
3521
+ // Anything after the end marker (rare) falls through as a fresh
3522
+ // buffer for the normal handlers.
3523
+ data = Buffer.from(s, 'utf8');
3524
+ }
3525
+
3526
+ const bytes2 = [...data];
3527
+ const text2 = data.toString('utf8');
3528
+
3529
+ // Esc key (single byte 0x1b, not part of arrow sequence)
3530
+ if (bytes2.length === 1 && bytes2[0] === 0x1b) {
3531
+ if (executionInputVisible || executionInputBuffer) {
3532
+ executionInputBuffer = '';
3533
+ if (isInputDockMounted()) {
3534
+ clearInputPrompt();
3535
+ renderDockInput(executionInputPrefix(), '', {
3536
+ context: buildContextStrip(),
3537
+ meta: buildDockMeta(),
3538
+ tips: executionInputTips(),
3539
+ });
3540
+ moveToContent();
3541
+ } else if (executionInputVisible) {
3542
+ readline.clearLine(process.stderr, 0);
3543
+ readline.cursorTo(process.stderr, 0);
3544
+ }
3545
+ executionInputVisible = false;
3546
+ return;
3547
+ }
3548
+ stopSpinner();
3549
+ if (isInputDockMounted()) {
3550
+ clearInputPrompt();
3551
+ moveToContent();
3552
+ }
3553
+ process.stderr.write(`\n ${c.yellow('⏹')} ${c.dim('Cancelled.')}\n`);
3554
+ // cancel() now aborts the in-flight SSE reader; the for-await loop
3555
+ // wakes up immediately and the prompt returns. No more "stuck"
3556
+ // Cancelling… message.
3557
+ client.cancel();
3558
+ return;
3559
+ }
3560
+
3561
+ if (bytes2.length === 1 && (bytes2[0] === 0x7f || bytes2[0] === 0x08)) {
3562
+ backspaceExecutionInput();
3563
+ return;
3564
+ }
3565
+
3566
+ if (text2.includes('\r') || text2.includes('\n')) {
3567
+ const parts = text2.split(/\r?\n|\r/);
3568
+ if (parts[0]) appendExecutionInput(parts[0]);
3569
+ submitExecutionInstruction();
3570
+ // Extra lines from a raw (non-bracketed) paste get concatenated
3571
+ // into the current follow-up buffer instead of firing multiple
3572
+ // resume() calls. If the user presses Enter again, they send.
3573
+ for (const extra of parts.slice(1)) {
3574
+ if (extra) appendExecutionInput('\n' + extra);
3575
+ }
3576
+ return;
3577
+ }
3578
+
3579
+ // Ctrl+P — pause/resume (moved off Space so follow-up input can start
3580
+ // with a space without triggering pause).
3581
+ if (bytes2.length === 1 && bytes2[0] === 0x10) {
3582
+ if (executionPaused) {
3583
+ executionPaused = false;
3584
+ if (isInputDockMounted()) moveToContent();
3585
+ process.stderr.write(` ${c.green('▶')} ${c.dim('Resumed')}\n`);
3586
+ client.resume();
3587
+ if (orbitRef.current) orbitRef.current.onResume();
3588
+ } else {
3589
+ executionPaused = true;
3590
+ stopSpinner();
3591
+ if (isInputDockMounted()) moveToContent();
3592
+ process.stderr.write(` ${c.yellow('⏸')} ${c.dim('Paused — press Ctrl+P to resume, Esc to cancel')}\n`);
3593
+ client.pause();
3594
+ if (orbitRef.current) orbitRef.current.onPause();
3595
+ }
3596
+ return;
3597
+ }
3598
+
3599
+ // Ctrl+C during execution — PRD-055 §8.4 two-step semantics:
3600
+ // first press → cancel current backend run, stay in REPL
3601
+ // second press within 2s → exit the CLI
3602
+ if (bytes2[0] === 0x03) {
3603
+ stopSpinner();
3604
+ const now = Date.now();
3605
+ if (lastCtrlCAt && (now - lastCtrlCAt) < 2000) {
3606
+ if (isInputDockMounted()) unmountInputDock();
3607
+ process.stderr.write(`\n ${c.dim('exiting…')}\n`);
3608
+ try { client.cancel(); } catch {}
3609
+ process.exit(0);
3610
+ }
3611
+ lastCtrlCAt = now;
3612
+ if (isInputDockMounted()) {
3613
+ clearInputPrompt();
3614
+ moveToContent();
3615
+ }
3616
+ process.stderr.write(`\n ${c.yellow('⏹')} ${c.dim('Cancelled. Press Ctrl+C again within 2s to exit.')}\n`);
3617
+ try { client.cancel(); } catch {}
3618
+ return;
3619
+ }
3620
+
3621
+ // Ctrl+D — expand last tool card (Mission Control §6.2). Only when
3622
+ // there's no in-progress follow-up input.
3623
+ if (!executionInputBuffer && bytes2.length === 1 && bytes2[0] === 0x04) {
3624
+ stopSpinner();
3625
+ if (isInputDockMounted()) moveToContent();
3626
+ expandLast();
3627
+ return;
3628
+ }
3629
+
3630
+ // Any safe text (unicode, tabs, spaces, symbols) becomes a live
3631
+ // follow-up instruction. Enter sends it via resume(instruction).
3632
+ if (isSafeFollowUpText(text2)) {
3633
+ appendExecutionInput(text2);
3634
+ return;
3635
+ }
3636
+ };
3637
+
3638
+ process.stdin.on('data', onData);
3639
+
3640
+ // Let approval manager pause/resume this listener
3641
+ approval.setExecutionHooks({
3642
+ onPause: () => { execListenerActive = false; },
3643
+ onResume: () => { execListenerActive = true; },
3644
+ });
3645
+
3646
+ keypressCleanup = () => {
3647
+ process.stdin.removeListener('data', onData);
3648
+ process.stdin.setRawMode(wasRaw || false);
3649
+ execListenerActive = false;
3650
+ approval.setExecutionHooks({}); // clear hooks
3651
+ rl.resume();
3652
+ };
3653
+ }
3654
+
3655
+ try {
3656
+ if (isInputDockMounted()) {
3657
+ renderDockInput(executionInputPrefix(), '', {
3658
+ context: buildContextStrip(),
3659
+ meta: buildDockMeta(),
3660
+ tips: executionInputTips(),
3661
+ });
3662
+ moveToContent();
3663
+ }
3664
+ startContentStream();
3665
+ process.stderr.write(`\n${transcriptHeader('bahulam', { tone: 'assistant' })}\n`);
3666
+ runtime.contentHeaderPrinted = true;
3667
+
3668
+ // Immediate feedback so the screen isn't blank between submit and the
3669
+ // first backend event. The first `status`, `thinking`, or `content_*`
3670
+ // event will replace this text; stopSpinner clears it before content
3671
+ // renders.
3672
+ startSpinner('thinking…');
3673
+
3674
+ const execContext = { cwd: safeCwd() };
3675
+ if (skipPerms) execContext.freeswim = true;
3676
+ effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
3677
+ approval.policy = effectivePolicy.policy;
3678
+ if (approval.trustStore) approval.trustStore.policy = effectivePolicy.policy;
3679
+ hookRunner.reload();
3680
+ latestProjectContext = loadProjectContext({ cwd: safeCwd(), previous: latestProjectContext });
3681
+ let projectResources = toolExecutor.getProjectResources();
3682
+ const promptRoots = promptProjectRoots(input);
3683
+ if (promptRoots.length > 0) {
3684
+ await toolExecutor.registerProjectRoots(promptRoots);
3685
+ projectResources = toolExecutor.getProjectResources();
3686
+ }
3687
+ const promptHook = await hookRunner.run('UserPromptSubmit', {
3688
+ input: { prompt: input },
3689
+ turnId: String(session.turns),
3690
+ });
3691
+ const hookHints = (promptHook.results || [])
3692
+ .map(r => r.parsed?.feedback)
3693
+ .filter(Boolean)
3694
+ .map(text => ({ source: 'hook', kind: 'feedback', text, ttl_turns: 1, priority: 'medium' }));
3695
+ const rejectionHints = (approval.consumeRejectionHints?.() || [])
3696
+ .map(h => ({
3697
+ source: 'hitl',
3698
+ kind: 'approval_rejection',
3699
+ text: `${h.decision === 'replan' ? 'User requested a re-plan' : 'User rejected approval'} for ${h.tool}. ${h.note ? `Reason: ${h.note}` : h.reason}. Adjust the approach before retrying.`,
3700
+ ttl_turns: 1,
3701
+ priority: 'high',
3702
+ }));
3703
+ latestEnvelope = buildContextEnvelope({
3704
+ cwd: safeCwd(),
3705
+ effectivePolicy,
3706
+ projectContext: latestProjectContext,
3707
+ activeHints: [...hookHints, ...rejectionHints],
3708
+ projectResources,
3709
+ agentContext: toolExecutor.getAgentContext(),
3710
+ });
3711
+ ctx.effectivePolicy = effectivePolicy;
3712
+ ctx.latestProjectContext = latestProjectContext;
3713
+ ctx.latestEnvelope = latestEnvelope;
3714
+ Object.assign(execContext, latestEnvelope);
3715
+ if (skipPerms) execContext.freeswim = true;
3716
+ const modelOverrides = Object.fromEntries(sessionModelOverrideEntries());
3717
+ if (Object.keys(modelOverrides).length > 0) {
3718
+ execContext.model_overrides = modelOverrides;
3719
+ if (modelOverrides.reasoning) execContext.model_override = modelOverrides.reasoning;
3720
+ }
3721
+ // PRD-071: seed work_scope from CLI so the backend has a byte-stable
3722
+ // scope block from turn 1. Uses projectResources already gathered by
3723
+ // the envelope above.
3724
+ execContext.work_scope = buildWorkScope({
3725
+ instruction: input,
3726
+ cwd: safeCwd(),
3727
+ projectResources,
3728
+ });
3729
+ for (const file of latestProjectContext.changed || []) {
3730
+ if (effectivePolicy.policy.context?.showReloadNotice) {
3731
+ process.stderr.write(` ${c.dim(`[Context] ${file.label} updated — re-read`)}\n`);
3732
+ }
3733
+ }
3734
+
3735
+ for await (const event of client.execute(input, execContext, session.agentHistory)) {
3736
+ jsonlWriter.writeKeplerEvent(event);
3737
+ if (event.type === 'plan_created' || event.type === 'goal_created') {
3738
+ persistProjectArtifacts(
3739
+ event.data,
3740
+ toolExecutor.getProjectResources(),
3741
+ message => process.stderr.write(` ${c.dim(message)}\n`),
3742
+ );
3743
+ }
3744
+ if (isInputDockMounted()) moveToContent();
3745
+ renderEvent(event);
3746
+ focusExecutionInput();
3747
+
3748
+ if (event.type === 'content_partial') {
3749
+ const text = event.data?.text || '';
3750
+ assistantContent += text;
3751
+ agentTurnHistory.addAssistantText(text);
3752
+ jsonlWriter.accumulateContent(text);
3753
+ } else if (event.type === 'content') {
3754
+ const text = event.data?.text || '';
3755
+ const newText = assistantContent && text.startsWith(assistantContent)
3756
+ ? text.slice(assistantContent.length)
3757
+ : text === assistantContent ? '' : text;
3758
+ if (text) {
3759
+ assistantContent = assistantContent && !text.startsWith(assistantContent)
3760
+ ? assistantContent + text
3761
+ : text;
3762
+ }
3763
+ if (newText) {
3764
+ agentTurnHistory.addAssistantText(newText);
3765
+ jsonlWriter.accumulateContent(newText);
3766
+ }
3767
+ }
3768
+
3769
+ // Local JSONL: capture session ID from backend
3770
+ if (event.type === 'session_info' && event.data?.session_id) {
3771
+ jsonlWriter.setSessionId(event.data.session_id);
3772
+ hookRunner.sessionId = event.data.session_id;
3773
+ writeCurrentUserTurn();
3774
+ }
3775
+
3776
+ // Local JSONL: accumulate tool calls
3777
+ if (event.type === 'tool_call' || event.type === 'tool_request') {
3778
+ const d = event.data || {};
3779
+ agentTurnHistory.addToolUse(d);
3780
+ jsonlWriter.accumulateToolCall(d.call_id || d.request_id, d.tool, d.args);
3781
+ }
3782
+
3783
+ // Local JSONL: record tool results
3784
+ if (event.type === 'tool_done' || event.type === 'tool_result') {
3785
+ const d = event.data || {};
3786
+ agentTurnHistory.addToolResult(d);
3787
+ jsonlWriter.recordToolResult(d.call_id || d._callId, d.output, d.success === false, d);
3788
+ }
3789
+
3790
+ // Local JSONL: flush assistant turn on complete
3791
+ if (event.type === 'complete') {
3792
+ jsonlWriter.setTurnUsage(event.data?.usage, session.model);
3793
+ jsonlWriter.flushAssistantTurn();
3794
+ }
3795
+ }
3796
+
3797
+ flushContent();
3798
+ } catch (err) {
3799
+ inPlace('');
3800
+ flushContent();
3801
+ process.stderr.write(` ${c.red('Error: ' + err.message)}\n`);
3802
+ } finally {
3803
+ // Clean up execution keypress listener
3804
+ runtime.afterContentFlush = null;
3805
+ if (keypressCleanup) keypressCleanup();
3806
+ }
3807
+
3808
+ if (assistantContent) {
3809
+ const assistantMessage = { role: 'assistant', content: assistantContent };
3810
+ session.history.push(assistantMessage);
3811
+ }
3812
+ const structuredTurn = agentTurnHistory.finish();
3813
+ if (structuredTurn.length) {
3814
+ session.agentHistory.push(...structuredTurn);
3815
+ } else if (assistantContent) {
3816
+ session.agentHistory.push({ role: 'assistant', content: assistantContent });
3817
+ }
3818
+
3819
+ showPrompt();
3820
+ }
3821
+
3822
+ rl.on('close', async () => {
3823
+ clearSlashHint({ restoreCursor: false });
3824
+ inputActive = false;
3825
+ stopSpinner();
3826
+ if (isInputDockMounted()) unmountInputDock();
3827
+ await hookRunner.run('Stop', { input: { session_id: session.id || '' } });
3828
+ await jsonlWriter.close();
3829
+ process.stderr.write(`\n ${c.dim('session ended')}\n\n`);
3830
+ process.exit(0);
3831
+ });
3832
+ }