tokenmaw 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +150 -0
  2. package/agents/coordinator.md +13 -0
  3. package/agents/explorer.md +21 -0
  4. package/agents/implement.md +23 -0
  5. package/agents/main.md +25 -0
  6. package/agents/review.md +21 -0
  7. package/dist/backend.js +595 -0
  8. package/dist/cli.js +101 -0
  9. package/dist/config.js +155 -0
  10. package/dist/diff.js +45 -0
  11. package/dist/domain/agent.js +1 -0
  12. package/dist/fetch.js +110 -0
  13. package/dist/infra/file-snapshot.js +54 -0
  14. package/dist/infra/tools.js +1300 -0
  15. package/dist/markdown.js +274 -0
  16. package/dist/model-config.js +48 -0
  17. package/dist/policy.js +80 -0
  18. package/dist/responses.js +81 -0
  19. package/dist/runtime/agent-registry.js +139 -0
  20. package/dist/runtime/agent-runtime.js +993 -0
  21. package/dist/runtime/agent-store.js +152 -0
  22. package/dist/runtime/locks.js +46 -0
  23. package/dist/runtime/session-timeline.js +92 -0
  24. package/dist/tools/index.js +4 -0
  25. package/dist/tools/registry.js +51 -0
  26. package/dist/tools/types.js +1 -0
  27. package/dist/ui/clipboard.js +24 -0
  28. package/dist/ui/commands.js +20 -0
  29. package/dist/ui/composer-layout.js +31 -0
  30. package/dist/ui/fullscreen-tui.js +1405 -0
  31. package/dist/ui/markdown.js +81 -0
  32. package/dist/ui/syntax.js +17 -0
  33. package/dist/ui/tui-design.js +94 -0
  34. package/dist/ui/welcome.js +24 -0
  35. package/dist/version.js +4 -0
  36. package/docs/architecture-revision.md +281 -0
  37. package/package.json +47 -0
  38. package/skills/debugging.md +18 -0
  39. package/skills/git-workflow.md +14 -0
  40. package/skills/node-express.md +27 -0
  41. package/skills/python-flask.md +22 -0
  42. package/skills/react-component.md +24 -0
  43. package/skills/sql-database.md +18 -0
  44. package/skills/testing.md +12 -0
@@ -0,0 +1,595 @@
1
+ /**
2
+ * backend.ts — LLM backend abstraction.
3
+ *
4
+ * Supports:
5
+ * • OpenAI-compatible chat completions over SSE
6
+ * • Anthropic Messages API over SSE
7
+ * • Local NDJSON chat endpoints used by some OpenAI-style runtimes
8
+ *
9
+ * The backend is selected via the LLM_BACKEND env var or .agentrc config.
10
+ */
11
+ import { resilientFetch, FetchError } from './fetch.js';
12
+ import { responsesStream } from './responses.js';
13
+ import { CODER_VERSION } from './version.js';
14
+ const USER_AGENT = `tokenmaw/${CODER_VERSION}`;
15
+ export function transportHeaders(config) {
16
+ const headers = { 'user-agent': USER_AGENT };
17
+ if (config.sessionId)
18
+ headers['x-opencode-session'] = config.sessionId;
19
+ return headers;
20
+ }
21
+ const ANTHROPIC_VERSION = '2023-06-01';
22
+ const DEFAULT_ANTHROPIC_MAX_TOKENS = 8192;
23
+ export function normalizeOpenAIBaseUrl(baseUrl) {
24
+ return baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
25
+ }
26
+ export function openAIChatCompletionsUrl(baseUrl) {
27
+ return `${normalizeOpenAIBaseUrl(baseUrl)}/v1/chat/completions`;
28
+ }
29
+ function parseNdjsonLine(line) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed)
32
+ return null;
33
+ try {
34
+ return JSON.parse(trimmed);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function normalizeToolArguments(args) {
41
+ const normalized = {};
42
+ for (const [key, value] of Object.entries(args ?? {})) {
43
+ normalized[key] = typeof value === 'string' ? value : JSON.stringify(value);
44
+ }
45
+ return normalized;
46
+ }
47
+ function toolCallId(prefix = 'call') {
48
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
49
+ }
50
+ // ── Local NDJSON backend ────────────────────────────────────────────────────
51
+ async function* ollamaStream(config, systemPrompt, messages, tools, signal) {
52
+ const body = {
53
+ model: config.model,
54
+ stream: true,
55
+ messages: [{ role: 'system', content: String(systemPrompt) }, ...messages],
56
+ };
57
+ if (tools?.length)
58
+ body.tools = tools;
59
+ const response = await resilientFetch(`${config.baseUrl.replace(/\/$/, '')}/api/chat`, {
60
+ method: 'POST',
61
+ headers: { 'content-type': 'application/json', ...transportHeaders(config) },
62
+ body: JSON.stringify(body),
63
+ retries: 2,
64
+ timeout: 120_000,
65
+ signal,
66
+ });
67
+ if (!response.ok)
68
+ throw new FetchError(`NDJSON backend HTTP ${response.status}`, response.status, false);
69
+ if (!response.body)
70
+ throw new Error('No response body from NDJSON backend');
71
+ const reader = response.body.getReader();
72
+ const decoder = new TextDecoder();
73
+ let buffer = '';
74
+ while (true) {
75
+ const { done, value } = await reader.read();
76
+ buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
77
+ const lines = buffer.split('\n');
78
+ buffer = done ? '' : lines.pop() ?? '';
79
+ for (const line of lines) {
80
+ const obj = parseNdjsonLine(line);
81
+ if (!obj)
82
+ continue;
83
+ if (obj.message?.thinking)
84
+ yield { content: null, thinking: obj.message.thinking, done: false };
85
+ if (obj.message?.content) {
86
+ yield { content: obj.message.content, done: false };
87
+ }
88
+ if (obj.message?.tool_calls?.length) {
89
+ yield { content: null, toolCalls: obj.message.tool_calls, done: false };
90
+ }
91
+ if (obj.done) {
92
+ yield { content: null, done: true };
93
+ return;
94
+ }
95
+ }
96
+ if (done)
97
+ break;
98
+ }
99
+ }
100
+ async function ollamaNonStream(config, systemPrompt, messages, tools) {
101
+ const body = {
102
+ model: config.model,
103
+ stream: false,
104
+ messages: [{ role: 'system', content: String(systemPrompt) }, ...messages],
105
+ };
106
+ if (tools?.length)
107
+ body.tools = tools;
108
+ const response = await resilientFetch(`${config.baseUrl.replace(/\/$/, '')}/api/chat`, {
109
+ method: 'POST',
110
+ headers: { 'content-type': 'application/json', ...transportHeaders(config) },
111
+ body: JSON.stringify(body),
112
+ retries: 2,
113
+ timeout: 120_000,
114
+ });
115
+ if (!response.ok) {
116
+ throw new FetchError(`NDJSON backend HTTP ${response.status}: ${await response.text()}`, response.status, false);
117
+ }
118
+ const data = await response.json();
119
+ return {
120
+ content: data.message?.content ?? null,
121
+ toolCalls: data.message?.tool_calls,
122
+ done: true,
123
+ };
124
+ }
125
+ function convertToOpenAIMessages(systemPrompt, messages) {
126
+ const result = [{ role: 'system', content: systemPrompt }];
127
+ for (const message of messages) {
128
+ if (message.role === 'tool') {
129
+ result.push({
130
+ role: 'tool',
131
+ content: message.content,
132
+ ...(message.tool_use_id ? { tool_call_id: message.tool_use_id } : {}),
133
+ });
134
+ continue;
135
+ }
136
+ if (message.tool_calls?.length) {
137
+ result.push({
138
+ role: 'assistant',
139
+ content: message.content,
140
+ tool_calls: message.tool_calls.map((toolCall) => ({
141
+ id: toolCall.id ?? toolCallId(),
142
+ type: 'function',
143
+ function: {
144
+ name: toolCall.function.name,
145
+ arguments: JSON.stringify(toolCall.function.arguments),
146
+ },
147
+ })),
148
+ });
149
+ continue;
150
+ }
151
+ result.push({ role: message.role, content: message.content });
152
+ }
153
+ return result;
154
+ }
155
+ function convertToOllamaToolCalls(openaiCalls) {
156
+ return openaiCalls.map((toolCall) => {
157
+ let args = {};
158
+ try {
159
+ args = JSON.parse(toolCall.function.arguments);
160
+ }
161
+ catch {
162
+ // Keep empty args if malformed.
163
+ }
164
+ return {
165
+ id: toolCall.id,
166
+ function: { name: toolCall.function.name, arguments: args },
167
+ };
168
+ });
169
+ }
170
+ function convertToolsToOpenAI(tools) {
171
+ if (!tools?.length)
172
+ return undefined;
173
+ return tools.map((tool) => ({
174
+ type: 'function',
175
+ function: {
176
+ name: tool.function.name,
177
+ description: tool.function.description,
178
+ parameters: tool.function.parameters,
179
+ },
180
+ }));
181
+ }
182
+ function applyOpenAIRequestOptions(body, config) {
183
+ const options = config.requestOptions;
184
+ if (!options)
185
+ return;
186
+ if (typeof options.temperature === 'number')
187
+ body.temperature = options.temperature;
188
+ if (typeof options.topP === 'number')
189
+ body.top_p = options.topP;
190
+ if (typeof options.maxTokens === 'number')
191
+ body.max_tokens = options.maxTokens;
192
+ if (options.extraBody)
193
+ Object.assign(body, options.extraBody);
194
+ }
195
+ async function* openaiStream(config, systemPrompt, messages, tools, signal) {
196
+ const headers = { 'content-type': 'application/json', ...transportHeaders(config) };
197
+ if (config.apiKey)
198
+ headers['authorization'] = `Bearer ${config.apiKey}`;
199
+ const body = {
200
+ model: config.model,
201
+ stream: true,
202
+ messages: convertToOpenAIMessages(systemPrompt, messages),
203
+ };
204
+ applyOpenAIRequestOptions(body, config);
205
+ const openaiTools = convertToolsToOpenAI(tools);
206
+ if (openaiTools)
207
+ body.tools = openaiTools;
208
+ const response = await resilientFetch(openAIChatCompletionsUrl(config.baseUrl), {
209
+ method: 'POST',
210
+ headers,
211
+ body: JSON.stringify(body),
212
+ retries: 2,
213
+ timeout: 120_000,
214
+ signal,
215
+ });
216
+ if (!response.ok)
217
+ throw new FetchError(`OpenAI-compatible HTTP ${response.status}: ${await response.text()}`, response.status, false);
218
+ if (!response.body)
219
+ throw new Error('No response body from OpenAI-compatible backend');
220
+ const reader = response.body.getReader();
221
+ const decoder = new TextDecoder();
222
+ let buffer = '';
223
+ const pendingToolCalls = new Map();
224
+ while (true) {
225
+ const { done, value } = await reader.read();
226
+ if (done)
227
+ break;
228
+ buffer += decoder.decode(value, { stream: true });
229
+ const lines = buffer.split('\n');
230
+ buffer = lines.pop() ?? '';
231
+ for (const line of lines) {
232
+ const trimmed = line.trim();
233
+ if (!trimmed || trimmed === 'data: [DONE]') {
234
+ if (trimmed === 'data: [DONE]' && pendingToolCalls.size > 0) {
235
+ yield { content: null, toolCalls: convertToOllamaToolCalls([...pendingToolCalls.values()]), done: false };
236
+ pendingToolCalls.clear();
237
+ }
238
+ if (trimmed === 'data: [DONE]') {
239
+ yield { content: null, done: true };
240
+ return;
241
+ }
242
+ continue;
243
+ }
244
+ if (!trimmed.startsWith('data: '))
245
+ continue;
246
+ try {
247
+ const parsed = JSON.parse(trimmed.slice(6));
248
+ const choice = parsed.choices?.[0];
249
+ if (!choice)
250
+ continue;
251
+ const thinking = choice.delta?.reasoning_content ?? choice.delta?.reasoning;
252
+ if (thinking)
253
+ yield { content: null, thinking, done: false };
254
+ if (choice.delta?.content) {
255
+ yield { content: choice.delta.content, done: false };
256
+ }
257
+ if (choice.delta?.tool_calls) {
258
+ for (const toolCall of choice.delta.tool_calls) {
259
+ if (toolCall.id) {
260
+ pendingToolCalls.set(toolCall.index, {
261
+ id: toolCall.id,
262
+ type: 'function',
263
+ function: {
264
+ name: toolCall.function?.name ?? '',
265
+ arguments: toolCall.function?.arguments ?? '',
266
+ },
267
+ });
268
+ }
269
+ else if (pendingToolCalls.has(toolCall.index)) {
270
+ const existing = pendingToolCalls.get(toolCall.index);
271
+ if (toolCall.function?.name)
272
+ existing.function.name += toolCall.function.name;
273
+ if (toolCall.function?.arguments)
274
+ existing.function.arguments += toolCall.function.arguments;
275
+ }
276
+ }
277
+ }
278
+ if (choice.finish_reason === 'tool_calls' && pendingToolCalls.size > 0) {
279
+ yield { content: null, toolCalls: convertToOllamaToolCalls([...pendingToolCalls.values()]), done: false };
280
+ pendingToolCalls.clear();
281
+ }
282
+ if (choice.finish_reason === 'stop') {
283
+ yield { content: null, done: true };
284
+ return;
285
+ }
286
+ }
287
+ catch {
288
+ // Skip malformed SSE chunks.
289
+ }
290
+ }
291
+ }
292
+ }
293
+ async function openaiNonStream(config, systemPrompt, messages, tools) {
294
+ const headers = { 'content-type': 'application/json', ...transportHeaders(config) };
295
+ if (config.apiKey)
296
+ headers['authorization'] = `Bearer ${config.apiKey}`;
297
+ const body = {
298
+ model: config.model,
299
+ stream: false,
300
+ messages: convertToOpenAIMessages(systemPrompt, messages),
301
+ };
302
+ applyOpenAIRequestOptions(body, config);
303
+ const openaiTools = convertToolsToOpenAI(tools);
304
+ if (openaiTools)
305
+ body.tools = openaiTools;
306
+ const response = await resilientFetch(openAIChatCompletionsUrl(config.baseUrl), {
307
+ method: 'POST',
308
+ headers,
309
+ body: JSON.stringify(body),
310
+ retries: 2,
311
+ timeout: 120_000,
312
+ });
313
+ if (!response.ok)
314
+ throw new FetchError(`OpenAI-compatible HTTP ${response.status}: ${await response.text()}`, response.status, false);
315
+ const data = await response.json();
316
+ const choice = data.choices?.[0];
317
+ return {
318
+ content: choice?.message?.content ?? null,
319
+ toolCalls: choice?.message?.tool_calls ? convertToOllamaToolCalls(choice.message.tool_calls) : undefined,
320
+ done: true,
321
+ };
322
+ }
323
+ function convertToolsToAnthropic(tools) {
324
+ if (!tools?.length)
325
+ return undefined;
326
+ return tools.map((tool) => ({
327
+ name: tool.function.name,
328
+ description: tool.function.description,
329
+ input_schema: tool.function.parameters,
330
+ }));
331
+ }
332
+ function convertToAnthropicMessages(messages) {
333
+ const result = [];
334
+ for (const message of messages) {
335
+ if (message.role === 'tool') {
336
+ if (message.tool_use_id) {
337
+ result.push({
338
+ role: 'user',
339
+ content: [{
340
+ type: 'tool_result',
341
+ tool_use_id: message.tool_use_id,
342
+ content: message.content ?? '',
343
+ }],
344
+ });
345
+ }
346
+ else {
347
+ result.push({ role: 'user', content: message.content ?? '' });
348
+ }
349
+ continue;
350
+ }
351
+ if (message.tool_calls?.length) {
352
+ const content = [];
353
+ if (message.content)
354
+ content.push({ type: 'text', text: message.content });
355
+ for (const toolCall of message.tool_calls) {
356
+ content.push({
357
+ type: 'tool_use',
358
+ id: toolCall.id ?? toolCallId('toolu'),
359
+ name: toolCall.function.name,
360
+ input: toolCall.function.arguments,
361
+ });
362
+ }
363
+ result.push({ role: 'assistant', content });
364
+ continue;
365
+ }
366
+ result.push({
367
+ role: message.role === 'assistant' ? 'assistant' : 'user',
368
+ content: message.content ?? '',
369
+ });
370
+ }
371
+ return result;
372
+ }
373
+ function buildAnthropicHeaders(config) {
374
+ const headers = {
375
+ 'content-type': 'application/json',
376
+ 'anthropic-version': ANTHROPIC_VERSION,
377
+ ...transportHeaders(config),
378
+ };
379
+ if (config.apiKey)
380
+ headers['x-api-key'] = config.apiKey;
381
+ return headers;
382
+ }
383
+ function anthropicToolStateToCall(state) {
384
+ let input = {};
385
+ if (state.inputText.trim()) {
386
+ try {
387
+ input = normalizeToolArguments(JSON.parse(state.inputText));
388
+ }
389
+ catch {
390
+ input = normalizeToolArguments(state.initialInput);
391
+ }
392
+ }
393
+ else {
394
+ input = normalizeToolArguments(state.initialInput);
395
+ }
396
+ return {
397
+ id: state.id,
398
+ function: {
399
+ name: state.name,
400
+ arguments: input,
401
+ },
402
+ };
403
+ }
404
+ function parseSseFrame(frame) {
405
+ const lines = frame.split('\n');
406
+ let event;
407
+ const dataLines = [];
408
+ for (const line of lines) {
409
+ if (line.startsWith('event:')) {
410
+ event = line.slice(6).trim();
411
+ }
412
+ else if (line.startsWith('data:')) {
413
+ dataLines.push(line.slice(5).trimStart());
414
+ }
415
+ }
416
+ return { event, data: dataLines.join('\n') };
417
+ }
418
+ async function* anthropicStream(config, systemPrompt, messages, tools, signal) {
419
+ const body = {
420
+ model: config.model,
421
+ max_tokens: DEFAULT_ANTHROPIC_MAX_TOKENS,
422
+ stream: true,
423
+ system: systemPrompt,
424
+ messages: convertToAnthropicMessages(messages),
425
+ };
426
+ const anthropicTools = convertToolsToAnthropic(tools);
427
+ if (anthropicTools)
428
+ body.tools = anthropicTools;
429
+ const response = await resilientFetch(`${config.baseUrl.replace(/\/$/, '')}/v1/messages`, {
430
+ method: 'POST',
431
+ headers: buildAnthropicHeaders(config),
432
+ body: JSON.stringify(body),
433
+ retries: 2,
434
+ timeout: 120_000,
435
+ signal,
436
+ });
437
+ if (!response.ok)
438
+ throw new FetchError(`Anthropic HTTP ${response.status}: ${await response.text()}`, response.status, false);
439
+ if (!response.body)
440
+ throw new Error('No response body from Anthropic backend');
441
+ const reader = response.body.getReader();
442
+ const decoder = new TextDecoder();
443
+ let buffer = '';
444
+ const pendingToolCalls = new Map();
445
+ while (true) {
446
+ const { done, value } = await reader.read();
447
+ if (done)
448
+ break;
449
+ buffer += decoder.decode(value, { stream: true });
450
+ const frames = buffer.split('\n\n');
451
+ buffer = frames.pop() ?? '';
452
+ for (const frame of frames) {
453
+ const { event, data } = parseSseFrame(frame);
454
+ if (!data || data === '[DONE]') {
455
+ if (data === '[DONE]') {
456
+ yield { content: null, done: true };
457
+ return;
458
+ }
459
+ continue;
460
+ }
461
+ if (event === 'ping')
462
+ continue;
463
+ try {
464
+ const parsed = JSON.parse(data);
465
+ if (event === 'error') {
466
+ throw new Error(parsed.error?.message ?? 'Anthropic streaming error');
467
+ }
468
+ if (event === 'content_block_start' && parsed.content_block?.type === 'tool_use') {
469
+ pendingToolCalls.set(parsed.index ?? pendingToolCalls.size, {
470
+ id: parsed.content_block.id ?? toolCallId('toolu'),
471
+ name: parsed.content_block.name ?? '',
472
+ inputText: '',
473
+ initialInput: parsed.content_block.input,
474
+ });
475
+ continue;
476
+ }
477
+ if (event === 'content_block_delta') {
478
+ if (parsed.delta?.type === 'thinking_delta' && parsed.delta.thinking) {
479
+ yield { content: null, thinking: parsed.delta.thinking, done: false };
480
+ }
481
+ if (parsed.delta?.type === 'text_delta' && parsed.delta.text) {
482
+ yield { content: parsed.delta.text, done: false };
483
+ }
484
+ if (parsed.delta?.type === 'input_json_delta' && typeof parsed.index === 'number') {
485
+ const existing = pendingToolCalls.get(parsed.index);
486
+ if (existing && parsed.delta.partial_json) {
487
+ existing.inputText += parsed.delta.partial_json;
488
+ }
489
+ }
490
+ continue;
491
+ }
492
+ if (event === 'content_block_stop' && typeof parsed.index === 'number' && pendingToolCalls.has(parsed.index)) {
493
+ const toolCall = anthropicToolStateToCall(pendingToolCalls.get(parsed.index));
494
+ pendingToolCalls.delete(parsed.index);
495
+ yield { content: null, toolCalls: [toolCall], done: false };
496
+ continue;
497
+ }
498
+ if (event === 'message_stop') {
499
+ yield { content: null, done: true };
500
+ return;
501
+ }
502
+ }
503
+ catch {
504
+ // Ignore malformed frames and keep streaming.
505
+ }
506
+ }
507
+ }
508
+ }
509
+ async function anthropicNonStream(config, systemPrompt, messages, tools) {
510
+ const body = {
511
+ model: config.model,
512
+ max_tokens: DEFAULT_ANTHROPIC_MAX_TOKENS,
513
+ stream: false,
514
+ system: systemPrompt,
515
+ messages: convertToAnthropicMessages(messages),
516
+ };
517
+ const anthropicTools = convertToolsToAnthropic(tools);
518
+ if (anthropicTools)
519
+ body.tools = anthropicTools;
520
+ const response = await resilientFetch(`${config.baseUrl.replace(/\/$/, '')}/v1/messages`, {
521
+ method: 'POST',
522
+ headers: buildAnthropicHeaders(config),
523
+ body: JSON.stringify(body),
524
+ retries: 2,
525
+ timeout: 120_000,
526
+ });
527
+ if (!response.ok)
528
+ throw new FetchError(`Anthropic HTTP ${response.status}: ${await response.text()}`, response.status, false);
529
+ const data = await response.json();
530
+ const textParts = [];
531
+ const toolCalls = [];
532
+ for (const block of data.content ?? []) {
533
+ if (block.type === 'text' && block.text) {
534
+ textParts.push(block.text);
535
+ continue;
536
+ }
537
+ if (block.type === 'tool_use') {
538
+ toolCalls.push({
539
+ id: block.id ?? toolCallId('toolu'),
540
+ function: {
541
+ name: block.name ?? '',
542
+ arguments: normalizeToolArguments(block.input),
543
+ },
544
+ });
545
+ }
546
+ }
547
+ return {
548
+ content: textParts.join('') || null,
549
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
550
+ done: true,
551
+ };
552
+ }
553
+ // ── Public API ──────────────────────────────────────────────────────────────
554
+ export function chatStream(config, systemPrompt, messages, tools, signal) {
555
+ if (config.type === 'openai' && config.wireApi === 'responses')
556
+ return responsesStream(config, systemPrompt, messages, tools, signal);
557
+ if (config.type === 'anthropic')
558
+ return anthropicStream(config, systemPrompt, messages, tools, signal);
559
+ if (config.type === 'openai')
560
+ return openaiStream(config, systemPrompt, messages, tools, signal);
561
+ return ollamaStream(config, systemPrompt, messages, tools, signal);
562
+ }
563
+ export async function chatNonStream(config, systemPrompt, messages, tools) {
564
+ if (config.type === 'openai' && config.wireApi === 'responses') {
565
+ const result = { content: '', thinking: '', toolCalls: [], done: true };
566
+ for await (const chunk of responsesStream(config, systemPrompt, messages, tools)) {
567
+ result.content += chunk.content ?? '';
568
+ result.thinking += chunk.thinking ?? '';
569
+ result.toolCalls.push(...chunk.toolCalls ?? []);
570
+ }
571
+ return result;
572
+ }
573
+ if (config.type === 'anthropic')
574
+ return anthropicNonStream(config, systemPrompt, messages, tools);
575
+ if (config.type === 'openai')
576
+ return openaiNonStream(config, systemPrompt, messages, tools);
577
+ return ollamaNonStream(config, systemPrompt, messages, tools);
578
+ }
579
+ /**
580
+ * Detect backend type from a URL heuristically.
581
+ * - Anthropic hosts or `/v1/messages` → anthropic
582
+ * - `/v1` or known OpenAI hosts → openai
583
+ * - localhost:11434 → ollama transport
584
+ * - Otherwise → openai
585
+ */
586
+ export function detectBackend(url) {
587
+ const lower = url.toLowerCase();
588
+ if (lower.includes('api.anthropic.com') || lower.includes('/v1/messages'))
589
+ return 'anthropic';
590
+ if (lower.includes('/v1') || lower.includes('api.openai.com'))
591
+ return 'openai';
592
+ if (lower.includes('localhost:11434') || lower.includes('127.0.0.1:11434'))
593
+ return 'ollama';
594
+ return 'openai';
595
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { loadConfig, saveConfig, saveSelectedModel } from './config.js';
4
+ import { setToolPolicy } from './infra/tools.js';
5
+ import { resolveModelConfig } from './model-config.js';
6
+ import { defaultPolicy } from './policy.js';
7
+ import { AgentRegistry } from './runtime/agent-registry.js';
8
+ import { AgentRuntime } from './runtime/agent-runtime.js';
9
+ import { runFullscreenTui } from './ui/fullscreen-tui.js';
10
+ import { CODER_VERSION } from './version.js';
11
+ async function main() {
12
+ const program = new Command();
13
+ program.name('maw').description('Document-driven coding agent runtime').version(CODER_VERSION);
14
+ program.allowExcessArguments(false).showSuggestionAfterError();
15
+ program.option('--model <name>', 'default model name or .agentrc alias');
16
+ let config = await loadConfig();
17
+ const selectedFromCli = () => program.opts().model;
18
+ setToolPolicy(defaultPolicy(config.policyLevel ?? 'moderate', process.cwd()));
19
+ const registry = new AgentRegistry({ workspaceRoot: process.cwd() });
20
+ const runtime = new AgentRuntime({
21
+ registry,
22
+ workspaceRoot: process.cwd(),
23
+ defaultModel: selectedFromCli() ?? config.model,
24
+ resolveModel: (alias) => resolveModelConfig(config, alias).config,
25
+ });
26
+ const configManager = {
27
+ getConfig: () => config,
28
+ saveConfig: async (next) => {
29
+ await saveConfig(next);
30
+ config = next;
31
+ setToolPolicy(defaultPolicy(config.policyLevel ?? 'moderate', process.cwd()));
32
+ },
33
+ };
34
+ program
35
+ .command('run')
36
+ .description('Run one non-interactive main-agent session')
37
+ .requiredOption('--prompt <text>', 'message for the main agent')
38
+ .option('--session <id>', 'session id')
39
+ .action(async (options) => {
40
+ runtime.setDefaultModel(selectedFromCli() ?? config.model);
41
+ try {
42
+ await runtime.whenReady();
43
+ const sessionId = options.session ?? `run-${Date.now()}`;
44
+ await runtime.openSession(sessionId);
45
+ if (selectedFromCli())
46
+ await runtime.setSessionDefaultModel(sessionId, selectedFromCli());
47
+ const turnId = await runtime.submitMessage(sessionId, options.prompt);
48
+ await runtime.waitForIdle(sessionId);
49
+ const session = runtime.getSession(sessionId);
50
+ const failed = runtime.listInstances(sessionId).find((instance) => instance.status === 'failed');
51
+ if (failed)
52
+ throw new Error(failed.lastError ?? 'Agent failed');
53
+ const submitted = session.messages.findIndex((message) => message.role === 'user' && message.turnId === turnId);
54
+ const response = session.messages.slice(submitted + 1).reverse().find((message) => message.role === 'assistant');
55
+ if (response)
56
+ process.stdout.write(`${response.content}\n`);
57
+ }
58
+ finally {
59
+ await runtime.shutdown();
60
+ }
61
+ });
62
+ program
63
+ .command('agents')
64
+ .description('List effective Agent Specs')
65
+ .action(async () => {
66
+ await runtime.whenReady();
67
+ for (const spec of runtime.listAgentSpecs()) {
68
+ process.stdout.write(`${spec.id}\t${spec.scope}\t${spec.model ?? 'inherit'}\t${spec.description}\n`);
69
+ }
70
+ await runtime.shutdown();
71
+ });
72
+ program.action(async () => {
73
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
74
+ throw new Error('Interactive mode requires a terminal. Use maw run --prompt "..." for non-interactive execution.');
75
+ await runtime.whenReady();
76
+ const requested = selectedFromCli();
77
+ const selected = resolveModelConfig(config, requested);
78
+ runtime.setDefaultModel(requested ?? config.model);
79
+ await runFullscreenTui(runtime, {
80
+ modelName: selected.name,
81
+ modelAliases: Object.keys(config.models ?? {}),
82
+ resolveModel: (alias) => resolveModelConfig(config, alias),
83
+ persistModelSelection: async (alias) => {
84
+ await saveSelectedModel(alias);
85
+ config = { ...config, model: alias };
86
+ },
87
+ configManager,
88
+ });
89
+ await runtime.shutdown();
90
+ });
91
+ const shutdown = async () => {
92
+ await runtime.shutdown().catch(() => undefined);
93
+ process.exit(0);
94
+ };
95
+ process.once('SIGTERM', () => { void shutdown(); });
96
+ await program.parseAsync(process.argv);
97
+ }
98
+ main().catch((error) => {
99
+ console.error(error instanceof Error ? error.message : String(error));
100
+ process.exitCode = 1;
101
+ });