@axplusb/kepler 2.0.7 → 2.3.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 (42) hide show
  1. package/KEPLER-README.md +157 -142
  2. package/README.md +37 -65
  3. package/package.json +2 -2
  4. package/src/config/cli-args.mjs +14 -0
  5. package/src/config/hook-runner.mjs +100 -0
  6. package/src/config/memory-loader.mjs +32 -0
  7. package/src/config/settings-loader.mjs +45 -0
  8. package/src/core/agent-loop.mjs +8 -2
  9. package/src/core/approval-log.mjs +104 -0
  10. package/src/core/approval.mjs +164 -24
  11. package/src/core/cache-control.mjs +92 -0
  12. package/src/core/context-envelope.mjs +54 -0
  13. package/src/core/headless.mjs +99 -10
  14. package/src/core/jsonl-writer.mjs +50 -0
  15. package/src/core/local-agent.mjs +121 -14
  16. package/src/core/local-store.mjs +486 -5
  17. package/src/core/policy-resolver.mjs +156 -0
  18. package/src/core/project-context-loader.mjs +139 -0
  19. package/src/core/rate-limit-display.mjs +97 -0
  20. package/src/core/resume-mode.mjs +154 -0
  21. package/src/core/risk-tier.mjs +88 -2
  22. package/src/core/safety.mjs +3 -0
  23. package/src/core/session-manager.mjs +53 -10
  24. package/src/core/stream-client.mjs +69 -10
  25. package/src/core/system-prompt.mjs +6 -1
  26. package/src/core/tasks.mjs +196 -0
  27. package/src/core/tool-executor.mjs +72 -6
  28. package/src/core/trust.mjs +158 -0
  29. package/src/core/work-scope.mjs +217 -0
  30. package/src/onboarding/preflight.mjs +27 -11
  31. package/src/permissions/command-classifier.mjs +78 -0
  32. package/src/terminal/init.mjs +145 -0
  33. package/src/terminal/main.mjs +9 -0
  34. package/src/terminal/repl.mjs +1822 -140
  35. package/src/tools/bash.mjs +57 -9
  36. package/src/tools/project-overview.mjs +83 -10
  37. package/src/ui/approval.mjs +154 -35
  38. package/src/ui/commands.mjs +5 -4
  39. package/src/ui/formatter.mjs +39 -5
  40. package/src/ui/mission-report.mjs +55 -22
  41. package/src/ui/slash-commands.mjs +57 -5
  42. package/src/ui/tool-card.mjs +51 -1
@@ -17,12 +17,15 @@
17
17
 
18
18
  import * as readline from 'node:readline';
19
19
  import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
20
21
  import { c, progressBar, spinner, inPlace, renderMarkdown, renderDiff, formatElapsed, formatCost, stripAnsi } from './ansi.mjs';
21
22
  import { calculateCost, formatCostValue, formatTokens, costToCredits, formatCredits } from '../core/pricing.mjs';
22
23
  import { TarangStreamClient, EVENT_TYPES } from '../core/stream-client.mjs';
23
24
  import { JsonlWriter } from '../core/jsonl-writer.mjs';
24
25
  import { createToolExecutor } from '../core/tool-executor.mjs';
26
+ import { buildWorkScope } from '../core/work-scope.mjs';
25
27
  import { CheckpointManager } from '../core/checkpoints.mjs';
28
+ import { HookRunner } from '../config/hook-runner.mjs';
26
29
  import { runPreflight } from '../onboarding/preflight.mjs';
27
30
  import { printBanner as printBrandedBanner } from '../ui/banner.mjs';
28
31
  import { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
@@ -36,10 +39,17 @@ import { persistProjectArtifacts } from '../core/project-artifacts.mjs';
36
39
  import { TarangAuth } from '../auth/tarang-auth.mjs';
37
40
  import { ApprovalManager } from '../core/approval.mjs';
38
41
  import { resolveBackendUrl } from '../core/backend-url.mjs';
42
+ import { formatMessageWindow, lowWindowStatus, messagesRemaining } from '../core/rate-limit-display.mjs';
39
43
  import { BUILTIN_AGENTS, runAgent } from './agents.mjs';
40
44
  import { SessionManager } from '../core/session-manager.mjs';
41
45
  import { parseArgs } from '../config/cli-args.mjs';
42
- import { toolDisplayLabel } from './tool-display.mjs';
46
+ import { loadEffectivePolicy, formatPolicySourceRows } from '../core/policy-resolver.mjs';
47
+ import { loadProjectContext } from '../core/project-context-loader.mjs';
48
+ import { buildContextEnvelope } from '../core/context-envelope.mjs';
49
+ import { buildResumeHistory, combineResumeSummaries, getRecentSessions, getSessionDetail, getTranscriptProjectRoots } from '../core/local-store.mjs';
50
+ import { decideResumeMode, projectedTokensForChoice, formatTokens as formatCtxTokens } from '../core/resume-mode.mjs';
51
+ import { appendTask, ensureTaskFiles, loadTaskBoard, moveTask, removeTask, taskCounts, TASK_FILES, updateTask } from '../core/tasks.mjs';
52
+ import { toolDisplayLabel, toolDisplaySummary } from './tool-display.mjs';
43
53
  import { createOrbit } from '../state/orbit.mjs';
44
54
  import { attachOrbit, unmount as unmountStatusBar } from '../ui/status-bar.mjs';
45
55
  import { term } from '../ui/term.mjs';
@@ -89,6 +99,667 @@ function safeCwd() {
89
99
  }
90
100
  }
91
101
 
102
+ function messageCountLabel(count) {
103
+ return `${count} ${count === 1 ? 'message' : 'messages'}`;
104
+ }
105
+
106
+ function sessionListTimestamp(s) {
107
+ const value = s.updatedAt || s.startedAt;
108
+ return value ? new Date(value).toLocaleString() : '?';
109
+ }
110
+
111
+ function oneLineInstruction(text, max = 72) {
112
+ const compact = String(text || '(no instruction)').replace(/\s+/g, ' ').trim();
113
+ return compact.length > max ? compact.slice(0, max - 3) + '...' : compact;
114
+ }
115
+
116
+ function fitAnsiLine(text, maxColumns) {
117
+ const max = Math.max(1, Number(maxColumns) || 80);
118
+ const value = String(text || '');
119
+ if (stripAnsi(value).length <= max) return value;
120
+
121
+ let visible = 0;
122
+ let out = '';
123
+ for (let i = 0; i < value.length; i++) {
124
+ if (value[i] === '\x1b') {
125
+ const match = value.slice(i).match(/^\x1b\[[0-9;]*m/);
126
+ if (match) {
127
+ out += match[0];
128
+ i += match[0].length - 1;
129
+ continue;
130
+ }
131
+ }
132
+ if (visible >= max - 1) break;
133
+ out += value[i];
134
+ visible++;
135
+ }
136
+ return `${out}${c.dim('…')}`;
137
+ }
138
+
139
+ function normalizeResumableSession(s) {
140
+ return {
141
+ sessionId: s.sessionId,
142
+ instruction: s.firstPrompt || s.instruction || '(no instruction)',
143
+ startedAt: s.startTime || s.startedAt || '',
144
+ updatedAt: s.endTime || s.updatedAt || (s.mtime ? new Date(s.mtime).toISOString() : ''),
145
+ project: s.project ? path.basename(s.project) : s.projectName || s.project || '',
146
+ projectPath: s.project || s.projectPath || '',
147
+ transcriptPath: s.filePath || s.transcriptPath || '',
148
+ messageCount: (s.userMessages || 0) + (s.assistantMessages || 0),
149
+ // PRD-068 §5.14.11 derived fields for the picker
150
+ endStatus: s.endStatus || 'unknown', // 'completed' | 'interrupted' | 'errored' | 'unknown'
151
+ contextTokens: s.contextTokens || 0, // projected transcript token count
152
+ resumeSummary: s.resumeSummary || null, // latest resume_summary checkpoint metadata
153
+ costUsd: typeof s.costUsd === 'number' ? s.costUsd : 0,
154
+ partial: !!s.partial, // true if the transcript file was partially malformed
155
+ source: 'transcript',
156
+ };
157
+ }
158
+
159
+ async function listResumableSessions() {
160
+ // PRD-068 §5.14.6: JSONL is the single source of truth. The legacy
161
+ // per-project state-only entries never had a transcript, so they can't be
162
+ // replayed — silently dropping them removes a source of "picked a session
163
+ // and got a flat history" surprises.
164
+ const rich = (await getRecentSessions(Infinity)).map(normalizeResumableSession);
165
+ return rich.sort((a, b) => {
166
+ const at = Date.parse(a.updatedAt || a.startedAt || 0) || 0;
167
+ const bt = Date.parse(b.updatedAt || b.startedAt || 0) || 0;
168
+ return bt - at;
169
+ });
170
+ }
171
+
172
+ // ── PRD-068 §5.14 helpers ────────────────────────────────────────────
173
+
174
+ function endStatusMarker(status) {
175
+ switch (status) {
176
+ case 'completed': return c.green('✓');
177
+ case 'interrupted': return c.yellow('⚠');
178
+ case 'errored': return c.red('✗');
179
+ default: return c.dim('·');
180
+ }
181
+ }
182
+
183
+ function formatSessionCost(usd) {
184
+ const n = Number(usd);
185
+ if (!Number.isFinite(n) || n <= 0) return c.dim(' ');
186
+ if (n < 0.01) return c.dim('<$0.01');
187
+ return c.dim(`$${n.toFixed(2)}`);
188
+ }
189
+
190
+ function formatResumeCheckpointStatus(session) {
191
+ const marker = session?.resumeSummary;
192
+ if (!marker || !Number(marker.sourceMessageCount)) return '';
193
+ const full = Number(marker.fullMessageCount) || 0;
194
+ const covered = Number(marker.sourceMessageCount) || 0;
195
+ const pct = full > 0 ? ` ${Math.min(100, Math.round((covered / full) * 100))}%` : '';
196
+ return c.dim(` · summarized${pct}`);
197
+ }
198
+
199
+ function formatRelativeTime(iso) {
200
+ if (!iso) return '';
201
+ const t = Date.parse(iso);
202
+ if (!Number.isFinite(t)) return '';
203
+ const ago = Date.now() - t;
204
+ const m = Math.floor(ago / 60_000);
205
+ if (m < 1) return 'just now';
206
+ if (m < 60) return `${m}m ago`;
207
+ const h = Math.floor(m / 60);
208
+ if (h < 24) return `${h}h ago`;
209
+ const d = Math.floor(h / 24);
210
+ if (d < 30) return `${d}d ago`;
211
+ const mo = Math.floor(d / 30);
212
+ return `${mo}mo ago`;
213
+ }
214
+
215
+ /**
216
+ * PRD-068 §5.14.2 — enriched one-prompt picker.
217
+ * Returns the picked session, `null` on cancel, or `{ action: 'preview', session }`
218
+ * when the user hits P.
219
+ */
220
+ async function pickResumableSession(resumable, ctx) {
221
+ const rl = ctx._rl || null;
222
+ if (rl) rl.pause();
223
+
224
+ return await new Promise((resolve) => {
225
+ if (!process.stdin.isTTY) { resolve(null); return; }
226
+ const wasRaw = process.stdin.isRaw;
227
+ const pageSize = Math.min(10, resumable.length);
228
+ const numWidth = String(resumable.length).length;
229
+ let selected = 0;
230
+ let offset = 0;
231
+ let renderedLines = 0;
232
+
233
+ const renderMenu = () => {
234
+ if (renderedLines > 0) {
235
+ process.stderr.write(`\x1b[${renderedLines}F\r\x1b[J`);
236
+ }
237
+ if (selected < offset) offset = selected;
238
+ if (selected >= offset + pageSize) offset = selected - pageSize + 1;
239
+
240
+ const cols = Math.max(60, process.stderr.columns || 120);
241
+ const lines = [];
242
+ lines.push(` ${c.bold('Resume a session')}`);
243
+ lines.push('');
244
+ const end = Math.min(offset + pageSize, resumable.length);
245
+ for (let i = offset; i < end; i++) {
246
+ const s = resumable[i];
247
+ const marker = i === selected ? c.brand('▸') : ' ';
248
+ const num = c.dim(`[${String(i + 1).padStart(numWidth, ' ')}]`);
249
+ const project = (s.project || '(unknown)').padEnd(18, ' ').slice(0, 18);
250
+ const ago = formatRelativeTime(s.updatedAt || s.startedAt).padEnd(9, ' ').slice(0, 9);
251
+ const status = endStatusMarker(s.endStatus);
252
+ const msgs = String(s.messageCount).padStart(3, ' ') + ' msgs';
253
+ const ctx = c.dim(`${formatCtxTokens(s.contextTokens).padStart(5, ' ')} ctx`) + formatResumeCheckpointStatus(s);
254
+ const cost = formatSessionCost(s.costUsd);
255
+ const partial = s.partial ? c.yellow(' ⚠partial') : '';
256
+ const instr = oneLineInstruction(s.instruction, 48);
257
+ lines.push(fitAnsiLine(
258
+ ` ${marker} ${num} ${c.brand(project)} ${c.dim(ago)} ${status} ${c.dim(msgs)} ${ctx} ${cost}${partial} ${c.dim(instr)}`,
259
+ cols - 1
260
+ ));
261
+ }
262
+ lines.push('');
263
+ lines.push(fitAnsiLine(
264
+ ` ${c.dim(`↑↓ move · Enter resume · P preview · Esc cancel · ${selected + 1}/${resumable.length}`)}`,
265
+ cols - 1
266
+ ));
267
+ process.stderr.write(lines.join('\n') + '\n');
268
+ renderedLines = lines.length;
269
+ };
270
+
271
+ const cleanup = (value) => {
272
+ process.stdin.removeListener('data', onData);
273
+ process.stdin.setRawMode(wasRaw || false);
274
+ if (rl) rl.resume();
275
+ resolve(value);
276
+ };
277
+ const onData = (data) => {
278
+ const key = data.toString('utf8');
279
+ if (key === '' || key === '') { cleanup(null); return; }
280
+ if (key === '\r' || key === '\n') { cleanup({ action: 'resume', session: resumable[selected] }); return; }
281
+ if (key === 'p' || key === 'P') { cleanup({ action: 'preview', session: resumable[selected] }); return; }
282
+ if (key === '') { selected = Math.max(0, selected - 1); renderMenu(); return; }
283
+ if (key === '') { selected = Math.min(resumable.length - 1, selected + 1); renderMenu(); return; }
284
+ if (key === '[5~') { selected = Math.max(0, selected - pageSize); renderMenu(); return; }
285
+ if (key === '[6~') { selected = Math.min(resumable.length - 1, selected + pageSize); renderMenu(); return; }
286
+ if (key === '' || key === '[1~') { selected = 0; renderMenu(); return; }
287
+ if (key === '' || key === '[4~') { selected = resumable.length - 1; renderMenu(); return; }
288
+ if (/^[1-9]$/.test(key)) {
289
+ const index = Number(key) - 1;
290
+ if (index < resumable.length) cleanup({ action: 'resume', session: resumable[index] });
291
+ }
292
+ };
293
+
294
+ process.stdin.setRawMode(true);
295
+ process.stdin.resume();
296
+ process.stdin.on('data', onData);
297
+ renderMenu();
298
+ });
299
+ }
300
+
301
+ /**
302
+ * PRD-068 §5.14.4 — tri-choice overlay shown only when projected ctx > highWatermark.
303
+ * Returns 'full' | 'summary' | 'tail-10' | 'tail-20' | null (cancel).
304
+ */
305
+ async function chooseThresholdMode(ctx, decision) {
306
+ if (!process.stdin.isTTY) return decision.defaultChoice;
307
+ const rl = ctx._rl || null;
308
+ if (rl) rl.pause();
309
+
310
+ const canFull = decision.mode !== 'no-full-allowed';
311
+ const options = [
312
+ { key: 'f', value: 'full', label: 'full transcript', enabled: canFull },
313
+ { key: 's', value: 'summary', label: 'summary only', enabled: true },
314
+ { key: '1', value: 'tail-10', label: 'summary + last 10 turns', enabled: true },
315
+ { key: '2', value: 'tail-20', label: 'summary + last 20 turns', enabled: true },
316
+ ];
317
+ let selected = options.findIndex(o => o.value === decision.defaultChoice && o.enabled);
318
+ if (selected < 0) selected = options.findIndex(o => o.enabled);
319
+
320
+ return await new Promise((resolve) => {
321
+ const wasRaw = process.stdin.isRaw;
322
+ let renderedLines = 0;
323
+
324
+ const render = () => {
325
+ if (renderedLines > 0) process.stderr.write(`\x1b[${renderedLines}F\r\x1b[J`);
326
+ const cols = Math.max(60, process.stderr.columns || 120);
327
+ const pct = Math.round(decision.usageRatio * 100);
328
+ const projected = formatCtxTokens(decision.projected);
329
+ const win = formatCtxTokens(decision.windowSize);
330
+ const lines = [];
331
+ lines.push(` This session would use ${c.brand(`${projected} / ${win}`)} tokens (${pct}%)`);
332
+ if (decision.resumeSummary?.sourceMessageCount) {
333
+ const covered = Number(decision.resumeSummary.sourceMessageCount) || 0;
334
+ const full = Number(decision.resumeSummary.fullMessageCount) || 0;
335
+ const suffix = full > 0 ? ` (${covered}/${full} resume messages)` : '';
336
+ lines.push(` ${c.green('✓')} ${c.dim(`summary checkpoint available${suffix}; summary/tail modes reuse it`)}`);
337
+ }
338
+ lines.push(canFull
339
+ ? ` ${c.yellow('⚠')} ${c.dim('close to the highWatermark — consider a leaner mode:')}`
340
+ : ` ${c.red('⛔')} ${c.dim('over hardCap — full mode disabled:')}`);
341
+ lines.push('');
342
+ for (let i = 0; i < options.length; i++) {
343
+ const o = options[i];
344
+ const disabled = !o.enabled;
345
+ const marker = i === selected && !disabled ? c.brand('▸') : ' ';
346
+ const keyTag = c.dim('[') + (disabled ? c.dim(o.key) : c.brand(o.key)) + c.dim(']');
347
+ const proj = formatCtxTokens(projectedTokensForChoice(o.value, decision.projected));
348
+ const label = disabled ? c.dim(o.label) : (i === selected ? c.brand(o.label) : o.label);
349
+ const projCol = c.dim(`${proj.padStart(5, ' ')} ctx`);
350
+ const suffix = disabled ? c.dim(' (over hardCap)') : '';
351
+ lines.push(fitAnsiLine(` ${marker} ${keyTag} ${label.padEnd(30, ' ')} ${projCol}${suffix}`, cols - 1));
352
+ }
353
+ lines.push('');
354
+ lines.push(fitAnsiLine(` ${c.dim('↑↓ move · Enter pick · f/s/1/2 shortcut · Esc cancel')}`, cols - 1));
355
+ process.stderr.write(lines.join('\n') + '\n');
356
+ renderedLines = lines.length;
357
+ };
358
+
359
+ const cleanup = (value) => {
360
+ process.stdin.removeListener('data', onData);
361
+ process.stdin.setRawMode(wasRaw || false);
362
+ if (rl) rl.resume();
363
+ resolve(value);
364
+ };
365
+ const onData = (data) => {
366
+ const key = data.toString('utf8');
367
+ const low = key.toLowerCase();
368
+ if (key === '' || key === '') { cleanup(null); return; }
369
+ if (key === '\r' || key === '\n') { cleanup(options[selected]?.value || null); return; }
370
+ if (key === '') {
371
+ // step to previous enabled option
372
+ for (let i = selected - 1; i >= 0; i--) if (options[i].enabled) { selected = i; render(); return; }
373
+ return;
374
+ }
375
+ if (key === '') {
376
+ for (let i = selected + 1; i < options.length; i++) if (options[i].enabled) { selected = i; render(); return; }
377
+ return;
378
+ }
379
+ for (let i = 0; i < options.length; i++) {
380
+ if (options[i].key === low && options[i].enabled) { cleanup(options[i].value); return; }
381
+ }
382
+ };
383
+
384
+ process.stdin.setRawMode(true);
385
+ process.stdin.resume();
386
+ process.stdin.on('data', onData);
387
+ render();
388
+ });
389
+ }
390
+
391
+ function resumeModeLabel(mode = 'full') {
392
+ if (mode === 'summary') return 'summary only';
393
+ const tailTurns = resumeTailTurnCount(mode);
394
+ if (tailTurns) return `summary + last ${tailTurns} turns`;
395
+ return mode || 'full';
396
+ }
397
+
398
+ /**
399
+ * PRD-068 §5.14.5 — preview overlay for a session/mode. Read-only, `q` to return.
400
+ * If user hits Enter, resolve to the currently-previewed mode so caller can activate.
401
+ */
402
+ async function previewResumeSession(session, ctx) {
403
+ if (!process.stdin.isTTY) return null;
404
+ const detail = await getSessionDetail(session.sessionId, { filePath: session.transcriptPath });
405
+ if (!detail) return null;
406
+
407
+ const rl = ctx._rl || null;
408
+ if (rl) rl.pause();
409
+
410
+ let mode = 'summary';
411
+ const rich = () => buildResumeHistory({ ...detail, recapTailTurns: 8 }, mode);
412
+ let history = rich();
413
+
414
+ return await new Promise((resolve) => {
415
+ const wasRaw = process.stdin.isRaw;
416
+ let renderedLines = 0;
417
+ let scrollOffset = 0;
418
+
419
+ const render = () => {
420
+ if (renderedLines > 0) process.stderr.write(`\x1b[${renderedLines}F\r\x1b[J`);
421
+ const cols = Math.max(60, process.stderr.columns || 120);
422
+ const rows = Math.max(10, Math.min((process.stderr.rows || 30) - 6, 20));
423
+ const contentLines = (history.summary || '').split('\n');
424
+ // For tail/full modes, also append serialized tail so preview reflects
425
+ // what the agent will actually receive.
426
+ if (mode !== 'summary') {
427
+ contentLines.push('', c.dim('── conversation tail ──'));
428
+ for (const msg of history.agentHistory.slice(1)) {
429
+ contentLines.push(`${msg.role === 'user' ? c.dim('You:') : c.brand('Kepler:')} ${String(msg.content).slice(0, 300)}`);
430
+ }
431
+ }
432
+ const totalLines = contentLines.length;
433
+ const maxOffset = Math.max(0, totalLines - rows);
434
+ if (scrollOffset > maxOffset) scrollOffset = maxOffset;
435
+
436
+ const lines = [];
437
+ lines.push(` ${c.bold('Preview:')} ${c.brand(session.project || '(unknown)')} ${c.dim('Mode:')} ${c.brand(resumeModeLabel(mode))} ${c.dim(formatCtxTokens(projectedTokensForChoice(mode, session.contextTokens)) + ' ctx')}`);
438
+ lines.push(` ${c.dim('─'.repeat(60))}`);
439
+ for (let i = scrollOffset; i < Math.min(scrollOffset + rows, totalLines); i++) {
440
+ lines.push(fitAnsiLine(` ${c.dim(contentLines[i] || '')}`, cols - 1));
441
+ }
442
+ lines.push('');
443
+ lines.push(fitAnsiLine(` ${c.dim(`↑↓/PgUp/PgDn scroll · f/s/1/2 switch mode · Enter resume this · q back · ${scrollOffset + 1}-${Math.min(scrollOffset + rows, totalLines)}/${totalLines}`)}`, cols - 1));
444
+ process.stderr.write(lines.join('\n') + '\n');
445
+ renderedLines = lines.length;
446
+ };
447
+
448
+ const cleanup = (value) => {
449
+ process.stdin.removeListener('data', onData);
450
+ process.stdin.setRawMode(wasRaw || false);
451
+ if (rl) rl.resume();
452
+ resolve(value);
453
+ };
454
+ const onData = (data) => {
455
+ const key = data.toString('utf8');
456
+ const low = key.toLowerCase();
457
+ if (key === '' || key === '' || low === 'q') { cleanup({ action: 'back' }); return; }
458
+ if (key === '\r' || key === '\n') { cleanup({ action: 'resume', mode }); return; }
459
+ if (key === '') { scrollOffset = Math.max(0, scrollOffset - 1); render(); return; }
460
+ if (key === '') { scrollOffset += 1; render(); return; }
461
+ if (key === '[5~') { scrollOffset = Math.max(0, scrollOffset - 10); render(); return; }
462
+ if (key === '[6~') { scrollOffset += 10; render(); return; }
463
+ if (low === 'f') { mode = 'full'; history = rich(); scrollOffset = 0; render(); return; }
464
+ if (low === 's') { mode = 'summary'; history = rich(); scrollOffset = 0; render(); return; }
465
+ if (low === '1') { mode = 'tail-10'; history = rich(); scrollOffset = 0; render(); return; }
466
+ if (low === '2') { mode = 'tail-20'; history = rich(); scrollOffset = 0; render(); return; }
467
+ };
468
+
469
+ process.stdin.setRawMode(true);
470
+ process.stdin.resume();
471
+ process.stdin.on('data', onData);
472
+ render();
473
+ });
474
+ }
475
+
476
+ /**
477
+ * PRD-068 §5.14.7 — explicit cwd confirmation when the picked session lives
478
+ * elsewhere. Returns 'switch' | 'stay' | 'cancel'.
479
+ */
480
+ async function confirmCwdSwitch(ctx, savedPath, currentPath) {
481
+ if (!process.stdin.isTTY) return 'switch';
482
+ process.stderr.write(`\n ${c.dim('This session lives in another repo:')}\n`);
483
+ process.stderr.write(` ${c.dim('→')} ${c.brand(savedPath)} ${c.dim(`(current cwd: ${currentPath})`)}\n`);
484
+ process.stderr.write(` ${c.dim('[Enter]')} switch cwd and resume · ${c.dim('[s]')} stay here and resume anyway · ${c.dim('[n]')} cancel `);
485
+ const rl = ctx._rl || null;
486
+ if (rl) rl.pause();
487
+ return await new Promise((resolve) => {
488
+ const wasRaw = process.stdin.isRaw;
489
+ const cleanup = (value) => {
490
+ process.stdin.removeListener('data', onData);
491
+ process.stdin.setRawMode(wasRaw || false);
492
+ if (rl) rl.resume();
493
+ process.stderr.write('\n');
494
+ resolve(value);
495
+ };
496
+ const onData = (data) => {
497
+ const key = data.toString('utf8').toLowerCase();
498
+ if (key === '' || key === '' || key === 'n') { cleanup('cancel'); return; }
499
+ if (key === '\r' || key === '\n') { cleanup('switch'); return; }
500
+ if (key === 's') { cleanup('stay'); return; }
501
+ };
502
+ process.stdin.setRawMode(true);
503
+ process.stdin.resume();
504
+ process.stdin.on('data', onData);
505
+ });
506
+ }
507
+
508
+ // Legacy prompt (kept as a fallback for callers that force compact/full explicitly).
509
+ async function chooseResumeHistoryMode(ctx, { defaultMode = 'compact' } = {}) {
510
+ if (!process.stdin.isTTY) return defaultMode;
511
+ process.stderr.write(`\n ${c.dim('Load history for agent:')} ${c.brand('[c]')} ${c.dim('compact summary')} ${c.brand('[f]')} ${c.dim('full transcript')} ${c.dim('(Enter = compact, Esc = cancel):')} `);
512
+ const rl = ctx._rl || null;
513
+ if (rl) rl.pause();
514
+ return await new Promise((resolve) => {
515
+ const wasRaw = process.stdin.isRaw;
516
+ const cleanup = (value) => {
517
+ process.stdin.removeListener('data', onData);
518
+ process.stdin.setRawMode(wasRaw || false);
519
+ if (rl) rl.resume();
520
+ process.stderr.write('\n');
521
+ resolve(value);
522
+ };
523
+ const onData = (data) => {
524
+ const key = data.toString('utf8').toLowerCase();
525
+ if (key === '\u0003' || key === '\u001b') { cleanup(null); return; }
526
+ if (key === '\r' || key === '\n' || key === 'c') { cleanup('compact'); return; }
527
+ if (key === 'f') { cleanup('full'); }
528
+ };
529
+ process.stdin.setRawMode(true);
530
+ process.stdin.resume();
531
+ process.stdin.on('data', onData);
532
+ });
533
+ }
534
+
535
+ function historyRoleLabel(role) {
536
+ return role === 'user'
537
+ ? c.white('You')
538
+ : role === 'tool'
539
+ ? c.dim('Tool')
540
+ : c.brand('Kepler');
541
+ }
542
+
543
+ function renderHistoryEntries(entries, { limit = 20, maxChars = 120, title = 'Conversation' } = {}) {
544
+ const shown = limit === Infinity ? entries : entries.slice(-limit);
545
+ process.stderr.write(`\n ${c.bold(title)} (${shown.length}${shown.length === entries.length ? '' : ` of ${entries.length}`} entries)\n`);
546
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
547
+ for (const msg of shown) {
548
+ const content = String(msg.content || '').replace(/\s+/g, ' ').trim();
549
+ process.stderr.write(` ${historyRoleLabel(msg.role)}: ${content.slice(0, maxChars)}${content.length > maxChars ? '...' : ''}\n`);
550
+ }
551
+ process.stderr.write('\n');
552
+ }
553
+
554
+ function renderResumePreview(resumed) {
555
+ const tailTurns = resumeTailTurnCount(resumed.historyMode);
556
+ if (resumed.historyMode === 'compact' || resumed.historyMode === 'summary') {
557
+ if (!resumed.summary) return;
558
+ process.stderr.write(`\n ${c.bold('Continuity Summary Sent To Agent')}\n`);
559
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
560
+ for (const line of resumed.summary.split('\n')) {
561
+ process.stderr.write(` ${c.dim(line)}\n`);
562
+ }
563
+ process.stderr.write('\n');
564
+ return;
565
+ }
566
+
567
+ if (tailTurns && resumed.summary) {
568
+ process.stderr.write(`\n ${c.bold(`Summary + Last ${tailTurns} Turns`)}\n`);
569
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
570
+ for (const line of resumed.summary.split('\n')) {
571
+ process.stderr.write(` ${c.dim(line)}\n`);
572
+ }
573
+ process.stderr.write('\n');
574
+ }
575
+
576
+ if (resumed.replayEvents?.length) {
577
+ const replayStartOrder = replayStartOrderForMode(resumed.history || [], resumed.historyMode);
578
+ const replayEvents = filterResumeReplayEvents(resumed.replayEvents)
579
+ .filter(item => replayStartOrder == null || !Number.isFinite(Number(item.order)) || Number(item.order) >= replayStartOrder);
580
+ const userTurns = (resumed.history || [])
581
+ .filter(m => m.role === 'user')
582
+ .filter(m => replayStartOrder == null || !Number.isFinite(Number(m.order)) || Number(m.order) >= replayStartOrder);
583
+ const replayItems = mergeResumeReplayItems(userTurns, replayEvents);
584
+ const replayTitle = tailTurns ? `Last ${tailTurns} Turns Replay` : 'Replayed Live Session Events';
585
+ process.stderr.write(`\n ${c.bold(replayTitle)} (${userTurns.length} turns, ${replayEvents.length} events)\n`);
586
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
587
+ const sessionSnapshot = JSON.parse(JSON.stringify(session));
588
+ const savedOrbit = _orbit;
589
+ const savedSessionMgr = _sessionMgr;
590
+ _orbit = null;
591
+ _sessionMgr = null;
592
+ try {
593
+ startContentStream();
594
+ for (const item of replayItems) {
595
+ if (item.kind === 'user') {
596
+ flushContent();
597
+ stopSpinner();
598
+ const content = String(item.message.content || '').replace(/\s+/g, ' ').trim();
599
+ process.stderr.write(`\n ${historyRoleLabel('user')}: ${content}\n`);
600
+ continue;
601
+ }
602
+ renderEvent(item.event.event);
603
+ }
604
+ flushContent();
605
+ stopSpinner();
606
+ } finally {
607
+ _orbit = savedOrbit;
608
+ _sessionMgr = savedSessionMgr;
609
+ for (const key of Object.keys(session)) delete session[key];
610
+ Object.assign(session, sessionSnapshot);
611
+ }
612
+ process.stderr.write('\n');
613
+ return;
614
+ }
615
+
616
+ if (resumed.history?.length) {
617
+ renderHistoryEntries(resumed.history, {
618
+ limit: Infinity,
619
+ maxChars: 220,
620
+ title: tailTurns ? `Last ${tailTurns} Turns` : 'Replayed Session History',
621
+ });
622
+ }
623
+ }
624
+
625
+ async function summarizeResumeTranscript({
626
+ auth,
627
+ toolExecutor,
628
+ sessionId,
629
+ projectPath,
630
+ messages,
631
+ }) {
632
+ const creds = auth?.loadCredentials?.() || {};
633
+ if (!creds.backendUrl || !creds.token || !Array.isArray(messages) || messages.length === 0) {
634
+ return {
635
+ ok: false,
636
+ source: 'local',
637
+ reason: !creds.backendUrl || !creds.token ? 'missing backend credentials' : 'empty transcript',
638
+ };
639
+ }
640
+ try {
641
+ const client = new TarangStreamClient({
642
+ baseUrl: creds.backendUrl,
643
+ token: creds.token,
644
+ toolExecutor,
645
+ });
646
+ const result = await client.summarizeSession(messages, {
647
+ sessionId,
648
+ projectPath,
649
+ maxTokens: 800,
650
+ timeoutMs: 15000,
651
+ });
652
+ return { ...result, ok: true };
653
+ } catch (err) {
654
+ return {
655
+ ok: false,
656
+ source: 'local',
657
+ reason: err?.message || 'backend summary request failed',
658
+ };
659
+ }
660
+ }
661
+
662
+ function resumeTailTurnCount(mode = '') {
663
+ const match = String(mode || '').match(/^tail-(\d+)$/);
664
+ if (!match) return null;
665
+ return Math.max(1, Number(match[1]) || 1);
666
+ }
667
+
668
+ function replayStartOrderForMode(history = [], mode = '') {
669
+ const match = String(mode || '').match(/^tail-(\d+)$/);
670
+ if (!match) return null;
671
+ const wanted = Math.max(1, Number(match[1]) || 1);
672
+ let seen = 0;
673
+ const userTurns = history.filter(m => m.role === 'user' && typeof m.content === 'string');
674
+ for (let i = userTurns.length - 1; i >= 0; i--) {
675
+ seen++;
676
+ if (seen >= wanted) {
677
+ const order = Number(userTurns[i].order);
678
+ return Number.isFinite(order) ? order : null;
679
+ }
680
+ }
681
+ return null;
682
+ }
683
+
684
+ function filterResumeReplayEvents(events = []) {
685
+ return events.filter(item => {
686
+ const type = item?.event?.type;
687
+ return !['status', 'session_info', 'complete', 'resumed', 'paused'].includes(type);
688
+ });
689
+ }
690
+
691
+ function mergeResumeReplayItems(userTurns = [], replayEvents = []) {
692
+ const items = [];
693
+ let order = 0;
694
+ for (const message of userTurns) {
695
+ items.push({
696
+ kind: 'user',
697
+ message,
698
+ fileOrder: Number.isFinite(Number(message.order)) ? Number(message.order) : null,
699
+ order: order++,
700
+ time: Date.parse(message.timestamp || '') || 0,
701
+ });
702
+ }
703
+ for (const event of replayEvents) {
704
+ items.push({
705
+ kind: 'event',
706
+ event,
707
+ fileOrder: Number.isFinite(Number(event.order)) ? Number(event.order) : null,
708
+ order: order++,
709
+ time: Date.parse(event.timestamp || '') || 0,
710
+ });
711
+ }
712
+ return items.sort((a, b) => {
713
+ if (a.fileOrder !== null && b.fileOrder !== null && a.fileOrder !== b.fileOrder) {
714
+ return a.fileOrder - b.fileOrder;
715
+ }
716
+ if (a.fileOrder !== null && b.fileOrder === null) return -1;
717
+ if (a.fileOrder === null && b.fileOrder !== null) return 1;
718
+ const at = a.time || Number.MAX_SAFE_INTEGER;
719
+ const bt = b.time || Number.MAX_SAFE_INTEGER;
720
+ return at - bt || a.order - b.order;
721
+ });
722
+ }
723
+
724
+ function resumeProgressBar(percent, width = 12) {
725
+ const p = Math.max(0, Math.min(100, Math.round(percent)));
726
+ const filled = Math.round((p / 100) * width);
727
+ return `${c.brand('█'.repeat(filled))}${c.gray('░'.repeat(width - filled))} ${String(p).padStart(3)}%`;
728
+ }
729
+
730
+ function startResumeProgress(mode = 'full') {
731
+ let percent = 8;
732
+ let label = `resuming as ${resumeModeLabel(mode)}`;
733
+ let active = true;
734
+ const started = Date.now();
735
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
736
+ let frame = 0;
737
+
738
+ const render = () => {
739
+ if (!active) return;
740
+ const glyph = frames[frame % frames.length];
741
+ frame++;
742
+ inPlace(` ${c.brand(glyph)} ${c.dim(label)} ${resumeProgressBar(percent)} ${c.dim(formatElapsed(started))}`);
743
+ };
744
+
745
+ render();
746
+ const timer = setInterval(render, 100);
747
+ return {
748
+ update(nextLabel, nextPercent) {
749
+ if (!active) return;
750
+ if (nextLabel) label = nextLabel;
751
+ if (Number.isFinite(nextPercent)) percent = Math.max(percent, Math.min(98, nextPercent));
752
+ render();
753
+ },
754
+ stop() {
755
+ if (!active) return;
756
+ active = false;
757
+ clearInterval(timer);
758
+ inPlace('');
759
+ },
760
+ };
761
+ }
762
+
92
763
  // ── Session State ──
93
764
 
94
765
  let _sessionMgr = null; // Set in startTerminalRepl, used by renderEvent
@@ -102,7 +773,8 @@ const session = {
102
773
  toolCalls: 0,
103
774
  totalToolCalls: 0, // across all turns
104
775
  turns: 0,
105
- history: [], // conversation messages
776
+ history: [], // display transcript (can include reconstructed tool entries)
777
+ agentHistory: [], // backend continuity payload (compact or full)
106
778
  inputHistory: [], // previous prompts (for Up/Down)
107
779
  user: null, // { github_username, email, role }
108
780
  model: null, // from backend user profile
@@ -111,6 +783,7 @@ const session = {
111
783
  phases: [], // phase history: { name, time }
112
784
  inSubAgent: false, // true while a sub-agent is running (for indented tool display)
113
785
  filesChanged: [], // files modified this session
786
+ filesRead: [], // files read this turn
114
787
  lastTurnDuration: 0,
115
788
  toolCounts: {}, // per-tool histogram (mission report)
116
789
  subAgentCounts: {}, // per-sub-agent histogram (mission report)
@@ -132,6 +805,8 @@ const session = {
132
805
  creditsLimit: null, // per-period included credits limit
133
806
  creditsCharged: 0, // session-cumulative server-reported charges
134
807
  creditsLowWarned: false, // emit the low-balance hint only once per turn
808
+ rateLimit: null, // rolling message-window state from backend
809
+ msgsLowWarned: false, // emit the low-window hint only once per turn
135
810
  };
136
811
 
137
812
  // ── Commands ──
@@ -141,12 +816,15 @@ const COMMANDS = {
141
816
  '/login': 'Sign in via browser',
142
817
  '/whoami': 'Show logged-in user',
143
818
  '/status': 'Session status & system info',
819
+ '/plan': 'Show plan/tasks',
820
+ '/tasks': 'Show or update project tasks',
144
821
  '/stats': 'Progress bars & metrics',
145
822
  '/clear': 'Clear conversation',
146
823
  '/git': 'Git status',
147
824
  '/diff': 'Git diff',
148
825
  '/cost': 'Show session cost',
149
826
  '/history': 'Show conversation',
827
+ '/settings': 'Show policy/settings',
150
828
  '/last': 'Expand last tool output',
151
829
  '/expand': 'Expand tool output by index (or "all")',
152
830
  '/fold': 'Hide previously expanded tool output',
@@ -173,6 +851,240 @@ const COMMANDS = {
173
851
  '/exit': 'Exit CLI',
174
852
  };
175
853
 
854
+ const HELP_GROUPS = [
855
+ {
856
+ key: 'plan',
857
+ title: 'Plan',
858
+ summary: 'plan and project tasks',
859
+ commands: [
860
+ ['/plan', 'Plan and task overview'],
861
+ ['/plan status', 'Plan owner and task files'],
862
+ ['/plan edit', 'Show editable task/plan paths'],
863
+ ['/tasks', 'List project tasks'],
864
+ ['/tasks add <text>', 'Add backlog task'],
865
+ ['/tasks active|blocked|done <text>', 'Append to a task list'],
866
+ ],
867
+ },
868
+ {
869
+ key: 'status',
870
+ title: 'Status',
871
+ summary: 'session, usage, budget',
872
+ commands: [
873
+ ['/status', 'Session snapshot'],
874
+ ['/status context', 'Loaded .kepler context'],
875
+ ['/status metrics', 'Progress bars and runtime metrics'],
876
+ ['/status cost', 'Credits and message window'],
877
+ ['/status budget <amount|clear>', 'Set or clear session budget'],
878
+ ],
879
+ },
880
+ {
881
+ key: 'history',
882
+ title: 'History',
883
+ summary: 'transcript, reports, undo',
884
+ commands: [
885
+ ['/history', 'Recent transcript'],
886
+ ['/history approvals', 'Approval log'],
887
+ ['/history last', 'Expand last tool output'],
888
+ ['/history expand [n|all]', 'Expand tool output'],
889
+ ['/history checkpoint', 'List checkpoints'],
890
+ ['/history undo', 'Restore latest checkpoint'],
891
+ ['/history report', 'Save mission report'],
892
+ ],
893
+ },
894
+ {
895
+ key: 'settings',
896
+ title: 'Settings',
897
+ summary: 'auth, policy, verbosity',
898
+ commands: [
899
+ ['/settings policy', 'Effective project policy'],
900
+ ['/settings login', 'Sign in'],
901
+ ['/settings logout', 'Sign out'],
902
+ ['/settings whoami', 'Current user'],
903
+ ['/settings quiet|verbose|surgical', 'Verbosity'],
904
+ ['/settings revoke', 'Revoke auto-approvals'],
905
+ ],
906
+ },
907
+ {
908
+ key: 'worktree',
909
+ title: 'Worktree',
910
+ summary: 'git and files',
911
+ commands: [
912
+ ['/git', 'Git status'],
913
+ ['/diff', 'Git diff'],
914
+ ['/map', 'Registered project tree'],
915
+ ['/preflight', 'Onboarding diagnostic'],
916
+ ['/safety', 'Safety guardrail status'],
917
+ ],
918
+ },
919
+ {
920
+ key: 'agents',
921
+ title: 'Agents',
922
+ summary: 'specialist modes',
923
+ commands: [
924
+ ['/agents', 'List built-in agents'],
925
+ ['/explore <instruction>', 'Explore code'],
926
+ ['/review <instruction>', 'Review code'],
927
+ ['/architect <instruction>', 'Design an approach'],
928
+ ],
929
+ },
930
+ {
931
+ key: 'session',
932
+ title: 'Session',
933
+ summary: 'resume and clear',
934
+ commands: [
935
+ ['/sessions', 'List resumable sessions'],
936
+ ['/resume [id]', 'Resume a session'],
937
+ ['/compact', 'Compact conversation context'],
938
+ ['/clear', 'Clear conversation'],
939
+ ['/exit', 'Exit CLI'],
940
+ ],
941
+ },
942
+ ];
943
+
944
+ const HELP_GROUP_ALIASES = new Map(
945
+ HELP_GROUPS.flatMap(group => [[group.key, group], [group.title.toLowerCase(), group]])
946
+ );
947
+
948
+ const LEGACY_COMMAND_HINTS = {
949
+ '/stats': '/status metrics',
950
+ '/cost': '/status cost',
951
+ '/budget': '/status budget',
952
+ '/last': '/history last',
953
+ '/expand': '/history expand',
954
+ '/fold': '/history fold',
955
+ '/undo': '/history undo',
956
+ '/checkpoint': '/history checkpoint',
957
+ '/report': '/history report',
958
+ '/login': '/settings login',
959
+ '/logout': '/settings logout',
960
+ '/whoami': '/settings whoami',
961
+ '/quiet': '/settings quiet',
962
+ '/verbose': '/settings verbose',
963
+ '/surgical': '/settings surgical',
964
+ '/revoke': '/settings revoke',
965
+ };
966
+
967
+ const NAMESPACED_COMMANDS = {
968
+ '/status': {
969
+ metrics: '/stats',
970
+ stats: '/stats',
971
+ cost: '/cost',
972
+ credits: '/cost',
973
+ budget: '/budget',
974
+ },
975
+ '/history': {
976
+ last: '/last',
977
+ expand: '/expand',
978
+ fold: '/fold',
979
+ undo: '/undo',
980
+ checkpoint: '/checkpoint',
981
+ checkpoints: '/checkpoint',
982
+ report: '/report',
983
+ },
984
+ '/settings': {
985
+ login: '/login',
986
+ logout: '/logout',
987
+ whoami: '/whoami',
988
+ quiet: '/quiet',
989
+ verbose: '/verbose',
990
+ surgical: '/surgical',
991
+ revoke: '/revoke',
992
+ },
993
+ };
994
+
995
+ function normalizeCommandInput(input) {
996
+ const parts = input.trim().split(/\s+/).filter(Boolean);
997
+ const rawCmd = (parts[0] || '').toLowerCase();
998
+ const restParts = parts.slice(1);
999
+ const sub = (restParts[0] || '').toLowerCase();
1000
+ const namespaced = NAMESPACED_COMMANDS[rawCmd]?.[sub];
1001
+ if (namespaced) {
1002
+ return {
1003
+ cmd: namespaced,
1004
+ rest: restParts.slice(1).join(' '),
1005
+ rawCmd,
1006
+ aliasTarget: null,
1007
+ };
1008
+ }
1009
+ return {
1010
+ cmd: rawCmd,
1011
+ rest: restParts.join(' '),
1012
+ rawCmd,
1013
+ aliasTarget: LEGACY_COMMAND_HINTS[rawCmd] || null,
1014
+ };
1015
+ }
1016
+
1017
+ function renderHelp(topic = '') {
1018
+ const key = String(topic || '').trim().toLowerCase();
1019
+ if (!key) {
1020
+ process.stderr.write(`\n ${c.bold('Kepler Commands')}\n`);
1021
+ process.stderr.write(` ${c.gray('─'.repeat(52))}\n`);
1022
+ const top = [
1023
+ ['/help', 'Grouped command help'],
1024
+ ['/status', 'Session snapshot'],
1025
+ ['/plan', 'Task list and plan'],
1026
+ ['/tasks', 'Project task files'],
1027
+ ['/history', 'Transcript, approvals, undo'],
1028
+ ['/settings', 'Policy, auth, verbosity'],
1029
+ ['/why', 'Explain last reasoning'],
1030
+ ];
1031
+ for (const [name, desc] of top) {
1032
+ process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}\n`);
1033
+ }
1034
+ process.stderr.write(`\n ${c.bold('Categories')}\n`);
1035
+ for (const group of HELP_GROUPS) {
1036
+ process.stderr.write(` ${c.brand(('/help ' + group.key).padEnd(20))} ${c.dim(group.summary)}\n`);
1037
+ }
1038
+ process.stderr.write(`\n ${c.dim('Use /help all for legacy command aliases.')}\n`);
1039
+ renderKeyboardHelp();
1040
+ return;
1041
+ }
1042
+
1043
+ if (key === 'all' || key === 'commands') {
1044
+ process.stderr.write(`\n ${c.bold('All Commands')}\n`);
1045
+ process.stderr.write(` ${c.gray('─'.repeat(52))}\n`);
1046
+ for (const [name, desc] of Object.entries(COMMANDS)) {
1047
+ const alias = LEGACY_COMMAND_HINTS[name] ? c.dim(` alias for ${LEGACY_COMMAND_HINTS[name]}`) : '';
1048
+ process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}${alias}\n`);
1049
+ }
1050
+ process.stderr.write('\n');
1051
+ return;
1052
+ }
1053
+
1054
+ const group = HELP_GROUP_ALIASES.get(key);
1055
+ if (!group) {
1056
+ process.stderr.write(` ${c.gray(`Unknown help category: ${key}. Use /help.`)}\n`);
1057
+ return;
1058
+ }
1059
+
1060
+ process.stderr.write(`\n ${c.bold(group.title)} ${c.dim(group.summary)}\n`);
1061
+ process.stderr.write(` ${c.gray('─'.repeat(52))}\n`);
1062
+ for (const [name, desc] of group.commands) {
1063
+ process.stderr.write(` ${c.brand(name.padEnd(30))} ${desc}\n`);
1064
+ }
1065
+ process.stderr.write('\n');
1066
+ }
1067
+
1068
+ function renderKeyboardHelp() {
1069
+ process.stderr.write(`\n ${c.bold('Keyboard')}\n`);
1070
+ process.stderr.write(` ${c.gray('Ctrl+C')} exit ${c.gray('↑↓')} history ${c.gray('Tab')} autocomplete\n`);
1071
+ process.stderr.write(` ${c.gray('d')} expand last tool ${c.gray('Space')} pause/resume ${c.gray('Esc')} interrupt\n\n`);
1072
+ }
1073
+
1074
+ function commandCompletions(line) {
1075
+ if (line.startsWith('/help ')) {
1076
+ const topic = line.slice('/help '.length).toLowerCase();
1077
+ const categories = ['all', ...HELP_GROUPS.map(g => g.key)];
1078
+ const hits = categories.map(c => `/help ${c}`).filter(cmd => cmd.startsWith(`/help ${topic}`));
1079
+ return hits.length ? hits : categories.map(c => `/help ${c}`);
1080
+ }
1081
+ const top = ['/help', '/status', '/plan', '/tasks', '/history', '/settings', '/why'];
1082
+ const namespaced = HELP_GROUPS.flatMap(g => g.commands.map(([name]) => name.split(/\s+/)[0]));
1083
+ const all = [...new Set([...top, ...namespaced, ...Object.keys(COMMANDS), '/quit'])].sort();
1084
+ const hits = all.filter(cmd => cmd.startsWith(line));
1085
+ return hits.length ? hits : all;
1086
+ }
1087
+
176
1088
  // ── Banner ──
177
1089
 
178
1090
  function printBanner(auth) {
@@ -210,19 +1122,26 @@ function printBanner(auth) {
210
1122
  * Left side: last-turn summary (tools, time, cost)
211
1123
  * Right side: session totals (ctx%, tokens)
212
1124
  */
1125
+ function computeCacheTotals() {
1126
+ let read = 0;
1127
+ let write = 0;
1128
+ for (const b of session.costBreakdown) {
1129
+ read += b.cache_read_tokens || 0;
1130
+ write += b.cache_creation_tokens || 0;
1131
+ }
1132
+ const denom = session.inputTokens + read;
1133
+ const hitRate = denom > 0 ? Math.round((read / denom) * 100) : 0;
1134
+ return { read, write, hitRate };
1135
+ }
1136
+
213
1137
  function buildContextStrip() {
214
1138
  const totalTokens = session.inputTokens + session.outputTokens;
215
1139
  const elapsed = formatElapsed(session.startTime);
1140
+ const cache = computeCacheTotals();
216
1141
 
217
- // BYOK: user pays the provider directly, suppress credits entirely.
218
- // Otherwise prefer the server-authoritative session counter, falling back
219
- // to the local estimate when the backend hasn't pushed any number yet.
220
- const usedCr = session.creditsCharged > 0
221
- ? session.creditsCharged
222
- : costToCredits(session.totalCost);
223
1142
  const right = [
224
1143
  c.dim(`${formatTokens(totalTokens)} tok`),
225
- ...(session.isByok ? [] : [c.dim(formatCredits(usedCr))]),
1144
+ ...(cache.read > 0 ? [c.dim(`cache ${cache.hitRate}%`)] : []),
226
1145
  c.dim(elapsed),
227
1146
  ].join(c.dim(' · '));
228
1147
 
@@ -269,12 +1188,21 @@ function printTurnSummary(toolCount, durationS, turnCost) {
269
1188
  const parts = [];
270
1189
  if (toolCount > 0) parts.push(`${toolCount} tools`);
271
1190
  if (durationS) parts.push(`${Number(durationS).toFixed(1)}s`);
272
- if (turnCost > 0 && !session.isByok) parts.push(formatCredits(costToCredits(turnCost)));
273
1191
  if (parts.length > 0) {
274
1192
  process.stderr.write(`\n ${c.green('✓')} ${c.dim(parts.join(' · '))}\n`);
275
1193
  }
276
1194
  }
277
1195
 
1196
+ function formatMessageChip(rateLimit) {
1197
+ const remaining = messagesRemaining(rateLimit);
1198
+ if (remaining === Infinity) return 'unlimited messages';
1199
+ const limit = Number(rateLimit?.msgs_per_window);
1200
+ if (typeof remaining === 'number' && Number.isFinite(limit)) {
1201
+ return `${remaining}/${limit} messages`;
1202
+ }
1203
+ return 'messages';
1204
+ }
1205
+
278
1206
  function updateStatusBar() {
279
1207
  // No-op: status is printed inline via printPromptBlock before each prompt
280
1208
  }
@@ -355,6 +1283,7 @@ function renderToolResult(data, eventType = 'tool_result') {
355
1283
 
356
1284
  const tool = data.tool || data._tool || '';
357
1285
  const durationMs = data?.duration_ms ?? (data?.duration_s != null ? data.duration_s * 1000 : null);
1286
+ recordReadActivity(tool, data.args || {});
358
1287
 
359
1288
  // Update the card buffer so /last and `d` can find it.
360
1289
  if (callId) recordCard({ id: callId, tool, args: data.args, result: data, durationMs });
@@ -379,7 +1308,7 @@ function renderToolResult(data, eventType = 'tool_result') {
379
1308
  // If the head for this call is still buffered (no interleaving content
380
1309
  // landed), and the combined line fits the terminal width, emit ONE line
381
1310
  // and skip the gutter entirely.
382
- if (_pendingHead && _pendingHead.callId === callId && !hasLint) {
1311
+ if (_pendingHead && _pendingHead.callId === callId && !hasLint && !_pendingHead.head.includes('\n')) {
383
1312
  const cols = process.stderr.columns || 120;
384
1313
  const combined = `${_pendingHead.head} ${outcome}`;
385
1314
  if (stripAnsi(combined).length <= cols) {
@@ -451,6 +1380,31 @@ function shortPath(p) {
451
1380
  return parts.length > 2 ? parts.slice(-2).join('/') : p;
452
1381
  }
453
1382
 
1383
+ function rememberReadFile(filePath) {
1384
+ const file = shortPath(String(filePath || '').trim());
1385
+ if (file && !session.filesRead.includes(file)) session.filesRead.push(file);
1386
+ }
1387
+
1388
+ function recordReadActivity(tool, args = {}) {
1389
+ const normalized = String(tool || '').toLowerCase();
1390
+ if (normalized === 'read_file' || normalized === 'read') {
1391
+ rememberReadFile(args.file_path || args.path);
1392
+ return;
1393
+ }
1394
+ if (normalized === 'read_files') {
1395
+ const files = args.file_paths || args.paths || args.files || [];
1396
+ for (const file of Array.isArray(files) ? files : []) {
1397
+ rememberReadFile(typeof file === 'string' ? file : file?.file_path || file?.path);
1398
+ }
1399
+ }
1400
+ }
1401
+
1402
+ function thinkingKind(text) {
1403
+ return /\b(read|reading|inspect|scan|search|open|trace|look(?:ing)?\s+at)\b/i.test(text)
1404
+ ? 'Reading'
1405
+ : 'Thinking';
1406
+ }
1407
+
454
1408
  // ── Live Spinner ──
455
1409
  // A real animated spinner that ticks on an interval, not just per-call.
456
1410
  // Shows what's happening right now — thinking, tool executing, etc.
@@ -553,7 +1507,7 @@ function renderEvent(event) {
553
1507
  if (text.length > 12 && text !== session._lastEmittedThinking) {
554
1508
  flushPendingHead();
555
1509
  stopSpinner();
556
- process.stderr.write(` ${c.italic(c.dim(text.slice(0, 200)))}\n`);
1510
+ process.stderr.write(` ${c.dim(thinkingKind(text) + ' · ')}${c.italic(c.dim(text.slice(0, 200)))}\n`);
557
1511
  session._lastEmittedThinking = text;
558
1512
  }
559
1513
  startSpinner(text.slice(0, 80));
@@ -612,8 +1566,17 @@ function renderEvent(event) {
612
1566
  }
613
1567
 
614
1568
  case 'approval_granted': {
615
- // approval.mjs _prompt already rendered the result line for human approvals.
616
- // Nothing extra needed here avoid duplicate output.
1569
+ // Human approvals are rendered by approval.mjs. Auto-read grants are
1570
+ // otherwise invisible, so show one dim confirmation before the tool card.
1571
+ const scope = data?.grant_scope || data?.scope || '';
1572
+ if (scope === 'auto_read') {
1573
+ const toolName = data?.tool || data?.tool_name || '';
1574
+ const args = data?.args || data?.input || {};
1575
+ const summary = toolDisplaySummary(toolName, args);
1576
+ const label = toolDisplayLabel(toolName);
1577
+ const subject = summary ? `${label} ${summary}` : label;
1578
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`${subject} · auto-approved read`)}\n`);
1579
+ }
617
1580
  break;
618
1581
  }
619
1582
 
@@ -790,6 +1753,7 @@ function renderEvent(event) {
790
1753
  if (typeof bal.included === 'number') session.creditsIncluded = bal.included;
791
1754
  if (typeof bal.purchased === 'number') session.creditsPurchased = bal.purchased;
792
1755
  }
1756
+ if (data?.rate_limit) session.rateLimit = data.rate_limit;
793
1757
  break;
794
1758
  }
795
1759
 
@@ -848,12 +1812,24 @@ function renderEvent(event) {
848
1812
  }
849
1813
 
850
1814
  session.lastTurnDuration = data?.duration_s || 0;
1815
+ if (data?.rate_limit) session.rateLimit = data.rate_limit;
851
1816
 
852
1817
  // ── Server-authoritative credits ──
853
1818
  // Backend sends usage.credits_charged (this turn) + balance (remaining)
854
1819
  // in the complete event. CLI uses these instead of the local
855
1820
  // costToCredits estimate so /status and /cost match the dashboard.
856
1821
  if (!session.isByok) {
1822
+ const msgStatus = lowWindowStatus(session.rateLimit);
1823
+ if (!session.msgsLowWarned && msgStatus !== 'ok') {
1824
+ const windowLine = formatMessageWindow(session.rateLimit);
1825
+ if (msgStatus === 'exhausted') {
1826
+ process.stderr.write(`\n ${c.red('✗')} ${c.dim(`${windowLine}. Wait for the window to reset or upgrade at codekepler.ai/pricing.`)}\n`);
1827
+ } else {
1828
+ process.stderr.write(`\n ${c.yellow('⚠')} ${c.dim(`${windowLine}. Message window is running low.`)}\n`);
1829
+ }
1830
+ session.msgsLowWarned = true;
1831
+ }
1832
+
857
1833
  const charged = data?.usage?.credits_charged;
858
1834
  if (typeof charged === 'number') session.creditsCharged += charged;
859
1835
  const bal = data?.balance;
@@ -863,11 +1839,12 @@ function renderEvent(event) {
863
1839
  if (typeof bal.purchased === 'number') session.creditsPurchased = bal.purchased;
864
1840
  }
865
1841
  // Warn once per turn when the remaining credits drop below 20% of the
866
- // tier's included limit (or below 10 absolute for tiny tiers).
1842
+ // tier's included limit (or below 10 absolute for tiny tiers). Credits
1843
+ // stay out of the always-on prompt strip; this warning is the exception.
867
1844
  if (!session.creditsLowWarned && typeof session.creditsTotal === 'number' && session.creditsLimit) {
868
1845
  const threshold = Math.max(10, Math.floor(session.creditsLimit * 0.2));
869
1846
  if (session.creditsTotal <= threshold && session.creditsTotal > 0) {
870
- process.stderr.write(`\n ${c.yellow('⚠')} ${c.dim(`${session.creditsTotal} of ${session.creditsLimit} credits remaining on the ${session.subscriptionTier || 'free'} plan. Upgrade at codekepler.ai/pricing.`)}\n`);
1847
+ process.stderr.write(`\n ${c.yellow('⚠')} ${c.dim(`${session.creditsTotal} of ${session.creditsLimit} credits remaining on the ${session.subscriptionTier || 'free'} plan. Upgrade or top up at codekepler.ai/pricing.`)}\n`);
871
1848
  session.creditsLowWarned = true;
872
1849
  } else if (session.creditsTotal <= 0) {
873
1850
  process.stderr.write(`\n ${c.red('✗')} ${c.dim(`Credit balance exhausted on the ${session.subscriptionTier || 'free'} plan. Purchase credits at codekepler.ai/pricing or switch to BYOK.`)}\n`);
@@ -892,10 +1869,10 @@ function renderEvent(event) {
892
1869
  task: session.lastTask,
893
1870
  success: successOverall,
894
1871
  filesChanged: session.filesChanged,
1872
+ filesRead: session.filesRead,
895
1873
  toolCounts: session.toolCounts,
896
- subAgents: { ...session.subAgentCounts, savedUsd: session.isByok ? 0 : session.savedUsd },
897
- // BYOK users pay their provider directly; suppress cost in the report.
898
- costUsd: session.isByok ? null : (turnCost || session.totalCost),
1874
+ subAgents: { ...session.subAgentCounts, savedUsd: 0 },
1875
+ costUsd: null,
899
1876
  durationS: data?.duration_s,
900
1877
  testsPass: data?.tests_passed != null
901
1878
  ? { passed: data.tests_passed, total: data.tests_total || data.tests_passed }
@@ -904,6 +1881,7 @@ function renderEvent(event) {
904
1881
  nextActions: successOverall
905
1882
  ? ['/commit', '/pr', '/undo', '/report']
906
1883
  : ['/why', '/undo', '/re-plan'],
1884
+ cwd: safeCwd(),
907
1885
  });
908
1886
  process.stderr.write(report + '\n');
909
1887
  } else {
@@ -935,22 +1913,248 @@ function renderEvent(event) {
935
1913
 
936
1914
  // ── Slash Commands ──
937
1915
 
1916
+ function taskListLabel(list) {
1917
+ return {
1918
+ active: 'Active',
1919
+ backlog: 'Backlog',
1920
+ blocked: 'Blocked',
1921
+ done: 'Done',
1922
+ }[list] || list;
1923
+ }
1924
+
1925
+ function firstMeaningfulLines(content, limit = 6) {
1926
+ return String(content || '')
1927
+ .split(/\r?\n/)
1928
+ .map(line => line.trim())
1929
+ .filter(line => line && !line.startsWith('#'))
1930
+ .slice(0, limit);
1931
+ }
1932
+
1933
+ function renderTaskBoard(board, { showDone = false } = {}) {
1934
+ const order = showDone
1935
+ ? ['active', 'blocked', 'backlog', 'done']
1936
+ : ['active', 'blocked', 'backlog'];
1937
+ let any = false;
1938
+ for (const list of order) {
1939
+ const tasks = board.lists[list]?.tasks || [];
1940
+ if (!tasks.length) continue;
1941
+ any = true;
1942
+ process.stderr.write(`\n ${c.bold(taskListLabel(list))} ${c.dim(board.lists[list].fileName)}\n`);
1943
+ for (const task of tasks.slice(0, 12)) {
1944
+ const marker = task.checked ? c.green('[x]') : c.dim('[ ]');
1945
+ const section = task.section && task.section !== taskListLabel(list) ? c.dim(` · ${task.section}`) : '';
1946
+ process.stderr.write(` ${marker} ${task.text}${section}\n`);
1947
+ }
1948
+ if (tasks.length > 12) {
1949
+ process.stderr.write(` ${c.dim(`+${tasks.length - 12} more`)}\n`);
1950
+ }
1951
+ }
1952
+ if (!any) {
1953
+ process.stderr.write(` ${c.dim('No project tasks yet. Add one with /tasks add <text>.')}\n`);
1954
+ }
1955
+ }
1956
+
1957
+ function renderPlanOverview({ ctx, mode = 'overview' } = {}) {
1958
+ const cwd = safeCwd();
1959
+ ensureTaskFiles({ cwd });
1960
+ const board = loadTaskBoard({ cwd });
1961
+ const counts = taskCounts(board);
1962
+ const planLines = firstMeaningfulLines(board.plan.content, 8);
1963
+ const goalLines = firstMeaningfulLines(board.goal.content, 3);
1964
+ const effective = ctx.effectivePolicy || loadEffectivePolicy({ cwd });
1965
+ const owner = effective.policy?.planning?.owner || 'auto';
1966
+
1967
+ process.stderr.write(`\n ${c.bold('Plan')}\n`);
1968
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
1969
+ process.stderr.write(` ${c.dim('Owner')} ${c.brand(owner)}\n`);
1970
+ process.stderr.write(` ${c.dim('Tasks')} ${counts.active} active, ${counts.blocked} blocked, ${counts.backlog} backlog, ${counts.done} done\n`);
1971
+ if (mode === 'status') {
1972
+ process.stderr.write(` ${c.dim('Plan file')} ${board.plan.exists ? board.plan.path : c.dim('(none)')}\n`);
1973
+ process.stderr.write(` ${c.dim('Tasks dir')} ${board.dir}\n`);
1974
+ }
1975
+
1976
+ if (goalLines.length) {
1977
+ process.stderr.write(`\n ${c.bold('Goal')}\n`);
1978
+ for (const line of goalLines) process.stderr.write(` ${line}\n`);
1979
+ }
1980
+ if (planLines.length) {
1981
+ process.stderr.write(`\n ${c.bold('Current Plan')}\n`);
1982
+ for (const line of planLines) process.stderr.write(` ${line}\n`);
1983
+ }
1984
+
1985
+ renderTaskBoard(board, { showDone: mode === 'status' });
1986
+ process.stderr.write(`\n ${c.dim('Update: /tasks add <text> · /tasks move active 1 done · /tasks edit active 1 <text>')}\n\n`);
1987
+ }
1988
+
1989
+ function refreshTaskContext(ctx) {
1990
+ try {
1991
+ const previous = ctx.latestProjectContext || null;
1992
+ ctx.latestProjectContext = loadProjectContext({ cwd: safeCwd(), previous });
1993
+ ctx.latestEnvelope = null;
1994
+ } catch { /* best effort */ }
1995
+ }
1996
+
1997
+ function handleTasksCommand(rest, ctx) {
1998
+ const raw = String(rest || '').trim();
1999
+ ensureTaskFiles({ cwd: safeCwd() });
2000
+ if (!raw || raw === 'list') {
2001
+ const board = loadTaskBoard({ cwd: safeCwd() });
2002
+ process.stderr.write(`\n ${c.bold('Tasks')}\n`);
2003
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
2004
+ renderTaskBoard(board, { showDone: true });
2005
+ process.stderr.write(`\n ${c.dim('Update: /tasks add <text> · /tasks move active 1 done · /tasks edit active 1 <text>')}\n\n`);
2006
+ return;
2007
+ }
2008
+
2009
+ if (raw === 'help') {
2010
+ renderHelp('plan');
2011
+ return;
2012
+ }
2013
+
2014
+ const parts = raw.split(/\s+/);
2015
+ let verb = (parts.shift() || '').toLowerCase();
2016
+
2017
+ if (verb === 'move') {
2018
+ try {
2019
+ const [from, index, to, ...textParts] = parts;
2020
+ const result = moveTask({ cwd: safeCwd(), from, index, to, text: textParts.join(' ') || undefined });
2021
+ refreshTaskContext(ctx);
2022
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`moved ${result.from} #${result.index} → ${result.to}`)} ${result.text}\n`);
2023
+ } catch (err) {
2024
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
2025
+ process.stderr.write(` ${c.gray('Usage: /tasks move <active|backlog|blocked|done> <number> <active|backlog|blocked|done> [new text]')}\n`);
2026
+ }
2027
+ return;
2028
+ }
2029
+
2030
+ if (verb === 'edit' || verb === 'rename') {
2031
+ try {
2032
+ const [list, index, ...textParts] = parts;
2033
+ const result = updateTask({ cwd: safeCwd(), list, index, text: textParts.join(' ') });
2034
+ refreshTaskContext(ctx);
2035
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`updated ${result.list} #${result.index}`)} ${result.text}\n`);
2036
+ } catch (err) {
2037
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
2038
+ process.stderr.write(` ${c.gray('Usage: /tasks edit <active|backlog|blocked|done> <number> <new text>')}\n`);
2039
+ }
2040
+ return;
2041
+ }
2042
+
2043
+ if (verb === 'remove' || verb === 'rm' || verb === 'delete') {
2044
+ try {
2045
+ const [list, index] = parts;
2046
+ const result = removeTask({ cwd: safeCwd(), list, index });
2047
+ refreshTaskContext(ctx);
2048
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`removed ${result.list} #${result.index}`)} ${result.task.text}\n`);
2049
+ } catch (err) {
2050
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
2051
+ process.stderr.write(` ${c.gray('Usage: /tasks remove <active|backlog|blocked|done> <number>')}\n`);
2052
+ }
2053
+ return;
2054
+ }
2055
+
2056
+ if (verb === 'finish' || verb === 'complete' || verb === 'block' || verb === 'unblock') {
2057
+ try {
2058
+ const [from, index, ...textParts] = parts;
2059
+ const to = verb === 'block' ? 'blocked' : verb === 'unblock' ? 'active' : 'done';
2060
+ const result = moveTask({ cwd: safeCwd(), from, index, to, text: textParts.join(' ') || undefined });
2061
+ refreshTaskContext(ctx);
2062
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`moved ${result.from} #${result.index} → ${result.to}`)} ${result.text}\n`);
2063
+ } catch (err) {
2064
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
2065
+ process.stderr.write(` ${c.gray(`Usage: /tasks ${verb} <active|backlog|blocked|done> <number> [new text]`)}\n`);
2066
+ }
2067
+ return;
2068
+ }
2069
+
2070
+ let list = 'backlog';
2071
+ if (verb === 'add' || verb === 'new') {
2072
+ const maybeList = (parts[0] || '').toLowerCase();
2073
+ if (maybeList in TASK_FILES || ['todo', 'pending', 'current', 'doing', 'complete', 'completed'].includes(maybeList)) {
2074
+ list = parts.shift();
2075
+ }
2076
+ } else if (verb in TASK_FILES || ['todo', 'pending', 'current', 'doing', 'complete', 'completed'].includes(verb)) {
2077
+ list = verb;
2078
+ } else {
2079
+ parts.unshift(verb);
2080
+ verb = 'add';
2081
+ }
2082
+
2083
+ try {
2084
+ const result = appendTask({ cwd: safeCwd(), list, text: parts.join(' ') });
2085
+ refreshTaskContext(ctx);
2086
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`added to ${result.list}`)} ${result.text}\n`);
2087
+ } catch (err) {
2088
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
2089
+ }
2090
+ }
2091
+
938
2092
  async function handleCommand(input, ctx) {
939
- const parts = input.split(/\s+/);
940
- const cmd = parts[0].toLowerCase();
941
- const rest = parts.slice(1).join(' ');
2093
+ const { cmd, rest, aliasTarget } = normalizeCommandInput(input);
2094
+ if (aliasTarget) {
2095
+ process.stderr.write(` ${c.dim(`Legacy alias: use ${aliasTarget}`)}\n`);
2096
+ }
942
2097
 
943
2098
  switch (cmd) {
944
- case '/help':
945
- process.stderr.write(`\n ${c.bold('Kepler Commands')}\n`);
946
- process.stderr.write(` ${c.gray('─'.repeat(44))}\n`);
947
- for (const [name, desc] of Object.entries(COMMANDS)) {
948
- process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}\n`);
2099
+ case '/help': {
2100
+ renderHelp(rest);
2101
+ return;
2102
+ }
2103
+
2104
+ case '/plan': {
2105
+ const mode = rest.trim().toLowerCase();
2106
+ if (mode === 'help') {
2107
+ renderHelp('plan');
2108
+ return;
2109
+ }
2110
+ if (mode === 'edit') {
2111
+ ensureTaskFiles({ cwd: safeCwd() });
2112
+ const board = loadTaskBoard({ cwd: safeCwd() });
2113
+ process.stderr.write(`\n ${c.bold('Editable Plan Files')}\n`);
2114
+ process.stderr.write(` ${c.dim('Plan')} ${board.plan.path}\n`);
2115
+ process.stderr.write(` ${c.dim('Active')} ${board.lists.active.path}\n`);
2116
+ process.stderr.write(` ${c.dim('Backlog')} ${board.lists.backlog.path}\n`);
2117
+ process.stderr.write(` ${c.dim('Blocked')} ${board.lists.blocked.path}\n`);
2118
+ process.stderr.write(` ${c.dim('Done')} ${board.lists.done.path}\n\n`);
2119
+ return;
2120
+ }
2121
+ renderPlanOverview({ ctx, mode: mode === 'status' ? 'status' : 'overview' });
2122
+ return;
2123
+ }
2124
+
2125
+ case '/tasks':
2126
+ handleTasksCommand(rest, ctx);
2127
+ return;
2128
+
2129
+ case '/history': {
2130
+ if (rest.trim() === 'fold') {
2131
+ process.stderr.write(` ${c.gray('Output is folded by default — there is nothing to hide. Use /history last or d to expand.')}\n`);
2132
+ return;
2133
+ }
2134
+ if (rest.trim() === 'help') {
2135
+ renderHelp('history');
2136
+ return;
2137
+ }
2138
+ if (rest.trim() === 'approvals') {
2139
+ const entries = ctx.approval?.approvalLog?.readRecent?.(20) || [];
2140
+ if (!entries.length) {
2141
+ process.stderr.write(` ${c.gray('No approval log entries yet.')}\n`);
2142
+ return;
2143
+ }
2144
+ process.stderr.write(`\n ${c.bold('Approval History')}\n`);
2145
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
2146
+ for (const e of entries) {
2147
+ const when = e.ts ? String(e.ts).slice(0, 19).replace('T', ' ') : '';
2148
+ const decision = e.decision?.includes('reject') || e.decision?.includes('deny') ? c.red(e.decision) : c.green(e.decision);
2149
+ process.stderr.write(` ${c.dim(when)} ${decision} ${c.brand(e.tool || '?')} ${c.dim(e.scope || 'once')} ${c.dim(e.args || '')}\n`);
2150
+ }
2151
+ process.stderr.write('\n');
2152
+ return;
949
2153
  }
950
- process.stderr.write(`\n ${c.bold('Keyboard')}\n`);
951
- process.stderr.write(` ${c.gray('Ctrl+C')} exit ${c.gray('↑↓')} history ${c.gray('Tab')} autocomplete\n`);
952
- process.stderr.write(` ${c.gray('d')} expand last tool ${c.gray('Space')} pause/resume ${c.gray('Esc')} interrupt\n\n`);
2154
+ if (session.history.length === 0) { process.stderr.write(` ${c.gray('No conversation yet.')}\n`); return; }
2155
+ renderHistoryEntries(session.history, { limit: 20, maxChars: 120 });
953
2156
  return;
2157
+ }
954
2158
 
955
2159
  case '/login':
956
2160
  process.stderr.write(`${c.brand('Starting login flow...')}\n`);
@@ -977,6 +2181,46 @@ async function handleCommand(input, ctx) {
977
2181
  }
978
2182
 
979
2183
  case '/status': {
2184
+ if (rest.trim() === 'help') {
2185
+ renderHelp('status');
2186
+ return;
2187
+ }
2188
+ if (rest.trim() === 'context') {
2189
+ const current = ctx.latestProjectContext || loadProjectContext({ cwd: safeCwd() });
2190
+ const envelope = ctx.latestEnvelope || buildContextEnvelope({
2191
+ cwd: safeCwd(),
2192
+ effectivePolicy: ctx.effectivePolicy,
2193
+ projectContext: current,
2194
+ projectResources: ctx.toolExecutor?.getProjectResources?.() || [],
2195
+ agentContext: ctx.toolExecutor?.getAgentContext?.() || {},
2196
+ });
2197
+ process.stderr.write(`\n ${c.bold('Context')}\n`);
2198
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
2199
+ const loaded = current.loaded || [];
2200
+ if (!loaded.length) {
2201
+ process.stderr.write(` ${c.dim('No .kepler context files loaded yet.')}\n`);
2202
+ } else {
2203
+ for (const file of loaded) {
2204
+ const changed = file.changed ? ` ${c.yellow('updated')}` : '';
2205
+ process.stderr.write(` ${c.brand(file.label.padEnd(18))} ${c.dim(file.hash)} ${c.dim(file.path)}${changed}\n`);
2206
+ }
2207
+ }
2208
+ process.stderr.write(`\n ${c.bold('Command Context')}\n`);
2209
+ process.stderr.write(` ${c.dim('Active')} ${envelope.command_context.active_command || c.dim('(none)')}\n`);
2210
+ process.stderr.write(` ${c.dim('Source')} ${envelope.command_context.source}\n`);
2211
+ 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`);
2212
+ process.stderr.write(` ${c.dim('Plan owner')} ${envelope.effective_options.plan_owner}\n`);
2213
+ process.stderr.write(` ${c.dim('HITL scope')} ${envelope.effective_options.hitl_default_scope} ${c.dim(`(reask ${envelope.effective_options.reask_after_minutes}m)`)}\n`);
2214
+ if (envelope.available_skills.length) {
2215
+ process.stderr.write(`\n ${c.bold('Skills')}\n`);
2216
+ for (const skill of envelope.available_skills.slice(0, 12)) {
2217
+ process.stderr.write(` ${c.brand(skill.name)} ${c.dim(skill.scope)} ${c.dim(skill.description || '')}\n`);
2218
+ }
2219
+ }
2220
+ process.stderr.write('\n');
2221
+ return;
2222
+ }
2223
+
980
2224
  const creds = ctx.auth.loadCredentials();
981
2225
  const env = process.env.TARANG_ENV || 'production';
982
2226
  const os = await import('node:os');
@@ -1003,6 +2247,10 @@ async function handleCommand(input, ctx) {
1003
2247
  if (session.subscriptionTier) {
1004
2248
  process.stderr.write(` ${c.dim('Plan')} ${c.brand(session.subscriptionTier.toUpperCase())}\n`);
1005
2249
  }
2250
+ const messageWindow = formatMessageWindow(session.rateLimit);
2251
+ if (messageWindow) {
2252
+ process.stderr.write(` ${c.dim('Messages')} ${messageWindow}\n`);
2253
+ }
1006
2254
  if (typeof session.creditsTotal === 'number') {
1007
2255
  const limit = session.creditsLimit ? ` ${c.dim('/ ' + formatCredits(session.creditsLimit))}` : '';
1008
2256
  const used = session.creditsCharged ? ` ${c.dim(`(${formatCredits(session.creditsCharged)} used this session)`)}` : '';
@@ -1013,6 +2261,15 @@ async function handleCommand(input, ctx) {
1013
2261
  }
1014
2262
  process.stderr.write(` ${c.dim('CWD')} ${safeCwd()}\n`);
1015
2263
 
2264
+ // Cache — PRD-071 §1.2. Only surface when we have data; a fresh session
2265
+ // shows nothing rather than a misleading "0%".
2266
+ const cache = computeCacheTotals();
2267
+ if (cache.read > 0 || cache.write > 0) {
2268
+ const readLabel = formatTokens(cache.read);
2269
+ const writeLabel = formatTokens(cache.write);
2270
+ process.stderr.write(` ${c.dim('Cache')} ${cache.hitRate}% hit ${c.dim('·')} ${readLabel} read ${c.dim('·')} ${writeLabel} write\n`);
2271
+ }
2272
+
1016
2273
  // Permissions
1017
2274
  process.stderr.write(`\n ${c.bold('Permissions')}\n`);
1018
2275
  process.stderr.write(` ${c.dim('─'.repeat(44))}\n`);
@@ -1023,6 +2280,9 @@ async function handleCommand(input, ctx) {
1023
2280
  if (approvalSummary.autoApprovedTypes.length > 0) {
1024
2281
  process.stderr.write(` ${c.dim('Auto-types')} ${approvalSummary.autoApprovedTypes.join(', ')}\n`);
1025
2282
  }
2283
+ if (approvalSummary.trust) {
2284
+ process.stderr.write(` ${c.dim('Trust')} ${approvalSummary.trust.sessionRules} session, ${approvalSummary.trust.projectRules} project rules\n`);
2285
+ }
1026
2286
  process.stderr.write(` ${c.dim('Blocked')} ${session.blockedOps} by safety guardrails\n`);
1027
2287
 
1028
2288
  // Orchestration
@@ -1062,6 +2322,34 @@ async function handleCommand(input, ctx) {
1062
2322
  return;
1063
2323
  }
1064
2324
 
2325
+ case '/settings': {
2326
+ const sub = rest.trim() || 'policy';
2327
+ if (sub === 'help') {
2328
+ renderHelp('settings');
2329
+ return;
2330
+ }
2331
+ if (sub !== 'policy') {
2332
+ process.stderr.write(` ${c.gray('Usage: /settings policy or /help settings')}\n`);
2333
+ return;
2334
+ }
2335
+ const effective = loadEffectivePolicy({ cwd: safeCwd() });
2336
+ ctx.effectivePolicy = effective;
2337
+ const rows = formatPolicySourceRows(effective);
2338
+ process.stderr.write(`\n ${c.bold('Effective Policy')}\n`);
2339
+ process.stderr.write(` ${c.dim('─'.repeat(86))}\n`);
2340
+ for (const row of rows.slice(0, 80)) {
2341
+ const value = typeof row.value === 'string' ? row.value : JSON.stringify(row.value);
2342
+ process.stderr.write(` ${c.brand(row.key.padEnd(38))} ${c.dim(row.source.padEnd(8))} ${String(value).slice(0, 34)}\n`);
2343
+ }
2344
+ if (rows.length > 80) process.stderr.write(` ${c.dim(`...and ${rows.length - 80} more`)}\n`);
2345
+ const projectLayer = effective.layers.find(l => l.name === 'project');
2346
+ if (projectLayer?.error) {
2347
+ process.stderr.write(`\n ${c.yellow('Project config error:')} ${projectLayer.error}\n`);
2348
+ }
2349
+ process.stderr.write('\n');
2350
+ return;
2351
+ }
2352
+
1065
2353
  case '/stats': {
1066
2354
  const os = await import('node:os');
1067
2355
  const mem = process.memoryUsage();
@@ -1103,6 +2391,10 @@ async function handleCommand(input, ctx) {
1103
2391
  const limit = session.creditsLimit ? ` / ${formatCredits(session.creditsLimit)}` : '';
1104
2392
  process.stderr.write(` ${c.dim('Plan')} ${c.brand(session.subscriptionTier.toUpperCase())} ${c.dim('· remaining')} ${c.brand(remaining)}${c.dim(limit)}\n`);
1105
2393
  }
2394
+ const messageWindow = formatMessageWindow(session.rateLimit);
2395
+ if (messageWindow) {
2396
+ process.stderr.write(` ${c.dim('Messages')} ${messageWindow}\n`);
2397
+ }
1106
2398
  process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
1107
2399
 
1108
2400
  if (session.costBreakdown.length > 0) {
@@ -1135,21 +2427,10 @@ async function handleCommand(input, ctx) {
1135
2427
  `${''.padStart(10)}` +
1136
2428
  `${formatCredits(costToCredits(session.totalCost)).padStart(10)}\n`
1137
2429
  );
1138
- process.stderr.write(` ${c.dim(`Turns: ${session.turns} Duration: ${formatElapsed(session.startTime)} Provider: ${formatCostValue(session.totalCost)}`)}\n\n`);
2430
+ process.stderr.write(` ${c.dim(`Turns: ${session.turns} Duration: ${formatElapsed(session.startTime)}`)}\n\n`);
1139
2431
  return;
1140
2432
  }
1141
2433
 
1142
- case '/history':
1143
- if (session.history.length === 0) { process.stderr.write(` ${c.gray('No conversation yet.')}\n`); return; }
1144
- process.stderr.write(`\n ${c.bold('Conversation')} (${session.history.length} messages)\n`);
1145
- process.stderr.write(` ${c.gray('─'.repeat(40))}\n`);
1146
- for (const msg of session.history.slice(-20)) {
1147
- const role = msg.role === 'user' ? c.white('You') : c.brand('Kepler');
1148
- process.stderr.write(` ${role}: ${msg.content.slice(0, 80)}${msg.content.length > 80 ? '...' : ''}\n`);
1149
- }
1150
- process.stderr.write('\n');
1151
- return;
1152
-
1153
2434
  case '/last':
1154
2435
  expandLast();
1155
2436
  return;
@@ -1207,7 +2488,7 @@ async function handleCommand(input, ctx) {
1207
2488
  }
1208
2489
 
1209
2490
  case '/report': {
1210
- if (Object.keys(session.toolCounts).length === 0 && session.filesChanged.length === 0) {
2491
+ if (Object.keys(session.toolCounts).length === 0 && session.filesChanged.length === 0 && session.filesRead.length === 0) {
1211
2492
  process.stderr.write(` ${c.gray('Nothing to report yet — run a task first.')}\n`);
1212
2493
  return;
1213
2494
  }
@@ -1215,11 +2496,13 @@ async function handleCommand(input, ctx) {
1215
2496
  task: session.lastTask,
1216
2497
  success: true,
1217
2498
  filesChanged: session.filesChanged,
2499
+ filesRead: session.filesRead,
1218
2500
  toolCounts: session.toolCounts,
1219
2501
  subAgents: { ...session.subAgentCounts, savedUsd: session.isByok ? 0 : session.savedUsd },
1220
2502
  costUsd: session.isByok ? null : session.totalCost,
1221
2503
  durationS: (Date.now() - session.startTime) / 1000,
1222
2504
  nextActions: ['/commit', '/pr', '/undo'],
2505
+ cwd: safeCwd(),
1223
2506
  };
1224
2507
  const out = saveReport(state, { cwd: safeCwd() });
1225
2508
  process.stderr.write(` ${c.green('✓')} ${c.dim('Saved')} ${out}\n`);
@@ -1259,7 +2542,12 @@ async function handleCommand(input, ctx) {
1259
2542
 
1260
2543
  case '/budget': {
1261
2544
  const arg = rest.trim();
1262
- if (!arg || arg === 'clear' || arg === 'off') {
2545
+ if (!arg) {
2546
+ const current = session.budgetUsd ? `$${session.budgetUsd.toFixed(2)}` : 'not set';
2547
+ process.stderr.write(` ${c.dim('Budget cap:')} ${c.brand(current)} ${c.dim('· set with /status budget <amount> or clear with /status budget clear')}\n`);
2548
+ return;
2549
+ }
2550
+ if (arg === 'clear' || arg === 'off') {
1263
2551
  session.budgetUsd = null;
1264
2552
  session.budgetExceeded = false;
1265
2553
  process.stderr.write(` ${c.gray('Budget cap cleared.')}\n`);
@@ -1288,15 +2576,16 @@ async function handleCommand(input, ctx) {
1288
2576
  }
1289
2577
 
1290
2578
  case '/compact': {
1291
- const before = session.history.length;
2579
+ const before = session.agentHistory.length || session.history.length;
1292
2580
  if (before <= 4) { process.stderr.write(` ${c.gray('Nothing to compact.')}\n`); return; }
1293
- session.history.splice(2, session.history.length - 6);
1294
- process.stderr.write(` ${c.gray(`Compacted: ${before} → ${session.history.length} messages`)}\n`);
2581
+ session.agentHistory.splice(2, session.agentHistory.length - 6);
2582
+ process.stderr.write(` ${c.gray(`Compacted agent context: ${before} → ${session.agentHistory.length} messages`)}\n`);
1295
2583
  return;
1296
2584
  }
1297
2585
 
1298
2586
  case '/clear':
1299
2587
  session.history.length = 0;
2588
+ session.agentHistory.length = 0;
1300
2589
  session.toolCalls = 0;
1301
2590
  process.stderr.write(` ${c.gray('Conversation cleared.')}\n`);
1302
2591
  return;
@@ -1348,7 +2637,7 @@ async function handleCommand(input, ctx) {
1348
2637
  }
1349
2638
 
1350
2639
  case '/sessions': {
1351
- const resumable = ctx.sessionMgr.listResumable(10);
2640
+ const resumable = await listResumableSessions();
1352
2641
  if (resumable.length === 0) {
1353
2642
  process.stderr.write(` ${c.gray('No resumable sessions found.')}\n`);
1354
2643
  return;
@@ -1356,89 +2645,168 @@ async function handleCommand(input, ctx) {
1356
2645
  process.stderr.write(`\n ${c.bold('Resumable Sessions')}\n`);
1357
2646
  process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
1358
2647
  for (const s of resumable) {
1359
- const date = s.startedAt ? new Date(s.startedAt).toLocaleDateString() : '?';
1360
- const instr = s.instruction ? s.instruction.slice(0, 40) : '(no instruction)';
1361
- process.stderr.write(` ${c.brand(s.sessionId)} ${c.dim(date)} ${s.messageCount} msgs ${c.dim(instr)}\n`);
2648
+ const date = sessionListTimestamp(s);
2649
+ const instr = oneLineInstruction(s.instruction, 72);
2650
+ const project = s.project || path.basename(s.projectPath || '') || '(unknown)';
2651
+ process.stderr.write(` ${c.brand(s.sessionId)} ${c.brand(project)} ${c.dim(date)} ${messageCountLabel(s.messageCount)} ${c.dim(instr)}\n`);
1362
2652
  }
1363
2653
  process.stderr.write(`\n ${c.dim('Resume with:')} kepler --resume <sessionId>\n`);
1364
2654
  return;
1365
2655
  }
1366
2656
 
1367
2657
  case '/resume': {
1368
- const parts = input.split(/\s+/);
1369
- const targetId = parts[1]; // /resume <sessionId>
2658
+ // PRD-068 §5.14: one-prompt picker, context-length driven auto-decision,
2659
+ // direct-resume mode flags (--full, --tail10, --tail20, --summary).
2660
+ const parts = input.split(/\s+/).filter(Boolean);
2661
+ const forcedFlag = parts.find(p => /^(--full|--tail10|--tail20|--recap|--summary|-f|-1|-2|-r|-s)$/.test(p));
2662
+ const forcedMode = forcedFlag
2663
+ ? ({ '--full': 'full', '-f': 'full',
2664
+ '--tail10': 'tail-10', '-1': 'tail-10',
2665
+ '--tail20': 'tail-20', '-2': 'tail-20',
2666
+ '--recap': 'tail-20', '-r': 'tail-20',
2667
+ '--summary': 'summary', '-s': 'summary' })[forcedFlag]
2668
+ : null;
2669
+ const targetId = parts.slice(1).find(p => !p.startsWith('-'));
2670
+
2671
+ const resumable = await listResumableSessions();
2672
+ if (resumable.length === 0) {
2673
+ process.stderr.write(` ${c.gray('No resumable sessions found.')}\n`);
2674
+ return;
2675
+ }
1370
2676
 
2677
+ // 1. Resolve which session to resume.
2678
+ let picked = null;
1371
2679
  if (targetId) {
1372
- // Direct resume by ID
1373
- const messages = ctx.sessionMgr.loadMessages(targetId);
1374
- if (messages.length === 0) {
1375
- process.stderr.write(` ${c.yellow('!')} ${c.dim('No conversation found for session ' + targetId)}\n`);
2680
+ picked = resumable.find(s => s.sessionId === targetId || s.sessionId?.startsWith(targetId));
2681
+ if (!picked) {
2682
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`No session found matching id: ${targetId}`)}\n`);
1376
2683
  return;
1377
2684
  }
1378
- session.history = messages;
1379
- session.id = targetId;
1380
- session.turns = Math.floor(messages.length / 2);
1381
- process.stderr.write(` ${c.green('')} ${c.dim(`Resumed: ${messages.length} messages`)}\n`);
1382
- return;
2685
+ } else {
2686
+ while (!picked) {
2687
+ const pickResult = await pickResumableSession(resumable, ctx);
2688
+ if (!pickResult) { process.stderr.write(`\n ${c.dim('Cancelled.')}\n`); return; }
2689
+ if (pickResult.action === 'resume') {
2690
+ if (!pickResult.session) {
2691
+ process.stderr.write(`\n ${c.yellow('!')} ${c.dim('Empty session — pick another.')}\n`);
2692
+ continue;
2693
+ }
2694
+ picked = pickResult.session;
2695
+ break;
2696
+ }
2697
+ if (pickResult.action === 'preview') {
2698
+ // Yield to the event loop so any queued keystrokes from the picker
2699
+ // don't spill into the preview's stdin listener (PRD-068 §5.14 bugfix).
2700
+ await new Promise(r => setImmediate(r));
2701
+ const previewResult = await previewResumeSession(pickResult.session, ctx);
2702
+ if (previewResult && previewResult.action === 'resume') {
2703
+ picked = pickResult.session;
2704
+ // Preview already committed to a mode — skip the threshold overlay.
2705
+ picked._presetMode = previewResult.mode;
2706
+ break;
2707
+ }
2708
+ if (previewResult === null) {
2709
+ // getSessionDetail failed — file missing, unreadable, or huge.
2710
+ // Tell the user why before looping back to the picker.
2711
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('Could not load transcript for preview — pick another session or press Enter to resume without preview.')}\n`);
2712
+ }
2713
+ // Yield again so the loop-back render doesn't collide with the
2714
+ // just-closed preview's stdin cleanup.
2715
+ await new Promise(r => setImmediate(r));
2716
+ }
2717
+ }
1383
2718
  }
1384
2719
 
1385
- // No ID given list sessions and let user pick by number
1386
- const resumable = ctx.sessionMgr.listResumable(10);
1387
- if (resumable.length === 0) {
1388
- process.stderr.write(` ${c.gray('No resumable sessions found.')}\n`);
1389
- return;
2720
+ // 2. Decide the mode. Force-flag > preview-preset > auto-decide > overlay.
2721
+ let mode = forcedMode || picked._presetMode || null;
2722
+ if (!mode) {
2723
+ const currentModel = session?.model || session?.user?.default_reasoning_model || null;
2724
+ const decision = decideResumeMode({
2725
+ transcriptTokens: picked.contextTokens,
2726
+ model: currentModel,
2727
+ settings: ctx.effectivePolicy?.policy?.resume ? { resume: ctx.effectivePolicy.policy.resume } : {},
2728
+ });
2729
+ decision.resumeSummary = picked.resumeSummary || null;
2730
+ if (decision.mode === 'full') {
2731
+ mode = 'full';
2732
+ } else {
2733
+ // Yield before attaching the overlay listener — same race guard as
2734
+ // the preview branch above. Prevents a queued Enter from the picker
2735
+ // slipping into the overlay's stdin, which otherwise looked like a
2736
+ // duplicate picker render.
2737
+ await new Promise(r => setImmediate(r));
2738
+ const chosen = await chooseThresholdMode(ctx, decision);
2739
+ if (!chosen) { process.stderr.write(`\n ${c.dim('Cancelled.')}\n`); return; }
2740
+ mode = chosen;
2741
+ }
1390
2742
  }
1391
2743
 
1392
- process.stderr.write(`\n ${c.bold('Pick a session to resume:')}\n\n`);
1393
- for (let i = 0; i < resumable.length; i++) {
1394
- const s = resumable[i];
1395
- const date = s.startedAt ? new Date(s.startedAt).toLocaleString() : '?';
1396
- const instr = (s.instruction || '(no instruction)').slice(0, 45);
1397
- const proj = s.project ? c.brand(s.project) + ' ' : '';
1398
- const num = `[${i + 1}]`;
1399
- process.stderr.write(` ${c.brand(num)} ${proj}${c.dim(date)} ${s.messageCount} msgs\n`);
1400
- process.stderr.write(` ${c.dim(instr)}\n`);
1401
- }
1402
- process.stderr.write(`\n ${c.dim('Enter number (or Esc to cancel):')} `);
1403
-
1404
- // Read single key for selection
1405
- const rl = ctx._rl || null;
1406
- if (rl) rl.pause();
1407
- const choice = await new Promise((resolve) => {
1408
- if (!process.stdin.isTTY) { resolve(null); return; }
1409
- const wasRaw = process.stdin.isRaw;
1410
- process.stdin.setRawMode(true);
1411
- process.stdin.resume();
1412
- process.stdin.once('data', (data) => {
1413
- process.stdin.setRawMode(wasRaw || false);
1414
- if (rl) rl.resume();
1415
- const bytes = [...data];
1416
- if (bytes[0] === 0x1b || bytes[0] === 0x03) { resolve(null); return; }
1417
- const num = parseInt(data.toString(), 10);
1418
- resolve(isNaN(num) ? null : num);
2744
+ // 3. Activate.
2745
+ const source = targetId ? 'direct' : 'picker';
2746
+ const progress = startResumeProgress(mode);
2747
+ let resumed;
2748
+ try {
2749
+ resumed = await ctx.activateResumedSession(picked.sessionId, source, mode, picked, {
2750
+ onProgress: progress.update,
2751
+ onProgressStop: progress.stop,
1419
2752
  });
1420
- });
1421
-
1422
- if (!choice || choice < 1 || choice > resumable.length) {
1423
- process.stderr.write(`\n ${c.dim('Cancelled.')}\n`);
2753
+ } finally {
2754
+ progress.stop();
2755
+ }
2756
+ if (!resumed.ok) {
2757
+ if (resumed.reason === 'cwd-cancelled') {
2758
+ process.stderr.write(`\n ${c.dim('Cancelled.')}\n`);
2759
+ } else {
2760
+ process.stderr.write(`\n ${c.yellow('!')} ${c.dim(resumed.reason || 'No messages in that session.')}\n`);
2761
+ }
1424
2762
  return;
1425
2763
  }
1426
2764
 
1427
- const picked = resumable[choice - 1];
1428
- const messages = ctx.sessionMgr.loadMessages(picked.sessionId);
1429
- if (messages.length === 0) {
1430
- process.stderr.write(`\n ${c.yellow('!')} ${c.dim('No messages in that session.')}\n`);
1431
- return;
2765
+ // 4. Report succinct honest single line, then optional hydration warning.
2766
+ const toolSummary = resumed.stats && resumed.stats.toolCalls
2767
+ ? `${resumed.stats.toolCalls} tool calls`
2768
+ : `${resumed.messages} msgs`;
2769
+ const summaryLabel = mode !== 'full' && resumed.summarySource
2770
+ ? ` · summary: ${resumed.summarySource}`
2771
+ : '';
2772
+ 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`);
2773
+ if (resumed.summaryWarning) {
2774
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`backend summary unavailable — using local summary (${resumed.summaryWarning})`)}\n`);
2775
+ }
2776
+ if (resumed.hydrationFailures?.length) {
2777
+ for (const failure of resumed.hydrationFailures) {
2778
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`could not re-read project root: ${failure}`)}\n`);
2779
+ }
2780
+ }
2781
+ if (resumed.stayedInCwd) {
2782
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`resumed transcript from ${resumed.savedProjectPath} — running against ${safeCwd()}`)}\n`);
1432
2783
  }
1433
2784
 
1434
- session.history = messages;
1435
- session.id = picked.sessionId;
1436
- session.turns = Math.floor(messages.length / 2);
1437
- process.stderr.write(`\n ${c.green('↺')} ${c.dim(`Resumed: ${messages.length} messages`)}`);
1438
- if (picked.instruction) {
1439
- process.stderr.write(` ${c.dim('—')} ${c.dim(picked.instruction.slice(0, 50))}`);
2785
+ // 5. Show continuity context. Non-summary modes use the captured
2786
+ // kepler_event stream when available so the terminal replay matches
2787
+ // the original styled interaction; older sessions fall back to
2788
+ // reconstructed text.
2789
+ if (mode === 'summary' && resumed.summary) {
2790
+ // In summary mode the agent gets only the summary block. Show it so
2791
+ // the user knows what continuity context was included.
2792
+ process.stderr.write(`\n ${c.bold('Continuity Summary')}\n`);
2793
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
2794
+ for (const line of resumed.summary.split('\n')) {
2795
+ process.stderr.write(` ${c.dim(line)}\n`);
2796
+ }
2797
+ process.stderr.write('\n');
2798
+ } else if (resumed.replayEvents?.length) {
2799
+ renderResumePreview(resumed);
2800
+ } else if (resumed.history?.length) {
2801
+ // Full/tail modes feed real conversation to the agent — show
2802
+ // the tail so the user has visual context. Cap at 30 entries to avoid
2803
+ // flooding the terminal on long sessions.
2804
+ renderHistoryEntries(resumed.history, {
2805
+ limit: 30,
2806
+ maxChars: 200,
2807
+ title: mode?.startsWith('tail-') ? `Recent turns (${mode.replace('tail-', 'last ')})` : 'Conversation history (last 30 entries)',
2808
+ });
1440
2809
  }
1441
- process.stderr.write('\n');
1442
2810
  return;
1443
2811
  }
1444
2812
 
@@ -1512,22 +2880,239 @@ export async function startTerminalRepl() {
1512
2880
 
1513
2881
  // Projects are registered and indexed on demand through get_project_overview.
1514
2882
  // CheckpointManager records per-file snapshots before edits so /undo works.
1515
- const checkpoints = new CheckpointManager(safeCwd());
1516
- const toolExecutor = createToolExecutor({ checkpoints });
2883
+ let checkpoints = new CheckpointManager(safeCwd());
2884
+ let effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
2885
+ let latestProjectContext = null;
2886
+ let latestEnvelope = null;
2887
+ let hookRunner = new HookRunner({ cwd: safeCwd() });
2888
+ let toolExecutor = createToolExecutor({ checkpoints, hookRunner });
1517
2889
  const skipPerms = cliArgs.freeswim;
1518
- const approval = new ApprovalManager({ autoApprove: skipPerms });
2890
+ let approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
1519
2891
 
1520
2892
  // Session manager — persists conversation messages to .kepler/conversations/
1521
- const sessionMgr = new SessionManager(safeCwd());
2893
+ let sessionMgr = new SessionManager(safeCwd());
1522
2894
  _sessionMgr = sessionMgr; // expose to renderEvent
1523
2895
 
1524
2896
  // Local JSONL writer — writes cc-lens compatible session data to ~/.kepler/
1525
- const jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
2897
+ let jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
1526
2898
 
1527
2899
  // Persistent stream client — session_id captured from backend on first turn
1528
2900
  let streamClient = null;
1529
2901
 
1530
- const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints };
2902
+ const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints, effectivePolicy, latestProjectContext, latestEnvelope };
2903
+
2904
+ /**
2905
+ * Activate a previously-recorded session for continuation.
2906
+ *
2907
+ * Contract (PRD-068 §5.14 and follow-up clarification):
2908
+ * 1. Keep the same sessionId. The resumed session IS the same session,
2909
+ * not a fork. Future turns are appended to the SAME .jsonl file that
2910
+ * was read here.
2911
+ * 2. Do not re-write the loaded transcript back to disk. The file already
2912
+ * contains every historical entry; the load path is read-only. Any
2913
+ * duplication would double-count tokens on the next resume.
2914
+ * 3. Fresh sessions (kepler started without /resume) get a fresh UUID
2915
+ * the first time jsonlWriter.writeUserTurn() runs — that path is
2916
+ * untouched by resume, so brand-new sessions never inherit an old id.
2917
+ * 4. In-memory history (session.history / session.agentHistory) mirrors
2918
+ * what the agent will see next turn; it is NOT written back to the
2919
+ * transcript at activation time.
2920
+ */
2921
+ async function activateResumedSession(sessionId, source = 'resume', historyMode = 'full', resumeEntry = null, options = {}) {
2922
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
2923
+ const onProgressStop = typeof options.onProgressStop === 'function' ? options.onProgressStop : () => {};
2924
+ // PRD-068 §5.14.6: JSONL is the only source. No legacy conversation fallback.
2925
+ onProgress('reading saved transcript', 14);
2926
+ const detail = await getSessionDetail(sessionId, { filePath: resumeEntry?.transcriptPath });
2927
+ if (!detail) {
2928
+ return { ok: false, reason: `No transcript found for session ${sessionId}` };
2929
+ }
2930
+ onProgress('building resume context', 28);
2931
+ const richHistory = buildResumeHistory({ ...detail, recapTailTurns: 8 }, historyMode);
2932
+ const displayHistory = richHistory.displayHistory;
2933
+ if (!displayHistory.length) {
2934
+ return { ok: false, reason: `Session ${sessionId} has no readable messages` };
2935
+ }
2936
+
2937
+ // PRD-068 §5.14.7: explicit cwd confirmation if saved path differs.
2938
+ const savedProjectPath = detail?.meta?.project || '';
2939
+ let summarySource = 'local';
2940
+ let summaryWarning = '';
2941
+ if (historyMode !== 'full' && richHistory.sourceMessages?.length) {
2942
+ onProgress('summarizing transcript', 34);
2943
+ const backendSummary = await summarizeResumeTranscript({
2944
+ auth,
2945
+ toolExecutor,
2946
+ sessionId,
2947
+ projectPath: savedProjectPath || safeCwd(),
2948
+ messages: richHistory.sourceMessages,
2949
+ });
2950
+ if (backendSummary?.summary) {
2951
+ richHistory.summary = combineResumeSummaries(richHistory.priorSummary, backendSummary.summary);
2952
+ const summaryIndex = Number.isInteger(richHistory.summaryMessageIndex)
2953
+ ? richHistory.summaryMessageIndex
2954
+ : 0;
2955
+ if (richHistory.agentHistory?.[summaryIndex]) {
2956
+ const tailTurns = resumeTailTurnCount(historyMode);
2957
+ const prefix = tailTurns
2958
+ ? `Summary of earlier turns before the retained last ${tailTurns} conversation messages:\n`
2959
+ : 'Session continuity summary:\n';
2960
+ richHistory.agentHistory[summaryIndex] = {
2961
+ ...richHistory.agentHistory[summaryIndex],
2962
+ content: `${prefix}${richHistory.summary}`,
2963
+ };
2964
+ }
2965
+ summarySource = backendSummary.source || 'backend';
2966
+ } else {
2967
+ summarySource = 'local fallback';
2968
+ summaryWarning = backendSummary?.reason || 'backend summary unavailable';
2969
+ }
2970
+ } else if (historyMode !== 'full') {
2971
+ summarySource = 'not needed';
2972
+ summaryWarning = resumeTailTurnCount(historyMode)
2973
+ ? 'retained tail covers the whole transcript'
2974
+ : 'empty transcript';
2975
+ }
2976
+ const agentHistory = richHistory.agentHistory;
2977
+ const originalCwd = safeCwd();
2978
+ let switchedProject = false;
2979
+ let projectMissing = false;
2980
+ let stayedInCwd = false;
2981
+ onProgress('checking project cwd', 40);
2982
+ if (savedProjectPath && savedProjectPath !== originalCwd) {
2983
+ if (fs.existsSync(savedProjectPath)) {
2984
+ onProgressStop();
2985
+ const choice = await confirmCwdSwitch(ctx, savedProjectPath, originalCwd);
2986
+ if (choice === 'cancel') return { ok: false, reason: 'cwd-cancelled' };
2987
+ if (choice === 'switch') {
2988
+ try {
2989
+ process.chdir(savedProjectPath);
2990
+ _cachedCwd = process.cwd();
2991
+ switchedProject = true;
2992
+ } catch {
2993
+ projectMissing = true;
2994
+ }
2995
+ } else if (choice === 'stay') {
2996
+ stayedInCwd = true;
2997
+ }
2998
+ } else {
2999
+ projectMissing = true;
3000
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`saved project path unavailable: ${savedProjectPath} — using current cwd`)}\n`);
3001
+ }
3002
+ }
3003
+
3004
+ onProgress('rebuilding local session state', 55);
3005
+ checkpoints = new CheckpointManager(safeCwd());
3006
+ effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
3007
+ hookRunner = new HookRunner({ cwd: safeCwd(), sessionId });
3008
+ toolExecutor = createToolExecutor({ checkpoints, hookRunner });
3009
+ approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
3010
+ if (ctx._rl) approval.setReadline(ctx._rl);
3011
+ sessionMgr = new SessionManager(safeCwd());
3012
+ sessionMgr.activateSession(sessionId, {
3013
+ instruction: detail?.meta?.firstPrompt || '',
3014
+ started_at: detail?.meta?.startTime || new Date().toISOString(),
3015
+ }, displayHistory.filter(m => m.role === 'user' || m.role === 'assistant'));
3016
+ _sessionMgr = sessionMgr;
3017
+
3018
+ try { await jsonlWriter.close(); } catch {}
3019
+ jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
3020
+ jsonlWriter.setSessionId(sessionId);
3021
+ if (
3022
+ historyMode !== 'full'
3023
+ && richHistory.summary
3024
+ && Number(richHistory.summaryCoveredMessageCount) > Number(richHistory.summaryCheckpointMessageCount || 0)
3025
+ ) {
3026
+ jsonlWriter.writeKeplerEvent({
3027
+ type: 'resume_summary',
3028
+ data: {
3029
+ session_id: sessionId,
3030
+ mode: historyMode,
3031
+ mode_label: resumeModeLabel(historyMode),
3032
+ summary: richHistory.summary,
3033
+ summary_source: summarySource,
3034
+ summary_warning: summaryWarning || null,
3035
+ source_message_count: richHistory.summaryCoveredMessageCount,
3036
+ previous_source_message_count: richHistory.summaryCheckpointMessageCount || 0,
3037
+ full_message_count: richHistory.fullMessageCount || 0,
3038
+ },
3039
+ });
3040
+ }
3041
+ jsonlWriter.writeKeplerEvent({
3042
+ type: 'resume_context',
3043
+ data: {
3044
+ session_id: sessionId,
3045
+ source,
3046
+ mode: historyMode,
3047
+ mode_label: resumeModeLabel(historyMode),
3048
+ messages: displayHistory.length,
3049
+ summary_source: summarySource,
3050
+ summary_injected: historyMode !== 'full' && Boolean(richHistory.agentHistory?.[richHistory.summaryMessageIndex ?? 0]?.content),
3051
+ summary_warning: summaryWarning || null,
3052
+ summary_source_message_count: richHistory.summaryCoveredMessageCount || 0,
3053
+ previous_summary_source_message_count: richHistory.summaryCheckpointMessageCount || 0,
3054
+ project_path: savedProjectPath || safeCwd(),
3055
+ },
3056
+ });
3057
+
3058
+ streamClient = null;
3059
+ latestProjectContext = loadProjectContext({ cwd: safeCwd() });
3060
+ latestEnvelope = null;
3061
+
3062
+ // PRD-068 §5.14.8: report hydration failures instead of swallowing them.
3063
+ const hydrationFailures = [];
3064
+ const resumeRoots = getTranscriptProjectRoots(detail);
3065
+ const rootsToRegister = [...new Set([safeCwd(), ...resumeRoots].filter(Boolean))];
3066
+ onProgress('hydrating project roots', 68);
3067
+ for (let i = 0; i < rootsToRegister.length; i++) {
3068
+ const root = rootsToRegister[i];
3069
+ onProgress(`hydrating project root ${i + 1}/${rootsToRegister.length}`, 68 + Math.round((i / Math.max(1, rootsToRegister.length)) * 18));
3070
+ try {
3071
+ await toolExecutor.execute('get_project_overview', { path: root });
3072
+ } catch {
3073
+ hydrationFailures.push(root);
3074
+ }
3075
+ }
3076
+
3077
+ onProgress('preparing replay', 92);
3078
+ session.history = displayHistory;
3079
+ session.agentHistory = agentHistory;
3080
+ session.id = sessionId;
3081
+ session.turns = displayHistory.filter(m => m.role === 'user').length;
3082
+ session.lastTask = detail?.meta?.firstPrompt || session.history.find(m => m.role === 'user')?.content || '';
3083
+
3084
+ Object.assign(ctx, {
3085
+ toolExecutor,
3086
+ approval,
3087
+ jsonlWriter,
3088
+ sessionMgr,
3089
+ checkpoints,
3090
+ effectivePolicy,
3091
+ latestProjectContext,
3092
+ latestEnvelope,
3093
+ });
3094
+
3095
+ return {
3096
+ ok: true,
3097
+ messages: displayHistory.length,
3098
+ projectPath: savedProjectPath || safeCwd(),
3099
+ savedProjectPath,
3100
+ switchedProject,
3101
+ projectMissing,
3102
+ stayedInCwd,
3103
+ hydrationFailures,
3104
+ instruction: detail?.meta?.firstPrompt || '',
3105
+ historyMode,
3106
+ summary: richHistory.summary || '',
3107
+ summarySource,
3108
+ summaryWarning,
3109
+ history: displayHistory,
3110
+ replayEvents: detail.replayEvents || [],
3111
+ stats: richHistory.stats,
3112
+ source,
3113
+ };
3114
+ }
3115
+ ctx.activateResumedSession = activateResumedSession;
1531
3116
 
1532
3117
  // ── Print banner + preflight + init BEFORE mounting the status bar ──
1533
3118
  // The status bar shrinks the scroll region; if it mounts first, the
@@ -1558,18 +3143,18 @@ export async function startTerminalRepl() {
1558
3143
  : sessionMgr.getLastSession();
1559
3144
 
1560
3145
  if (lastSession) {
1561
- const messages = sessionMgr.loadMessages(lastSession.sessionId);
1562
- if (messages.length > 0) {
1563
- session.history = messages;
1564
- session.id = lastSession.sessionId;
1565
- session.turns = Math.floor(messages.length / 2);
1566
- process.stderr.write(` ${c.green('↺')} ${c.dim(`Resumed session: ${messages.length} messages`)}`);
1567
- if (lastSession.instruction) {
1568
- process.stderr.write(` ${c.dim('—')} ${c.dim(lastSession.instruction.slice(0, 50))}`);
1569
- }
3146
+ const resumed = await activateResumedSession(lastSession.sessionId, 'startup');
3147
+ if (resumed.ok) {
3148
+ process.stderr.write(` ${c.green('↺')} ${c.dim(`Resumed session: ${messageCountLabel(resumed.messages)}`)}`);
3149
+ process.stderr.write(` ${c.dim('· project')} ${c.brand(path.basename(safeCwd()))}`);
3150
+ process.stderr.write(` ${c.dim( agent ${resumed.historyMode}`)}`);
3151
+ if (resumed.switchedProject) process.stderr.write(` ${c.dim('(cwd restored)')}`);
3152
+ if (resumed.projectMissing) process.stderr.write(` ${c.yellow('(saved project path unavailable; using current cwd)')}`);
3153
+ if (resumed.instruction) process.stderr.write(` ${c.dim('—')} ${c.dim(resumed.instruction.slice(0, 50))}`);
1570
3154
  process.stderr.write('\n');
3155
+ renderResumePreview(resumed);
1571
3156
  } else {
1572
- process.stderr.write(` ${c.yellow('!')} ${c.dim('No conversation found for session ' + lastSession.sessionId)}\n`);
3157
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(resumed.reason || 'No conversation found for session ' + lastSession.sessionId)}\n`);
1573
3158
  }
1574
3159
  } else {
1575
3160
  process.stderr.write(` ${c.yellow('!')} ${c.dim('No previous session to resume')}\n`);
@@ -1615,8 +3200,7 @@ export async function startTerminalRepl() {
1615
3200
  prompt: userPrompt(),
1616
3201
  completer: (line) => {
1617
3202
  if (line.startsWith('/')) {
1618
- const hits = Object.keys(COMMANDS).filter(cmd => cmd.startsWith(line));
1619
- return [hits.length ? hits : Object.keys(COMMANDS), line];
3203
+ return [commandCompletions(line), line];
1620
3204
  }
1621
3205
  return [[], line];
1622
3206
  },
@@ -1637,7 +3221,44 @@ export async function startTerminalRepl() {
1637
3221
 
1638
3222
  showPrompt();
1639
3223
 
3224
+ // Guard against concurrent line handlers.
3225
+ //
3226
+ // rl.on('line', async ...) does NOT wait for the previous async handler to
3227
+ // complete before firing again. So a stray '\n' (from readline processing
3228
+ // '\r\n' as two events after an interactive picker resumes it, or from a
3229
+ // paste) can spawn a second handleCommand run while the first is still
3230
+ // awaiting inside /resume, /login, etc. Symptom: the picker or overlay
3231
+ // appears to render twice.
3232
+ //
3233
+ // Rules:
3234
+ // - Only one command runs at a time.
3235
+ // - Empty lines that arrive while a command is in flight are dropped
3236
+ // (they are almost always stray '\n's from raw-mode key handling).
3237
+ // - Non-empty lines are queued and replayed after the current command
3238
+ // finishes, so the user can queue up work while a long-running command
3239
+ // is still executing.
3240
+ let _lineInFlight = false;
3241
+ const _queuedLines = [];
1640
3242
  rl.on('line', async (line) => {
3243
+ if (_lineInFlight) {
3244
+ if (line && line.trim()) _queuedLines.push(line);
3245
+ return;
3246
+ }
3247
+ _lineInFlight = true;
3248
+ try {
3249
+ await _handleLine(line);
3250
+ } finally {
3251
+ _lineInFlight = false;
3252
+ if (_queuedLines.length) {
3253
+ const next = _queuedLines.shift();
3254
+ // Fire the next queued line asynchronously so we don't hold this
3255
+ // finally block open while the next command runs.
3256
+ setImmediate(() => rl.emit('line', next));
3257
+ }
3258
+ }
3259
+ });
3260
+
3261
+ async function _handleLine(line) {
1641
3262
  const input = line.trim();
1642
3263
  if (!input) { rl.prompt(); return; }
1643
3264
 
@@ -1660,16 +3281,20 @@ export async function startTerminalRepl() {
1660
3281
  }
1661
3282
 
1662
3283
  // Regular prompt
1663
- session.history.push({ role: 'user', content: input });
3284
+ const userMessage = { role: 'user', content: input };
3285
+ session.history.push(userMessage);
3286
+ session.agentHistory.push(userMessage);
1664
3287
  session.turns++;
1665
3288
  session.toolCalls = 0;
1666
3289
  session.lastTask = input;
1667
3290
  // Reset per-turn counts so the mission report reflects this turn only.
1668
3291
  session.toolCounts = {};
1669
3292
  session.subAgentCounts = {};
3293
+ session.filesRead = [];
1670
3294
  session.savedUsd = 0;
1671
3295
  session._lastEmittedThinking = '';
1672
3296
  session.creditsLowWarned = false;
3297
+ session.msgsLowWarned = false;
1673
3298
 
1674
3299
  // Tell the orbit a new turn started — switches to DISCOVERY and updates
1675
3300
  // task / turn counters in the status bar.
@@ -1679,11 +3304,14 @@ export async function startTerminalRepl() {
1679
3304
  if (session.turns === 1) {
1680
3305
  sessionMgr.start(input);
1681
3306
  }
1682
- sessionMgr.saveMessage('user', input);
1683
-
1684
- // Local JSONL: write user turn + history
1685
- jsonlWriter.writeUserTurn(input);
1686
- jsonlWriter.writeHistory(input);
3307
+ let userTurnWritten = false;
3308
+ const writeCurrentUserTurn = () => {
3309
+ if (userTurnWritten) return;
3310
+ jsonlWriter.writeUserTurn(input);
3311
+ jsonlWriter.writeHistory(input);
3312
+ userTurnWritten = true;
3313
+ };
3314
+ if (session.id) writeCurrentUserTurn();
1687
3315
 
1688
3316
  const creds = auth.loadCredentials();
1689
3317
  if (!creds.token) {
@@ -1705,6 +3333,9 @@ export async function startTerminalRepl() {
1705
3333
  });
1706
3334
  }
1707
3335
  const client = streamClient;
3336
+ if (session.id && !client.sessionId) {
3337
+ client.sessionId = session.id;
3338
+ }
1708
3339
 
1709
3340
  let assistantContent = '';
1710
3341
 
@@ -1800,10 +3431,57 @@ export async function startTerminalRepl() {
1800
3431
 
1801
3432
  const execContext = { cwd: safeCwd() };
1802
3433
  if (skipPerms) execContext.freeswim = true;
1803
- execContext.project_resources = toolExecutor.getProjectResources();
1804
- execContext.agent_context = toolExecutor.getAgentContext();
3434
+ effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
3435
+ approval.policy = effectivePolicy.policy;
3436
+ if (approval.trustStore) approval.trustStore.policy = effectivePolicy.policy;
3437
+ hookRunner.reload();
3438
+ latestProjectContext = loadProjectContext({ cwd: safeCwd(), previous: latestProjectContext });
3439
+ const projectResources = toolExecutor.getProjectResources();
3440
+ const promptHook = await hookRunner.run('UserPromptSubmit', {
3441
+ input: { prompt: input },
3442
+ turnId: String(session.turns),
3443
+ });
3444
+ const hookHints = (promptHook.results || [])
3445
+ .map(r => r.parsed?.feedback)
3446
+ .filter(Boolean)
3447
+ .map(text => ({ source: 'hook', kind: 'feedback', text, ttl_turns: 1, priority: 'medium' }));
3448
+ const rejectionHints = (approval.consumeRejectionHints?.() || [])
3449
+ .map(h => ({
3450
+ source: 'hitl',
3451
+ kind: 'approval_rejection',
3452
+ 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.`,
3453
+ ttl_turns: 1,
3454
+ priority: 'high',
3455
+ }));
3456
+ latestEnvelope = buildContextEnvelope({
3457
+ cwd: safeCwd(),
3458
+ effectivePolicy,
3459
+ projectContext: latestProjectContext,
3460
+ activeHints: [...hookHints, ...rejectionHints],
3461
+ projectResources,
3462
+ agentContext: toolExecutor.getAgentContext(),
3463
+ });
3464
+ ctx.effectivePolicy = effectivePolicy;
3465
+ ctx.latestProjectContext = latestProjectContext;
3466
+ ctx.latestEnvelope = latestEnvelope;
3467
+ Object.assign(execContext, latestEnvelope);
3468
+ if (skipPerms) execContext.freeswim = true;
3469
+ // PRD-071: seed work_scope from CLI so the backend has a byte-stable
3470
+ // scope block from turn 1. Uses projectResources already gathered by
3471
+ // the envelope above.
3472
+ execContext.work_scope = buildWorkScope({
3473
+ instruction: input,
3474
+ cwd: safeCwd(),
3475
+ projectResources,
3476
+ });
3477
+ for (const file of latestProjectContext.changed || []) {
3478
+ if (effectivePolicy.policy.context?.showReloadNotice) {
3479
+ process.stderr.write(` ${c.dim(`[Context] ${file.label} updated — re-read`)}\n`);
3480
+ }
3481
+ }
1805
3482
 
1806
- for await (const event of client.execute(input, execContext, session.history)) {
3483
+ for await (const event of client.execute(input, execContext, session.agentHistory)) {
3484
+ jsonlWriter.writeKeplerEvent(event);
1807
3485
  if (event.type === 'plan_created' || event.type === 'goal_created') {
1808
3486
  persistProjectArtifacts(
1809
3487
  event.data,
@@ -1833,6 +3511,8 @@ export async function startTerminalRepl() {
1833
3511
  // Local JSONL: capture session ID from backend
1834
3512
  if (event.type === 'session_info' && event.data?.session_id) {
1835
3513
  jsonlWriter.setSessionId(event.data.session_id);
3514
+ hookRunner.sessionId = event.data.session_id;
3515
+ writeCurrentUserTurn();
1836
3516
  }
1837
3517
 
1838
3518
  // Local JSONL: accumulate tool calls
@@ -1865,15 +3545,17 @@ export async function startTerminalRepl() {
1865
3545
  }
1866
3546
 
1867
3547
  if (assistantContent) {
1868
- session.history.push({ role: 'assistant', content: assistantContent });
1869
- sessionMgr.saveMessage('assistant', assistantContent);
3548
+ const assistantMessage = { role: 'assistant', content: assistantContent };
3549
+ session.history.push(assistantMessage);
3550
+ session.agentHistory.push(assistantMessage);
1870
3551
  }
1871
3552
 
1872
3553
  showPrompt();
1873
- });
3554
+ }
1874
3555
 
1875
3556
  rl.on('close', async () => {
1876
3557
  stopSpinner();
3558
+ await hookRunner.run('Stop', { input: { session_id: session.id || '' } });
1877
3559
  await jsonlWriter.close();
1878
3560
  process.stderr.write(`\n ${c.dim('session ended')}\n\n`);
1879
3561
  process.exit(0);