subrouter-cli 0.0.0-stage → 0.1.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 (62) hide show
  1. package/CLAUDE.md +51 -0
  2. package/README.md +397 -2
  3. package/config.example.json +9 -0
  4. package/package.json +21 -4
  5. package/scripts/sse-harness.ts +252 -0
  6. package/scripts/sub-wrapper.sh +11 -0
  7. package/src/adapters/anthropic.ts +311 -0
  8. package/src/adapters/openai.ts +227 -0
  9. package/src/approval.ts +21 -0
  10. package/src/chatviewport.ts +123 -0
  11. package/src/client.ts +398 -0
  12. package/src/clipboard.ts +62 -0
  13. package/src/commandpolicy.ts +272 -0
  14. package/src/commands.ts +18 -0
  15. package/src/config.ts +107 -0
  16. package/src/effort.ts +18 -0
  17. package/src/images.ts +77 -0
  18. package/src/index.ts +248 -0
  19. package/src/lineinput.ts +726 -0
  20. package/src/loop.ts +201 -0
  21. package/src/markdown.ts +244 -0
  22. package/src/repl.ts +700 -0
  23. package/src/sessions.ts +58 -0
  24. package/src/sse.ts +64 -0
  25. package/src/terminal.ts +228 -0
  26. package/src/token.ts +36 -0
  27. package/src/toolpreview.ts +54 -0
  28. package/src/tools.ts +790 -0
  29. package/src/types.ts +73 -0
  30. package/src/ui.ts +813 -0
  31. package/src/usage.ts +186 -0
  32. package/test/absolute-tools.test.ts +296 -0
  33. package/test/absolute-ui.test.ts +153 -0
  34. package/test/adapters.test.ts +205 -0
  35. package/test/anthropic.test.ts +246 -0
  36. package/test/auto-ui.test.ts +106 -0
  37. package/test/chatviewport.test.ts +49 -0
  38. package/test/client.test.ts +327 -0
  39. package/test/clipboard.test.ts +63 -0
  40. package/test/command-input.test.ts +183 -0
  41. package/test/command-tools.test.ts +141 -0
  42. package/test/commandpolicy.test.ts +252 -0
  43. package/test/disk-tools.test.ts +220 -0
  44. package/test/effort.test.ts +123 -0
  45. package/test/fixtures/openai-tools.sse +84 -0
  46. package/test/image-adapters.test.ts +78 -0
  47. package/test/image-ui.test.ts +151 -0
  48. package/test/images.test.ts +60 -0
  49. package/test/loop.test.ts +387 -0
  50. package/test/m5.test.ts +127 -0
  51. package/test/markdown.test.ts +201 -0
  52. package/test/repl-ui.test.ts +293 -0
  53. package/test/sse.test.ts +74 -0
  54. package/test/steering-ui.test.ts +182 -0
  55. package/test/steering.test.ts +68 -0
  56. package/test/terminal-ui.test.ts +144 -0
  57. package/test/terminal.test.ts +229 -0
  58. package/test/toolpreview.test.ts +51 -0
  59. package/test/tools.test.ts +227 -0
  60. package/test/ui.test.ts +635 -0
  61. package/test/usage-footer.test.ts +180 -0
  62. package/tsconfig.json +17 -0
package/src/loop.ts ADDED
@@ -0,0 +1,201 @@
1
+ // loop.ts — the agent round driver: stream a turn, run tool calls with the permission
2
+ // gate, feed results back, repeat. Dialect-agnostic: it only speaks ChatRequest /
3
+ // AssistantTurn / StreamEvent and an injectable tool executor.
4
+
5
+ import { dialectFor } from './client.ts';
6
+ import type { EffortLevel } from './effort.ts';
7
+ import { checkBudget, estimateTotal } from './token.ts';
8
+ import type { ChatMessage, ChatRequest, StopReason, StreamEvent, TokenUsage, ToolCall, ToolDefinition, AssistantTurn } from './types.ts';
9
+
10
+ /** The slice of RouterClient the loop needs — fakes implement this in tests. */
11
+ export interface ChatClient {
12
+ chat(req: ChatRequest, onEvent: (e: StreamEvent) => void, signal?: AbortSignal): Promise<AssistantTurn>;
13
+ }
14
+
15
+ export type Approval = 'yes' | 'all' | 'no';
16
+ export type ToolProgressPhase = 'start' | 'running' | 'done' | 'error' | 'denied';
17
+
18
+ export interface ToolResult {
19
+ toolCallId: string;
20
+ content: string;
21
+ isError?: boolean;
22
+ }
23
+
24
+ export interface LoopOptions {
25
+ client: ChatClient;
26
+ model: string;
27
+ /** Owned by the caller; the user message must already be appended. */
28
+ history: ChatMessage[];
29
+ system: string;
30
+ tools: ToolDefinition[];
31
+ execute: (call: ToolCall, signal?: AbortSignal) => Promise<ToolResult>;
32
+ ask: (call: ToolCall) => Promise<Approval>;
33
+ autoApprove: boolean;
34
+ /** Live file permission state; takes precedence over the turn-local approve-all latch. */
35
+ fileApproval?: () => boolean;
36
+ maxRounds: number;
37
+ maxTokens: number;
38
+ effort?: EffortLevel;
39
+ /** Model context window for 80%/95% budget warnings; warnings are skipped when absent. */
40
+ contextTokens?: number;
41
+ /** Exact token counter (anthropic count_tokens); falls back to the estimate on failure. */
42
+ countTokens?: (model: string, system: string, messages: ChatMessage[], tools?: ToolDefinition[]) => Promise<number>;
43
+ signal: AbortSignal;
44
+ onEvent: (e: StreamEvent) => void;
45
+ onToolProgress?: (phase: ToolProgressPhase, call: ToolCall, note?: string) => void;
46
+ onBudget?: (percent: 80 | 95) => void;
47
+ /** Drain queued user steering only at safe boundaries, never between tool calls/results. */
48
+ takeSteering?: () => ChatMessage[];
49
+ }
50
+
51
+ export interface TurnResult {
52
+ stopReason: StopReason;
53
+ usage?: TokenUsage;
54
+ rounds: number;
55
+ }
56
+
57
+ function abortError(): Error {
58
+ return new DOMException('The operation was aborted', 'AbortError');
59
+ }
60
+
61
+ function denialResult(call: ToolCall): ToolResult {
62
+ return { toolCallId: call.id, content: JSON.stringify({ error: 'Tool call denied by user' }), isError: true };
63
+ }
64
+
65
+ function budgetTexts(system: string, history: ChatMessage[], tools: ToolDefinition[]): string[] {
66
+ const texts = [system];
67
+ for (const m of history) {
68
+ texts.push(m.content);
69
+ for (const c of m.toolCalls ?? []) texts.push(c.name + c.argumentsJson);
70
+ }
71
+ for (const t of tools) texts.push(t.name + t.description + JSON.stringify(t.parameters));
72
+ return texts;
73
+ }
74
+
75
+ export async function runAgentTurn(opts: LoopOptions): Promise<TurnResult> {
76
+ const dialect = dialectFor(opts.model);
77
+ const toolByDef = new Map(opts.tools.map((t) => [t.name, t]));
78
+ let approveAll = opts.autoApprove;
79
+ let rounds = 0;
80
+ let warned80 = false;
81
+ let warned95 = false;
82
+ let exactFailed = false;
83
+ let usage: TokenUsage | undefined;
84
+ const applySteering = (): boolean => {
85
+ const messages = opts.takeSteering?.() ?? [];
86
+ opts.history.push(...messages);
87
+ return messages.length > 0;
88
+ };
89
+
90
+ while (rounds < opts.maxRounds) {
91
+ if (opts.signal.aborted) throw abortError();
92
+ applySteering();
93
+ if (opts.contextTokens && opts.onBudget) {
94
+ const texts = budgetTexts(opts.system, opts.history, opts.tools);
95
+ // Rough fallback only: image tokens vary by provider/resolution, not base64 length.
96
+ const imageAllowance = opts.history.reduce((total, m) => total + (m.role === 'user' ? m.images?.length ?? 0 : 0), 0) * 2048;
97
+ let total = estimateTotal(texts) + imageAllowance;
98
+ if (opts.countTokens && !exactFailed) {
99
+ try {
100
+ total = await opts.countTokens(opts.model, opts.system, opts.history, opts.tools);
101
+ } catch {
102
+ exactFailed = true; // advisory — fall back to the estimate, never retry per turn
103
+ }
104
+ }
105
+ const budget = checkBudget(total, opts.contextTokens);
106
+ if (budget.warn95 && !warned95) {
107
+ warned95 = true;
108
+ warned80 = true;
109
+ opts.onBudget(95);
110
+ } else if (budget.warn80 && !warned80) {
111
+ warned80 = true;
112
+ opts.onBudget(80);
113
+ }
114
+ }
115
+
116
+ const req: ChatRequest = {
117
+ model: opts.model,
118
+ dialect,
119
+ messages: [...opts.history], // snapshot — history grows as the round completes
120
+ system: opts.system,
121
+ tools: opts.tools,
122
+ stream: true,
123
+ maxTokens: opts.maxTokens,
124
+ effort: opts.effort,
125
+ };
126
+ const turn = await opts.client.chat(req, opts.onEvent, opts.signal);
127
+ if (turn.usage) {
128
+ usage = {
129
+ inputTokens: (usage?.inputTokens ?? 0) + turn.usage.inputTokens,
130
+ outputTokens: (usage?.outputTokens ?? 0) + turn.usage.outputTokens,
131
+ };
132
+ }
133
+ opts.history.push(turn.message);
134
+
135
+ const calls = turn.message.toolCalls ?? [];
136
+ if (turn.stopReason !== 'tool_calls' || calls.length === 0) {
137
+ if (opts.signal.aborted) throw abortError();
138
+ if (applySteering()) continue; // a final answer may have received steering while streaming
139
+ return { stopReason: turn.stopReason, usage, rounds };
140
+ }
141
+ rounds++;
142
+ // File approve-all never authorizes an always-scoped terminal command.
143
+ const denied = new Set<number>();
144
+ const beforeExecution: (ToolResult | undefined)[] = [];
145
+ const cancelled = (call: ToolCall): ToolResult => ({
146
+ toolCallId: call.id, content: JSON.stringify({ error: 'Tool call cancelled before execution' }), isError: true,
147
+ });
148
+ for (const [i, call] of calls.entries()) {
149
+ if (opts.signal.aborted) { beforeExecution[i] = cancelled(call); continue; }
150
+ if (call.argumentsInvalid) {
151
+ beforeExecution[i] = { toolCallId: call.id, content: 'invalid tool arguments', isError: true };
152
+ continue;
153
+ }
154
+ const def = toolByDef.get(call.name);
155
+ const always = def?.approvalScope === 'always';
156
+ if (!def?.requiresApproval || (!always && (opts.fileApproval?.() ?? approveAll))) continue;
157
+ try {
158
+ const answer = await opts.ask(call);
159
+ if (opts.signal.aborted) { beforeExecution[i] = cancelled(call); continue; }
160
+ if (always ? answer !== 'yes' : answer === 'no') {
161
+ denied.add(i);
162
+ beforeExecution[i] = denialResult(call);
163
+ } else if (!always && answer === 'all') {
164
+ approveAll = true;
165
+ }
166
+ } catch (err) {
167
+ beforeExecution[i] = opts.signal.aborted ? cancelled(call) : {
168
+ toolCallId: call.id, content: err instanceof Error ? err.message : 'tool approval failed', isError: true,
169
+ };
170
+ }
171
+ }
172
+
173
+ for (const call of calls) if (!opts.signal.aborted) opts.onToolProgress?.('start', call);
174
+ const results = await Promise.all(calls.map(async (call, i) => {
175
+ if (beforeExecution[i]) return beforeExecution[i]!;
176
+ if (opts.signal.aborted) return cancelled(call);
177
+ try { return await opts.execute(call, opts.signal); }
178
+ catch (err) {
179
+ return { toolCallId: call.id, content: err instanceof Error ? err.message : 'tool execution failed', isError: true };
180
+ }
181
+ }));
182
+ // Pair every recorded call before propagating abort, including approval cancellation.
183
+ for (const [i, result] of results.entries()) {
184
+ const call = calls[i];
185
+ opts.history.push({
186
+ role: 'tool',
187
+ content: result.content,
188
+ toolCallId: call.id,
189
+ isError: result.isError,
190
+ });
191
+ opts.onToolProgress?.(denied.has(i) ? 'denied' : result.isError ? 'error' : 'done', call, result.isError ? result.content : undefined);
192
+ }
193
+ if (opts.signal.aborted) throw abortError();
194
+ }
195
+
196
+ opts.history.push({
197
+ role: 'assistant',
198
+ content: '(stopped after too many tool calls)',
199
+ });
200
+ return { stopReason: 'length', usage, rounds };
201
+ }
@@ -0,0 +1,244 @@
1
+ // markdown.ts — a minimal, dependency-free markdown renderer for the terminal. Streams
2
+ // incrementally (the REPL) or renders a whole string at once (one-shot). Code spans/blocks
3
+ // use the blue accent, headings are violet, list markers are blue, blockquotes are dim.
4
+ // Italic is deliberately unsupported: `*`/`_` collide with identifiers and arithmetic.
5
+
6
+ import { ui, accent, violet, green, bold, dim } from './ui.ts';
7
+
8
+ const FENCE = /^\s*([`~]{3,})/;
9
+ const CLOSE_FENCE = /^\s*[`~]{3,}\s*$/;
10
+
11
+ /** Inline spans within a non-code line: `code`, **bold**, [text](url). Single-pass so
12
+ * code spans are never re-interpreted by the later bold/link rules. */
13
+ function renderInline(line: string): string {
14
+ let out = '';
15
+ let i = 0;
16
+ while (i < line.length) {
17
+ const c = line[i];
18
+ if (c === '`') {
19
+ const end = line.indexOf('`', i + 1);
20
+ if (end !== -1) {
21
+ out += accent(line.slice(i + 1, end));
22
+ i = end + 1;
23
+ continue;
24
+ }
25
+ }
26
+ if (c === '*' && line[i + 1] === '*') {
27
+ const end = line.indexOf('**', i + 2);
28
+ if (end !== -1) {
29
+ out += bold(renderInline(line.slice(i + 2, end)));
30
+ i = end + 2;
31
+ continue;
32
+ }
33
+ }
34
+ if (c === '[') {
35
+ const m = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(line.slice(i));
36
+ if (m) {
37
+ out += accent(m[1]); // drop the URL — it is rarely readable in a terminal
38
+ i += m[0].length;
39
+ continue;
40
+ }
41
+ }
42
+ out += c;
43
+ i++;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** One non-code line: headings, blockquotes, task/bullet/numbered lists, else inline. */
49
+ function renderLine(line: string): string {
50
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
51
+ if (heading) return violet(bold(line));
52
+ if (/^\s*>\s?/.test(line)) return dim(line);
53
+ const task = /^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/.exec(line);
54
+ if (task) {
55
+ const box = task[2].toLowerCase() === 'x' ? '☒' : '☐';
56
+ return task[1] + (box === '☒' ? green(box + ' ') : accent(box + ' ')) + renderInline(task[3]);
57
+ }
58
+ const bullet = /^(\s*)([-*+])\s+(.*)$/.exec(line);
59
+ if (bullet) return bullet[1] + accent(bullet[2] + ' ') + renderInline(bullet[3]);
60
+ const num = /^(\s*)(\d+)([.)])\s+(.*)$/.exec(line);
61
+ if (num) return num[1] + accent(num[2] + num[3] + ' ') + renderInline(num[4]);
62
+ return renderInline(line);
63
+ }
64
+
65
+ /** Render a whole markdown string to ANSI (the non-streaming one-shot path). */
66
+ export function renderMarkdown(text: string): string {
67
+ const out: string[] = [];
68
+ let fence: string | null = null;
69
+ for (const line of text.split('\n')) {
70
+ const open = FENCE.exec(line);
71
+ if (fence) {
72
+ if (open && open[1][0] === fence && CLOSE_FENCE.test(line)) {
73
+ fence = null;
74
+ out.push(dim(line));
75
+ } else {
76
+ out.push(accent(line));
77
+ }
78
+ continue;
79
+ }
80
+ if (open) {
81
+ fence = open[1][0];
82
+ out.push(dim(line));
83
+ continue;
84
+ }
85
+ out.push(renderLine(line));
86
+ }
87
+ return out.join('\n');
88
+ }
89
+
90
+ /**
91
+ * Append-only streaming renderer. Only ambiguous Markdown prefixes/tokens are held;
92
+ * ordinary text and opened bold/code spans are visible before a newline arrives.
93
+ * Styles are self-contained per chunk, so UI updates cannot inherit an open ANSI style.
94
+ * Unclosed inline delimiters style through the end of their line; no cursor rewrites.
95
+ */
96
+ export class MarkdownStream {
97
+ private pending = '';
98
+ private fence: string | null = null;
99
+ private mode: 'plain' | 'heading' | 'quote' | 'code' | 'fence' | null = null;
100
+ private inBold = false;
101
+ private inCode = false;
102
+ private started = false;
103
+ private blankLine = false;
104
+
105
+ write(text: string): void {
106
+ const lines = text.split('\n');
107
+ for (let i = 0; i < lines.length; i++) {
108
+ const complete = i < lines.length - 1;
109
+ this.pending += lines[i];
110
+ this.drain(complete);
111
+ if (complete) {
112
+ if (this.mode !== null) ui.say(''); // finish the already streamed logical line
113
+ else this.blankLine = this.started; // defer/drop prose blank gaps
114
+ this.resetLine();
115
+ }
116
+ }
117
+ }
118
+
119
+ flush(): void {
120
+ if (this.pending || this.mode !== null) this.drain(true);
121
+ this.resetLine(); // ui.endTurn() owns the final newline
122
+ this.blankLine = false;
123
+ }
124
+
125
+ private resetLine(): void {
126
+ this.pending = '';
127
+ this.mode = null;
128
+ this.inBold = false;
129
+ this.inCode = false;
130
+ }
131
+
132
+ private emit(text: string): void {
133
+ if (!text) return;
134
+ if (this.blankLine) ui.say('');
135
+ this.blankLine = false;
136
+ this.started = true;
137
+ ui.streamText(text);
138
+ }
139
+
140
+ private drain(final: boolean): void {
141
+ if (this.mode === null && !this.prefix(final)) return;
142
+ if (this.mode !== 'plain') {
143
+ const text = this.pending;
144
+ this.pending = '';
145
+ if (!text) return;
146
+ if (this.mode === 'heading') this.emit(violet(bold(text)));
147
+ else if (this.mode === 'code') this.emit(accent(text));
148
+ else this.emit(dim(text));
149
+ return;
150
+ }
151
+
152
+ let out = '';
153
+ let i = 0;
154
+ const style = (text: string): string => {
155
+ if (this.inCode) text = accent(text);
156
+ return this.inBold ? bold(text) : text;
157
+ };
158
+ while (i < this.pending.length) {
159
+ const rest = this.pending.slice(i);
160
+ if (rest[0] === '`') {
161
+ this.inCode = !this.inCode;
162
+ i++;
163
+ } else if (!this.inCode && rest[0] === '*') {
164
+ if (rest.length === 1 && !final) break; // possibly half of **
165
+ if (rest.startsWith('**')) {
166
+ this.inBold = !this.inBold;
167
+ i += 2;
168
+ } else {
169
+ out += style('*');
170
+ i++;
171
+ }
172
+ } else if (!this.inCode && rest[0] === '[') {
173
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)/.exec(rest);
174
+ if (link) {
175
+ out += style(accent(link[1]));
176
+ i += link[0].length;
177
+ } else {
178
+ // A bounded lookahead keeps links intact without buffering an entire reply
179
+ // that merely contains an unmatched '['. Later Markdown still gets styled.
180
+ const possible = /^\[[^\]]*$/.test(rest) || /^\[[^\]]+\](?:\([^\s)]*)?$/.test(rest);
181
+ if (!final && possible && rest.length < 256) break;
182
+ out += style('[');
183
+ i++;
184
+ }
185
+ } else {
186
+ const run = /^[^`*\[]+/.exec(rest);
187
+ const text = run ? run[0] : rest[0];
188
+ out += style(text);
189
+ i += text.length;
190
+ }
191
+ }
192
+ this.pending = this.pending.slice(i);
193
+ this.emit(out);
194
+ }
195
+
196
+ /** Decide the line style once, holding only prefixes that could change its meaning. */
197
+ private prefix(final: boolean): boolean {
198
+ const text = this.pending;
199
+ const open = FENCE.exec(text);
200
+ if (this.fence) {
201
+ if (open && open[1][0] === this.fence && CLOSE_FENCE.test(text)) {
202
+ if (!final) return false; // a closing fence might still acquire code text
203
+ this.fence = null;
204
+ this.mode = 'fence';
205
+ } else {
206
+ if (!final && /^\s*([`~]{0,2})$/.test(text)) return false;
207
+ this.mode = 'code';
208
+ }
209
+ return true;
210
+ }
211
+ if (!text.trim()) return false; // prose whitespace is deferred until content exists
212
+ if (open) {
213
+ this.fence = open[1][0];
214
+ this.mode = 'fence';
215
+ return true;
216
+ }
217
+ if (/^#{1,6}\s/.test(text)) this.mode = 'heading';
218
+ else if (/^\s*>/.test(text)) this.mode = 'quote';
219
+ else {
220
+ const bullet = /^(\s*)([-*+])\s+/.exec(text);
221
+ const numbered = /^(\s*)(\d+[.)])\s+/.exec(text);
222
+ if (bullet) {
223
+ const rest = text.slice(bullet[0].length);
224
+ if (!final && text.length < 256 && /^(?:|\[|\[[ xX]|\[[ xX]\])$/.test(rest)) return false;
225
+ const task = /^\[([ xX])\]\s+/.exec(rest);
226
+ if (task) {
227
+ const box = task[1].toLowerCase() === 'x' ? '☒ ' : '☐ ';
228
+ this.emit(bullet[1] + (task[1].toLowerCase() === 'x' ? green(box) : accent(box)));
229
+ this.pending = rest.slice(task[0].length);
230
+ } else {
231
+ this.emit(bullet[1] + accent(bullet[2] + ' '));
232
+ this.pending = rest;
233
+ }
234
+ } else if (numbered) {
235
+ this.emit(numbered[1] + accent(numbered[2] + ' '));
236
+ this.pending = text.slice(numbered[0].length);
237
+ } else if (!final && text.length < 256 && /^(?:\s*[`~]{1,2}|#{1,6}|\s*[-*+]|\s*\d+[.)]?)$/.test(text)) {
238
+ return false;
239
+ }
240
+ this.mode = 'plain';
241
+ }
242
+ return true;
243
+ }
244
+ }