min-agent 0.1.4 → 0.1.6

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/dist/agent.js CHANGED
@@ -7,9 +7,11 @@ import { initMcp, shutdownMcp, getMcpTools, loadMcpConfig, getMcpStatus } from "
7
7
  import { discoverSkills, getSkillsTool, getSkillsSystemPrompt, getSkills } from "./skills.js";
8
8
  import { loadInstructions } from "./instructions.js";
9
9
  import { getMemorySystemPrompt, getMemoryTools } from "./memory.js";
10
- import { needsCompaction, compactMessages, estimateTokens } from "./compaction.js";
10
+ import { needsCompaction, compactMessages, estimateTokens, TokenTracker } from "./compaction.js";
11
11
  import { loadPluginTools } from "./plugins.js";
12
12
  import { MarkdownRenderer } from "./markdown.js";
13
+ import { DoomLoopDetector } from "./doom-loop.js";
14
+ import { scanProject, buildCodeSystemPrompt } from "./code-mode.js";
13
15
  import { ThinkingBodySplitter, stripThinkingFromAssistantText } from "./assistant-stream.js";
14
16
  import { printHeader, printDivider, printToolCall, printToolResult, printDone } from "./output.js";
15
17
  import readline from "readline";
@@ -51,7 +53,7 @@ function buildSystemPrompt(instructions) {
51
53
  `Platform: ${process.platform}`,
52
54
  `Date: ${new Date().toDateString()}`,
53
55
  "",
54
- "min-agent: when the user asks to configure or install this agent (MCP, skills, rules, CLI, HTTP API, etc.), use the **read** tool on `README.md` in the working directory first, then follow what it says.",
56
+ `When the user asks to configure, install, or manage MCP servers, skills, rules, memory, or other min-agent features, use the read tool on the file at ${path.resolve(path.dirname(new URL(import.meta.url).pathname), "../README.md")} first, then follow what it says.`,
55
57
  ];
56
58
  const skillsPrompt = getSkillsSystemPrompt();
57
59
  if (skillsPrompt) {
@@ -109,7 +111,8 @@ export async function runAgent(message, modelId, imagePaths) {
109
111
  console.log(`\x1b[36m> ${message}\x1b[0m\n`);
110
112
  const content = await buildUserContent(message, imagePaths);
111
113
  const messages = [{ role: "user", content }];
112
- await runOnce(messages, instructions, modelId);
114
+ const tracker = new TokenTracker();
115
+ await runOnce(messages, instructions, modelId, undefined, undefined, tracker);
113
116
  await shutdownMcp();
114
117
  }
115
118
  /** Interactive multi-turn chat session */
@@ -123,6 +126,7 @@ export async function runChat(modelId, resumeSessionId) {
123
126
  printInitReady();
124
127
  let messages = [];
125
128
  let sessionId = resumeSessionId;
129
+ const tracker = new TokenTracker();
126
130
  // Resume existing session
127
131
  if (resumeSessionId) {
128
132
  const { loadSession } = await import("./sessions.js");
@@ -144,7 +148,7 @@ export async function runChat(modelId, resumeSessionId) {
144
148
  if (abortController) {
145
149
  abortController.abort();
146
150
  abortController = null;
147
- console.log("\n\x1b[90m(interrupted)\x1b[0m\n");
151
+ console.log("\n\x1b[90m(cancelled)\x1b[0m\n");
148
152
  rl.prompt();
149
153
  }
150
154
  else {
@@ -155,32 +159,166 @@ export async function runChat(modelId, resumeSessionId) {
155
159
  });
156
160
  console.log("\x1b[90m输入消息开始对话,输入 /help 查看命令,/exit 退出\x1b[0m\n");
157
161
  rl.prompt();
158
- for await (const line of rl) {
159
- const input = line.trim();
162
+ // Multi-line paste detection: collect rapid successive lines
163
+ let pasteBuffer = [];
164
+ let pasteTimer = null;
165
+ const PASTE_DEBOUNCE_MS = 50;
166
+ const processInput = async (text) => {
167
+ const input = text.trim();
160
168
  if (!input) {
161
169
  rl.prompt();
162
- continue;
170
+ return;
163
171
  }
164
- // Handle slash commands
165
172
  if (input.startsWith("/")) {
166
- const handled = await handleSlashCommand(input, messages, instructions, modelId, rl);
167
- if (handled === "exit")
168
- break;
173
+ const handled = await handleSlashCommand(input, messages, instructions, modelId, rl, tracker);
174
+ if (handled === "exit") {
175
+ rl.close();
176
+ return;
177
+ }
178
+ if (handled === "paste") {
179
+ console.log();
180
+ abortController = new AbortController();
181
+ await runOnce(messages, instructions, modelId, abortController.signal, undefined, tracker);
182
+ abortController = null;
183
+ console.log();
184
+ }
169
185
  rl.prompt();
170
- continue;
186
+ return;
171
187
  }
188
+ // Show paste feedback for large inputs
189
+ const { processPastedInput, printPasteFeedback } = await import("./paste-handler.js");
190
+ const pasteResult = processPastedInput(input);
191
+ printPasteFeedback(pasteResult);
172
192
  console.log();
173
- messages.push({ role: "user", content: input });
193
+ messages.push({ role: "user", content: pasteResult.fullText });
174
194
  abortController = new AbortController();
175
- await runOnce(messages, instructions, modelId, abortController.signal);
195
+ await runOnce(messages, instructions, modelId, abortController.signal, undefined, tracker);
176
196
  abortController = null;
177
197
  console.log();
178
198
  rl.prompt();
199
+ };
200
+ rl.on("line", (line) => {
201
+ pasteBuffer.push(line);
202
+ if (pasteTimer)
203
+ clearTimeout(pasteTimer);
204
+ pasteTimer = setTimeout(() => {
205
+ const combined = pasteBuffer.join("\n");
206
+ pasteBuffer = [];
207
+ pasteTimer = null;
208
+ processInput(combined);
209
+ }, PASTE_DEBOUNCE_MS);
210
+ });
211
+ // Wait for close
212
+ await new Promise((resolve) => rl.on("close", resolve));
213
+ // Auto-save session on exit with LLM-generated title
214
+ if (messages.length > 0) {
215
+ const { saveSessionWithTitle } = await import("./sessions.js");
216
+ const model = resolveModel(modelId);
217
+ sessionId = await saveSessionWithTitle(messages, model, sessionId);
218
+ console.log(`\x1b[90m Session saved: ${sessionId}\x1b[0m`);
179
219
  }
180
- // Auto-save session on exit
220
+ printDivider();
221
+ console.log("\x1b[90mBye!\x1b[0m");
222
+ await shutdownMcp();
223
+ }
224
+ /** AI Coding mode: project-aware interactive session */
225
+ export async function runCode(modelId, resumeSessionId) {
226
+ printHeader(modelId);
227
+ console.log("\x1b[90m Mode: code\x1b[0m");
228
+ printDivider();
229
+ // Scan project
230
+ console.log("\x1b[90m⟳ Scanning project...\x1b[0m");
231
+ const project = scanProject();
232
+ console.log(`\x1b[90m ${project.languages.join(", ") || "Unknown language"}${project.framework ? ` (${project.framework})` : ""} | ${project.isGitRepo ? `git:${project.branch}` : "no git"}\x1b[0m`);
233
+ await initMcp();
234
+ discoverSkills();
235
+ const instructions = await loadInstructions();
236
+ const codePrompt = buildCodeSystemPrompt(project, instructions);
237
+ console.log("\x1b[90m✓ Ready\x1b[0m");
238
+ let messages = [];
239
+ let sessionId = resumeSessionId;
240
+ const tracker = new TokenTracker();
241
+ if (resumeSessionId) {
242
+ const { loadSession } = await import("./sessions.js");
243
+ const session = loadSession(resumeSessionId);
244
+ if (session) {
245
+ messages = session.messages;
246
+ sessionId = resumeSessionId;
247
+ console.log(`\x1b[90m Resumed: ${session.meta.title} (${messages.length} msgs)\x1b[0m`);
248
+ }
249
+ }
250
+ const rl = readline.createInterface({
251
+ input: process.stdin,
252
+ output: process.stdout,
253
+ prompt: "\x1b[32m❯ \x1b[0m",
254
+ });
255
+ let abortController = null;
256
+ process.on("SIGINT", () => {
257
+ if (abortController) {
258
+ abortController.abort();
259
+ abortController = null;
260
+ console.log("\n\x1b[90m(cancelled)\x1b[0m\n");
261
+ rl.prompt();
262
+ }
263
+ else {
264
+ console.log();
265
+ rl.close();
266
+ }
267
+ });
268
+ console.log("\x1b[90m输入任务开始编码,/help 查看命令,Ctrl+C 中断\x1b[0m\n");
269
+ rl.prompt();
270
+ let pasteBuffer = [];
271
+ let pasteTimer = null;
272
+ const PASTE_DEBOUNCE_MS = 50;
273
+ const processCodeInput = async (text) => {
274
+ const input = text.trim();
275
+ if (!input) {
276
+ rl.prompt();
277
+ return;
278
+ }
279
+ if (input.startsWith("/")) {
280
+ const handled = await handleSlashCommand(input, messages, [codePrompt], modelId, rl, tracker);
281
+ if (handled === "exit") {
282
+ rl.close();
283
+ return;
284
+ }
285
+ if (handled === "paste") {
286
+ console.log();
287
+ abortController = new AbortController();
288
+ await runOnceWithSystem(messages, codePrompt, modelId, abortController.signal, undefined, tracker);
289
+ abortController = null;
290
+ console.log();
291
+ }
292
+ rl.prompt();
293
+ return;
294
+ }
295
+ const { processPastedInput, printPasteFeedback } = await import("./paste-handler.js");
296
+ const pasteResult = processPastedInput(input);
297
+ printPasteFeedback(pasteResult);
298
+ console.log();
299
+ messages.push({ role: "user", content: pasteResult.fullText });
300
+ abortController = new AbortController();
301
+ await runOnceWithSystem(messages, codePrompt, modelId, abortController.signal, undefined, tracker);
302
+ abortController = null;
303
+ console.log();
304
+ rl.prompt();
305
+ };
306
+ rl.on("line", (line) => {
307
+ pasteBuffer.push(line);
308
+ if (pasteTimer)
309
+ clearTimeout(pasteTimer);
310
+ pasteTimer = setTimeout(() => {
311
+ const combined = pasteBuffer.join("\n");
312
+ pasteBuffer = [];
313
+ pasteTimer = null;
314
+ processCodeInput(combined);
315
+ }, PASTE_DEBOUNCE_MS);
316
+ });
317
+ await new Promise((resolve) => rl.on("close", resolve));
181
318
  if (messages.length > 0) {
182
- const { saveSession } = await import("./sessions.js");
183
- sessionId = saveSession(messages, sessionId);
319
+ const { saveSessionWithTitle } = await import("./sessions.js");
320
+ const model = resolveModel(modelId);
321
+ sessionId = await saveSessionWithTitle(messages, model, sessionId);
184
322
  console.log(`\x1b[90m Session saved: ${sessionId}\x1b[0m`);
185
323
  }
186
324
  rl.close();
@@ -188,13 +326,216 @@ export async function runChat(modelId, resumeSessionId) {
188
326
  console.log("\x1b[90mBye!\x1b[0m");
189
327
  await shutdownMcp();
190
328
  }
191
- async function handleSlashCommand(input, messages, instructions, modelId, rl) {
329
+ /** runOnce variant that accepts a pre-built system prompt (for code mode) */
330
+ export async function runOnceWithSystem(messages, systemPrompt, modelId, abortSignal, callbacks, tracker) {
331
+ const model = resolveModel(modelId);
332
+ const api = !!callbacks;
333
+ if (needsCompaction(messages, tracker)) {
334
+ console.log("\x1b[90m⟳ Compacting context...\x1b[0m");
335
+ const result = await compactMessages(messages, model);
336
+ if (result.compacted) {
337
+ messages.length = 0;
338
+ messages.push(...result.messages);
339
+ if (result.shouldContinue) {
340
+ messages.push({ role: "user", content: "Continue with your task." });
341
+ }
342
+ if (tracker)
343
+ tracker.resetContext();
344
+ console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`);
345
+ }
346
+ }
347
+ const builtinTools = createTools();
348
+ const mcpTools = getMcpTools();
349
+ const memoryTools = getMemoryTools();
350
+ const pluginTools = await loadPluginTools();
351
+ const { createTaskTool } = await import("./tools/task.js");
352
+ const { createExploreTool } = await import("./tools/explore.js");
353
+ const skills = getSkills();
354
+ const allTools = { ...builtinTools, ...memoryTools, ...pluginTools };
355
+ for (const [id, t] of Object.entries(mcpTools))
356
+ allTools[id] = t;
357
+ if (skills.length > 0)
358
+ allTools["skill"] = getSkillsTool();
359
+ allTools["task"] = createTaskTool(modelId);
360
+ allTools["explore"] = createExploreTool(modelId);
361
+ let stepCount = 0;
362
+ let hasError = false;
363
+ const doomLoop = new DoomLoopDetector();
364
+ const result = streamText({
365
+ model,
366
+ system: systemPrompt,
367
+ messages,
368
+ tools: allTools,
369
+ stopWhen: stepCountIs(MAX_STEPS),
370
+ maxRetries: 3,
371
+ abortSignal,
372
+ onStepFinish() { stepCount++; },
373
+ onError() { },
374
+ });
375
+ Promise.resolve(result.usage).catch(() => { });
376
+ let rawText = "";
377
+ let assistantText = "";
378
+ const md = new MarkdownRenderer();
379
+ const thinkingSplit = new ThinkingBodySplitter();
380
+ try {
381
+ for await (const event of result.fullStream) {
382
+ switch (event.type) {
383
+ case "text-delta": {
384
+ const { display, thinking } = thinkingSplit.feed(event.text);
385
+ if (thinking) {
386
+ if (callbacks?.onThinkingDelta)
387
+ callbacks.onThinkingDelta(thinking);
388
+ else
389
+ writeThinkingDelta(thinking);
390
+ }
391
+ if (display) {
392
+ rawText += display;
393
+ assistantText += display;
394
+ if (callbacks?.onAssistantDisplayDelta)
395
+ callbacks.onAssistantDisplayDelta(display);
396
+ else {
397
+ const formatted = md.write(display);
398
+ if (formatted)
399
+ process.stdout.write(formatted);
400
+ }
401
+ }
402
+ break;
403
+ }
404
+ case "tool-call": {
405
+ if (doomLoop.record(event.toolName, event.input)) {
406
+ const warn = `Doom loop detected: "${event.toolName}" — breaking.`;
407
+ if (callbacks?.onStreamError)
408
+ callbacks.onStreamError(warn);
409
+ else
410
+ console.log(`\n\x1b[33m⚠ ${warn}\x1b[0m`);
411
+ hasError = true;
412
+ break;
413
+ }
414
+ const flush = thinkingSplit.flush();
415
+ if (flush.thinking) {
416
+ if (callbacks?.onThinkingDelta)
417
+ callbacks.onThinkingDelta(flush.thinking);
418
+ else
419
+ writeThinkingDelta(flush.thinking);
420
+ }
421
+ if (flush.display) {
422
+ rawText += flush.display;
423
+ assistantText += flush.display;
424
+ if (callbacks?.onAssistantDisplayDelta)
425
+ callbacks.onAssistantDisplayDelta(flush.display);
426
+ else {
427
+ const extra = md.write(flush.display);
428
+ if (extra)
429
+ process.stdout.write(extra);
430
+ }
431
+ }
432
+ if (!api) {
433
+ const flushed = md.flush();
434
+ if (flushed)
435
+ process.stdout.write(flushed);
436
+ if (rawText.trim())
437
+ console.log();
438
+ }
439
+ rawText = "";
440
+ if (callbacks?.onToolCall)
441
+ callbacks.onToolCall(event.toolName, event.input);
442
+ else
443
+ printToolCall(event.toolName, event.input);
444
+ break;
445
+ }
446
+ case "tool-result":
447
+ if (callbacks?.onToolResult)
448
+ callbacks.onToolResult(event.toolName, event.output);
449
+ else
450
+ printToolResult(event.toolName, event.output);
451
+ break;
452
+ case "error":
453
+ hasError = true;
454
+ if (callbacks?.onStreamError)
455
+ callbacks.onStreamError(String(event.error));
456
+ else
457
+ console.error(`\x1b[31mError: ${event.error}\x1b[0m`);
458
+ break;
459
+ case "finish":
460
+ break;
461
+ }
462
+ }
463
+ const end = thinkingSplit.flush();
464
+ if (end.thinking) {
465
+ if (callbacks?.onThinkingDelta)
466
+ callbacks.onThinkingDelta(end.thinking);
467
+ else
468
+ writeThinkingDelta(end.thinking);
469
+ }
470
+ if (end.display) {
471
+ assistantText += end.display;
472
+ if (callbacks?.onAssistantDisplayDelta)
473
+ callbacks.onAssistantDisplayDelta(end.display);
474
+ else {
475
+ const tail = md.write(end.display);
476
+ if (tail)
477
+ process.stdout.write(tail);
478
+ }
479
+ }
480
+ if (!api) {
481
+ const remaining = md.flush();
482
+ if (remaining)
483
+ process.stdout.write(remaining);
484
+ if (rawText.trim())
485
+ console.log();
486
+ }
487
+ const cleaned = stripThinkingFromAssistantText(assistantText);
488
+ if (cleaned.trim())
489
+ messages.push({ role: "assistant", content: cleaned });
490
+ let usage;
491
+ try {
492
+ usage = await result.usage;
493
+ }
494
+ catch {
495
+ usage = undefined;
496
+ }
497
+ if (usage && tracker)
498
+ tracker.update(usage);
499
+ if (callbacks?.onRunFinish) {
500
+ callbacks.onRunFinish({ stepCount, usage, hasError, aborted: false });
501
+ }
502
+ else {
503
+ if (!hasError && usage) {
504
+ printDivider();
505
+ const { getContextWindow } = await import("./context-window.js");
506
+ const ctxWindow = await getContextWindow(modelId);
507
+ printDone(stepCount, usage, ctxWindow);
508
+ }
509
+ else {
510
+ printDivider();
511
+ }
512
+ }
513
+ }
514
+ catch (err) {
515
+ if (err.name === "AbortError" || abortSignal?.aborted) {
516
+ const cleaned = stripThinkingFromAssistantText(assistantText);
517
+ if (cleaned.trim())
518
+ messages.push({ role: "assistant", content: cleaned });
519
+ if (callbacks?.onRunFinish)
520
+ callbacks.onRunFinish({ stepCount, usage: undefined, hasError: false, aborted: true });
521
+ return;
522
+ }
523
+ if (callbacks?.onStreamError)
524
+ callbacks.onStreamError(err.message);
525
+ else {
526
+ printDivider();
527
+ console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
528
+ }
529
+ }
530
+ }
531
+ async function handleSlashCommand(input, messages, instructions, modelId, rl, tracker) {
192
532
  const [cmd, ...rest] = input.slice(1).split(/\s+/);
193
533
  const arg = rest.join(" ");
194
534
  switch (cmd) {
195
535
  case "exit":
196
536
  case "quit":
197
537
  case "q":
538
+ console.log("\x1b[90m⟳ Exiting...\x1b[0m");
198
539
  return "exit";
199
540
  case "clear":
200
541
  messages.length = 0;
@@ -207,9 +548,11 @@ async function handleSlashCommand(input, messages, instructions, modelId, rl) {
207
548
  else {
208
549
  console.log("\x1b[90m ⟳ Compacting...\x1b[0m");
209
550
  const model = resolveModel(modelId);
210
- const result = await compactMessages(messages, model, { keepRecentTurns: 2 });
551
+ const result = await compactMessages(messages, model, { keepRecentTurns: 2, autoContinue: false });
211
552
  messages.length = 0;
212
553
  messages.push(...result.messages);
554
+ if (tracker)
555
+ tracker.resetContext();
213
556
  console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`);
214
557
  }
215
558
  return "handled";
@@ -275,12 +618,11 @@ async function handleSlashCommand(input, messages, instructions, modelId, rl) {
275
618
  discoverSkills();
276
619
  const skills = getSkills();
277
620
  if (skills.length === 0) {
278
- console.log("\x1b[90m No skills found\x1b[0m");
621
+ console.log("\x1b[90m No skills available\x1b[0m");
279
622
  }
280
623
  else {
281
- console.log(`\x1b[90m Available skills (${skills.length}):\x1b[0m`);
282
624
  for (const skill of skills) {
283
- console.log(`\x1b[90m - ${skill.name}: ${skill.description}\x1b[0m`);
625
+ console.log(`\x1b[90m ${skill.name} enabled\x1b[0m`);
284
626
  }
285
627
  }
286
628
  return "handled";
@@ -305,10 +647,41 @@ async function handleSlashCommand(input, messages, instructions, modelId, rl) {
305
647
  }
306
648
  return "handled";
307
649
  }
308
- case "tokens":
309
- console.log(`\x1b[90m Estimated tokens in context: ${estimateTokens(messages)}\x1b[0m`);
650
+ case "tokens": {
651
+ if (tracker && tracker.lastInputTokens > 0) {
652
+ const { getContextWindow } = await import("./context-window.js");
653
+ const ctxWindow = await getContextWindow(modelId);
654
+ const pct = Math.round((tracker.lastInputTokens / ctxWindow) * 100);
655
+ console.log(`\x1b[90m Context: ${tracker.lastInputTokens} / ${ctxWindow} tokens (${pct}%)\x1b[0m`);
656
+ console.log(`\x1b[90m Total: ${tracker.totalInputTokens} in / ${tracker.totalOutputTokens} out\x1b[0m`);
657
+ }
658
+ else {
659
+ console.log(`\x1b[90m Estimated tokens in context: ${estimateTokens(messages)}\x1b[0m`);
660
+ }
310
661
  console.log(`\x1b[90m Messages: ${messages.length}\x1b[0m`);
311
662
  return "handled";
663
+ }
664
+ case "path":
665
+ case "pwd":
666
+ console.log(`\x1b[90m ${process.cwd()}\x1b[0m`);
667
+ return "handled";
668
+ case "paste": {
669
+ const { getClipboardImage } = await import("./clipboard.js");
670
+ const img = getClipboardImage();
671
+ if (!img) {
672
+ console.log("\x1b[90m No image found in clipboard\x1b[0m");
673
+ return "handled";
674
+ }
675
+ console.log(`\x1b[90m 📎 Clipboard image (${(img.data.length / 1024).toFixed(1)} KB)\x1b[0m`);
676
+ const text = arg || "What's in this image?";
677
+ const content = [
678
+ { type: "text", text },
679
+ { type: "image", image: img.data, mimeType: img.mimeType },
680
+ ];
681
+ messages.push({ role: "user", content });
682
+ // Return a special signal to trigger runOnce
683
+ return "paste";
684
+ }
312
685
  case "help":
313
686
  console.log(`\x1b[90m Slash commands:
314
687
  /clear Clear conversation history
@@ -319,6 +692,8 @@ async function handleSlashCommand(input, messages, instructions, modelId, rl) {
319
692
  /skills List discovered skills
320
693
  /mcp List MCP servers and connection status
321
694
  /tokens Show estimated token usage
695
+ /path Show current working directory
696
+ /paste [text] Paste clipboard image + optional prompt
322
697
  /help Show this help
323
698
  /exit Exit the chat\x1b[0m`);
324
699
  return "handled";
@@ -327,11 +702,11 @@ async function handleSlashCommand(input, messages, instructions, modelId, rl) {
327
702
  return "handled";
328
703
  }
329
704
  }
330
- export async function runOnce(messages, instructions, modelId, abortSignal, callbacks) {
705
+ export async function runOnce(messages, instructions, modelId, abortSignal, callbacks, tracker) {
331
706
  const model = resolveModel(modelId);
332
707
  const api = !!callbacks;
333
708
  // Auto-compact if context is getting too large
334
- if (needsCompaction(messages)) {
709
+ if (needsCompaction(messages, tracker)) {
335
710
  if (api) {
336
711
  callbacks.onCompaction?.("compacting_start");
337
712
  }
@@ -342,6 +717,15 @@ export async function runOnce(messages, instructions, modelId, abortSignal, call
342
717
  if (result.compacted) {
343
718
  messages.length = 0;
344
719
  messages.push(...result.messages);
720
+ // Auto-continue: inject a message so the agent keeps working
721
+ if (result.shouldContinue) {
722
+ messages.push({
723
+ role: "user",
724
+ content: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.",
725
+ });
726
+ }
727
+ if (tracker)
728
+ tracker.resetContext();
345
729
  if (api) {
346
730
  callbacks.onCompaction?.(`compacted_ok estimated_tokens=${estimateTokens(messages)}`);
347
731
  }
@@ -350,11 +734,12 @@ export async function runOnce(messages, instructions, modelId, abortSignal, call
350
734
  }
351
735
  }
352
736
  }
353
- // Merge all tools: builtin + MCP + skill + memory + plugins
737
+ // Merge all tools: builtin + MCP + skill + memory + plugins + task
354
738
  const builtinTools = createTools();
355
739
  const mcpTools = getMcpTools();
356
740
  const memoryTools = getMemoryTools();
357
741
  const pluginTools = await loadPluginTools();
742
+ const { createTaskTool } = await import("./tools/task.js");
358
743
  const skills = getSkills();
359
744
  const allTools = { ...builtinTools, ...memoryTools, ...pluginTools };
360
745
  for (const [id, t] of Object.entries(mcpTools)) {
@@ -363,8 +748,10 @@ export async function runOnce(messages, instructions, modelId, abortSignal, call
363
748
  if (skills.length > 0) {
364
749
  allTools["skill"] = getSkillsTool();
365
750
  }
751
+ allTools["task"] = createTaskTool(modelId);
366
752
  let stepCount = 0;
367
753
  let hasError = false;
754
+ const doomLoop = new DoomLoopDetector();
368
755
  const result = streamText({
369
756
  model,
370
757
  system: buildSystemPrompt(instructions),
@@ -411,6 +798,16 @@ export async function runOnce(messages, instructions, modelId, abortSignal, call
411
798
  break;
412
799
  }
413
800
  case "tool-call": {
801
+ // Doom loop detection
802
+ if (doomLoop.record(event.toolName, event.input)) {
803
+ const warning = `\x1b[33m⚠ Doom loop detected: "${event.toolName}" called ${3} times with same args. Breaking loop.\x1b[0m`;
804
+ if (callbacks?.onStreamError)
805
+ callbacks.onStreamError(warning);
806
+ else
807
+ console.log(`\n${warning}`);
808
+ hasError = true;
809
+ break;
810
+ }
414
811
  const splitFlush = thinkingSplit.flush();
415
812
  emitThinking(splitFlush.thinking);
416
813
  if (splitFlush.display) {
@@ -492,6 +889,10 @@ export async function runOnce(messages, instructions, modelId, abortSignal, call
492
889
  catch {
493
890
  usage = undefined;
494
891
  }
892
+ // Update token tracker with real usage from API
893
+ if (usage && tracker) {
894
+ tracker.update(usage);
895
+ }
495
896
  if (callbacks?.onRunFinish) {
496
897
  callbacks.onRunFinish({ stepCount, usage, hasError, aborted: false });
497
898
  }
@@ -501,7 +902,9 @@ export async function runOnce(messages, instructions, modelId, abortSignal, call
501
902
  return;
502
903
  }
503
904
  printDivider();
504
- printDone(stepCount, usage);
905
+ const { getContextWindow } = await import("./context-window.js");
906
+ const ctxWindow = await getContextWindow(modelId);
907
+ printDone(stepCount, usage, ctxWindow);
505
908
  }
506
909
  }
507
910
  catch (err) {