@axplusb/kepler 2.0.7 → 2.2.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.
@@ -17,12 +17,14 @@
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';
25
26
  import { CheckpointManager } from '../core/checkpoints.mjs';
27
+ import { HookRunner } from '../config/hook-runner.mjs';
26
28
  import { runPreflight } from '../onboarding/preflight.mjs';
27
29
  import { printBanner as printBrandedBanner } from '../ui/banner.mjs';
28
30
  import { renderMissionReport, saveReport, toMarkdown as missionMarkdown } from '../ui/mission-report.mjs';
@@ -36,10 +38,17 @@ import { persistProjectArtifacts } from '../core/project-artifacts.mjs';
36
38
  import { TarangAuth } from '../auth/tarang-auth.mjs';
37
39
  import { ApprovalManager } from '../core/approval.mjs';
38
40
  import { resolveBackendUrl } from '../core/backend-url.mjs';
41
+ import { formatMessageWindow, lowWindowStatus, messagesRemaining } from '../core/rate-limit-display.mjs';
39
42
  import { BUILTIN_AGENTS, runAgent } from './agents.mjs';
40
43
  import { SessionManager } from '../core/session-manager.mjs';
41
44
  import { parseArgs } from '../config/cli-args.mjs';
42
- import { toolDisplayLabel } from './tool-display.mjs';
45
+ import { loadEffectivePolicy, formatPolicySourceRows } from '../core/policy-resolver.mjs';
46
+ import { loadProjectContext } from '../core/project-context-loader.mjs';
47
+ import { buildContextEnvelope } from '../core/context-envelope.mjs';
48
+ import { buildResumeHistory, combineResumeSummaries, getRecentSessions, getSessionDetail, getTranscriptProjectRoots } from '../core/local-store.mjs';
49
+ import { decideResumeMode, projectedTokensForChoice, formatTokens as formatCtxTokens } from '../core/resume-mode.mjs';
50
+ import { appendTask, ensureTaskFiles, loadTaskBoard, taskCounts, TASK_FILES } from '../core/tasks.mjs';
51
+ import { toolDisplayLabel, toolDisplaySummary } from './tool-display.mjs';
43
52
  import { createOrbit } from '../state/orbit.mjs';
44
53
  import { attachOrbit, unmount as unmountStatusBar } from '../ui/status-bar.mjs';
45
54
  import { term } from '../ui/term.mjs';
@@ -89,6 +98,667 @@ function safeCwd() {
89
98
  }
90
99
  }
91
100
 
101
+ function messageCountLabel(count) {
102
+ return `${count} ${count === 1 ? 'message' : 'messages'}`;
103
+ }
104
+
105
+ function sessionListTimestamp(s) {
106
+ const value = s.updatedAt || s.startedAt;
107
+ return value ? new Date(value).toLocaleString() : '?';
108
+ }
109
+
110
+ function oneLineInstruction(text, max = 72) {
111
+ const compact = String(text || '(no instruction)').replace(/\s+/g, ' ').trim();
112
+ return compact.length > max ? compact.slice(0, max - 3) + '...' : compact;
113
+ }
114
+
115
+ function fitAnsiLine(text, maxColumns) {
116
+ const max = Math.max(1, Number(maxColumns) || 80);
117
+ const value = String(text || '');
118
+ if (stripAnsi(value).length <= max) return value;
119
+
120
+ let visible = 0;
121
+ let out = '';
122
+ for (let i = 0; i < value.length; i++) {
123
+ if (value[i] === '\x1b') {
124
+ const match = value.slice(i).match(/^\x1b\[[0-9;]*m/);
125
+ if (match) {
126
+ out += match[0];
127
+ i += match[0].length - 1;
128
+ continue;
129
+ }
130
+ }
131
+ if (visible >= max - 1) break;
132
+ out += value[i];
133
+ visible++;
134
+ }
135
+ return `${out}${c.dim('…')}`;
136
+ }
137
+
138
+ function normalizeResumableSession(s) {
139
+ return {
140
+ sessionId: s.sessionId,
141
+ instruction: s.firstPrompt || s.instruction || '(no instruction)',
142
+ startedAt: s.startTime || s.startedAt || '',
143
+ updatedAt: s.endTime || s.updatedAt || (s.mtime ? new Date(s.mtime).toISOString() : ''),
144
+ project: s.project ? path.basename(s.project) : s.projectName || s.project || '',
145
+ projectPath: s.project || s.projectPath || '',
146
+ transcriptPath: s.filePath || s.transcriptPath || '',
147
+ messageCount: (s.userMessages || 0) + (s.assistantMessages || 0),
148
+ // PRD-068 §5.14.11 derived fields for the picker
149
+ endStatus: s.endStatus || 'unknown', // 'completed' | 'interrupted' | 'errored' | 'unknown'
150
+ contextTokens: s.contextTokens || 0, // projected transcript token count
151
+ resumeSummary: s.resumeSummary || null, // latest resume_summary checkpoint metadata
152
+ costUsd: typeof s.costUsd === 'number' ? s.costUsd : 0,
153
+ partial: !!s.partial, // true if the transcript file was partially malformed
154
+ source: 'transcript',
155
+ };
156
+ }
157
+
158
+ async function listResumableSessions() {
159
+ // PRD-068 §5.14.6: JSONL is the single source of truth. The legacy
160
+ // per-project state-only entries never had a transcript, so they can't be
161
+ // replayed — silently dropping them removes a source of "picked a session
162
+ // and got a flat history" surprises.
163
+ const rich = (await getRecentSessions(Infinity)).map(normalizeResumableSession);
164
+ return rich.sort((a, b) => {
165
+ const at = Date.parse(a.updatedAt || a.startedAt || 0) || 0;
166
+ const bt = Date.parse(b.updatedAt || b.startedAt || 0) || 0;
167
+ return bt - at;
168
+ });
169
+ }
170
+
171
+ // ── PRD-068 §5.14 helpers ────────────────────────────────────────────
172
+
173
+ function endStatusMarker(status) {
174
+ switch (status) {
175
+ case 'completed': return c.green('✓');
176
+ case 'interrupted': return c.yellow('⚠');
177
+ case 'errored': return c.red('✗');
178
+ default: return c.dim('·');
179
+ }
180
+ }
181
+
182
+ function formatSessionCost(usd) {
183
+ const n = Number(usd);
184
+ if (!Number.isFinite(n) || n <= 0) return c.dim(' ');
185
+ if (n < 0.01) return c.dim('<$0.01');
186
+ return c.dim(`$${n.toFixed(2)}`);
187
+ }
188
+
189
+ function formatResumeCheckpointStatus(session) {
190
+ const marker = session?.resumeSummary;
191
+ if (!marker || !Number(marker.sourceMessageCount)) return '';
192
+ const full = Number(marker.fullMessageCount) || 0;
193
+ const covered = Number(marker.sourceMessageCount) || 0;
194
+ const pct = full > 0 ? ` ${Math.min(100, Math.round((covered / full) * 100))}%` : '';
195
+ return c.dim(` · summarized${pct}`);
196
+ }
197
+
198
+ function formatRelativeTime(iso) {
199
+ if (!iso) return '';
200
+ const t = Date.parse(iso);
201
+ if (!Number.isFinite(t)) return '';
202
+ const ago = Date.now() - t;
203
+ const m = Math.floor(ago / 60_000);
204
+ if (m < 1) return 'just now';
205
+ if (m < 60) return `${m}m ago`;
206
+ const h = Math.floor(m / 60);
207
+ if (h < 24) return `${h}h ago`;
208
+ const d = Math.floor(h / 24);
209
+ if (d < 30) return `${d}d ago`;
210
+ const mo = Math.floor(d / 30);
211
+ return `${mo}mo ago`;
212
+ }
213
+
214
+ /**
215
+ * PRD-068 §5.14.2 — enriched one-prompt picker.
216
+ * Returns the picked session, `null` on cancel, or `{ action: 'preview', session }`
217
+ * when the user hits P.
218
+ */
219
+ async function pickResumableSession(resumable, ctx) {
220
+ const rl = ctx._rl || null;
221
+ if (rl) rl.pause();
222
+
223
+ return await new Promise((resolve) => {
224
+ if (!process.stdin.isTTY) { resolve(null); return; }
225
+ const wasRaw = process.stdin.isRaw;
226
+ const pageSize = Math.min(10, resumable.length);
227
+ const numWidth = String(resumable.length).length;
228
+ let selected = 0;
229
+ let offset = 0;
230
+ let renderedLines = 0;
231
+
232
+ const renderMenu = () => {
233
+ if (renderedLines > 0) {
234
+ process.stderr.write(`\x1b[${renderedLines}F\r\x1b[J`);
235
+ }
236
+ if (selected < offset) offset = selected;
237
+ if (selected >= offset + pageSize) offset = selected - pageSize + 1;
238
+
239
+ const cols = Math.max(60, process.stderr.columns || 120);
240
+ const lines = [];
241
+ lines.push(` ${c.bold('Resume a session')}`);
242
+ lines.push('');
243
+ const end = Math.min(offset + pageSize, resumable.length);
244
+ for (let i = offset; i < end; i++) {
245
+ const s = resumable[i];
246
+ const marker = i === selected ? c.brand('▸') : ' ';
247
+ const num = c.dim(`[${String(i + 1).padStart(numWidth, ' ')}]`);
248
+ const project = (s.project || '(unknown)').padEnd(18, ' ').slice(0, 18);
249
+ const ago = formatRelativeTime(s.updatedAt || s.startedAt).padEnd(9, ' ').slice(0, 9);
250
+ const status = endStatusMarker(s.endStatus);
251
+ const msgs = String(s.messageCount).padStart(3, ' ') + ' msgs';
252
+ const ctx = c.dim(`${formatCtxTokens(s.contextTokens).padStart(5, ' ')} ctx`) + formatResumeCheckpointStatus(s);
253
+ const cost = formatSessionCost(s.costUsd);
254
+ const partial = s.partial ? c.yellow(' ⚠partial') : '';
255
+ const instr = oneLineInstruction(s.instruction, 48);
256
+ lines.push(fitAnsiLine(
257
+ ` ${marker} ${num} ${c.brand(project)} ${c.dim(ago)} ${status} ${c.dim(msgs)} ${ctx} ${cost}${partial} ${c.dim(instr)}`,
258
+ cols - 1
259
+ ));
260
+ }
261
+ lines.push('');
262
+ lines.push(fitAnsiLine(
263
+ ` ${c.dim(`↑↓ move · Enter resume · P preview · Esc cancel · ${selected + 1}/${resumable.length}`)}`,
264
+ cols - 1
265
+ ));
266
+ process.stderr.write(lines.join('\n') + '\n');
267
+ renderedLines = lines.length;
268
+ };
269
+
270
+ const cleanup = (value) => {
271
+ process.stdin.removeListener('data', onData);
272
+ process.stdin.setRawMode(wasRaw || false);
273
+ if (rl) rl.resume();
274
+ resolve(value);
275
+ };
276
+ const onData = (data) => {
277
+ const key = data.toString('utf8');
278
+ if (key === '' || key === '') { cleanup(null); return; }
279
+ if (key === '\r' || key === '\n') { cleanup({ action: 'resume', session: resumable[selected] }); return; }
280
+ if (key === 'p' || key === 'P') { cleanup({ action: 'preview', session: resumable[selected] }); return; }
281
+ if (key === '') { selected = Math.max(0, selected - 1); renderMenu(); return; }
282
+ if (key === '') { selected = Math.min(resumable.length - 1, selected + 1); renderMenu(); return; }
283
+ if (key === '[5~') { selected = Math.max(0, selected - pageSize); renderMenu(); return; }
284
+ if (key === '[6~') { selected = Math.min(resumable.length - 1, selected + pageSize); renderMenu(); return; }
285
+ if (key === '' || key === '[1~') { selected = 0; renderMenu(); return; }
286
+ if (key === '' || key === '[4~') { selected = resumable.length - 1; renderMenu(); return; }
287
+ if (/^[1-9]$/.test(key)) {
288
+ const index = Number(key) - 1;
289
+ if (index < resumable.length) cleanup({ action: 'resume', session: resumable[index] });
290
+ }
291
+ };
292
+
293
+ process.stdin.setRawMode(true);
294
+ process.stdin.resume();
295
+ process.stdin.on('data', onData);
296
+ renderMenu();
297
+ });
298
+ }
299
+
300
+ /**
301
+ * PRD-068 §5.14.4 — tri-choice overlay shown only when projected ctx > highWatermark.
302
+ * Returns 'full' | 'summary' | 'tail-10' | 'tail-20' | null (cancel).
303
+ */
304
+ async function chooseThresholdMode(ctx, decision) {
305
+ if (!process.stdin.isTTY) return decision.defaultChoice;
306
+ const rl = ctx._rl || null;
307
+ if (rl) rl.pause();
308
+
309
+ const canFull = decision.mode !== 'no-full-allowed';
310
+ const options = [
311
+ { key: 'f', value: 'full', label: 'full transcript', enabled: canFull },
312
+ { key: 's', value: 'summary', label: 'summary only', enabled: true },
313
+ { key: '1', value: 'tail-10', label: 'summary + last 10 turns', enabled: true },
314
+ { key: '2', value: 'tail-20', label: 'summary + last 20 turns', enabled: true },
315
+ ];
316
+ let selected = options.findIndex(o => o.value === decision.defaultChoice && o.enabled);
317
+ if (selected < 0) selected = options.findIndex(o => o.enabled);
318
+
319
+ return await new Promise((resolve) => {
320
+ const wasRaw = process.stdin.isRaw;
321
+ let renderedLines = 0;
322
+
323
+ const render = () => {
324
+ if (renderedLines > 0) process.stderr.write(`\x1b[${renderedLines}F\r\x1b[J`);
325
+ const cols = Math.max(60, process.stderr.columns || 120);
326
+ const pct = Math.round(decision.usageRatio * 100);
327
+ const projected = formatCtxTokens(decision.projected);
328
+ const win = formatCtxTokens(decision.windowSize);
329
+ const lines = [];
330
+ lines.push(` This session would use ${c.brand(`${projected} / ${win}`)} tokens (${pct}%)`);
331
+ if (decision.resumeSummary?.sourceMessageCount) {
332
+ const covered = Number(decision.resumeSummary.sourceMessageCount) || 0;
333
+ const full = Number(decision.resumeSummary.fullMessageCount) || 0;
334
+ const suffix = full > 0 ? ` (${covered}/${full} resume messages)` : '';
335
+ lines.push(` ${c.green('✓')} ${c.dim(`summary checkpoint available${suffix}; summary/tail modes reuse it`)}`);
336
+ }
337
+ lines.push(canFull
338
+ ? ` ${c.yellow('⚠')} ${c.dim('close to the highWatermark — consider a leaner mode:')}`
339
+ : ` ${c.red('⛔')} ${c.dim('over hardCap — full mode disabled:')}`);
340
+ lines.push('');
341
+ for (let i = 0; i < options.length; i++) {
342
+ const o = options[i];
343
+ const disabled = !o.enabled;
344
+ const marker = i === selected && !disabled ? c.brand('▸') : ' ';
345
+ const keyTag = c.dim('[') + (disabled ? c.dim(o.key) : c.brand(o.key)) + c.dim(']');
346
+ const proj = formatCtxTokens(projectedTokensForChoice(o.value, decision.projected));
347
+ const label = disabled ? c.dim(o.label) : (i === selected ? c.brand(o.label) : o.label);
348
+ const projCol = c.dim(`${proj.padStart(5, ' ')} ctx`);
349
+ const suffix = disabled ? c.dim(' (over hardCap)') : '';
350
+ lines.push(fitAnsiLine(` ${marker} ${keyTag} ${label.padEnd(30, ' ')} ${projCol}${suffix}`, cols - 1));
351
+ }
352
+ lines.push('');
353
+ lines.push(fitAnsiLine(` ${c.dim('↑↓ move · Enter pick · f/s/1/2 shortcut · Esc cancel')}`, cols - 1));
354
+ process.stderr.write(lines.join('\n') + '\n');
355
+ renderedLines = lines.length;
356
+ };
357
+
358
+ const cleanup = (value) => {
359
+ process.stdin.removeListener('data', onData);
360
+ process.stdin.setRawMode(wasRaw || false);
361
+ if (rl) rl.resume();
362
+ resolve(value);
363
+ };
364
+ const onData = (data) => {
365
+ const key = data.toString('utf8');
366
+ const low = key.toLowerCase();
367
+ if (key === '' || key === '') { cleanup(null); return; }
368
+ if (key === '\r' || key === '\n') { cleanup(options[selected]?.value || null); return; }
369
+ if (key === '') {
370
+ // step to previous enabled option
371
+ for (let i = selected - 1; i >= 0; i--) if (options[i].enabled) { selected = i; render(); return; }
372
+ return;
373
+ }
374
+ if (key === '') {
375
+ for (let i = selected + 1; i < options.length; i++) if (options[i].enabled) { selected = i; render(); return; }
376
+ return;
377
+ }
378
+ for (let i = 0; i < options.length; i++) {
379
+ if (options[i].key === low && options[i].enabled) { cleanup(options[i].value); return; }
380
+ }
381
+ };
382
+
383
+ process.stdin.setRawMode(true);
384
+ process.stdin.resume();
385
+ process.stdin.on('data', onData);
386
+ render();
387
+ });
388
+ }
389
+
390
+ function resumeModeLabel(mode = 'full') {
391
+ if (mode === 'summary') return 'summary only';
392
+ const tailTurns = resumeTailTurnCount(mode);
393
+ if (tailTurns) return `summary + last ${tailTurns} turns`;
394
+ return mode || 'full';
395
+ }
396
+
397
+ /**
398
+ * PRD-068 §5.14.5 — preview overlay for a session/mode. Read-only, `q` to return.
399
+ * If user hits Enter, resolve to the currently-previewed mode so caller can activate.
400
+ */
401
+ async function previewResumeSession(session, ctx) {
402
+ if (!process.stdin.isTTY) return null;
403
+ const detail = await getSessionDetail(session.sessionId, { filePath: session.transcriptPath });
404
+ if (!detail) return null;
405
+
406
+ const rl = ctx._rl || null;
407
+ if (rl) rl.pause();
408
+
409
+ let mode = 'summary';
410
+ const rich = () => buildResumeHistory({ ...detail, recapTailTurns: 8 }, mode);
411
+ let history = rich();
412
+
413
+ return await new Promise((resolve) => {
414
+ const wasRaw = process.stdin.isRaw;
415
+ let renderedLines = 0;
416
+ let scrollOffset = 0;
417
+
418
+ const render = () => {
419
+ if (renderedLines > 0) process.stderr.write(`\x1b[${renderedLines}F\r\x1b[J`);
420
+ const cols = Math.max(60, process.stderr.columns || 120);
421
+ const rows = Math.max(10, Math.min((process.stderr.rows || 30) - 6, 20));
422
+ const contentLines = (history.summary || '').split('\n');
423
+ // For tail/full modes, also append serialized tail so preview reflects
424
+ // what the agent will actually receive.
425
+ if (mode !== 'summary') {
426
+ contentLines.push('', c.dim('── conversation tail ──'));
427
+ for (const msg of history.agentHistory.slice(1)) {
428
+ contentLines.push(`${msg.role === 'user' ? c.dim('You:') : c.brand('Kepler:')} ${String(msg.content).slice(0, 300)}`);
429
+ }
430
+ }
431
+ const totalLines = contentLines.length;
432
+ const maxOffset = Math.max(0, totalLines - rows);
433
+ if (scrollOffset > maxOffset) scrollOffset = maxOffset;
434
+
435
+ const lines = [];
436
+ 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')}`);
437
+ lines.push(` ${c.dim('─'.repeat(60))}`);
438
+ for (let i = scrollOffset; i < Math.min(scrollOffset + rows, totalLines); i++) {
439
+ lines.push(fitAnsiLine(` ${c.dim(contentLines[i] || '')}`, cols - 1));
440
+ }
441
+ lines.push('');
442
+ 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));
443
+ process.stderr.write(lines.join('\n') + '\n');
444
+ renderedLines = lines.length;
445
+ };
446
+
447
+ const cleanup = (value) => {
448
+ process.stdin.removeListener('data', onData);
449
+ process.stdin.setRawMode(wasRaw || false);
450
+ if (rl) rl.resume();
451
+ resolve(value);
452
+ };
453
+ const onData = (data) => {
454
+ const key = data.toString('utf8');
455
+ const low = key.toLowerCase();
456
+ if (key === '' || key === '' || low === 'q') { cleanup({ action: 'back' }); return; }
457
+ if (key === '\r' || key === '\n') { cleanup({ action: 'resume', mode }); return; }
458
+ if (key === '') { scrollOffset = Math.max(0, scrollOffset - 1); render(); return; }
459
+ if (key === '') { scrollOffset += 1; render(); return; }
460
+ if (key === '[5~') { scrollOffset = Math.max(0, scrollOffset - 10); render(); return; }
461
+ if (key === '[6~') { scrollOffset += 10; render(); return; }
462
+ if (low === 'f') { mode = 'full'; history = rich(); scrollOffset = 0; render(); return; }
463
+ if (low === 's') { mode = 'summary'; history = rich(); scrollOffset = 0; render(); return; }
464
+ if (low === '1') { mode = 'tail-10'; history = rich(); scrollOffset = 0; render(); return; }
465
+ if (low === '2') { mode = 'tail-20'; history = rich(); scrollOffset = 0; render(); return; }
466
+ };
467
+
468
+ process.stdin.setRawMode(true);
469
+ process.stdin.resume();
470
+ process.stdin.on('data', onData);
471
+ render();
472
+ });
473
+ }
474
+
475
+ /**
476
+ * PRD-068 §5.14.7 — explicit cwd confirmation when the picked session lives
477
+ * elsewhere. Returns 'switch' | 'stay' | 'cancel'.
478
+ */
479
+ async function confirmCwdSwitch(ctx, savedPath, currentPath) {
480
+ if (!process.stdin.isTTY) return 'switch';
481
+ process.stderr.write(`\n ${c.dim('This session lives in another repo:')}\n`);
482
+ process.stderr.write(` ${c.dim('→')} ${c.brand(savedPath)} ${c.dim(`(current cwd: ${currentPath})`)}\n`);
483
+ process.stderr.write(` ${c.dim('[Enter]')} switch cwd and resume · ${c.dim('[s]')} stay here and resume anyway · ${c.dim('[n]')} cancel `);
484
+ const rl = ctx._rl || null;
485
+ if (rl) rl.pause();
486
+ return await new Promise((resolve) => {
487
+ const wasRaw = process.stdin.isRaw;
488
+ const cleanup = (value) => {
489
+ process.stdin.removeListener('data', onData);
490
+ process.stdin.setRawMode(wasRaw || false);
491
+ if (rl) rl.resume();
492
+ process.stderr.write('\n');
493
+ resolve(value);
494
+ };
495
+ const onData = (data) => {
496
+ const key = data.toString('utf8').toLowerCase();
497
+ if (key === '' || key === '' || key === 'n') { cleanup('cancel'); return; }
498
+ if (key === '\r' || key === '\n') { cleanup('switch'); return; }
499
+ if (key === 's') { cleanup('stay'); return; }
500
+ };
501
+ process.stdin.setRawMode(true);
502
+ process.stdin.resume();
503
+ process.stdin.on('data', onData);
504
+ });
505
+ }
506
+
507
+ // Legacy prompt (kept as a fallback for callers that force compact/full explicitly).
508
+ async function chooseResumeHistoryMode(ctx, { defaultMode = 'compact' } = {}) {
509
+ if (!process.stdin.isTTY) return defaultMode;
510
+ 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):')} `);
511
+ const rl = ctx._rl || null;
512
+ if (rl) rl.pause();
513
+ return await new Promise((resolve) => {
514
+ const wasRaw = process.stdin.isRaw;
515
+ const cleanup = (value) => {
516
+ process.stdin.removeListener('data', onData);
517
+ process.stdin.setRawMode(wasRaw || false);
518
+ if (rl) rl.resume();
519
+ process.stderr.write('\n');
520
+ resolve(value);
521
+ };
522
+ const onData = (data) => {
523
+ const key = data.toString('utf8').toLowerCase();
524
+ if (key === '\u0003' || key === '\u001b') { cleanup(null); return; }
525
+ if (key === '\r' || key === '\n' || key === 'c') { cleanup('compact'); return; }
526
+ if (key === 'f') { cleanup('full'); }
527
+ };
528
+ process.stdin.setRawMode(true);
529
+ process.stdin.resume();
530
+ process.stdin.on('data', onData);
531
+ });
532
+ }
533
+
534
+ function historyRoleLabel(role) {
535
+ return role === 'user'
536
+ ? c.white('You')
537
+ : role === 'tool'
538
+ ? c.dim('Tool')
539
+ : c.brand('Kepler');
540
+ }
541
+
542
+ function renderHistoryEntries(entries, { limit = 20, maxChars = 120, title = 'Conversation' } = {}) {
543
+ const shown = limit === Infinity ? entries : entries.slice(-limit);
544
+ process.stderr.write(`\n ${c.bold(title)} (${shown.length}${shown.length === entries.length ? '' : ` of ${entries.length}`} entries)\n`);
545
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
546
+ for (const msg of shown) {
547
+ const content = String(msg.content || '').replace(/\s+/g, ' ').trim();
548
+ process.stderr.write(` ${historyRoleLabel(msg.role)}: ${content.slice(0, maxChars)}${content.length > maxChars ? '...' : ''}\n`);
549
+ }
550
+ process.stderr.write('\n');
551
+ }
552
+
553
+ function renderResumePreview(resumed) {
554
+ const tailTurns = resumeTailTurnCount(resumed.historyMode);
555
+ if (resumed.historyMode === 'compact' || resumed.historyMode === 'summary') {
556
+ if (!resumed.summary) return;
557
+ process.stderr.write(`\n ${c.bold('Continuity Summary Sent To Agent')}\n`);
558
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
559
+ for (const line of resumed.summary.split('\n')) {
560
+ process.stderr.write(` ${c.dim(line)}\n`);
561
+ }
562
+ process.stderr.write('\n');
563
+ return;
564
+ }
565
+
566
+ if (tailTurns && resumed.summary) {
567
+ process.stderr.write(`\n ${c.bold(`Summary + Last ${tailTurns} Turns`)}\n`);
568
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
569
+ for (const line of resumed.summary.split('\n')) {
570
+ process.stderr.write(` ${c.dim(line)}\n`);
571
+ }
572
+ process.stderr.write('\n');
573
+ }
574
+
575
+ if (resumed.replayEvents?.length) {
576
+ const replayStartOrder = replayStartOrderForMode(resumed.history || [], resumed.historyMode);
577
+ const replayEvents = filterResumeReplayEvents(resumed.replayEvents)
578
+ .filter(item => replayStartOrder == null || !Number.isFinite(Number(item.order)) || Number(item.order) >= replayStartOrder);
579
+ const userTurns = (resumed.history || [])
580
+ .filter(m => m.role === 'user')
581
+ .filter(m => replayStartOrder == null || !Number.isFinite(Number(m.order)) || Number(m.order) >= replayStartOrder);
582
+ const replayItems = mergeResumeReplayItems(userTurns, replayEvents);
583
+ const replayTitle = tailTurns ? `Last ${tailTurns} Turns Replay` : 'Replayed Live Session Events';
584
+ process.stderr.write(`\n ${c.bold(replayTitle)} (${userTurns.length} turns, ${replayEvents.length} events)\n`);
585
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
586
+ const sessionSnapshot = JSON.parse(JSON.stringify(session));
587
+ const savedOrbit = _orbit;
588
+ const savedSessionMgr = _sessionMgr;
589
+ _orbit = null;
590
+ _sessionMgr = null;
591
+ try {
592
+ startContentStream();
593
+ for (const item of replayItems) {
594
+ if (item.kind === 'user') {
595
+ flushContent();
596
+ stopSpinner();
597
+ const content = String(item.message.content || '').replace(/\s+/g, ' ').trim();
598
+ process.stderr.write(`\n ${historyRoleLabel('user')}: ${content}\n`);
599
+ continue;
600
+ }
601
+ renderEvent(item.event.event);
602
+ }
603
+ flushContent();
604
+ stopSpinner();
605
+ } finally {
606
+ _orbit = savedOrbit;
607
+ _sessionMgr = savedSessionMgr;
608
+ for (const key of Object.keys(session)) delete session[key];
609
+ Object.assign(session, sessionSnapshot);
610
+ }
611
+ process.stderr.write('\n');
612
+ return;
613
+ }
614
+
615
+ if (resumed.history?.length) {
616
+ renderHistoryEntries(resumed.history, {
617
+ limit: Infinity,
618
+ maxChars: 220,
619
+ title: tailTurns ? `Last ${tailTurns} Turns` : 'Replayed Session History',
620
+ });
621
+ }
622
+ }
623
+
624
+ async function summarizeResumeTranscript({
625
+ auth,
626
+ toolExecutor,
627
+ sessionId,
628
+ projectPath,
629
+ messages,
630
+ }) {
631
+ const creds = auth?.loadCredentials?.() || {};
632
+ if (!creds.backendUrl || !creds.token || !Array.isArray(messages) || messages.length === 0) {
633
+ return {
634
+ ok: false,
635
+ source: 'local',
636
+ reason: !creds.backendUrl || !creds.token ? 'missing backend credentials' : 'empty transcript',
637
+ };
638
+ }
639
+ try {
640
+ const client = new TarangStreamClient({
641
+ baseUrl: creds.backendUrl,
642
+ token: creds.token,
643
+ toolExecutor,
644
+ });
645
+ const result = await client.summarizeSession(messages, {
646
+ sessionId,
647
+ projectPath,
648
+ maxTokens: 800,
649
+ timeoutMs: 15000,
650
+ });
651
+ return { ...result, ok: true };
652
+ } catch (err) {
653
+ return {
654
+ ok: false,
655
+ source: 'local',
656
+ reason: err?.message || 'backend summary request failed',
657
+ };
658
+ }
659
+ }
660
+
661
+ function resumeTailTurnCount(mode = '') {
662
+ const match = String(mode || '').match(/^tail-(\d+)$/);
663
+ if (!match) return null;
664
+ return Math.max(1, Number(match[1]) || 1);
665
+ }
666
+
667
+ function replayStartOrderForMode(history = [], mode = '') {
668
+ const match = String(mode || '').match(/^tail-(\d+)$/);
669
+ if (!match) return null;
670
+ const wanted = Math.max(1, Number(match[1]) || 1);
671
+ let seen = 0;
672
+ const userTurns = history.filter(m => m.role === 'user' && typeof m.content === 'string');
673
+ for (let i = userTurns.length - 1; i >= 0; i--) {
674
+ seen++;
675
+ if (seen >= wanted) {
676
+ const order = Number(userTurns[i].order);
677
+ return Number.isFinite(order) ? order : null;
678
+ }
679
+ }
680
+ return null;
681
+ }
682
+
683
+ function filterResumeReplayEvents(events = []) {
684
+ return events.filter(item => {
685
+ const type = item?.event?.type;
686
+ return !['status', 'session_info', 'complete', 'resumed', 'paused'].includes(type);
687
+ });
688
+ }
689
+
690
+ function mergeResumeReplayItems(userTurns = [], replayEvents = []) {
691
+ const items = [];
692
+ let order = 0;
693
+ for (const message of userTurns) {
694
+ items.push({
695
+ kind: 'user',
696
+ message,
697
+ fileOrder: Number.isFinite(Number(message.order)) ? Number(message.order) : null,
698
+ order: order++,
699
+ time: Date.parse(message.timestamp || '') || 0,
700
+ });
701
+ }
702
+ for (const event of replayEvents) {
703
+ items.push({
704
+ kind: 'event',
705
+ event,
706
+ fileOrder: Number.isFinite(Number(event.order)) ? Number(event.order) : null,
707
+ order: order++,
708
+ time: Date.parse(event.timestamp || '') || 0,
709
+ });
710
+ }
711
+ return items.sort((a, b) => {
712
+ if (a.fileOrder !== null && b.fileOrder !== null && a.fileOrder !== b.fileOrder) {
713
+ return a.fileOrder - b.fileOrder;
714
+ }
715
+ if (a.fileOrder !== null && b.fileOrder === null) return -1;
716
+ if (a.fileOrder === null && b.fileOrder !== null) return 1;
717
+ const at = a.time || Number.MAX_SAFE_INTEGER;
718
+ const bt = b.time || Number.MAX_SAFE_INTEGER;
719
+ return at - bt || a.order - b.order;
720
+ });
721
+ }
722
+
723
+ function resumeProgressBar(percent, width = 12) {
724
+ const p = Math.max(0, Math.min(100, Math.round(percent)));
725
+ const filled = Math.round((p / 100) * width);
726
+ return `${c.brand('█'.repeat(filled))}${c.gray('░'.repeat(width - filled))} ${String(p).padStart(3)}%`;
727
+ }
728
+
729
+ function startResumeProgress(mode = 'full') {
730
+ let percent = 8;
731
+ let label = `resuming as ${resumeModeLabel(mode)}`;
732
+ let active = true;
733
+ const started = Date.now();
734
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
735
+ let frame = 0;
736
+
737
+ const render = () => {
738
+ if (!active) return;
739
+ const glyph = frames[frame % frames.length];
740
+ frame++;
741
+ inPlace(` ${c.brand(glyph)} ${c.dim(label)} ${resumeProgressBar(percent)} ${c.dim(formatElapsed(started))}`);
742
+ };
743
+
744
+ render();
745
+ const timer = setInterval(render, 100);
746
+ return {
747
+ update(nextLabel, nextPercent) {
748
+ if (!active) return;
749
+ if (nextLabel) label = nextLabel;
750
+ if (Number.isFinite(nextPercent)) percent = Math.max(percent, Math.min(98, nextPercent));
751
+ render();
752
+ },
753
+ stop() {
754
+ if (!active) return;
755
+ active = false;
756
+ clearInterval(timer);
757
+ inPlace('');
758
+ },
759
+ };
760
+ }
761
+
92
762
  // ── Session State ──
93
763
 
94
764
  let _sessionMgr = null; // Set in startTerminalRepl, used by renderEvent
@@ -102,7 +772,8 @@ const session = {
102
772
  toolCalls: 0,
103
773
  totalToolCalls: 0, // across all turns
104
774
  turns: 0,
105
- history: [], // conversation messages
775
+ history: [], // display transcript (can include reconstructed tool entries)
776
+ agentHistory: [], // backend continuity payload (compact or full)
106
777
  inputHistory: [], // previous prompts (for Up/Down)
107
778
  user: null, // { github_username, email, role }
108
779
  model: null, // from backend user profile
@@ -111,6 +782,7 @@ const session = {
111
782
  phases: [], // phase history: { name, time }
112
783
  inSubAgent: false, // true while a sub-agent is running (for indented tool display)
113
784
  filesChanged: [], // files modified this session
785
+ filesRead: [], // files read this turn
114
786
  lastTurnDuration: 0,
115
787
  toolCounts: {}, // per-tool histogram (mission report)
116
788
  subAgentCounts: {}, // per-sub-agent histogram (mission report)
@@ -132,6 +804,8 @@ const session = {
132
804
  creditsLimit: null, // per-period included credits limit
133
805
  creditsCharged: 0, // session-cumulative server-reported charges
134
806
  creditsLowWarned: false, // emit the low-balance hint only once per turn
807
+ rateLimit: null, // rolling message-window state from backend
808
+ msgsLowWarned: false, // emit the low-window hint only once per turn
135
809
  };
136
810
 
137
811
  // ── Commands ──
@@ -141,12 +815,15 @@ const COMMANDS = {
141
815
  '/login': 'Sign in via browser',
142
816
  '/whoami': 'Show logged-in user',
143
817
  '/status': 'Session status & system info',
818
+ '/plan': 'Show plan/tasks',
819
+ '/tasks': 'Show or update project tasks',
144
820
  '/stats': 'Progress bars & metrics',
145
821
  '/clear': 'Clear conversation',
146
822
  '/git': 'Git status',
147
823
  '/diff': 'Git diff',
148
824
  '/cost': 'Show session cost',
149
825
  '/history': 'Show conversation',
826
+ '/settings': 'Show policy/settings',
150
827
  '/last': 'Expand last tool output',
151
828
  '/expand': 'Expand tool output by index (or "all")',
152
829
  '/fold': 'Hide previously expanded tool output',
@@ -173,6 +850,240 @@ const COMMANDS = {
173
850
  '/exit': 'Exit CLI',
174
851
  };
175
852
 
853
+ const HELP_GROUPS = [
854
+ {
855
+ key: 'plan',
856
+ title: 'Plan',
857
+ summary: 'plan and project tasks',
858
+ commands: [
859
+ ['/plan', 'Plan and task overview'],
860
+ ['/plan status', 'Plan owner and task files'],
861
+ ['/plan edit', 'Show editable task/plan paths'],
862
+ ['/tasks', 'List project tasks'],
863
+ ['/tasks add <text>', 'Add backlog task'],
864
+ ['/tasks active|blocked|done <text>', 'Append to a task list'],
865
+ ],
866
+ },
867
+ {
868
+ key: 'status',
869
+ title: 'Status',
870
+ summary: 'session, usage, budget',
871
+ commands: [
872
+ ['/status', 'Session snapshot'],
873
+ ['/status context', 'Loaded .kepler context'],
874
+ ['/status metrics', 'Progress bars and runtime metrics'],
875
+ ['/status cost', 'Credits and message window'],
876
+ ['/status budget <amount|clear>', 'Set or clear session budget'],
877
+ ],
878
+ },
879
+ {
880
+ key: 'history',
881
+ title: 'History',
882
+ summary: 'transcript, reports, undo',
883
+ commands: [
884
+ ['/history', 'Recent transcript'],
885
+ ['/history approvals', 'Approval log'],
886
+ ['/history last', 'Expand last tool output'],
887
+ ['/history expand [n|all]', 'Expand tool output'],
888
+ ['/history checkpoint', 'List checkpoints'],
889
+ ['/history undo', 'Restore latest checkpoint'],
890
+ ['/history report', 'Save mission report'],
891
+ ],
892
+ },
893
+ {
894
+ key: 'settings',
895
+ title: 'Settings',
896
+ summary: 'auth, policy, verbosity',
897
+ commands: [
898
+ ['/settings policy', 'Effective project policy'],
899
+ ['/settings login', 'Sign in'],
900
+ ['/settings logout', 'Sign out'],
901
+ ['/settings whoami', 'Current user'],
902
+ ['/settings quiet|verbose|surgical', 'Verbosity'],
903
+ ['/settings revoke', 'Revoke auto-approvals'],
904
+ ],
905
+ },
906
+ {
907
+ key: 'worktree',
908
+ title: 'Worktree',
909
+ summary: 'git and files',
910
+ commands: [
911
+ ['/git', 'Git status'],
912
+ ['/diff', 'Git diff'],
913
+ ['/map', 'Registered project tree'],
914
+ ['/preflight', 'Onboarding diagnostic'],
915
+ ['/safety', 'Safety guardrail status'],
916
+ ],
917
+ },
918
+ {
919
+ key: 'agents',
920
+ title: 'Agents',
921
+ summary: 'specialist modes',
922
+ commands: [
923
+ ['/agents', 'List built-in agents'],
924
+ ['/explore <instruction>', 'Explore code'],
925
+ ['/review <instruction>', 'Review code'],
926
+ ['/architect <instruction>', 'Design an approach'],
927
+ ],
928
+ },
929
+ {
930
+ key: 'session',
931
+ title: 'Session',
932
+ summary: 'resume and clear',
933
+ commands: [
934
+ ['/sessions', 'List resumable sessions'],
935
+ ['/resume [id]', 'Resume a session'],
936
+ ['/compact', 'Compact conversation context'],
937
+ ['/clear', 'Clear conversation'],
938
+ ['/exit', 'Exit CLI'],
939
+ ],
940
+ },
941
+ ];
942
+
943
+ const HELP_GROUP_ALIASES = new Map(
944
+ HELP_GROUPS.flatMap(group => [[group.key, group], [group.title.toLowerCase(), group]])
945
+ );
946
+
947
+ const LEGACY_COMMAND_HINTS = {
948
+ '/stats': '/status metrics',
949
+ '/cost': '/status cost',
950
+ '/budget': '/status budget',
951
+ '/last': '/history last',
952
+ '/expand': '/history expand',
953
+ '/fold': '/history fold',
954
+ '/undo': '/history undo',
955
+ '/checkpoint': '/history checkpoint',
956
+ '/report': '/history report',
957
+ '/login': '/settings login',
958
+ '/logout': '/settings logout',
959
+ '/whoami': '/settings whoami',
960
+ '/quiet': '/settings quiet',
961
+ '/verbose': '/settings verbose',
962
+ '/surgical': '/settings surgical',
963
+ '/revoke': '/settings revoke',
964
+ };
965
+
966
+ const NAMESPACED_COMMANDS = {
967
+ '/status': {
968
+ metrics: '/stats',
969
+ stats: '/stats',
970
+ cost: '/cost',
971
+ credits: '/cost',
972
+ budget: '/budget',
973
+ },
974
+ '/history': {
975
+ last: '/last',
976
+ expand: '/expand',
977
+ fold: '/fold',
978
+ undo: '/undo',
979
+ checkpoint: '/checkpoint',
980
+ checkpoints: '/checkpoint',
981
+ report: '/report',
982
+ },
983
+ '/settings': {
984
+ login: '/login',
985
+ logout: '/logout',
986
+ whoami: '/whoami',
987
+ quiet: '/quiet',
988
+ verbose: '/verbose',
989
+ surgical: '/surgical',
990
+ revoke: '/revoke',
991
+ },
992
+ };
993
+
994
+ function normalizeCommandInput(input) {
995
+ const parts = input.trim().split(/\s+/).filter(Boolean);
996
+ const rawCmd = (parts[0] || '').toLowerCase();
997
+ const restParts = parts.slice(1);
998
+ const sub = (restParts[0] || '').toLowerCase();
999
+ const namespaced = NAMESPACED_COMMANDS[rawCmd]?.[sub];
1000
+ if (namespaced) {
1001
+ return {
1002
+ cmd: namespaced,
1003
+ rest: restParts.slice(1).join(' '),
1004
+ rawCmd,
1005
+ aliasTarget: null,
1006
+ };
1007
+ }
1008
+ return {
1009
+ cmd: rawCmd,
1010
+ rest: restParts.join(' '),
1011
+ rawCmd,
1012
+ aliasTarget: LEGACY_COMMAND_HINTS[rawCmd] || null,
1013
+ };
1014
+ }
1015
+
1016
+ function renderHelp(topic = '') {
1017
+ const key = String(topic || '').trim().toLowerCase();
1018
+ if (!key) {
1019
+ process.stderr.write(`\n ${c.bold('Kepler Commands')}\n`);
1020
+ process.stderr.write(` ${c.gray('─'.repeat(52))}\n`);
1021
+ const top = [
1022
+ ['/help', 'Grouped command help'],
1023
+ ['/status', 'Session snapshot'],
1024
+ ['/plan', 'Task list and plan'],
1025
+ ['/tasks', 'Project task files'],
1026
+ ['/history', 'Transcript, approvals, undo'],
1027
+ ['/settings', 'Policy, auth, verbosity'],
1028
+ ['/why', 'Explain last reasoning'],
1029
+ ];
1030
+ for (const [name, desc] of top) {
1031
+ process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}\n`);
1032
+ }
1033
+ process.stderr.write(`\n ${c.bold('Categories')}\n`);
1034
+ for (const group of HELP_GROUPS) {
1035
+ process.stderr.write(` ${c.brand(('/help ' + group.key).padEnd(20))} ${c.dim(group.summary)}\n`);
1036
+ }
1037
+ process.stderr.write(`\n ${c.dim('Use /help all for legacy command aliases.')}\n`);
1038
+ renderKeyboardHelp();
1039
+ return;
1040
+ }
1041
+
1042
+ if (key === 'all' || key === 'commands') {
1043
+ process.stderr.write(`\n ${c.bold('All Commands')}\n`);
1044
+ process.stderr.write(` ${c.gray('─'.repeat(52))}\n`);
1045
+ for (const [name, desc] of Object.entries(COMMANDS)) {
1046
+ const alias = LEGACY_COMMAND_HINTS[name] ? c.dim(` alias for ${LEGACY_COMMAND_HINTS[name]}`) : '';
1047
+ process.stderr.write(` ${c.brand(name.padEnd(14))} ${desc}${alias}\n`);
1048
+ }
1049
+ process.stderr.write('\n');
1050
+ return;
1051
+ }
1052
+
1053
+ const group = HELP_GROUP_ALIASES.get(key);
1054
+ if (!group) {
1055
+ process.stderr.write(` ${c.gray(`Unknown help category: ${key}. Use /help.`)}\n`);
1056
+ return;
1057
+ }
1058
+
1059
+ process.stderr.write(`\n ${c.bold(group.title)} ${c.dim(group.summary)}\n`);
1060
+ process.stderr.write(` ${c.gray('─'.repeat(52))}\n`);
1061
+ for (const [name, desc] of group.commands) {
1062
+ process.stderr.write(` ${c.brand(name.padEnd(30))} ${desc}\n`);
1063
+ }
1064
+ process.stderr.write('\n');
1065
+ }
1066
+
1067
+ function renderKeyboardHelp() {
1068
+ process.stderr.write(`\n ${c.bold('Keyboard')}\n`);
1069
+ process.stderr.write(` ${c.gray('Ctrl+C')} exit ${c.gray('↑↓')} history ${c.gray('Tab')} autocomplete\n`);
1070
+ process.stderr.write(` ${c.gray('d')} expand last tool ${c.gray('Space')} pause/resume ${c.gray('Esc')} interrupt\n\n`);
1071
+ }
1072
+
1073
+ function commandCompletions(line) {
1074
+ if (line.startsWith('/help ')) {
1075
+ const topic = line.slice('/help '.length).toLowerCase();
1076
+ const categories = ['all', ...HELP_GROUPS.map(g => g.key)];
1077
+ const hits = categories.map(c => `/help ${c}`).filter(cmd => cmd.startsWith(`/help ${topic}`));
1078
+ return hits.length ? hits : categories.map(c => `/help ${c}`);
1079
+ }
1080
+ const top = ['/help', '/status', '/plan', '/tasks', '/history', '/settings', '/why'];
1081
+ const namespaced = HELP_GROUPS.flatMap(g => g.commands.map(([name]) => name.split(/\s+/)[0]));
1082
+ const all = [...new Set([...top, ...namespaced, ...Object.keys(COMMANDS), '/quit'])].sort();
1083
+ const hits = all.filter(cmd => cmd.startsWith(line));
1084
+ return hits.length ? hits : all;
1085
+ }
1086
+
176
1087
  // ── Banner ──
177
1088
 
178
1089
  function printBanner(auth) {
@@ -214,15 +1125,8 @@ function buildContextStrip() {
214
1125
  const totalTokens = session.inputTokens + session.outputTokens;
215
1126
  const elapsed = formatElapsed(session.startTime);
216
1127
 
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
1128
  const right = [
224
1129
  c.dim(`${formatTokens(totalTokens)} tok`),
225
- ...(session.isByok ? [] : [c.dim(formatCredits(usedCr))]),
226
1130
  c.dim(elapsed),
227
1131
  ].join(c.dim(' · '));
228
1132
 
@@ -269,12 +1173,21 @@ function printTurnSummary(toolCount, durationS, turnCost) {
269
1173
  const parts = [];
270
1174
  if (toolCount > 0) parts.push(`${toolCount} tools`);
271
1175
  if (durationS) parts.push(`${Number(durationS).toFixed(1)}s`);
272
- if (turnCost > 0 && !session.isByok) parts.push(formatCredits(costToCredits(turnCost)));
273
1176
  if (parts.length > 0) {
274
1177
  process.stderr.write(`\n ${c.green('✓')} ${c.dim(parts.join(' · '))}\n`);
275
1178
  }
276
1179
  }
277
1180
 
1181
+ function formatMessageChip(rateLimit) {
1182
+ const remaining = messagesRemaining(rateLimit);
1183
+ if (remaining === Infinity) return 'unlimited messages';
1184
+ const limit = Number(rateLimit?.msgs_per_window);
1185
+ if (typeof remaining === 'number' && Number.isFinite(limit)) {
1186
+ return `${remaining}/${limit} messages`;
1187
+ }
1188
+ return 'messages';
1189
+ }
1190
+
278
1191
  function updateStatusBar() {
279
1192
  // No-op: status is printed inline via printPromptBlock before each prompt
280
1193
  }
@@ -355,6 +1268,7 @@ function renderToolResult(data, eventType = 'tool_result') {
355
1268
 
356
1269
  const tool = data.tool || data._tool || '';
357
1270
  const durationMs = data?.duration_ms ?? (data?.duration_s != null ? data.duration_s * 1000 : null);
1271
+ recordReadActivity(tool, data.args || {});
358
1272
 
359
1273
  // Update the card buffer so /last and `d` can find it.
360
1274
  if (callId) recordCard({ id: callId, tool, args: data.args, result: data, durationMs });
@@ -379,7 +1293,7 @@ function renderToolResult(data, eventType = 'tool_result') {
379
1293
  // If the head for this call is still buffered (no interleaving content
380
1294
  // landed), and the combined line fits the terminal width, emit ONE line
381
1295
  // and skip the gutter entirely.
382
- if (_pendingHead && _pendingHead.callId === callId && !hasLint) {
1296
+ if (_pendingHead && _pendingHead.callId === callId && !hasLint && !_pendingHead.head.includes('\n')) {
383
1297
  const cols = process.stderr.columns || 120;
384
1298
  const combined = `${_pendingHead.head} ${outcome}`;
385
1299
  if (stripAnsi(combined).length <= cols) {
@@ -451,6 +1365,31 @@ function shortPath(p) {
451
1365
  return parts.length > 2 ? parts.slice(-2).join('/') : p;
452
1366
  }
453
1367
 
1368
+ function rememberReadFile(filePath) {
1369
+ const file = shortPath(String(filePath || '').trim());
1370
+ if (file && !session.filesRead.includes(file)) session.filesRead.push(file);
1371
+ }
1372
+
1373
+ function recordReadActivity(tool, args = {}) {
1374
+ const normalized = String(tool || '').toLowerCase();
1375
+ if (normalized === 'read_file' || normalized === 'read') {
1376
+ rememberReadFile(args.file_path || args.path);
1377
+ return;
1378
+ }
1379
+ if (normalized === 'read_files') {
1380
+ const files = args.file_paths || args.paths || args.files || [];
1381
+ for (const file of Array.isArray(files) ? files : []) {
1382
+ rememberReadFile(typeof file === 'string' ? file : file?.file_path || file?.path);
1383
+ }
1384
+ }
1385
+ }
1386
+
1387
+ function thinkingKind(text) {
1388
+ return /\b(read|reading|inspect|scan|search|open|trace|look(?:ing)?\s+at)\b/i.test(text)
1389
+ ? 'Reading'
1390
+ : 'Thinking';
1391
+ }
1392
+
454
1393
  // ── Live Spinner ──
455
1394
  // A real animated spinner that ticks on an interval, not just per-call.
456
1395
  // Shows what's happening right now — thinking, tool executing, etc.
@@ -553,7 +1492,7 @@ function renderEvent(event) {
553
1492
  if (text.length > 12 && text !== session._lastEmittedThinking) {
554
1493
  flushPendingHead();
555
1494
  stopSpinner();
556
- process.stderr.write(` ${c.italic(c.dim(text.slice(0, 200)))}\n`);
1495
+ process.stderr.write(` ${c.dim(thinkingKind(text) + ' · ')}${c.italic(c.dim(text.slice(0, 200)))}\n`);
557
1496
  session._lastEmittedThinking = text;
558
1497
  }
559
1498
  startSpinner(text.slice(0, 80));
@@ -612,8 +1551,17 @@ function renderEvent(event) {
612
1551
  }
613
1552
 
614
1553
  case 'approval_granted': {
615
- // approval.mjs _prompt already rendered the result line for human approvals.
616
- // Nothing extra needed here avoid duplicate output.
1554
+ // Human approvals are rendered by approval.mjs. Auto-read grants are
1555
+ // otherwise invisible, so show one dim confirmation before the tool card.
1556
+ const scope = data?.grant_scope || data?.scope || '';
1557
+ if (scope === 'auto_read') {
1558
+ const toolName = data?.tool || data?.tool_name || '';
1559
+ const args = data?.args || data?.input || {};
1560
+ const summary = toolDisplaySummary(toolName, args);
1561
+ const label = toolDisplayLabel(toolName);
1562
+ const subject = summary ? `${label} ${summary}` : label;
1563
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`${subject} · auto-approved read`)}\n`);
1564
+ }
617
1565
  break;
618
1566
  }
619
1567
 
@@ -790,6 +1738,7 @@ function renderEvent(event) {
790
1738
  if (typeof bal.included === 'number') session.creditsIncluded = bal.included;
791
1739
  if (typeof bal.purchased === 'number') session.creditsPurchased = bal.purchased;
792
1740
  }
1741
+ if (data?.rate_limit) session.rateLimit = data.rate_limit;
793
1742
  break;
794
1743
  }
795
1744
 
@@ -848,12 +1797,24 @@ function renderEvent(event) {
848
1797
  }
849
1798
 
850
1799
  session.lastTurnDuration = data?.duration_s || 0;
1800
+ if (data?.rate_limit) session.rateLimit = data.rate_limit;
851
1801
 
852
1802
  // ── Server-authoritative credits ──
853
1803
  // Backend sends usage.credits_charged (this turn) + balance (remaining)
854
1804
  // in the complete event. CLI uses these instead of the local
855
1805
  // costToCredits estimate so /status and /cost match the dashboard.
856
1806
  if (!session.isByok) {
1807
+ const msgStatus = lowWindowStatus(session.rateLimit);
1808
+ if (!session.msgsLowWarned && msgStatus !== 'ok') {
1809
+ const windowLine = formatMessageWindow(session.rateLimit);
1810
+ if (msgStatus === 'exhausted') {
1811
+ process.stderr.write(`\n ${c.red('✗')} ${c.dim(`${windowLine}. Wait for the window to reset or upgrade at codekepler.ai/pricing.`)}\n`);
1812
+ } else {
1813
+ process.stderr.write(`\n ${c.yellow('⚠')} ${c.dim(`${windowLine}. Message window is running low.`)}\n`);
1814
+ }
1815
+ session.msgsLowWarned = true;
1816
+ }
1817
+
857
1818
  const charged = data?.usage?.credits_charged;
858
1819
  if (typeof charged === 'number') session.creditsCharged += charged;
859
1820
  const bal = data?.balance;
@@ -863,11 +1824,12 @@ function renderEvent(event) {
863
1824
  if (typeof bal.purchased === 'number') session.creditsPurchased = bal.purchased;
864
1825
  }
865
1826
  // 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).
1827
+ // tier's included limit (or below 10 absolute for tiny tiers). Credits
1828
+ // stay out of the always-on prompt strip; this warning is the exception.
867
1829
  if (!session.creditsLowWarned && typeof session.creditsTotal === 'number' && session.creditsLimit) {
868
1830
  const threshold = Math.max(10, Math.floor(session.creditsLimit * 0.2));
869
1831
  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`);
1832
+ 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
1833
  session.creditsLowWarned = true;
872
1834
  } else if (session.creditsTotal <= 0) {
873
1835
  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 +1854,10 @@ function renderEvent(event) {
892
1854
  task: session.lastTask,
893
1855
  success: successOverall,
894
1856
  filesChanged: session.filesChanged,
1857
+ filesRead: session.filesRead,
895
1858
  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),
1859
+ subAgents: { ...session.subAgentCounts, savedUsd: 0 },
1860
+ costUsd: null,
899
1861
  durationS: data?.duration_s,
900
1862
  testsPass: data?.tests_passed != null
901
1863
  ? { passed: data.tests_passed, total: data.tests_total || data.tests_passed }
@@ -904,6 +1866,7 @@ function renderEvent(event) {
904
1866
  nextActions: successOverall
905
1867
  ? ['/commit', '/pr', '/undo', '/report']
906
1868
  : ['/why', '/undo', '/re-plan'],
1869
+ cwd: safeCwd(),
907
1870
  });
908
1871
  process.stderr.write(report + '\n');
909
1872
  } else {
@@ -935,22 +1898,194 @@ function renderEvent(event) {
935
1898
 
936
1899
  // ── Slash Commands ──
937
1900
 
1901
+ function taskListLabel(list) {
1902
+ return {
1903
+ active: 'Active',
1904
+ backlog: 'Backlog',
1905
+ blocked: 'Blocked',
1906
+ done: 'Done',
1907
+ }[list] || list;
1908
+ }
1909
+
1910
+ function firstMeaningfulLines(content, limit = 6) {
1911
+ return String(content || '')
1912
+ .split(/\r?\n/)
1913
+ .map(line => line.trim())
1914
+ .filter(line => line && !line.startsWith('#'))
1915
+ .slice(0, limit);
1916
+ }
1917
+
1918
+ function renderTaskBoard(board, { showDone = false } = {}) {
1919
+ const order = showDone
1920
+ ? ['active', 'blocked', 'backlog', 'done']
1921
+ : ['active', 'blocked', 'backlog'];
1922
+ let any = false;
1923
+ for (const list of order) {
1924
+ const tasks = board.lists[list]?.tasks || [];
1925
+ if (!tasks.length) continue;
1926
+ any = true;
1927
+ process.stderr.write(`\n ${c.bold(taskListLabel(list))} ${c.dim(board.lists[list].fileName)}\n`);
1928
+ for (const task of tasks.slice(0, 12)) {
1929
+ const marker = task.checked ? c.green('[x]') : c.dim('[ ]');
1930
+ const section = task.section && task.section !== taskListLabel(list) ? c.dim(` · ${task.section}`) : '';
1931
+ process.stderr.write(` ${marker} ${task.text}${section}\n`);
1932
+ }
1933
+ if (tasks.length > 12) {
1934
+ process.stderr.write(` ${c.dim(`+${tasks.length - 12} more`)}\n`);
1935
+ }
1936
+ }
1937
+ if (!any) {
1938
+ process.stderr.write(` ${c.dim('No project tasks yet. Add one with /tasks add <text>.')}\n`);
1939
+ }
1940
+ }
1941
+
1942
+ function renderPlanOverview({ ctx, mode = 'overview' } = {}) {
1943
+ const cwd = safeCwd();
1944
+ ensureTaskFiles({ cwd });
1945
+ const board = loadTaskBoard({ cwd });
1946
+ const counts = taskCounts(board);
1947
+ const planLines = firstMeaningfulLines(board.plan.content, 8);
1948
+ const goalLines = firstMeaningfulLines(board.goal.content, 3);
1949
+ const effective = ctx.effectivePolicy || loadEffectivePolicy({ cwd });
1950
+ const owner = effective.policy?.planning?.owner || 'auto';
1951
+
1952
+ process.stderr.write(`\n ${c.bold('Plan')}\n`);
1953
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
1954
+ process.stderr.write(` ${c.dim('Owner')} ${c.brand(owner)}\n`);
1955
+ process.stderr.write(` ${c.dim('Tasks')} ${counts.active} active, ${counts.blocked} blocked, ${counts.backlog} backlog, ${counts.done} done\n`);
1956
+ if (mode === 'status') {
1957
+ process.stderr.write(` ${c.dim('Plan file')} ${board.plan.exists ? board.plan.path : c.dim('(none)')}\n`);
1958
+ process.stderr.write(` ${c.dim('Tasks dir')} ${board.dir}\n`);
1959
+ }
1960
+
1961
+ if (goalLines.length) {
1962
+ process.stderr.write(`\n ${c.bold('Goal')}\n`);
1963
+ for (const line of goalLines) process.stderr.write(` ${line}\n`);
1964
+ }
1965
+ if (planLines.length) {
1966
+ process.stderr.write(`\n ${c.bold('Current Plan')}\n`);
1967
+ for (const line of planLines) process.stderr.write(` ${line}\n`);
1968
+ }
1969
+
1970
+ renderTaskBoard(board, { showDone: mode === 'status' });
1971
+ process.stderr.write(`\n ${c.dim('Update: /tasks add <text> · /tasks active|blocked|done <text>')}\n\n`);
1972
+ }
1973
+
1974
+ function refreshTaskContext(ctx) {
1975
+ try {
1976
+ const previous = ctx.latestProjectContext || null;
1977
+ ctx.latestProjectContext = loadProjectContext({ cwd: safeCwd(), previous });
1978
+ ctx.latestEnvelope = null;
1979
+ } catch { /* best effort */ }
1980
+ }
1981
+
1982
+ function handleTasksCommand(rest, ctx) {
1983
+ const raw = String(rest || '').trim();
1984
+ ensureTaskFiles({ cwd: safeCwd() });
1985
+ if (!raw || raw === 'list') {
1986
+ const board = loadTaskBoard({ cwd: safeCwd() });
1987
+ process.stderr.write(`\n ${c.bold('Tasks')}\n`);
1988
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
1989
+ renderTaskBoard(board, { showDone: true });
1990
+ process.stderr.write(`\n ${c.dim('Update: /tasks add <text> · /tasks active|backlog|blocked|done <text>')}\n\n`);
1991
+ return;
1992
+ }
1993
+
1994
+ if (raw === 'help') {
1995
+ renderHelp('plan');
1996
+ return;
1997
+ }
1998
+
1999
+ const parts = raw.split(/\s+/);
2000
+ let verb = (parts.shift() || '').toLowerCase();
2001
+ let list = 'backlog';
2002
+ if (verb === 'add' || verb === 'new') {
2003
+ const maybeList = (parts[0] || '').toLowerCase();
2004
+ if (maybeList in TASK_FILES || ['todo', 'pending', 'current', 'doing', 'complete', 'completed'].includes(maybeList)) {
2005
+ list = parts.shift();
2006
+ }
2007
+ } else if (verb in TASK_FILES || ['todo', 'pending', 'current', 'doing', 'complete', 'completed'].includes(verb)) {
2008
+ list = verb;
2009
+ } else {
2010
+ parts.unshift(verb);
2011
+ verb = 'add';
2012
+ }
2013
+
2014
+ try {
2015
+ const result = appendTask({ cwd: safeCwd(), list, text: parts.join(' ') });
2016
+ refreshTaskContext(ctx);
2017
+ process.stderr.write(` ${c.green('✓')} ${c.dim(`added to ${result.list}`)} ${result.text}\n`);
2018
+ } catch (err) {
2019
+ process.stderr.write(` ${c.red(err.message || String(err))}\n`);
2020
+ }
2021
+ }
2022
+
938
2023
  async function handleCommand(input, ctx) {
939
- const parts = input.split(/\s+/);
940
- const cmd = parts[0].toLowerCase();
941
- const rest = parts.slice(1).join(' ');
2024
+ const { cmd, rest, aliasTarget } = normalizeCommandInput(input);
2025
+ if (aliasTarget) {
2026
+ process.stderr.write(` ${c.dim(`Legacy alias: use ${aliasTarget}`)}\n`);
2027
+ }
942
2028
 
943
2029
  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`);
2030
+ case '/help': {
2031
+ renderHelp(rest);
2032
+ return;
2033
+ }
2034
+
2035
+ case '/plan': {
2036
+ const mode = rest.trim().toLowerCase();
2037
+ if (mode === 'help') {
2038
+ renderHelp('plan');
2039
+ return;
2040
+ }
2041
+ if (mode === 'edit') {
2042
+ ensureTaskFiles({ cwd: safeCwd() });
2043
+ const board = loadTaskBoard({ cwd: safeCwd() });
2044
+ process.stderr.write(`\n ${c.bold('Editable Plan Files')}\n`);
2045
+ process.stderr.write(` ${c.dim('Plan')} ${board.plan.path}\n`);
2046
+ process.stderr.write(` ${c.dim('Active')} ${board.lists.active.path}\n`);
2047
+ process.stderr.write(` ${c.dim('Backlog')} ${board.lists.backlog.path}\n`);
2048
+ process.stderr.write(` ${c.dim('Blocked')} ${board.lists.blocked.path}\n`);
2049
+ process.stderr.write(` ${c.dim('Done')} ${board.lists.done.path}\n\n`);
2050
+ return;
2051
+ }
2052
+ renderPlanOverview({ ctx, mode: mode === 'status' ? 'status' : 'overview' });
2053
+ return;
2054
+ }
2055
+
2056
+ case '/tasks':
2057
+ handleTasksCommand(rest, ctx);
2058
+ return;
2059
+
2060
+ case '/history': {
2061
+ if (rest.trim() === 'fold') {
2062
+ process.stderr.write(` ${c.gray('Output is folded by default — there is nothing to hide. Use /history last or d to expand.')}\n`);
2063
+ return;
2064
+ }
2065
+ if (rest.trim() === 'help') {
2066
+ renderHelp('history');
2067
+ return;
949
2068
  }
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`);
2069
+ if (rest.trim() === 'approvals') {
2070
+ const entries = ctx.approval?.approvalLog?.readRecent?.(20) || [];
2071
+ if (!entries.length) {
2072
+ process.stderr.write(` ${c.gray('No approval log entries yet.')}\n`);
2073
+ return;
2074
+ }
2075
+ process.stderr.write(`\n ${c.bold('Approval History')}\n`);
2076
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
2077
+ for (const e of entries) {
2078
+ const when = e.ts ? String(e.ts).slice(0, 19).replace('T', ' ') : '';
2079
+ const decision = e.decision?.includes('reject') || e.decision?.includes('deny') ? c.red(e.decision) : c.green(e.decision);
2080
+ process.stderr.write(` ${c.dim(when)} ${decision} ${c.brand(e.tool || '?')} ${c.dim(e.scope || 'once')} ${c.dim(e.args || '')}\n`);
2081
+ }
2082
+ process.stderr.write('\n');
2083
+ return;
2084
+ }
2085
+ if (session.history.length === 0) { process.stderr.write(` ${c.gray('No conversation yet.')}\n`); return; }
2086
+ renderHistoryEntries(session.history, { limit: 20, maxChars: 120 });
953
2087
  return;
2088
+ }
954
2089
 
955
2090
  case '/login':
956
2091
  process.stderr.write(`${c.brand('Starting login flow...')}\n`);
@@ -977,6 +2112,46 @@ async function handleCommand(input, ctx) {
977
2112
  }
978
2113
 
979
2114
  case '/status': {
2115
+ if (rest.trim() === 'help') {
2116
+ renderHelp('status');
2117
+ return;
2118
+ }
2119
+ if (rest.trim() === 'context') {
2120
+ const current = ctx.latestProjectContext || loadProjectContext({ cwd: safeCwd() });
2121
+ const envelope = ctx.latestEnvelope || buildContextEnvelope({
2122
+ cwd: safeCwd(),
2123
+ effectivePolicy: ctx.effectivePolicy,
2124
+ projectContext: current,
2125
+ projectResources: ctx.toolExecutor?.getProjectResources?.() || [],
2126
+ agentContext: ctx.toolExecutor?.getAgentContext?.() || {},
2127
+ });
2128
+ process.stderr.write(`\n ${c.bold('Context')}\n`);
2129
+ process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
2130
+ const loaded = current.loaded || [];
2131
+ if (!loaded.length) {
2132
+ process.stderr.write(` ${c.dim('No .kepler context files loaded yet.')}\n`);
2133
+ } else {
2134
+ for (const file of loaded) {
2135
+ const changed = file.changed ? ` ${c.yellow('updated')}` : '';
2136
+ process.stderr.write(` ${c.brand(file.label.padEnd(18))} ${c.dim(file.hash)} ${c.dim(file.path)}${changed}\n`);
2137
+ }
2138
+ }
2139
+ process.stderr.write(`\n ${c.bold('Command Context')}\n`);
2140
+ process.stderr.write(` ${c.dim('Active')} ${envelope.command_context.active_command || c.dim('(none)')}\n`);
2141
+ process.stderr.write(` ${c.dim('Source')} ${envelope.command_context.source}\n`);
2142
+ 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`);
2143
+ process.stderr.write(` ${c.dim('Plan owner')} ${envelope.effective_options.plan_owner}\n`);
2144
+ process.stderr.write(` ${c.dim('HITL scope')} ${envelope.effective_options.hitl_default_scope} ${c.dim(`(reask ${envelope.effective_options.reask_after_minutes}m)`)}\n`);
2145
+ if (envelope.available_skills.length) {
2146
+ process.stderr.write(`\n ${c.bold('Skills')}\n`);
2147
+ for (const skill of envelope.available_skills.slice(0, 12)) {
2148
+ process.stderr.write(` ${c.brand(skill.name)} ${c.dim(skill.scope)} ${c.dim(skill.description || '')}\n`);
2149
+ }
2150
+ }
2151
+ process.stderr.write('\n');
2152
+ return;
2153
+ }
2154
+
980
2155
  const creds = ctx.auth.loadCredentials();
981
2156
  const env = process.env.TARANG_ENV || 'production';
982
2157
  const os = await import('node:os');
@@ -1003,6 +2178,10 @@ async function handleCommand(input, ctx) {
1003
2178
  if (session.subscriptionTier) {
1004
2179
  process.stderr.write(` ${c.dim('Plan')} ${c.brand(session.subscriptionTier.toUpperCase())}\n`);
1005
2180
  }
2181
+ const messageWindow = formatMessageWindow(session.rateLimit);
2182
+ if (messageWindow) {
2183
+ process.stderr.write(` ${c.dim('Messages')} ${messageWindow}\n`);
2184
+ }
1006
2185
  if (typeof session.creditsTotal === 'number') {
1007
2186
  const limit = session.creditsLimit ? ` ${c.dim('/ ' + formatCredits(session.creditsLimit))}` : '';
1008
2187
  const used = session.creditsCharged ? ` ${c.dim(`(${formatCredits(session.creditsCharged)} used this session)`)}` : '';
@@ -1023,6 +2202,9 @@ async function handleCommand(input, ctx) {
1023
2202
  if (approvalSummary.autoApprovedTypes.length > 0) {
1024
2203
  process.stderr.write(` ${c.dim('Auto-types')} ${approvalSummary.autoApprovedTypes.join(', ')}\n`);
1025
2204
  }
2205
+ if (approvalSummary.trust) {
2206
+ process.stderr.write(` ${c.dim('Trust')} ${approvalSummary.trust.sessionRules} session, ${approvalSummary.trust.projectRules} project rules\n`);
2207
+ }
1026
2208
  process.stderr.write(` ${c.dim('Blocked')} ${session.blockedOps} by safety guardrails\n`);
1027
2209
 
1028
2210
  // Orchestration
@@ -1062,6 +2244,34 @@ async function handleCommand(input, ctx) {
1062
2244
  return;
1063
2245
  }
1064
2246
 
2247
+ case '/settings': {
2248
+ const sub = rest.trim() || 'policy';
2249
+ if (sub === 'help') {
2250
+ renderHelp('settings');
2251
+ return;
2252
+ }
2253
+ if (sub !== 'policy') {
2254
+ process.stderr.write(` ${c.gray('Usage: /settings policy or /help settings')}\n`);
2255
+ return;
2256
+ }
2257
+ const effective = loadEffectivePolicy({ cwd: safeCwd() });
2258
+ ctx.effectivePolicy = effective;
2259
+ const rows = formatPolicySourceRows(effective);
2260
+ process.stderr.write(`\n ${c.bold('Effective Policy')}\n`);
2261
+ process.stderr.write(` ${c.dim('─'.repeat(86))}\n`);
2262
+ for (const row of rows.slice(0, 80)) {
2263
+ const value = typeof row.value === 'string' ? row.value : JSON.stringify(row.value);
2264
+ process.stderr.write(` ${c.brand(row.key.padEnd(38))} ${c.dim(row.source.padEnd(8))} ${String(value).slice(0, 34)}\n`);
2265
+ }
2266
+ if (rows.length > 80) process.stderr.write(` ${c.dim(`...and ${rows.length - 80} more`)}\n`);
2267
+ const projectLayer = effective.layers.find(l => l.name === 'project');
2268
+ if (projectLayer?.error) {
2269
+ process.stderr.write(`\n ${c.yellow('Project config error:')} ${projectLayer.error}\n`);
2270
+ }
2271
+ process.stderr.write('\n');
2272
+ return;
2273
+ }
2274
+
1065
2275
  case '/stats': {
1066
2276
  const os = await import('node:os');
1067
2277
  const mem = process.memoryUsage();
@@ -1103,6 +2313,10 @@ async function handleCommand(input, ctx) {
1103
2313
  const limit = session.creditsLimit ? ` / ${formatCredits(session.creditsLimit)}` : '';
1104
2314
  process.stderr.write(` ${c.dim('Plan')} ${c.brand(session.subscriptionTier.toUpperCase())} ${c.dim('· remaining')} ${c.brand(remaining)}${c.dim(limit)}\n`);
1105
2315
  }
2316
+ const messageWindow = formatMessageWindow(session.rateLimit);
2317
+ if (messageWindow) {
2318
+ process.stderr.write(` ${c.dim('Messages')} ${messageWindow}\n`);
2319
+ }
1106
2320
  process.stderr.write(` ${c.dim('─'.repeat(70))}\n`);
1107
2321
 
1108
2322
  if (session.costBreakdown.length > 0) {
@@ -1135,21 +2349,10 @@ async function handleCommand(input, ctx) {
1135
2349
  `${''.padStart(10)}` +
1136
2350
  `${formatCredits(costToCredits(session.totalCost)).padStart(10)}\n`
1137
2351
  );
1138
- process.stderr.write(` ${c.dim(`Turns: ${session.turns} Duration: ${formatElapsed(session.startTime)} Provider: ${formatCostValue(session.totalCost)}`)}\n\n`);
2352
+ process.stderr.write(` ${c.dim(`Turns: ${session.turns} Duration: ${formatElapsed(session.startTime)}`)}\n\n`);
1139
2353
  return;
1140
2354
  }
1141
2355
 
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
2356
  case '/last':
1154
2357
  expandLast();
1155
2358
  return;
@@ -1207,7 +2410,7 @@ async function handleCommand(input, ctx) {
1207
2410
  }
1208
2411
 
1209
2412
  case '/report': {
1210
- if (Object.keys(session.toolCounts).length === 0 && session.filesChanged.length === 0) {
2413
+ if (Object.keys(session.toolCounts).length === 0 && session.filesChanged.length === 0 && session.filesRead.length === 0) {
1211
2414
  process.stderr.write(` ${c.gray('Nothing to report yet — run a task first.')}\n`);
1212
2415
  return;
1213
2416
  }
@@ -1215,11 +2418,13 @@ async function handleCommand(input, ctx) {
1215
2418
  task: session.lastTask,
1216
2419
  success: true,
1217
2420
  filesChanged: session.filesChanged,
2421
+ filesRead: session.filesRead,
1218
2422
  toolCounts: session.toolCounts,
1219
2423
  subAgents: { ...session.subAgentCounts, savedUsd: session.isByok ? 0 : session.savedUsd },
1220
2424
  costUsd: session.isByok ? null : session.totalCost,
1221
2425
  durationS: (Date.now() - session.startTime) / 1000,
1222
2426
  nextActions: ['/commit', '/pr', '/undo'],
2427
+ cwd: safeCwd(),
1223
2428
  };
1224
2429
  const out = saveReport(state, { cwd: safeCwd() });
1225
2430
  process.stderr.write(` ${c.green('✓')} ${c.dim('Saved')} ${out}\n`);
@@ -1259,7 +2464,12 @@ async function handleCommand(input, ctx) {
1259
2464
 
1260
2465
  case '/budget': {
1261
2466
  const arg = rest.trim();
1262
- if (!arg || arg === 'clear' || arg === 'off') {
2467
+ if (!arg) {
2468
+ const current = session.budgetUsd ? `$${session.budgetUsd.toFixed(2)}` : 'not set';
2469
+ process.stderr.write(` ${c.dim('Budget cap:')} ${c.brand(current)} ${c.dim('· set with /status budget <amount> or clear with /status budget clear')}\n`);
2470
+ return;
2471
+ }
2472
+ if (arg === 'clear' || arg === 'off') {
1263
2473
  session.budgetUsd = null;
1264
2474
  session.budgetExceeded = false;
1265
2475
  process.stderr.write(` ${c.gray('Budget cap cleared.')}\n`);
@@ -1288,15 +2498,16 @@ async function handleCommand(input, ctx) {
1288
2498
  }
1289
2499
 
1290
2500
  case '/compact': {
1291
- const before = session.history.length;
2501
+ const before = session.agentHistory.length || session.history.length;
1292
2502
  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`);
2503
+ session.agentHistory.splice(2, session.agentHistory.length - 6);
2504
+ process.stderr.write(` ${c.gray(`Compacted agent context: ${before} → ${session.agentHistory.length} messages`)}\n`);
1295
2505
  return;
1296
2506
  }
1297
2507
 
1298
2508
  case '/clear':
1299
2509
  session.history.length = 0;
2510
+ session.agentHistory.length = 0;
1300
2511
  session.toolCalls = 0;
1301
2512
  process.stderr.write(` ${c.gray('Conversation cleared.')}\n`);
1302
2513
  return;
@@ -1348,7 +2559,7 @@ async function handleCommand(input, ctx) {
1348
2559
  }
1349
2560
 
1350
2561
  case '/sessions': {
1351
- const resumable = ctx.sessionMgr.listResumable(10);
2562
+ const resumable = await listResumableSessions();
1352
2563
  if (resumable.length === 0) {
1353
2564
  process.stderr.write(` ${c.gray('No resumable sessions found.')}\n`);
1354
2565
  return;
@@ -1356,89 +2567,168 @@ async function handleCommand(input, ctx) {
1356
2567
  process.stderr.write(`\n ${c.bold('Resumable Sessions')}\n`);
1357
2568
  process.stderr.write(` ${c.dim('─'.repeat(60))}\n`);
1358
2569
  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`);
2570
+ const date = sessionListTimestamp(s);
2571
+ const instr = oneLineInstruction(s.instruction, 72);
2572
+ const project = s.project || path.basename(s.projectPath || '') || '(unknown)';
2573
+ process.stderr.write(` ${c.brand(s.sessionId)} ${c.brand(project)} ${c.dim(date)} ${messageCountLabel(s.messageCount)} ${c.dim(instr)}\n`);
1362
2574
  }
1363
2575
  process.stderr.write(`\n ${c.dim('Resume with:')} kepler --resume <sessionId>\n`);
1364
2576
  return;
1365
2577
  }
1366
2578
 
1367
2579
  case '/resume': {
1368
- const parts = input.split(/\s+/);
1369
- const targetId = parts[1]; // /resume <sessionId>
2580
+ // PRD-068 §5.14: one-prompt picker, context-length driven auto-decision,
2581
+ // direct-resume mode flags (--full, --tail10, --tail20, --summary).
2582
+ const parts = input.split(/\s+/).filter(Boolean);
2583
+ const forcedFlag = parts.find(p => /^(--full|--tail10|--tail20|--recap|--summary|-f|-1|-2|-r|-s)$/.test(p));
2584
+ const forcedMode = forcedFlag
2585
+ ? ({ '--full': 'full', '-f': 'full',
2586
+ '--tail10': 'tail-10', '-1': 'tail-10',
2587
+ '--tail20': 'tail-20', '-2': 'tail-20',
2588
+ '--recap': 'tail-20', '-r': 'tail-20',
2589
+ '--summary': 'summary', '-s': 'summary' })[forcedFlag]
2590
+ : null;
2591
+ const targetId = parts.slice(1).find(p => !p.startsWith('-'));
2592
+
2593
+ const resumable = await listResumableSessions();
2594
+ if (resumable.length === 0) {
2595
+ process.stderr.write(` ${c.gray('No resumable sessions found.')}\n`);
2596
+ return;
2597
+ }
1370
2598
 
2599
+ // 1. Resolve which session to resume.
2600
+ let picked = null;
1371
2601
  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`);
2602
+ picked = resumable.find(s => s.sessionId === targetId || s.sessionId?.startsWith(targetId));
2603
+ if (!picked) {
2604
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(`No session found matching id: ${targetId}`)}\n`);
1376
2605
  return;
1377
2606
  }
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;
2607
+ } else {
2608
+ while (!picked) {
2609
+ const pickResult = await pickResumableSession(resumable, ctx);
2610
+ if (!pickResult) { process.stderr.write(`\n ${c.dim('Cancelled.')}\n`); return; }
2611
+ if (pickResult.action === 'resume') {
2612
+ if (!pickResult.session) {
2613
+ process.stderr.write(`\n ${c.yellow('!')} ${c.dim('Empty session — pick another.')}\n`);
2614
+ continue;
2615
+ }
2616
+ picked = pickResult.session;
2617
+ break;
2618
+ }
2619
+ if (pickResult.action === 'preview') {
2620
+ // Yield to the event loop so any queued keystrokes from the picker
2621
+ // don't spill into the preview's stdin listener (PRD-068 §5.14 bugfix).
2622
+ await new Promise(r => setImmediate(r));
2623
+ const previewResult = await previewResumeSession(pickResult.session, ctx);
2624
+ if (previewResult && previewResult.action === 'resume') {
2625
+ picked = pickResult.session;
2626
+ // Preview already committed to a mode — skip the threshold overlay.
2627
+ picked._presetMode = previewResult.mode;
2628
+ break;
2629
+ }
2630
+ if (previewResult === null) {
2631
+ // getSessionDetail failed — file missing, unreadable, or huge.
2632
+ // Tell the user why before looping back to the picker.
2633
+ process.stderr.write(` ${c.yellow('!')} ${c.dim('Could not load transcript for preview — pick another session or press Enter to resume without preview.')}\n`);
2634
+ }
2635
+ // Yield again so the loop-back render doesn't collide with the
2636
+ // just-closed preview's stdin cleanup.
2637
+ await new Promise(r => setImmediate(r));
2638
+ }
2639
+ }
1383
2640
  }
1384
2641
 
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;
2642
+ // 2. Decide the mode. Force-flag > preview-preset > auto-decide > overlay.
2643
+ let mode = forcedMode || picked._presetMode || null;
2644
+ if (!mode) {
2645
+ const currentModel = session?.model || session?.user?.default_reasoning_model || null;
2646
+ const decision = decideResumeMode({
2647
+ transcriptTokens: picked.contextTokens,
2648
+ model: currentModel,
2649
+ settings: ctx.effectivePolicy?.policy?.resume ? { resume: ctx.effectivePolicy.policy.resume } : {},
2650
+ });
2651
+ decision.resumeSummary = picked.resumeSummary || null;
2652
+ if (decision.mode === 'full') {
2653
+ mode = 'full';
2654
+ } else {
2655
+ // Yield before attaching the overlay listener — same race guard as
2656
+ // the preview branch above. Prevents a queued Enter from the picker
2657
+ // slipping into the overlay's stdin, which otherwise looked like a
2658
+ // duplicate picker render.
2659
+ await new Promise(r => setImmediate(r));
2660
+ const chosen = await chooseThresholdMode(ctx, decision);
2661
+ if (!chosen) { process.stderr.write(`\n ${c.dim('Cancelled.')}\n`); return; }
2662
+ mode = chosen;
2663
+ }
1390
2664
  }
1391
2665
 
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);
2666
+ // 3. Activate.
2667
+ const source = targetId ? 'direct' : 'picker';
2668
+ const progress = startResumeProgress(mode);
2669
+ let resumed;
2670
+ try {
2671
+ resumed = await ctx.activateResumedSession(picked.sessionId, source, mode, picked, {
2672
+ onProgress: progress.update,
2673
+ onProgressStop: progress.stop,
1419
2674
  });
1420
- });
1421
-
1422
- if (!choice || choice < 1 || choice > resumable.length) {
1423
- process.stderr.write(`\n ${c.dim('Cancelled.')}\n`);
2675
+ } finally {
2676
+ progress.stop();
2677
+ }
2678
+ if (!resumed.ok) {
2679
+ if (resumed.reason === 'cwd-cancelled') {
2680
+ process.stderr.write(`\n ${c.dim('Cancelled.')}\n`);
2681
+ } else {
2682
+ process.stderr.write(`\n ${c.yellow('!')} ${c.dim(resumed.reason || 'No messages in that session.')}\n`);
2683
+ }
1424
2684
  return;
1425
2685
  }
1426
2686
 
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;
2687
+ // 4. Report succinct honest single line, then optional hydration warning.
2688
+ const toolSummary = resumed.stats && resumed.stats.toolCalls
2689
+ ? `${resumed.stats.toolCalls} tool calls`
2690
+ : `${resumed.messages} msgs`;
2691
+ const summaryLabel = mode !== 'full' && resumed.summarySource
2692
+ ? ` · summary: ${resumed.summarySource}`
2693
+ : '';
2694
+ 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`);
2695
+ if (resumed.summaryWarning) {
2696
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`backend summary unavailable — using local summary (${resumed.summaryWarning})`)}\n`);
2697
+ }
2698
+ if (resumed.hydrationFailures?.length) {
2699
+ for (const failure of resumed.hydrationFailures) {
2700
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`could not re-read project root: ${failure}`)}\n`);
2701
+ }
2702
+ }
2703
+ if (resumed.stayedInCwd) {
2704
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`resumed transcript from ${resumed.savedProjectPath} — running against ${safeCwd()}`)}\n`);
1432
2705
  }
1433
2706
 
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))}`);
2707
+ // 5. Show continuity context. Non-summary modes use the captured
2708
+ // kepler_event stream when available so the terminal replay matches
2709
+ // the original styled interaction; older sessions fall back to
2710
+ // reconstructed text.
2711
+ if (mode === 'summary' && resumed.summary) {
2712
+ // In summary mode the agent gets only the summary block. Show it so
2713
+ // the user knows what continuity context was included.
2714
+ process.stderr.write(`\n ${c.bold('Continuity Summary')}\n`);
2715
+ process.stderr.write(` ${c.gray('─'.repeat(80))}\n`);
2716
+ for (const line of resumed.summary.split('\n')) {
2717
+ process.stderr.write(` ${c.dim(line)}\n`);
2718
+ }
2719
+ process.stderr.write('\n');
2720
+ } else if (resumed.replayEvents?.length) {
2721
+ renderResumePreview(resumed);
2722
+ } else if (resumed.history?.length) {
2723
+ // Full/tail modes feed real conversation to the agent — show
2724
+ // the tail so the user has visual context. Cap at 30 entries to avoid
2725
+ // flooding the terminal on long sessions.
2726
+ renderHistoryEntries(resumed.history, {
2727
+ limit: 30,
2728
+ maxChars: 200,
2729
+ title: mode?.startsWith('tail-') ? `Recent turns (${mode.replace('tail-', 'last ')})` : 'Conversation history (last 30 entries)',
2730
+ });
1440
2731
  }
1441
- process.stderr.write('\n');
1442
2732
  return;
1443
2733
  }
1444
2734
 
@@ -1512,22 +2802,239 @@ export async function startTerminalRepl() {
1512
2802
 
1513
2803
  // Projects are registered and indexed on demand through get_project_overview.
1514
2804
  // CheckpointManager records per-file snapshots before edits so /undo works.
1515
- const checkpoints = new CheckpointManager(safeCwd());
1516
- const toolExecutor = createToolExecutor({ checkpoints });
2805
+ let checkpoints = new CheckpointManager(safeCwd());
2806
+ let effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
2807
+ let latestProjectContext = null;
2808
+ let latestEnvelope = null;
2809
+ let hookRunner = new HookRunner({ cwd: safeCwd() });
2810
+ let toolExecutor = createToolExecutor({ checkpoints, hookRunner });
1517
2811
  const skipPerms = cliArgs.freeswim;
1518
- const approval = new ApprovalManager({ autoApprove: skipPerms });
2812
+ let approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
1519
2813
 
1520
2814
  // Session manager — persists conversation messages to .kepler/conversations/
1521
- const sessionMgr = new SessionManager(safeCwd());
2815
+ let sessionMgr = new SessionManager(safeCwd());
1522
2816
  _sessionMgr = sessionMgr; // expose to renderEvent
1523
2817
 
1524
2818
  // Local JSONL writer — writes cc-lens compatible session data to ~/.kepler/
1525
- const jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
2819
+ let jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
1526
2820
 
1527
2821
  // Persistent stream client — session_id captured from backend on first turn
1528
2822
  let streamClient = null;
1529
2823
 
1530
- const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints };
2824
+ const ctx = { auth, toolExecutor, approval, jsonlWriter, sessionMgr, checkpoints, effectivePolicy, latestProjectContext, latestEnvelope };
2825
+
2826
+ /**
2827
+ * Activate a previously-recorded session for continuation.
2828
+ *
2829
+ * Contract (PRD-068 §5.14 and follow-up clarification):
2830
+ * 1. Keep the same sessionId. The resumed session IS the same session,
2831
+ * not a fork. Future turns are appended to the SAME .jsonl file that
2832
+ * was read here.
2833
+ * 2. Do not re-write the loaded transcript back to disk. The file already
2834
+ * contains every historical entry; the load path is read-only. Any
2835
+ * duplication would double-count tokens on the next resume.
2836
+ * 3. Fresh sessions (kepler started without /resume) get a fresh UUID
2837
+ * the first time jsonlWriter.writeUserTurn() runs — that path is
2838
+ * untouched by resume, so brand-new sessions never inherit an old id.
2839
+ * 4. In-memory history (session.history / session.agentHistory) mirrors
2840
+ * what the agent will see next turn; it is NOT written back to the
2841
+ * transcript at activation time.
2842
+ */
2843
+ async function activateResumedSession(sessionId, source = 'resume', historyMode = 'full', resumeEntry = null, options = {}) {
2844
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
2845
+ const onProgressStop = typeof options.onProgressStop === 'function' ? options.onProgressStop : () => {};
2846
+ // PRD-068 §5.14.6: JSONL is the only source. No legacy conversation fallback.
2847
+ onProgress('reading saved transcript', 14);
2848
+ const detail = await getSessionDetail(sessionId, { filePath: resumeEntry?.transcriptPath });
2849
+ if (!detail) {
2850
+ return { ok: false, reason: `No transcript found for session ${sessionId}` };
2851
+ }
2852
+ onProgress('building resume context', 28);
2853
+ const richHistory = buildResumeHistory({ ...detail, recapTailTurns: 8 }, historyMode);
2854
+ const displayHistory = richHistory.displayHistory;
2855
+ if (!displayHistory.length) {
2856
+ return { ok: false, reason: `Session ${sessionId} has no readable messages` };
2857
+ }
2858
+
2859
+ // PRD-068 §5.14.7: explicit cwd confirmation if saved path differs.
2860
+ const savedProjectPath = detail?.meta?.project || '';
2861
+ let summarySource = 'local';
2862
+ let summaryWarning = '';
2863
+ if (historyMode !== 'full' && richHistory.sourceMessages?.length) {
2864
+ onProgress('summarizing transcript', 34);
2865
+ const backendSummary = await summarizeResumeTranscript({
2866
+ auth,
2867
+ toolExecutor,
2868
+ sessionId,
2869
+ projectPath: savedProjectPath || safeCwd(),
2870
+ messages: richHistory.sourceMessages,
2871
+ });
2872
+ if (backendSummary?.summary) {
2873
+ richHistory.summary = combineResumeSummaries(richHistory.priorSummary, backendSummary.summary);
2874
+ const summaryIndex = Number.isInteger(richHistory.summaryMessageIndex)
2875
+ ? richHistory.summaryMessageIndex
2876
+ : 0;
2877
+ if (richHistory.agentHistory?.[summaryIndex]) {
2878
+ const tailTurns = resumeTailTurnCount(historyMode);
2879
+ const prefix = tailTurns
2880
+ ? `Summary of earlier turns before the retained last ${tailTurns} conversation messages:\n`
2881
+ : 'Session continuity summary:\n';
2882
+ richHistory.agentHistory[summaryIndex] = {
2883
+ ...richHistory.agentHistory[summaryIndex],
2884
+ content: `${prefix}${richHistory.summary}`,
2885
+ };
2886
+ }
2887
+ summarySource = backendSummary.source || 'backend';
2888
+ } else {
2889
+ summarySource = 'local fallback';
2890
+ summaryWarning = backendSummary?.reason || 'backend summary unavailable';
2891
+ }
2892
+ } else if (historyMode !== 'full') {
2893
+ summarySource = 'not needed';
2894
+ summaryWarning = resumeTailTurnCount(historyMode)
2895
+ ? 'retained tail covers the whole transcript'
2896
+ : 'empty transcript';
2897
+ }
2898
+ const agentHistory = richHistory.agentHistory;
2899
+ const originalCwd = safeCwd();
2900
+ let switchedProject = false;
2901
+ let projectMissing = false;
2902
+ let stayedInCwd = false;
2903
+ onProgress('checking project cwd', 40);
2904
+ if (savedProjectPath && savedProjectPath !== originalCwd) {
2905
+ if (fs.existsSync(savedProjectPath)) {
2906
+ onProgressStop();
2907
+ const choice = await confirmCwdSwitch(ctx, savedProjectPath, originalCwd);
2908
+ if (choice === 'cancel') return { ok: false, reason: 'cwd-cancelled' };
2909
+ if (choice === 'switch') {
2910
+ try {
2911
+ process.chdir(savedProjectPath);
2912
+ _cachedCwd = process.cwd();
2913
+ switchedProject = true;
2914
+ } catch {
2915
+ projectMissing = true;
2916
+ }
2917
+ } else if (choice === 'stay') {
2918
+ stayedInCwd = true;
2919
+ }
2920
+ } else {
2921
+ projectMissing = true;
2922
+ process.stderr.write(` ${c.yellow('⚠')} ${c.dim(`saved project path unavailable: ${savedProjectPath} — using current cwd`)}\n`);
2923
+ }
2924
+ }
2925
+
2926
+ onProgress('rebuilding local session state', 55);
2927
+ checkpoints = new CheckpointManager(safeCwd());
2928
+ effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
2929
+ hookRunner = new HookRunner({ cwd: safeCwd(), sessionId });
2930
+ toolExecutor = createToolExecutor({ checkpoints, hookRunner });
2931
+ approval = new ApprovalManager({ autoApprove: skipPerms, cwd: safeCwd(), policy: effectivePolicy.policy });
2932
+ if (ctx._rl) approval.setReadline(ctx._rl);
2933
+ sessionMgr = new SessionManager(safeCwd());
2934
+ sessionMgr.activateSession(sessionId, {
2935
+ instruction: detail?.meta?.firstPrompt || '',
2936
+ started_at: detail?.meta?.startTime || new Date().toISOString(),
2937
+ }, displayHistory.filter(m => m.role === 'user' || m.role === 'assistant'));
2938
+ _sessionMgr = sessionMgr;
2939
+
2940
+ try { await jsonlWriter.close(); } catch {}
2941
+ jsonlWriter = new JsonlWriter(safeCwd(), VERSION);
2942
+ jsonlWriter.setSessionId(sessionId);
2943
+ if (
2944
+ historyMode !== 'full'
2945
+ && richHistory.summary
2946
+ && Number(richHistory.summaryCoveredMessageCount) > Number(richHistory.summaryCheckpointMessageCount || 0)
2947
+ ) {
2948
+ jsonlWriter.writeKeplerEvent({
2949
+ type: 'resume_summary',
2950
+ data: {
2951
+ session_id: sessionId,
2952
+ mode: historyMode,
2953
+ mode_label: resumeModeLabel(historyMode),
2954
+ summary: richHistory.summary,
2955
+ summary_source: summarySource,
2956
+ summary_warning: summaryWarning || null,
2957
+ source_message_count: richHistory.summaryCoveredMessageCount,
2958
+ previous_source_message_count: richHistory.summaryCheckpointMessageCount || 0,
2959
+ full_message_count: richHistory.fullMessageCount || 0,
2960
+ },
2961
+ });
2962
+ }
2963
+ jsonlWriter.writeKeplerEvent({
2964
+ type: 'resume_context',
2965
+ data: {
2966
+ session_id: sessionId,
2967
+ source,
2968
+ mode: historyMode,
2969
+ mode_label: resumeModeLabel(historyMode),
2970
+ messages: displayHistory.length,
2971
+ summary_source: summarySource,
2972
+ summary_injected: historyMode !== 'full' && Boolean(richHistory.agentHistory?.[richHistory.summaryMessageIndex ?? 0]?.content),
2973
+ summary_warning: summaryWarning || null,
2974
+ summary_source_message_count: richHistory.summaryCoveredMessageCount || 0,
2975
+ previous_summary_source_message_count: richHistory.summaryCheckpointMessageCount || 0,
2976
+ project_path: savedProjectPath || safeCwd(),
2977
+ },
2978
+ });
2979
+
2980
+ streamClient = null;
2981
+ latestProjectContext = loadProjectContext({ cwd: safeCwd() });
2982
+ latestEnvelope = null;
2983
+
2984
+ // PRD-068 §5.14.8: report hydration failures instead of swallowing them.
2985
+ const hydrationFailures = [];
2986
+ const resumeRoots = getTranscriptProjectRoots(detail);
2987
+ const rootsToRegister = [...new Set([safeCwd(), ...resumeRoots].filter(Boolean))];
2988
+ onProgress('hydrating project roots', 68);
2989
+ for (let i = 0; i < rootsToRegister.length; i++) {
2990
+ const root = rootsToRegister[i];
2991
+ onProgress(`hydrating project root ${i + 1}/${rootsToRegister.length}`, 68 + Math.round((i / Math.max(1, rootsToRegister.length)) * 18));
2992
+ try {
2993
+ await toolExecutor.execute('get_project_overview', { path: root });
2994
+ } catch {
2995
+ hydrationFailures.push(root);
2996
+ }
2997
+ }
2998
+
2999
+ onProgress('preparing replay', 92);
3000
+ session.history = displayHistory;
3001
+ session.agentHistory = agentHistory;
3002
+ session.id = sessionId;
3003
+ session.turns = displayHistory.filter(m => m.role === 'user').length;
3004
+ session.lastTask = detail?.meta?.firstPrompt || session.history.find(m => m.role === 'user')?.content || '';
3005
+
3006
+ Object.assign(ctx, {
3007
+ toolExecutor,
3008
+ approval,
3009
+ jsonlWriter,
3010
+ sessionMgr,
3011
+ checkpoints,
3012
+ effectivePolicy,
3013
+ latestProjectContext,
3014
+ latestEnvelope,
3015
+ });
3016
+
3017
+ return {
3018
+ ok: true,
3019
+ messages: displayHistory.length,
3020
+ projectPath: savedProjectPath || safeCwd(),
3021
+ savedProjectPath,
3022
+ switchedProject,
3023
+ projectMissing,
3024
+ stayedInCwd,
3025
+ hydrationFailures,
3026
+ instruction: detail?.meta?.firstPrompt || '',
3027
+ historyMode,
3028
+ summary: richHistory.summary || '',
3029
+ summarySource,
3030
+ summaryWarning,
3031
+ history: displayHistory,
3032
+ replayEvents: detail.replayEvents || [],
3033
+ stats: richHistory.stats,
3034
+ source,
3035
+ };
3036
+ }
3037
+ ctx.activateResumedSession = activateResumedSession;
1531
3038
 
1532
3039
  // ── Print banner + preflight + init BEFORE mounting the status bar ──
1533
3040
  // The status bar shrinks the scroll region; if it mounts first, the
@@ -1558,18 +3065,18 @@ export async function startTerminalRepl() {
1558
3065
  : sessionMgr.getLastSession();
1559
3066
 
1560
3067
  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
- }
3068
+ const resumed = await activateResumedSession(lastSession.sessionId, 'startup');
3069
+ if (resumed.ok) {
3070
+ process.stderr.write(` ${c.green('↺')} ${c.dim(`Resumed session: ${messageCountLabel(resumed.messages)}`)}`);
3071
+ process.stderr.write(` ${c.dim('· project')} ${c.brand(path.basename(safeCwd()))}`);
3072
+ process.stderr.write(` ${c.dim( agent ${resumed.historyMode}`)}`);
3073
+ if (resumed.switchedProject) process.stderr.write(` ${c.dim('(cwd restored)')}`);
3074
+ if (resumed.projectMissing) process.stderr.write(` ${c.yellow('(saved project path unavailable; using current cwd)')}`);
3075
+ if (resumed.instruction) process.stderr.write(` ${c.dim('—')} ${c.dim(resumed.instruction.slice(0, 50))}`);
1570
3076
  process.stderr.write('\n');
3077
+ renderResumePreview(resumed);
1571
3078
  } else {
1572
- process.stderr.write(` ${c.yellow('!')} ${c.dim('No conversation found for session ' + lastSession.sessionId)}\n`);
3079
+ process.stderr.write(` ${c.yellow('!')} ${c.dim(resumed.reason || 'No conversation found for session ' + lastSession.sessionId)}\n`);
1573
3080
  }
1574
3081
  } else {
1575
3082
  process.stderr.write(` ${c.yellow('!')} ${c.dim('No previous session to resume')}\n`);
@@ -1615,8 +3122,7 @@ export async function startTerminalRepl() {
1615
3122
  prompt: userPrompt(),
1616
3123
  completer: (line) => {
1617
3124
  if (line.startsWith('/')) {
1618
- const hits = Object.keys(COMMANDS).filter(cmd => cmd.startsWith(line));
1619
- return [hits.length ? hits : Object.keys(COMMANDS), line];
3125
+ return [commandCompletions(line), line];
1620
3126
  }
1621
3127
  return [[], line];
1622
3128
  },
@@ -1637,7 +3143,44 @@ export async function startTerminalRepl() {
1637
3143
 
1638
3144
  showPrompt();
1639
3145
 
3146
+ // Guard against concurrent line handlers.
3147
+ //
3148
+ // rl.on('line', async ...) does NOT wait for the previous async handler to
3149
+ // complete before firing again. So a stray '\n' (from readline processing
3150
+ // '\r\n' as two events after an interactive picker resumes it, or from a
3151
+ // paste) can spawn a second handleCommand run while the first is still
3152
+ // awaiting inside /resume, /login, etc. Symptom: the picker or overlay
3153
+ // appears to render twice.
3154
+ //
3155
+ // Rules:
3156
+ // - Only one command runs at a time.
3157
+ // - Empty lines that arrive while a command is in flight are dropped
3158
+ // (they are almost always stray '\n's from raw-mode key handling).
3159
+ // - Non-empty lines are queued and replayed after the current command
3160
+ // finishes, so the user can queue up work while a long-running command
3161
+ // is still executing.
3162
+ let _lineInFlight = false;
3163
+ const _queuedLines = [];
1640
3164
  rl.on('line', async (line) => {
3165
+ if (_lineInFlight) {
3166
+ if (line && line.trim()) _queuedLines.push(line);
3167
+ return;
3168
+ }
3169
+ _lineInFlight = true;
3170
+ try {
3171
+ await _handleLine(line);
3172
+ } finally {
3173
+ _lineInFlight = false;
3174
+ if (_queuedLines.length) {
3175
+ const next = _queuedLines.shift();
3176
+ // Fire the next queued line asynchronously so we don't hold this
3177
+ // finally block open while the next command runs.
3178
+ setImmediate(() => rl.emit('line', next));
3179
+ }
3180
+ }
3181
+ });
3182
+
3183
+ async function _handleLine(line) {
1641
3184
  const input = line.trim();
1642
3185
  if (!input) { rl.prompt(); return; }
1643
3186
 
@@ -1660,16 +3203,20 @@ export async function startTerminalRepl() {
1660
3203
  }
1661
3204
 
1662
3205
  // Regular prompt
1663
- session.history.push({ role: 'user', content: input });
3206
+ const userMessage = { role: 'user', content: input };
3207
+ session.history.push(userMessage);
3208
+ session.agentHistory.push(userMessage);
1664
3209
  session.turns++;
1665
3210
  session.toolCalls = 0;
1666
3211
  session.lastTask = input;
1667
3212
  // Reset per-turn counts so the mission report reflects this turn only.
1668
3213
  session.toolCounts = {};
1669
3214
  session.subAgentCounts = {};
3215
+ session.filesRead = [];
1670
3216
  session.savedUsd = 0;
1671
3217
  session._lastEmittedThinking = '';
1672
3218
  session.creditsLowWarned = false;
3219
+ session.msgsLowWarned = false;
1673
3220
 
1674
3221
  // Tell the orbit a new turn started — switches to DISCOVERY and updates
1675
3222
  // task / turn counters in the status bar.
@@ -1679,11 +3226,14 @@ export async function startTerminalRepl() {
1679
3226
  if (session.turns === 1) {
1680
3227
  sessionMgr.start(input);
1681
3228
  }
1682
- sessionMgr.saveMessage('user', input);
1683
-
1684
- // Local JSONL: write user turn + history
1685
- jsonlWriter.writeUserTurn(input);
1686
- jsonlWriter.writeHistory(input);
3229
+ let userTurnWritten = false;
3230
+ const writeCurrentUserTurn = () => {
3231
+ if (userTurnWritten) return;
3232
+ jsonlWriter.writeUserTurn(input);
3233
+ jsonlWriter.writeHistory(input);
3234
+ userTurnWritten = true;
3235
+ };
3236
+ if (session.id) writeCurrentUserTurn();
1687
3237
 
1688
3238
  const creds = auth.loadCredentials();
1689
3239
  if (!creds.token) {
@@ -1705,6 +3255,9 @@ export async function startTerminalRepl() {
1705
3255
  });
1706
3256
  }
1707
3257
  const client = streamClient;
3258
+ if (session.id && !client.sessionId) {
3259
+ client.sessionId = session.id;
3260
+ }
1708
3261
 
1709
3262
  let assistantContent = '';
1710
3263
 
@@ -1800,10 +3353,48 @@ export async function startTerminalRepl() {
1800
3353
 
1801
3354
  const execContext = { cwd: safeCwd() };
1802
3355
  if (skipPerms) execContext.freeswim = true;
1803
- execContext.project_resources = toolExecutor.getProjectResources();
1804
- execContext.agent_context = toolExecutor.getAgentContext();
3356
+ effectivePolicy = loadEffectivePolicy({ cwd: safeCwd() });
3357
+ approval.policy = effectivePolicy.policy;
3358
+ if (approval.trustStore) approval.trustStore.policy = effectivePolicy.policy;
3359
+ hookRunner.reload();
3360
+ latestProjectContext = loadProjectContext({ cwd: safeCwd(), previous: latestProjectContext });
3361
+ const promptHook = await hookRunner.run('UserPromptSubmit', {
3362
+ input: { prompt: input },
3363
+ turnId: String(session.turns),
3364
+ });
3365
+ const hookHints = (promptHook.results || [])
3366
+ .map(r => r.parsed?.feedback)
3367
+ .filter(Boolean)
3368
+ .map(text => ({ source: 'hook', kind: 'feedback', text, ttl_turns: 1, priority: 'medium' }));
3369
+ const rejectionHints = (approval.consumeRejectionHints?.() || [])
3370
+ .map(h => ({
3371
+ source: 'hitl',
3372
+ kind: 'approval_rejection',
3373
+ 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.`,
3374
+ ttl_turns: 1,
3375
+ priority: 'high',
3376
+ }));
3377
+ latestEnvelope = buildContextEnvelope({
3378
+ cwd: safeCwd(),
3379
+ effectivePolicy,
3380
+ projectContext: latestProjectContext,
3381
+ activeHints: [...hookHints, ...rejectionHints],
3382
+ projectResources: toolExecutor.getProjectResources(),
3383
+ agentContext: toolExecutor.getAgentContext(),
3384
+ });
3385
+ ctx.effectivePolicy = effectivePolicy;
3386
+ ctx.latestProjectContext = latestProjectContext;
3387
+ ctx.latestEnvelope = latestEnvelope;
3388
+ Object.assign(execContext, latestEnvelope);
3389
+ if (skipPerms) execContext.freeswim = true;
3390
+ for (const file of latestProjectContext.changed || []) {
3391
+ if (effectivePolicy.policy.context?.showReloadNotice) {
3392
+ process.stderr.write(` ${c.dim(`[Context] ${file.label} updated — re-read`)}\n`);
3393
+ }
3394
+ }
1805
3395
 
1806
- for await (const event of client.execute(input, execContext, session.history)) {
3396
+ for await (const event of client.execute(input, execContext, session.agentHistory)) {
3397
+ jsonlWriter.writeKeplerEvent(event);
1807
3398
  if (event.type === 'plan_created' || event.type === 'goal_created') {
1808
3399
  persistProjectArtifacts(
1809
3400
  event.data,
@@ -1833,6 +3424,8 @@ export async function startTerminalRepl() {
1833
3424
  // Local JSONL: capture session ID from backend
1834
3425
  if (event.type === 'session_info' && event.data?.session_id) {
1835
3426
  jsonlWriter.setSessionId(event.data.session_id);
3427
+ hookRunner.sessionId = event.data.session_id;
3428
+ writeCurrentUserTurn();
1836
3429
  }
1837
3430
 
1838
3431
  // Local JSONL: accumulate tool calls
@@ -1865,15 +3458,17 @@ export async function startTerminalRepl() {
1865
3458
  }
1866
3459
 
1867
3460
  if (assistantContent) {
1868
- session.history.push({ role: 'assistant', content: assistantContent });
1869
- sessionMgr.saveMessage('assistant', assistantContent);
3461
+ const assistantMessage = { role: 'assistant', content: assistantContent };
3462
+ session.history.push(assistantMessage);
3463
+ session.agentHistory.push(assistantMessage);
1870
3464
  }
1871
3465
 
1872
3466
  showPrompt();
1873
- });
3467
+ }
1874
3468
 
1875
3469
  rl.on('close', async () => {
1876
3470
  stopSpinner();
3471
+ await hookRunner.run('Stop', { input: { session_id: session.id || '' } });
1877
3472
  await jsonlWriter.close();
1878
3473
  process.stderr.write(`\n ${c.dim('session ended')}\n\n`);
1879
3474
  process.exit(0);