@axplusb/kepler 1.0.10 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,7 @@ import { execSync } from 'node:child_process';
28
28
  export function createToolExecutor({
29
29
  projectRegistry = new ProjectRegistry(),
30
30
  skillsLoader = new SkillsLoader().load(process.cwd()),
31
+ checkpoints = null,
31
32
  } = {}) {
32
33
  const occRegistry = createToolRegistry();
33
34
  const skillTool = occRegistry.get('Skill');
@@ -132,6 +133,30 @@ export function createToolExecutor({
132
133
  return parts.length ? `\n--- Verify ---\n${parts.join('\n')}` : '';
133
134
  }
134
135
 
136
+ // ── Solution nudge after exploration ───────────────────────
137
+ // After the agent has read enough code, nudge it to formulate
138
+ // a solution based on the goal — not to blindly edit, but to
139
+ // synthesize what it learned into a fix approach.
140
+ let _codeReadsCount = 0;
141
+ let _hasEdited = false;
142
+
143
+ function solutionNudge(filePath) {
144
+ const ext = path.extname(filePath).toLowerCase();
145
+ const isCode = ['.py', '.js', '.ts', '.tsx', '.mjs', '.go', '.rs', '.java', '.rb'].includes(ext);
146
+ if (!isCode || _hasEdited) return '';
147
+
148
+ _codeReadsCount++;
149
+ if (_codeReadsCount < 4) return '';
150
+
151
+ // Only nudge once at threshold, not every read after
152
+ if (_codeReadsCount === 4) {
153
+ return '\n\n--- You have explored enough code to formulate a solution. ' +
154
+ 'Based on what you have read, determine the fix and apply it. ' +
155
+ 'If the approach is unclear, call plan() with your findings. ---';
156
+ }
157
+ return '';
158
+ }
159
+
135
160
  // ── Tool mapping table ──────────────────────────────────────
136
161
 
137
162
  const toolMap = {
@@ -258,10 +283,11 @@ export function createToolExecutor({
258
283
  });
259
284
  const output = typeof result === 'string' ? result : String(result);
260
285
  const content = output.replace(/^\s*\d+[→\t]/gm, '');
286
+ const actNudge = solutionNudge(filePath);
261
287
  return {
262
288
  success: !isError(output),
263
289
  content,
264
- output: output + nudge,
290
+ output: output + nudge + actNudge,
265
291
  _tool: 'read_file',
266
292
  _output_type: 'file_content',
267
293
  };
@@ -284,6 +310,10 @@ export function createToolExecutor({
284
310
  await occRegistry.call('Read', { file_path: filePath, limit: 1 });
285
311
  }
286
312
  } catch { /* file may not exist yet */ }
313
+ // Checkpoint before overwrite so /undo can restore the previous content.
314
+ if (checkpoints && fs.existsSync(filePath)) {
315
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
316
+ }
287
317
  const result = await occRegistry.call('Write', {
288
318
  file_path: filePath,
289
319
  content: args.content,
@@ -380,6 +410,11 @@ export function createToolExecutor({
380
410
  await occRegistry.call('Read', { file_path: filePath, limit: 1 });
381
411
  } catch { /* best effort */ }
382
412
 
413
+ // Checkpoint before edit so /undo can restore the previous content.
414
+ if (checkpoints) {
415
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
416
+ }
417
+
383
418
  let result;
384
419
  try {
385
420
  result = await occRegistry.call('Edit', {
@@ -418,6 +453,7 @@ print('OK: replaced')
418
453
 
419
454
  const wrapped = wrapResult(result, 'edit_file');
420
455
  updateProjectIndex(filePath);
456
+ _hasEdited = true;
421
457
 
422
458
  // Auto-lint the edited file
423
459
  const lintOutput = autoLint(filePath);
@@ -484,9 +520,16 @@ print('OK: replaced')
484
520
  }
485
521
  } catch { /* rg not found or no results */ }
486
522
 
487
- // Layer 2: BM25semantic relevance (finds related code even without exact match)
523
+ // Layer 2: Symbol search AST-extracted functions/classes with signatures
488
524
  if (project?.retriever) {
489
525
  if (!project.retriever.index) project.retriever.loadIndex();
526
+ const symbols = project.retriever.searchSymbols(query, 5);
527
+ if (symbols.length > 0) {
528
+ const symOutput = project.retriever.formatSymbolResults(symbols);
529
+ parts.push(`## Symbols (functions/classes)\n${symOutput}`);
530
+ }
531
+
532
+ // Layer 3: BM25 chunks — broader context when symbols aren't enough
490
533
  const chunks = project.retriever.retrieve(query, 5);
491
534
  if (chunks.length > 0) {
492
535
  const bm25Output = chunks.map(c => {
@@ -609,7 +652,7 @@ print('OK: replaced')
609
652
  return { success: true, files: results, _tool: 'read_files' };
610
653
  },
611
654
 
612
- // 9. delete_file + safety check
655
+ // 9. delete_file + safety check + checkpoint for undo
613
656
  delete_file: async (args) => {
614
657
  try {
615
658
  const filePath = resolvePath(args.file_path || args.path, args);
@@ -617,6 +660,9 @@ print('OK: replaced')
617
660
  if (!delCheck.safe) {
618
661
  return { success: false, output: `🛡️ BLOCKED: ${delCheck.reason}`, _tool: 'delete_file', _blocked: true };
619
662
  }
663
+ if (checkpoints) {
664
+ try { checkpoints.save(filePath); } catch { /* best effort */ }
665
+ }
620
666
  fs.unlinkSync(filePath);
621
667
  updateProjectIndex(filePath);
622
668
  return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Preflight diagnostic — Mission Control (PRD-055 §9).
3
+ *
4
+ * Prints a non-blocking summary of the runtime environment before the REPL
5
+ * starts so the user can see what is and is not aligned:
6
+ *
7
+ * 🔭 Kepler v1.0.4 · initializing orbit
8
+ *
9
+ * [✓] Auth token
10
+ * [✓] OpenRouter key
11
+ * [✓] Backend http://127.0.0.1:8000
12
+ * [✓] Git repository main · clean
13
+ * [⚠] Linter (ruff) not found → /install ruff to enable lint_check
14
+ * [✓] Project map 142 files, Python + TypeScript
15
+ *
16
+ * All systems aligned. What are we building today?
17
+ *
18
+ * Checks are non-blocking. A failure shows a one-line next-step hint.
19
+ *
20
+ * Exposed via `runPreflight()` (called from REPL startup) and `/preflight`
21
+ * (registered as a slash command).
22
+ */
23
+
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { execSync } from 'node:child_process';
27
+ import http from 'node:http';
28
+ import https from 'node:https';
29
+ import { URL } from 'node:url';
30
+ import { paint } from '../ui/palette.mjs';
31
+ import { icons } from '../ui/icons.mjs';
32
+ import { term } from '../ui/term.mjs';
33
+
34
+ const OK = (s) => `${paint.state.success('[✓]')} ${s}`;
35
+ const WARN = (s) => `${paint.state.warn('[⚠]')} ${s}`;
36
+ const FAIL = (s) => `${paint.state.danger('[✗]')} ${s}`;
37
+
38
+ // ── Individual checks (each returns { status, label, hint? }) ──────────
39
+
40
+ function checkAuthToken(auth) {
41
+ const creds = auth.loadCredentials();
42
+ if (creds.token) return { status: 'ok', label: `Auth token` };
43
+ return { status: 'warn', label: 'Auth token missing', hint: '/login to sign in' };
44
+ }
45
+
46
+ function checkProviderKey(auth) {
47
+ const creds = auth.loadCredentials();
48
+ if (creds.openRouterKey) return { status: 'ok', label: 'OpenRouter key' };
49
+ if (creds.anthropicKey) return { status: 'ok', label: 'Anthropic key' };
50
+ if (creds.openaiKey) return { status: 'ok', label: 'OpenAI key' };
51
+ if (creds.googleKey) return { status: 'ok', label: 'Google key' };
52
+ return { status: 'warn', label: 'No model provider key configured', hint: 'set OPENROUTER_API_KEY or run /config' };
53
+ }
54
+
55
+ async function checkBackend(auth, { timeoutMs = 1500 } = {}) {
56
+ const creds = auth.loadCredentials();
57
+ const url = creds.backendUrl;
58
+ if (!url) return { status: 'warn', label: 'Backend not configured' };
59
+ try {
60
+ const reachable = await ping(url, timeoutMs);
61
+ if (reachable) return { status: 'ok', label: `Backend ${shorten(url, 48)}` };
62
+ return { status: 'warn', label: `Backend ${shorten(url, 48)}`, hint: 'unreachable — check network or start backend' };
63
+ } catch {
64
+ return { status: 'warn', label: `Backend ${shorten(url, 48)}`, hint: 'unreachable' };
65
+ }
66
+ }
67
+
68
+ function checkGit(cwd) {
69
+ if (!hasGitDir(cwd)) return { status: 'warn', label: 'Not a git repository', hint: '`git init` to enable diff / checkpoints' };
70
+ try {
71
+ const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { cwd, encoding: 'utf-8' }).trim();
72
+ const status = execSync('git status --porcelain 2>/dev/null', { cwd, encoding: 'utf-8' });
73
+ const dirty = status.split('\n').filter(Boolean).length;
74
+ const summary = dirty > 0
75
+ ? `${branch} · ${paint.state.warn(`${dirty} dirty`)}`
76
+ : `${branch} · clean`;
77
+ return { status: 'ok', label: `Git repository ${summary}` };
78
+ } catch {
79
+ return { status: 'warn', label: 'Git repository present but unreadable' };
80
+ }
81
+ }
82
+
83
+ function checkLinters(cwd) {
84
+ const present = [];
85
+ const missing = [];
86
+ for (const [name, kind] of LINTERS) {
87
+ if (which(name)) present.push({ name, kind });
88
+ else if (projectUses(cwd, kind)) missing.push({ name, kind });
89
+ }
90
+ if (present.length === 0 && missing.length === 0) {
91
+ return { status: 'ok', label: 'Linters none required' };
92
+ }
93
+ if (missing.length === 0) {
94
+ return { status: 'ok', label: `Linters ${present.map(p => p.name).join(', ')}` };
95
+ }
96
+ const hint = missing.map(m => `/install ${m.name} to enable lint_check for ${m.kind}`).join(' · ');
97
+ return { status: 'warn', label: `Linter (${missing.map(m => m.name).join(', ')}) not found`, hint };
98
+ }
99
+
100
+ const LINTERS = [
101
+ ['ruff', 'python'],
102
+ ['eslint', 'javascript'],
103
+ ['tsc', 'typescript'],
104
+ ['cargo', 'rust'],
105
+ ];
106
+
107
+ function projectUses(cwd, kind) {
108
+ try {
109
+ const files = fs.readdirSync(cwd);
110
+ switch (kind) {
111
+ case 'python': return files.some(f => /\.py$/.test(f)) || files.includes('pyproject.toml') || files.includes('requirements.txt');
112
+ case 'javascript': return files.includes('package.json');
113
+ case 'typescript': return files.includes('tsconfig.json');
114
+ case 'rust': return files.includes('Cargo.toml');
115
+ default: return false;
116
+ }
117
+ } catch { return false; }
118
+ }
119
+
120
+ function checkProjectMap(cwd) {
121
+ try {
122
+ const counts = quickFileCount(cwd, { max: 5000 });
123
+ if (!counts.total) return { status: 'warn', label: 'Project map no files indexed yet' };
124
+ const langs = topLanguages(counts.byExt, 2);
125
+ const langStr = langs.length ? langs.join(' + ') : 'mixed';
126
+ return { status: 'ok', label: `Project map ${counts.total} files, ${langStr}` };
127
+ } catch {
128
+ return { status: 'warn', label: 'Project map unreadable' };
129
+ }
130
+ }
131
+
132
+ // ── Helpers ─────────────────────────────────────────────────────────────
133
+
134
+ function shorten(s, n) {
135
+ const str = String(s || '');
136
+ return str.length <= n ? str : str.slice(0, n - 1) + '…';
137
+ }
138
+
139
+ function hasGitDir(cwd) {
140
+ let dir = cwd;
141
+ for (let i = 0; i < 6; i++) {
142
+ if (fs.existsSync(path.join(dir, '.git'))) return true;
143
+ const parent = path.dirname(dir);
144
+ if (parent === dir) break;
145
+ dir = parent;
146
+ }
147
+ return false;
148
+ }
149
+
150
+ function which(name) {
151
+ try {
152
+ execSync(`command -v ${name}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
153
+ return true;
154
+ } catch { return false; }
155
+ }
156
+
157
+ function ping(url, timeoutMs) {
158
+ return new Promise((resolve) => {
159
+ let u;
160
+ try { u = new URL(url); } catch { resolve(false); return; }
161
+ const lib = u.protocol === 'https:' ? https : http;
162
+ const req = lib.request({
163
+ hostname: u.hostname,
164
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
165
+ path: u.pathname || '/',
166
+ method: 'GET',
167
+ timeout: timeoutMs,
168
+ }, (res) => {
169
+ // Any response means the host is reachable, even 404.
170
+ res.resume();
171
+ resolve(true);
172
+ });
173
+ req.on('error', () => resolve(false));
174
+ req.on('timeout', () => { try { req.destroy(); } catch {} resolve(false); });
175
+ req.end();
176
+ });
177
+ }
178
+
179
+ const EXT_TO_LANG = {
180
+ '.py': 'Python', '.ts': 'TypeScript', '.tsx': 'TypeScript', '.js': 'JavaScript',
181
+ '.jsx': 'JavaScript', '.mjs': 'JavaScript', '.go': 'Go', '.rs': 'Rust',
182
+ '.java': 'Java', '.rb': 'Ruby', '.php': 'PHP', '.swift': 'Swift', '.kt': 'Kotlin',
183
+ '.c': 'C', '.cc': 'C++', '.cpp': 'C++', '.h': 'C/C++', '.hpp': 'C++',
184
+ };
185
+
186
+ function topLanguages(byExt, n) {
187
+ const ranked = Object.entries(byExt)
188
+ .map(([ext, count]) => [EXT_TO_LANG[ext], count])
189
+ .filter(([lang]) => lang)
190
+ .reduce((acc, [lang, count]) => { acc.set(lang, (acc.get(lang) || 0) + count); return acc; }, new Map());
191
+ return [...ranked.entries()]
192
+ .sort((a, b) => b[1] - a[1])
193
+ .slice(0, n)
194
+ .map(([lang]) => lang);
195
+ }
196
+
197
+ function quickFileCount(cwd, { max = 5000 } = {}) {
198
+ // Shallow walk: skip node_modules, .git, dist, build, .venv, __pycache__.
199
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.venv', 'venv', '__pycache__', '.kepler', '.terraform']);
200
+ const byExt = {};
201
+ let total = 0;
202
+ const stack = [cwd];
203
+ while (stack.length && total < max) {
204
+ const dir = stack.pop();
205
+ let entries;
206
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
207
+ catch { continue; }
208
+ for (const e of entries) {
209
+ if (e.name.startsWith('.') && e.name !== '.kepler') continue;
210
+ if (SKIP.has(e.name)) continue;
211
+ const full = path.join(dir, e.name);
212
+ if (e.isDirectory()) stack.push(full);
213
+ else if (e.isFile()) {
214
+ total++;
215
+ const ext = path.extname(e.name).toLowerCase();
216
+ if (ext) byExt[ext] = (byExt[ext] || 0) + 1;
217
+ if (total >= max) break;
218
+ }
219
+ }
220
+ }
221
+ return { total, byExt };
222
+ }
223
+
224
+ // ── Renderer ────────────────────────────────────────────────────────────
225
+
226
+ function formatRow(check) {
227
+ switch (check.status) {
228
+ case 'ok': return ` ${OK(paint.text.primary(check.label))}`;
229
+ case 'warn': return ` ${WARN(paint.text.primary(check.label))}` +
230
+ (check.hint ? ` ${paint.text.dim('→ ' + check.hint)}` : '');
231
+ case 'fail': return ` ${FAIL(paint.text.primary(check.label))}` +
232
+ (check.hint ? ` ${paint.text.dim('→ ' + check.hint)}` : '');
233
+ default: return ` ${paint.text.dim(check.label)}`;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Run the preflight diagnostic. Writes to stderr and resolves with the
239
+ * collected check results.
240
+ *
241
+ * @param {object} opts
242
+ * @param {object} opts.auth — TarangAuth instance
243
+ * @param {string} opts.cwd — working directory
244
+ * @param {string} opts.version — package version string
245
+ * @param {boolean} [opts.silent] — if true, do not write (useful for tests)
246
+ */
247
+ export async function runPreflight({ auth, cwd, version, silent = false } = {}) {
248
+ const t = term();
249
+ const write = (s) => { if (!silent) process.stderr.write(s); };
250
+
251
+ const header = `${icons.search} ${paint.bold(paint.brand.primary('Kepler v' + (version || '?')))} ${paint.text.dim('· initializing orbit')}`;
252
+ write('\n' + header + '\n\n');
253
+
254
+ const checks = [];
255
+ checks.push(checkAuthToken(auth));
256
+ checks.push(checkProviderKey(auth));
257
+ checks.push(await checkBackend(auth));
258
+ checks.push(checkGit(cwd));
259
+ checks.push(checkLinters(cwd));
260
+ checks.push(checkProjectMap(cwd));
261
+
262
+ for (const c of checks) write(formatRow(c) + '\n');
263
+
264
+ const fails = checks.filter(c => c.status === 'fail').length;
265
+ const warns = checks.filter(c => c.status === 'warn').length;
266
+ const tail = fails === 0 && warns === 0
267
+ ? paint.state.success('All systems aligned.')
268
+ : fails > 0
269
+ ? paint.state.danger(`${fails} blocker${fails === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'} — see hints above.`)
270
+ : paint.state.warn(`${warns} warning${warns === 1 ? '' : 's'} — non-blocking.`);
271
+
272
+ write('\n ' + tail + '\n\n');
273
+ return checks;
274
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Orbit state machine — Mission Control (PRD-055 §5.2).
3
+ *
4
+ * The "orbit" is the current phase of the session. The status bar reads
5
+ * from this module; the REPL pushes events into it. It is intentionally a
6
+ * pure state machine — no I/O, no side effects, no globals. Each REPL
7
+ * creates one instance.
8
+ *
9
+ * const orbit = createOrbit();
10
+ * orbit.on('change', state => statusBar.render(state));
11
+ * orbit.onEvent({ type: 'tool_call', data: { tool: 'edit_file' } });
12
+ *
13
+ * States (PRD §5.2):
14
+ * IDLE — waiting for user input
15
+ * DISCOVERY — first message until first plan or edit
16
+ * PLANNING — preflight plan running OR plan() sub-agent active
17
+ * EXECUTION — write/edit/shell tools firing
18
+ * ALIGNMENT — tests / validators running
19
+ * AWAITING — approval required
20
+ * PAUSED — user pressed `p`
21
+ *
22
+ * Transitions are derived from existing backend SSE events. We never
23
+ * teach the backend about orbits; the CLI infers them from tool activity.
24
+ */
25
+
26
+ // Tool families used for orbit inference. Mirrors src/ui/icons.mjs but
27
+ // scoped to the few orbits actually need.
28
+ const PLANNING_TOOLS = new Set(['plan']);
29
+ const EXECUTION_TOOLS = new Set(['edit_file', 'write_file', 'write_project', 'shell', 'delete_file']);
30
+ const ALIGNMENT_TOOLS = new Set(['run_tests', 'validate_build', 'lint_check', 'validate_file', 'validate_structure', 'git_diff', 'git_status']);
31
+ const RESEARCH_TOOLS = new Set(['search_code', 'search_files', 'grep', 'read_file', 'read_files', 'list_files', 'analyze_code', 'get_project_overview', 'explore']);
32
+
33
+ export const ORBITS = Object.freeze({
34
+ IDLE: 'IDLE',
35
+ DISCOVERY: 'DISCOVERY',
36
+ PLANNING: 'PLANNING',
37
+ EXECUTION: 'EXECUTION',
38
+ ALIGNMENT: 'ALIGNMENT',
39
+ AWAITING: 'AWAITING',
40
+ PAUSED: 'PAUSED',
41
+ });
42
+
43
+ /**
44
+ * @returns the snapshot consumed by the status bar.
45
+ */
46
+ function snapshot(s) {
47
+ return {
48
+ orbit: s.orbit,
49
+ task: s.task || '',
50
+ turn: s.turn,
51
+ maxTurn: s.maxTurn,
52
+ cost: s.cost,
53
+ activeTool: s.activeTool || '',
54
+ subAgents: s.subAgents,
55
+ paused: s.paused,
56
+ awaitingTier: s.awaitingTier || null,
57
+ awaitingTool: s.awaitingTool || '',
58
+ };
59
+ }
60
+
61
+ export function createOrbit() {
62
+ const state = {
63
+ orbit: ORBITS.IDLE,
64
+ task: '',
65
+ turn: 0,
66
+ maxTurn: 0,
67
+ cost: 0,
68
+ activeTool: '',
69
+ subAgents: 0, // count of currently-active sub-agents
70
+ paused: false,
71
+ awaitingTool: '',
72
+ awaitingTier: null,
73
+ _hasEdited: false, // for DISCOVERY → EXECUTION transition
74
+ _resumeOrbit: null, // remembered orbit when paused
75
+ };
76
+
77
+ const listeners = new Set();
78
+
79
+ function emit() {
80
+ const snap = snapshot(state);
81
+ for (const fn of listeners) {
82
+ try { fn(snap); } catch {}
83
+ }
84
+ }
85
+
86
+ function setOrbit(next) {
87
+ if (state.paused && next !== ORBITS.PAUSED && next !== ORBITS.IDLE) {
88
+ // While paused, remember the orbit that would have applied but stay paused.
89
+ state._resumeOrbit = next;
90
+ return;
91
+ }
92
+ if (state.orbit === next) return;
93
+ state.orbit = next;
94
+ emit();
95
+ }
96
+
97
+ function inferOrbitFromTool(toolName) {
98
+ if (state.paused) return null;
99
+ if (state.subAgents > 0 && PLANNING_TOOLS.has(toolName)) return ORBITS.PLANNING;
100
+ if (PLANNING_TOOLS.has(toolName)) return ORBITS.PLANNING;
101
+ if (EXECUTION_TOOLS.has(toolName)) {
102
+ state._hasEdited = true;
103
+ return ORBITS.EXECUTION;
104
+ }
105
+ if (ALIGNMENT_TOOLS.has(toolName)) return ORBITS.ALIGNMENT;
106
+ if (RESEARCH_TOOLS.has(toolName)) {
107
+ // Stay in DISCOVERY until first edit; afterwards research stays in
108
+ // current orbit (EXECUTION) so the status doesn't flicker back.
109
+ return state._hasEdited ? null : ORBITS.DISCOVERY;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ return {
115
+ state: () => snapshot(state),
116
+
117
+ on(event, fn) {
118
+ if (event !== 'change') return () => {};
119
+ listeners.add(fn);
120
+ return () => listeners.delete(fn);
121
+ },
122
+
123
+ // ── Inbound events from the REPL ──────────────────────────────────
124
+
125
+ onUserInput(text) {
126
+ // First user message of the session OR a new turn opens DISCOVERY.
127
+ state.turn++;
128
+ state.task = (text || '').replace(/\s+/g, ' ').trim().slice(0, 80);
129
+ state._hasEdited = false;
130
+ state.activeTool = '';
131
+ setOrbit(ORBITS.DISCOVERY);
132
+ },
133
+
134
+ onMaxTurn(n) {
135
+ if (typeof n === 'number' && n > 0) {
136
+ state.maxTurn = n;
137
+ emit();
138
+ }
139
+ },
140
+
141
+ onTask(text) {
142
+ if (!text) return;
143
+ state.task = String(text).replace(/\s+/g, ' ').trim().slice(0, 80);
144
+ emit();
145
+ },
146
+
147
+ onCost(value) {
148
+ if (typeof value === 'number' && Number.isFinite(value)) {
149
+ state.cost = value;
150
+ emit();
151
+ }
152
+ },
153
+
154
+ onToolCall(toolName) {
155
+ state.activeTool = toolName || '';
156
+ const next = inferOrbitFromTool(toolName);
157
+ if (next) setOrbit(next);
158
+ else emit();
159
+ },
160
+
161
+ onToolResult() {
162
+ state.activeTool = '';
163
+ emit();
164
+ },
165
+
166
+ onSubAgentStart() {
167
+ state.subAgents = Math.max(0, state.subAgents) + 1;
168
+ setOrbit(ORBITS.PLANNING);
169
+ },
170
+
171
+ onSubAgentEnd() {
172
+ state.subAgents = Math.max(0, state.subAgents - 1);
173
+ // Fall back to whatever the parent was doing — we don't track that
174
+ // precisely, so go to EXECUTION if an edit has happened, else
175
+ // DISCOVERY. The next tool_call event will refine.
176
+ if (state.subAgents === 0) {
177
+ setOrbit(state._hasEdited ? ORBITS.EXECUTION : ORBITS.DISCOVERY);
178
+ } else {
179
+ emit();
180
+ }
181
+ },
182
+
183
+ onApprovalRequired({ tool, tier } = {}) {
184
+ state.awaitingTool = tool || '';
185
+ state.awaitingTier = tier || null;
186
+ setOrbit(ORBITS.AWAITING);
187
+ },
188
+
189
+ onApprovalResolved() {
190
+ state.awaitingTool = '';
191
+ state.awaitingTier = null;
192
+ // Drop back to the inferred orbit for the active tool, or EXECUTION
193
+ // if we have an active tool but can't classify, or IDLE.
194
+ const inferred = inferOrbitFromTool(state.activeTool) || (state._hasEdited ? ORBITS.EXECUTION : ORBITS.DISCOVERY);
195
+ setOrbit(inferred);
196
+ },
197
+
198
+ onComplete({ cost } = {}) {
199
+ if (typeof cost === 'number') state.cost = cost;
200
+ state.activeTool = '';
201
+ setOrbit(ORBITS.IDLE);
202
+ },
203
+
204
+ onPause() {
205
+ if (state.paused) return;
206
+ state.paused = true;
207
+ state._resumeOrbit = state.orbit;
208
+ state.orbit = ORBITS.PAUSED;
209
+ emit();
210
+ },
211
+
212
+ onResume() {
213
+ if (!state.paused) return;
214
+ state.paused = false;
215
+ const resume = state._resumeOrbit || ORBITS.IDLE;
216
+ state._resumeOrbit = null;
217
+ state.orbit = resume;
218
+ emit();
219
+ },
220
+
221
+ /**
222
+ * Generic event router so the REPL can feed raw SSE events without a
223
+ * giant switch in this module. Returns true if the event was handled.
224
+ */
225
+ onEvent(event) {
226
+ if (!event || !event.type) return false;
227
+ const { type, data } = event;
228
+ switch (type) {
229
+ case 'tool_call':
230
+ case 'tool_request':
231
+ this.onToolCall(data?.tool || '');
232
+ return true;
233
+ case 'tool_result':
234
+ case 'tool_done':
235
+ this.onToolResult();
236
+ return true;
237
+ case 'sub_agent_start':
238
+ this.onSubAgentStart();
239
+ return true;
240
+ case 'sub_agent_complete':
241
+ this.onSubAgentEnd();
242
+ return true;
243
+ case 'approval_required':
244
+ this.onApprovalRequired({ tool: data?.tool, tier: data?.tier });
245
+ return true;
246
+ case 'approval_granted':
247
+ case 'approval_denied':
248
+ this.onApprovalResolved();
249
+ return true;
250
+ case 'complete': {
251
+ const usage = data?.usage || {};
252
+ this.onComplete({ cost: usage.total_cost_usd ?? usage.cost_usd });
253
+ return true;
254
+ }
255
+ case 'plan_created':
256
+ this.onTask(data?.title || data?.task || '');
257
+ return true;
258
+ default:
259
+ return false;
260
+ }
261
+ },
262
+ };
263
+ }