agent-tool-forge 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/lib/agent-registry.js +170 -0
  4. package/lib/api-client.js +792 -0
  5. package/lib/api-loader.js +260 -0
  6. package/lib/auth.d.ts +25 -0
  7. package/lib/auth.js +158 -0
  8. package/lib/checks/check-adapter.js +172 -0
  9. package/lib/checks/compose.js +42 -0
  10. package/lib/checks/content-match.js +14 -0
  11. package/lib/checks/cost-budget.js +11 -0
  12. package/lib/checks/index.js +18 -0
  13. package/lib/checks/json-valid.js +15 -0
  14. package/lib/checks/latency.js +11 -0
  15. package/lib/checks/length-bounds.js +17 -0
  16. package/lib/checks/negative-match.js +14 -0
  17. package/lib/checks/no-hallucinated-numbers.js +63 -0
  18. package/lib/checks/non-empty.js +34 -0
  19. package/lib/checks/regex-match.js +12 -0
  20. package/lib/checks/run-checks.js +84 -0
  21. package/lib/checks/schema-match.js +26 -0
  22. package/lib/checks/tool-call-count.js +16 -0
  23. package/lib/checks/tool-selection.js +34 -0
  24. package/lib/checks/types.js +45 -0
  25. package/lib/comparison/compare.js +86 -0
  26. package/lib/comparison/format.js +104 -0
  27. package/lib/comparison/index.js +6 -0
  28. package/lib/comparison/statistics.js +59 -0
  29. package/lib/comparison/types.js +41 -0
  30. package/lib/config-schema.js +200 -0
  31. package/lib/config.d.ts +66 -0
  32. package/lib/conversation-store.d.ts +77 -0
  33. package/lib/conversation-store.js +443 -0
  34. package/lib/db.d.ts +6 -0
  35. package/lib/db.js +1112 -0
  36. package/lib/dep-check.js +99 -0
  37. package/lib/drift-background.js +61 -0
  38. package/lib/drift-monitor.js +187 -0
  39. package/lib/eval-runner.js +566 -0
  40. package/lib/fixtures/fixture-store.js +161 -0
  41. package/lib/fixtures/index.js +11 -0
  42. package/lib/forge-engine.js +982 -0
  43. package/lib/forge-eval-generator.js +417 -0
  44. package/lib/forge-file-writer.js +386 -0
  45. package/lib/forge-service-client.js +190 -0
  46. package/lib/forge-service.d.ts +4 -0
  47. package/lib/forge-service.js +655 -0
  48. package/lib/forge-verifier-generator.js +271 -0
  49. package/lib/handlers/admin.js +151 -0
  50. package/lib/handlers/agents.js +229 -0
  51. package/lib/handlers/chat-resume.js +334 -0
  52. package/lib/handlers/chat-sync.js +320 -0
  53. package/lib/handlers/chat.js +320 -0
  54. package/lib/handlers/conversations.js +92 -0
  55. package/lib/handlers/preferences.js +88 -0
  56. package/lib/handlers/tools-list.js +58 -0
  57. package/lib/hitl-engine.d.ts +60 -0
  58. package/lib/hitl-engine.js +261 -0
  59. package/lib/http-utils.js +92 -0
  60. package/lib/index.d.ts +20 -0
  61. package/lib/index.js +141 -0
  62. package/lib/init.js +636 -0
  63. package/lib/manual-entry.js +59 -0
  64. package/lib/mcp-server.js +252 -0
  65. package/lib/output-groups.js +54 -0
  66. package/lib/postgres-store.d.ts +31 -0
  67. package/lib/postgres-store.js +465 -0
  68. package/lib/preference-store.d.ts +47 -0
  69. package/lib/preference-store.js +79 -0
  70. package/lib/prompt-store.d.ts +42 -0
  71. package/lib/prompt-store.js +60 -0
  72. package/lib/rate-limiter.d.ts +30 -0
  73. package/lib/rate-limiter.js +104 -0
  74. package/lib/react-engine.d.ts +110 -0
  75. package/lib/react-engine.js +337 -0
  76. package/lib/runner/cli.js +156 -0
  77. package/lib/runner/cost-estimator.js +71 -0
  78. package/lib/runner/gate.js +46 -0
  79. package/lib/runner/index.js +165 -0
  80. package/lib/sidecar.d.ts +83 -0
  81. package/lib/sidecar.js +161 -0
  82. package/lib/sse.d.ts +15 -0
  83. package/lib/sse.js +30 -0
  84. package/lib/tools-scanner.js +91 -0
  85. package/lib/tui.js +253 -0
  86. package/lib/verifier-report.js +78 -0
  87. package/lib/verifier-runner.js +338 -0
  88. package/lib/verifier-scanner.js +70 -0
  89. package/lib/verifier-worker-pool.js +196 -0
  90. package/lib/views/chat.js +340 -0
  91. package/lib/views/endpoints.js +203 -0
  92. package/lib/views/eval-run.js +206 -0
  93. package/lib/views/forge-agent.js +538 -0
  94. package/lib/views/forge.js +410 -0
  95. package/lib/views/main-menu.js +275 -0
  96. package/lib/views/mediation.js +381 -0
  97. package/lib/views/model-compare.js +430 -0
  98. package/lib/views/model-comparison.js +333 -0
  99. package/lib/views/onboarding.js +470 -0
  100. package/lib/views/performance.js +237 -0
  101. package/lib/views/run-evals.js +205 -0
  102. package/lib/views/settings.js +829 -0
  103. package/lib/views/tools-evals.js +514 -0
  104. package/lib/views/verifier-coverage.js +617 -0
  105. package/lib/workers/verifier-worker.js +52 -0
  106. package/package.json +123 -0
  107. package/widget/forge-chat.js +789 -0
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Forge Agent View — Stage-aware LLM chat panel for building MCP tool definitions.
3
+ *
4
+ * Layout:
5
+ * phaseBar (1 row, top) — current stage indicator
6
+ * log (fills middle) — chat history, auto-scroll
7
+ * inputBox (3 rows, bottom) — user input
8
+ *
9
+ * Stages: orient → report → name-describe → skeptic →
10
+ * tool-writing → eval-writing → verifier-creation → promote
11
+ *
12
+ * Stage skill files are loaded from context/forge-agent/stages/{name}.md.
13
+ * Base system prompt from context/forge-agent/system-prompt.md.
14
+ * Conversation history is persisted via cli/conversation-store.js (SQLite by default, Redis optional).
15
+ */
16
+
17
+ import blessed from 'blessed';
18
+ import { existsSync, readFileSync } from 'fs';
19
+ import { resolve, dirname } from 'path';
20
+ import { fileURLToPath } from 'url';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const PROJECT_ROOT = resolve(__dirname, '../..');
24
+ const STAGES_DIR = resolve(PROJECT_ROOT, 'context/forge-agent/stages');
25
+ const BASE_PROMPT_PATH = resolve(PROJECT_ROOT, 'context/forge-agent/system-prompt.md');
26
+
27
+ // ── Stage registry ──────────────────────────────────────────────────────────
28
+
29
+ export const STAGES = [
30
+ 'orient',
31
+ 'report',
32
+ 'name-describe',
33
+ 'skeptic',
34
+ 'tool-writing',
35
+ 'eval-writing',
36
+ 'verifier-creation',
37
+ 'promote'
38
+ ];
39
+
40
+ // ── Helpers ─────────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Load the base system prompt. Returns '' if file is missing.
44
+ * @returns {string}
45
+ */
46
+ export function loadBasePrompt() {
47
+ try {
48
+ if (!existsSync(BASE_PROMPT_PATH)) return '';
49
+ return readFileSync(BASE_PROMPT_PATH, 'utf-8');
50
+ } catch (_) {
51
+ return '';
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Load a stage skill file by stage name. Returns '' if missing or unreadable.
57
+ * @param {string} stageName
58
+ * @returns {string}
59
+ */
60
+ export function loadStageSkill(stageName) {
61
+ try {
62
+ const filePath = resolve(STAGES_DIR, `${stageName}.md`);
63
+ if (!existsSync(STAGES_DIR)) return '';
64
+ if (!existsSync(filePath)) return '';
65
+ return readFileSync(filePath, 'utf-8');
66
+ } catch (_) {
67
+ return '';
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Build the stage label string for the phase bar.
73
+ * @param {string} stageName
74
+ * @param {number} totalStages
75
+ * @returns {string}
76
+ */
77
+ export function computeStageLabel(stageName, totalStages) {
78
+ if (!totalStages || totalStages === 0) {
79
+ return `Stage ?/${totalStages || 0}: ${stageName}`;
80
+ }
81
+ const idx = STAGES.indexOf(stageName);
82
+ const n = idx === -1 ? '?' : idx + 1;
83
+ return `Stage ${n}/${totalStages}: ${stageName}`;
84
+ }
85
+
86
+ /**
87
+ * Build the combined system prompt for a turn.
88
+ * @param {string} baseContent
89
+ * @param {string} stageContent
90
+ * @returns {string}
91
+ */
92
+ function buildSystemPrompt(baseContent, stageContent) {
93
+ const parts = [];
94
+ if (baseContent && baseContent.trim()) parts.push(baseContent.trim());
95
+ if (stageContent && stageContent.trim()) parts.push(stageContent.trim());
96
+ return parts.join('\n\n---\n\n');
97
+ }
98
+
99
+ /**
100
+ * Load and parse .env file from project root.
101
+ * @returns {object}
102
+ */
103
+ function loadEnv() {
104
+ const envPath = resolve(PROJECT_ROOT, '.env');
105
+ if (!existsSync(envPath)) return {};
106
+ const out = {};
107
+ try {
108
+ for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
109
+ const t = line.trim();
110
+ if (!t || t.startsWith('#')) continue;
111
+ const eq = t.indexOf('=');
112
+ if (eq === -1) continue;
113
+ out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
114
+ }
115
+ } catch (_) { /* ignore */ }
116
+ return out;
117
+ }
118
+
119
+ // ── View ────────────────────────────────────────────────────────────────────
120
+
121
+ export function createView({
122
+ screen, content, config, navigate, setFooter,
123
+ screenKey, openPopup, closePopup
124
+ }) {
125
+ const container = blessed.box({
126
+ top: 0, left: 0, width: '100%', height: '100%', tags: true
127
+ });
128
+ // Escape/b navigates back immediately — session is auto-persisted.
129
+
130
+ // ── Layout ──────────────────────────────────────────────────────────────
131
+
132
+ const phaseBar = blessed.box({
133
+ parent: container, top: 0, left: 0, width: '100%', height: 1,
134
+ tags: true, style: { fg: '#888888' }
135
+ });
136
+
137
+ const log = blessed.log({
138
+ parent: container, top: 1, left: 0, width: '100%', height: '100%-4',
139
+ tags: true, scrollable: true, alwaysScroll: true, keys: true, mouse: true,
140
+ border: { type: 'line' }, label: ' Forge Agent ',
141
+ style: {
142
+ border: { fg: '#333333' },
143
+ focus: { border: { fg: 'cyan' } }
144
+ },
145
+ scrollbar: { ch: '│', style: { fg: '#555555' } }
146
+ });
147
+
148
+ const inputBox = blessed.textbox({
149
+ parent: container, bottom: 0, left: 0, width: '100%', height: 3,
150
+ border: { type: 'line' },
151
+ label: ' Message (Enter send, Esc shortcuts, ] next, [ prev) ',
152
+ style: {
153
+ border: { fg: '#333333' },
154
+ focus: { border: { fg: 'cyan' } }
155
+ }
156
+ });
157
+
158
+ setFooter(
159
+ ' {cyan-fg}Enter{/cyan-fg} send {cyan-fg}Esc{/cyan-fg} shortcuts ' +
160
+ '{cyan-fg}e{/cyan-fg} edit {cyan-fg}]{/cyan-fg} next {cyan-fg}[{/cyan-fg} prev {cyan-fg}b{/cyan-fg} back'
161
+ );
162
+
163
+ // ── Explicit input mode management ──────────────────────────────────────
164
+ // blessed.textbox with inputOnFocus captures ALL keystrokes at the program
165
+ // level, causing conflicts with screen-level hotkeys. Instead we manage
166
+ // readInput() explicitly so hotkeys only work outside of input mode.
167
+
168
+ let inputActive = false;
169
+
170
+ function startInput() {
171
+ inputActive = true;
172
+ inputBox.focus();
173
+ inputBox.style.border = { fg: 'cyan' };
174
+ log.style.border = { fg: '#333333' };
175
+ screen.render();
176
+ inputBox.readInput((err, value) => {
177
+ inputActive = false;
178
+ if (err || value === undefined || value === null) {
179
+ // Escape pressed — exit to command mode
180
+ log.focus();
181
+ log.style.border = { fg: 'cyan' };
182
+ inputBox.style.border = { fg: '#333333' };
183
+ screen.render();
184
+ return;
185
+ }
186
+ // Enter pressed — submit
187
+ const text = (value || '').trim();
188
+ inputBox.clearValue();
189
+ screen.render();
190
+ if (text) {
191
+ doStep(text);
192
+ } else {
193
+ startInput();
194
+ }
195
+ });
196
+ }
197
+
198
+ // ── State ────────────────────────────────────────────────────────────────
199
+
200
+ let currentStageIdx = 0;
201
+ let busy = false;
202
+ let modelConfig = null;
203
+ let db = null;
204
+ let sessionId = null;
205
+ let conversationStore = null;
206
+
207
+ // apiMessages is the LLM conversation history (role/content pairs)
208
+ let apiMessages = [];
209
+
210
+ // ── Log helpers ──────────────────────────────────────────────────────────
211
+
212
+ const appendSystem = (t) => {
213
+ log.log(`{#555555-fg}── ${t} ──{/#555555-fg}`);
214
+ screen.render();
215
+ };
216
+
217
+ const appendUser = (t) => {
218
+ log.log('');
219
+ log.log(`{cyan-fg}{bold}You:{/bold}{/cyan-fg} ${t}`);
220
+ screen.render();
221
+ };
222
+
223
+ const appendAssistant = (t) => {
224
+ if (!t || !t.trim()) return;
225
+ log.log(`{green-fg}{bold}Agent:{/bold}{/green-fg} ${t.replace(/\n/g, '\n ')}`);
226
+ screen.render();
227
+ };
228
+
229
+ // ── Phase bar ────────────────────────────────────────────────────────────
230
+
231
+ function updatePhaseBar() {
232
+ const stageName = STAGES[currentStageIdx] || 'unknown';
233
+ const label = computeStageLabel(stageName, STAGES.length);
234
+ phaseBar.setContent(
235
+ ` {cyan-fg}${label}{/cyan-fg}` +
236
+ ` {#888888-fg}Model: ${modelConfig?.model || 'n/a'}{/#888888-fg}`
237
+ );
238
+ screen.render();
239
+ }
240
+
241
+ // ── Stage navigation ─────────────────────────────────────────────────────
242
+
243
+ function advanceStage() {
244
+ if (busy) return;
245
+ if (currentStageIdx < STAGES.length - 1) {
246
+ currentStageIdx++;
247
+ appendSystem(`Advanced to stage: ${STAGES[currentStageIdx]}`);
248
+ updatePhaseBar();
249
+ }
250
+ }
251
+
252
+ function rewindStage() {
253
+ if (busy) return;
254
+ if (currentStageIdx > 0) {
255
+ currentStageIdx--;
256
+ appendSystem(`Rewound to stage: ${STAGES[currentStageIdx]}`);
257
+ updatePhaseBar();
258
+ }
259
+ }
260
+
261
+ // ── Session persistence ──────────────────────────────────────────────────
262
+
263
+ function persistMessage(role, content) {
264
+ if (!conversationStore || !sessionId) return;
265
+ conversationStore
266
+ .persistMessage(sessionId, STAGES[currentStageIdx] || 'unknown', role, content)
267
+ .catch((err) => process.stderr.write(`[forge-agent] store write failed: ${err.message}\n`));
268
+ }
269
+
270
+ // ── Core LLM step ────────────────────────────────────────────────────────
271
+
272
+ async function doStep(userText) {
273
+ if (busy) return;
274
+ busy = true;
275
+
276
+ if (!modelConfig || !modelConfig.apiKey) {
277
+ appendSystem('No API key found. Add ANTHROPIC_API_KEY or OPENAI_API_KEY in Settings → API Keys.');
278
+ busy = false;
279
+ return;
280
+ }
281
+
282
+ if (userText) {
283
+ appendUser(userText);
284
+ apiMessages.push({ role: 'user', content: userText });
285
+ persistMessage('user', userText);
286
+ }
287
+
288
+ // Build system prompt: base + current stage
289
+ const baseContent = loadBasePrompt();
290
+ const stageName = STAGES[currentStageIdx] || 'unknown';
291
+ const stageContent = loadStageSkill(stageName);
292
+ const systemPrompt = buildSystemPrompt(baseContent, stageContent);
293
+
294
+ if (!stageContent && stageName !== 'unknown') {
295
+ appendSystem(`Warning: stage file missing for '${stageName}' — using base prompt only.`);
296
+ }
297
+
298
+ try {
299
+ // Ensure messages array is non-empty and ends with a user turn
300
+ let callMessages = [...apiMessages];
301
+ if (callMessages.length === 0) {
302
+ // Seed the first turn so Anthropic never receives an empty messages array.
303
+ // Also push to apiMessages so the history starts with a user turn — without this
304
+ // the first assistant reply lands at index 0, making subsequent turns invalid.
305
+ const seed = { role: 'user', content: 'Begin.' };
306
+ callMessages = [seed];
307
+ apiMessages.push(seed);
308
+ } else if (
309
+ callMessages[callMessages.length - 1].role === 'assistant' &&
310
+ !userText
311
+ ) {
312
+ callMessages = [...callMessages, { role: 'user', content: '[continue]' }];
313
+ }
314
+
315
+ const { llmTurn } = await import('../api-client.js');
316
+ const result = await llmTurn({
317
+ provider: modelConfig.provider,
318
+ apiKey: modelConfig.apiKey,
319
+ model: modelConfig.model,
320
+ system: systemPrompt,
321
+ messages: callMessages,
322
+ maxTokens: 4096
323
+ });
324
+
325
+ let text = result.text || '';
326
+
327
+ // Check for [STAGE_COMPLETE] marker — handle both inline and line-anchored
328
+ const hasComplete = /\[STAGE_COMPLETE\]/.test(text);
329
+ if (hasComplete) {
330
+ text = text.replace(/\[STAGE_COMPLETE\]\s*/g, '').trim();
331
+ }
332
+
333
+ if (text) {
334
+ appendAssistant(text);
335
+ apiMessages.push({ role: 'assistant', content: text });
336
+ persistMessage('assistant', text);
337
+ }
338
+
339
+ // Advance stage after displaying text
340
+ if (hasComplete) {
341
+ if (currentStageIdx < STAGES.length - 1) {
342
+ currentStageIdx++;
343
+ appendSystem(`Stage complete. Moving to: ${STAGES[currentStageIdx]}`);
344
+ updatePhaseBar();
345
+ } else {
346
+ appendSystem('All stages complete. Session finished.');
347
+ persistMessage('system', '[COMPLETE]');
348
+ }
349
+ }
350
+
351
+ } catch (err) {
352
+ appendSystem(`Error: ${err.message}`);
353
+ }
354
+
355
+ busy = false;
356
+ startInput();
357
+ }
358
+
359
+ // ── Session resumption ───────────────────────────────────────────────────
360
+
361
+ async function promptResume(sessions, dbMod) {
362
+ return new Promise((resolve) => {
363
+ openPopup();
364
+
365
+ if (sessions.length === 1) {
366
+ const s = sessions[0];
367
+ const label = `Resume session at stage '${s.stage}'? (last: ${s.last_updated?.slice(0, 16) || '?'})`;
368
+ const q = blessed.question({
369
+ parent: screen, border: 'line', height: 'shrink', width: '60%',
370
+ top: 'center', left: 'center',
371
+ label: ' Resume Session? ', tags: true, keys: true
372
+ });
373
+ q.ask(`${label}\n[R]esume / [N]ew session (y=resume, n=new)`, (err, answer) => {
374
+ q.destroy();
375
+ closePopup();
376
+ if (!err && /^y/i.test(answer)) {
377
+ resolve(s.session_id);
378
+ } else {
379
+ resolve(null);
380
+ }
381
+ });
382
+ } else {
383
+ // Multiple sessions — show list
384
+ const listLines = sessions.slice(0, 5).map((s, i) =>
385
+ `${i + 1}. stage=${s.stage} last=${s.last_updated?.slice(0, 16) || '?'}`
386
+ ).join('\n');
387
+ const q = blessed.question({
388
+ parent: screen, border: 'line', height: 'shrink', width: '70%',
389
+ top: 'center', left: 'center',
390
+ label: ' Resume a Session? ', tags: true, keys: true
391
+ });
392
+ q.ask(
393
+ `Incomplete sessions:\n${listLines}\n\nEnter session number to resume, or 0 for new:`,
394
+ (err, answer) => {
395
+ q.destroy();
396
+ closePopup();
397
+ if (err) { resolve(null); return; }
398
+ const n = parseInt(answer, 10);
399
+ if (n >= 1 && n <= sessions.length) {
400
+ resolve(sessions[n - 1].session_id);
401
+ } else {
402
+ resolve(null);
403
+ }
404
+ }
405
+ );
406
+ }
407
+ });
408
+ }
409
+
410
+ // ── Init ──────────────────────────────────────────────────────────────────
411
+
412
+ setImmediate(async () => {
413
+ try {
414
+ const env = loadEnv();
415
+ const { resolveModelConfig } = await import('../api-client.js');
416
+ modelConfig = resolveModelConfig(config, env, 'generation');
417
+
418
+ if (!modelConfig.apiKey) {
419
+ appendSystem('No API key found. Add ANTHROPIC_API_KEY or OPENAI_API_KEY in Settings → API Keys.');
420
+ inputBox.style.border = { fg: 'red' };
421
+ updatePhaseBar();
422
+ screen.render();
423
+ return;
424
+ }
425
+
426
+ // Load DB + conversation store
427
+ let dbMod;
428
+ try {
429
+ dbMod = await import('../db.js');
430
+ const dbPath = resolve(process.cwd(), config?.dbPath || 'forge.db');
431
+ db = dbMod.getDb(dbPath);
432
+ } catch (err) {
433
+ appendSystem(`DB init failed (non-fatal): ${err.message}`);
434
+ dbMod = null;
435
+ }
436
+
437
+ try {
438
+ const { makeConversationStore } = await import('../conversation-store.js');
439
+ conversationStore = makeConversationStore(config, db);
440
+ } catch (err) {
441
+ appendSystem(`Conversation store init failed (non-fatal): ${err.message}`);
442
+ conversationStore = null;
443
+ }
444
+
445
+ // Check for incomplete sessions
446
+ if (conversationStore) {
447
+ let incompleteSessions = [];
448
+ try {
449
+ incompleteSessions = await conversationStore.getIncompleteSessions();
450
+ } catch (_) { /* ignore */ }
451
+
452
+ if (incompleteSessions.length > 0) {
453
+ const resumeId = await promptResume(incompleteSessions, dbMod);
454
+ if (resumeId) {
455
+ // Restore session
456
+ sessionId = resumeId;
457
+ const history = await conversationStore.getHistory(sessionId);
458
+
459
+ // Find the last stage used
460
+ const lastRow = [...history].reverse().find((r) => r.stage);
461
+ if (lastRow) {
462
+ const stageIdx = STAGES.indexOf(lastRow.stage);
463
+ if (stageIdx !== -1) currentStageIdx = stageIdx;
464
+ }
465
+
466
+ // Restore apiMessages (user + assistant only)
467
+ apiMessages = history
468
+ .filter((r) => r.role === 'user' || r.role === 'assistant')
469
+ .map((r) => ({ role: r.role, content: r.content }));
470
+
471
+ // Display history in log
472
+ appendSystem(`Resumed session. Stage: ${STAGES[currentStageIdx]}`);
473
+ for (const row of history.filter((r) => r.role !== 'system')) {
474
+ if (row.role === 'user') appendUser(row.content);
475
+ else if (row.role === 'assistant') appendAssistant(row.content);
476
+ }
477
+
478
+ updatePhaseBar();
479
+ startInput();
480
+ return;
481
+ }
482
+ }
483
+ }
484
+
485
+ // Start fresh session
486
+ if (conversationStore) {
487
+ sessionId = conversationStore.createSession();
488
+ }
489
+
490
+ updatePhaseBar();
491
+
492
+ // Kick off first LLM turn to greet the user
493
+ await doStep(null);
494
+
495
+ } catch (err) {
496
+ appendSystem(`Init error: ${err.message}`);
497
+ }
498
+
499
+ startInput();
500
+ });
501
+
502
+ // ── Input handling (managed by startInput / readInput) ───────────────────
503
+
504
+ // ── Key bindings ──────────────────────────────────────────────────────────
505
+
506
+ // e/i = enter input mode (vim-style)
507
+ screenKey(['e', 'i'], () => {
508
+ if (inputActive) return;
509
+ startInput();
510
+ });
511
+
512
+ screenKey('tab', () => {
513
+ if (inputActive) {
514
+ // Cancel textbox input → readInput callback moves focus to log
515
+ inputBox.cancel();
516
+ } else {
517
+ startInput();
518
+ }
519
+ });
520
+
521
+ screenKey(']', () => {
522
+ if (inputActive || busy) return;
523
+ advanceStage();
524
+ });
525
+
526
+ screenKey('[', () => {
527
+ if (inputActive || busy) return;
528
+ rewindStage();
529
+ });
530
+
531
+ screenKey('b', () => {
532
+ if (inputActive) return;
533
+ navigate('main-menu');
534
+ });
535
+
536
+ container.refresh = () => { /* live view — no-op */ };
537
+ return container;
538
+ }