u-foo 2.5.5 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "2.5.5",
3
+ "version": "2.5.6",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -61,8 +61,8 @@ function stripAnsi(text) {
61
61
  function rewriteTitleOscPrefix(text, prefix) {
62
62
  if (!prefix || !text) return text;
63
63
  return String(text).replace(/\x1b\]([012]);([^\x07\x1b]*)(\x07|\x1b\\)/g, (match, code, title, terminator) => {
64
- if (!title || title.startsWith(`${prefix}:`)) return match;
65
- return `\x1b]${code};${prefix}:${title}${terminator}`;
64
+ if (!title || title.startsWith(`${prefix}: `)) return match;
65
+ return `\x1b]${code};${prefix}: ${title}${terminator}`;
66
66
  });
67
67
  }
68
68
 
@@ -211,8 +211,8 @@ class PtyWrapper {
211
211
  if (!this.titlePrefix || !text) return text;
212
212
  const prefix = this.titlePrefix;
213
213
  return text.replace(/\x1b\]([012]);([^\x07\x1b]*)(\x07|\x1b\\)/g, (match, code, title, terminator) => {
214
- if (!title || title.startsWith(`${prefix}:`)) return match;
215
- return `\x1b]${code};${prefix}:${title}${terminator}`;
214
+ if (!title || title.startsWith(`${prefix}: `)) return match;
215
+ return `\x1b]${code};${prefix}: ${title}${terminator}`;
216
216
  });
217
217
  }
218
218
 
@@ -70,7 +70,7 @@ function getSystemPrompt({
70
70
  // --- Dynamic sections (may change per session/turn) ---
71
71
  const dynamicSectionDefs = [
72
72
  systemPromptSection("ufoo", () => getUfooIntegrationSection()),
73
- systemPromptSection("environment", () =>
73
+ uncachedSection("environment", () =>
74
74
  getEnvironmentSection({ workspaceRoot, model, provider }),
75
75
  ),
76
76
  uncachedSection("skills", () => {
@@ -6,7 +6,7 @@ function getBashToolDescription() {
6
6
  return `Run a single shell command in the workspace directory.
7
7
 
8
8
  Usage notes:
9
- - Default timeout is 60 seconds. Use timeoutMs to adjust for longer operations.
9
+ - Default timeout is 60 seconds. Use timeoutMs to adjust for longer operations (maximum 600 seconds; larger values are clamped).
10
10
  - Do NOT use bash for file operations when a dedicated tool exists:
11
11
  - Use read instead of cat/head/tail.
12
12
  - Use write instead of echo/cat heredoc.
@@ -7,11 +7,12 @@ function getReadToolDescription() {
7
7
 
8
8
  Usage notes:
9
9
  - The path parameter is relative to the workspace root.
10
- - By default reads the entire file. For large files, use startLine and endLine to read specific ranges.
10
+ - By default reads the entire file. Files larger than ~4MB are only partially read from the start; in that case truncated is true.
11
+ - Use startLine and endLine to read specific line ranges.
11
12
  - Use maxBytes to limit the amount of data returned (default ~200KB).
12
13
  - Cannot read directories — use bash with \`ls\` for that.
13
14
  - Always read a file before editing it to understand its current content and structure.
14
- - Results are returned with line numbers for easy reference.`;
15
+ - The content field contains the raw file text without line numbers. The result also includes totalLines (lines in the portion that was read) and truncated (true when the content was cut short by maxBytes or the large-file limit).`;
15
16
  }
16
17
 
17
18
  module.exports = { READ_TOOL_NAME, getReadToolDescription };
package/src/code/agent.js CHANGED
@@ -2,7 +2,7 @@ const readline = require("readline");
2
2
  const fs = require("fs");
3
3
  const path = require("path");
4
4
  const { execSync } = require("child_process");
5
- const { runToolCall } = require("./dispatch");
5
+ const { runToolCall, TOOL_NAMES } = require("./dispatch");
6
6
  const { runNativeAgentTask } = require("./nativeRunner");
7
7
  const {
8
8
  runDecomposedTask,
@@ -440,6 +440,7 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
440
440
  const timeoutMs = Number.isFinite(state.timeoutMs) ? state.timeoutMs : 600000;
441
441
  let streamed = false;
442
442
  let streamLastChar = "";
443
+ let toolEventsThisAttempt = 0;
443
444
  const onDelta = typeof options.onDelta === "function"
444
445
  ? options.onDelta
445
446
  : null;
@@ -495,23 +496,27 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
495
496
  : runNativeAgentTask;
496
497
  const onPhase = typeof options.onPhase === "function" ? options.onPhase : null;
497
498
  const onThinkingDelta = typeof options.onThinkingDelta === "function" ? options.onThinkingDelta : null;
498
- const invokeNative = (sessionIdValue = "", timeoutOverrideMs = timeoutMs) => runNativeAgentImpl({
499
- workspaceRoot,
500
- provider,
501
- model,
502
- prompt: effectiveTaskPrompt,
503
- systemPrompt: systemContext,
504
- messages: Array.isArray(state.nlMessages) ? state.nlMessages : [],
505
- sessionId: String(sessionIdValue || ""),
506
- timeoutMs: timeoutOverrideMs,
507
- onStreamDelta: onStream,
508
- onThinkingDelta,
509
- onPhase,
510
- onToolEvent: (event) => {
511
- pushToolLog(event);
512
- },
513
- signal: options.signal,
514
- });
499
+ const invokeNative = (sessionIdValue = "", timeoutOverrideMs = timeoutMs) => {
500
+ toolEventsThisAttempt = 0;
501
+ return runNativeAgentImpl({
502
+ workspaceRoot,
503
+ provider,
504
+ model,
505
+ prompt: effectiveTaskPrompt,
506
+ systemPrompt: systemContext,
507
+ messages: Array.isArray(state.nlMessages) ? state.nlMessages : [],
508
+ sessionId: String(sessionIdValue || ""),
509
+ timeoutMs: timeoutOverrideMs,
510
+ onStreamDelta: onStream,
511
+ onThinkingDelta,
512
+ onPhase,
513
+ onToolEvent: (event) => {
514
+ toolEventsThisAttempt += 1;
515
+ pushToolLog(event);
516
+ },
517
+ signal: options.signal,
518
+ });
519
+ };
515
520
 
516
521
  try {
517
522
  let cliRes;
@@ -551,7 +556,9 @@ async function runNaturalLanguageTask(task = "", state = {}, options = {}) {
551
556
 
552
557
  if (!cliRes || cliRes.ok === false) {
553
558
  const errMsg = String((cliRes && cliRes.error) || "");
554
- if (isCliTimeoutError(errMsg)) {
559
+ // Only replay the whole task when this attempt ran no tool calls;
560
+ // retrying after executed write/edit/bash steps would replay side effects.
561
+ if (isCliTimeoutError(errMsg) && toolEventsThisAttempt === 0) {
555
562
  const extendedTimeoutMs = computeExtendedTimeout(timeoutMs);
556
563
  cliRes = await invokeNative(String(state.sessionId || ""), extendedTimeoutMs);
557
564
  }
@@ -1317,6 +1324,13 @@ function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
1317
1324
  };
1318
1325
  }
1319
1326
  const tool = String(match[2] || "").trim().toLowerCase();
1327
+ if (String(match[1]).toLowerCase() === "run" && !TOOL_NAMES.includes(tool)) {
1328
+ // Natural language like "run the tests" is not a tool invocation.
1329
+ return {
1330
+ kind: "nl",
1331
+ task: text,
1332
+ };
1333
+ }
1320
1334
  const payload = String(match[3] || "").trim();
1321
1335
  let args = {};
1322
1336
  try {
@@ -1546,7 +1560,7 @@ async function runUcodeCoreAgent({
1546
1560
  }
1547
1561
  }
1548
1562
  if (result.kind === "resume") {
1549
- const resumed = resumeSessionState(state, result.sessionId, workspaceRoot);
1563
+ const resumed = resumeSessionState(state, result.sessionId, state.workspaceRoot || resolvedWorkspaceRoot);
1550
1564
  if (!resumed.ok) {
1551
1565
  stdout.write(`Error: ${resumed.error}\n`);
1552
1566
  } else {
@@ -224,8 +224,10 @@ function readLastArgValue(args = [], flag = "") {
224
224
  if (!item) continue;
225
225
  if (item === flag) {
226
226
  const next = String(args[i + 1] || "").trim();
227
- if (next) value = next;
228
- i += 1;
227
+ if (next && !next.startsWith("--")) {
228
+ value = next;
229
+ i += 1;
230
+ }
229
231
  continue;
230
232
  }
231
233
  if (item.startsWith(`${flag}=`)) {
@@ -342,7 +344,7 @@ function resolveUcodeLaunch({
342
344
  const promptFile = String(
343
345
  env.UFOO_UCODE_PROMPT_FILE
344
346
  || config.ucodePromptFile
345
- || defaultBundledPromptFile()
347
+ || ""
346
348
  ).trim();
347
349
  const bootstrapFile = String(
348
350
  env.UFOO_UCODE_BOOTSTRAP_FILE
@@ -3,7 +3,6 @@ const path = require("path");
3
3
  const { loadConfig } = require("../../config");
4
4
  const {
5
5
  resolveNativeFallbackCommand,
6
- defaultBundledPromptFile,
7
6
  } = require("./ucode");
8
7
  const { inspectUcodeBuildSetup } = require("./ucodeBuild");
9
8
  const { inspectUcodeRuntimeConfig } = require("./ucodeRuntimeConfig");
@@ -30,7 +29,7 @@ function inspectUcodeSetup({
30
29
  const promptFile = String(
31
30
  env.UFOO_UCODE_PROMPT_FILE
32
31
  || config.ucodePromptFile
33
- || defaultBundledPromptFile()
32
+ || ""
34
33
  ).trim();
35
34
  const bootstrapFile = String(
36
35
  env.UFOO_UCODE_BOOTSTRAP_FILE
@@ -130,7 +129,7 @@ function formatUcodeDoctor(result = {}) {
130
129
  if (result.configuredCommand) {
131
130
  lines.push(`configured command override (ignored in native-only mode): ${result.configuredCommand}`);
132
131
  }
133
- lines.push(`prompt: ${result.promptFile || "(none)"}${result.promptExists ? "" : " (missing)"}`);
132
+ lines.push(`prompt: ${result.promptFile || "(none)"}${result.promptFile && !result.promptExists ? " (missing)" : ""}`);
134
133
  lines.push(`bootstrap: ${result.bootstrapFile || "(none)"}`);
135
134
  if (result.build && result.build.coreRoot) {
136
135
  lines.push(`build: ${result.build.distCliExists ? "ready" : "missing dist"}`);
@@ -170,6 +169,10 @@ function prepareAndInspectUcode({
170
169
  projectRoot: inspection.projectRoot,
171
170
  promptFile: inspection.promptFile,
172
171
  targetFile: inspection.bootstrapFile,
172
+ // The native core already injects the ufoo protocol via the modular
173
+ // prompt (src/agents/prompts/native/ufoo.js); inlining it into the
174
+ // bootstrap file would append the same protocol text a second time.
175
+ includeDefaultProtocol: false,
173
176
  });
174
177
  return {
175
178
  ...inspection,
@@ -14,6 +14,11 @@ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
14
14
  // for non-trivial tasks while still catching runaway loops. Override via env.
15
15
  const DEFAULT_MAX_NATIVE_TOOL_CALLS = 100;
16
16
  const DEFAULT_MAX_NATIVE_TOOL_ERRORS = 5;
17
+ // Anthropic Messages rejects max_tokens above the model's real cap (64K on
18
+ // current models), so the transports use different defaults. Override either
19
+ // via UFOO_UCODE_MAX_TOKENS (positive integer).
20
+ const DEFAULT_OPENAI_MAX_TOKENS = 131072;
21
+ const DEFAULT_ANTHROPIC_MAX_TOKENS = 64000;
17
22
 
18
23
  function nowMs() {
19
24
  return Date.now();
@@ -38,6 +43,10 @@ function resolveNativeToolBudget(env = process.env) {
38
43
  };
39
44
  }
40
45
 
46
+ function resolveMaxTokens(fallback) {
47
+ return normalizePositiveInt(process.env.UFOO_UCODE_MAX_TOKENS, fallback);
48
+ }
49
+
41
50
  function enforceNativeToolBudget({
42
51
  toolCallsExecuted = 0,
43
52
  toolErrors = 0,
@@ -46,7 +55,7 @@ function enforceNativeToolBudget({
46
55
  lastTool = "",
47
56
  lastError = "",
48
57
  } = {}) {
49
- if (toolCallsExecuted > maxToolCalls) {
58
+ if (toolCallsExecuted >= maxToolCalls) {
50
59
  throw new Error(`tool call budget exceeded (${maxToolCalls})`);
51
60
  }
52
61
  if (toolErrors >= maxToolErrors) {
@@ -534,7 +543,7 @@ async function runOpenAiLikeTurn({
534
543
  } = {}) {
535
544
  const payload = {
536
545
  model,
537
- max_tokens: 131072,
546
+ max_tokens: resolveMaxTokens(DEFAULT_OPENAI_MAX_TOKENS),
538
547
  messages,
539
548
  tools: buildCoreToolSpecs(),
540
549
  tool_choice: "auto",
@@ -588,6 +597,8 @@ async function runOpenAiLikeTurn({
588
597
  let rawBuffer = "";
589
598
  let responseText = "";
590
599
  const announcedToolNames = new Set();
600
+ let nextSyntheticIndex = 0;
601
+ let lastSyntheticIndex = -1;
591
602
 
592
603
  while (true) {
593
604
  const { done, value } = await reader.read();
@@ -633,7 +644,23 @@ async function runOpenAiLikeTurn({
633
644
 
634
645
  if (Array.isArray(delta.tool_calls)) {
635
646
  for (const callPart of delta.tool_calls) {
636
- const index = Number.isFinite(callPart.index) ? callPart.index : 0;
647
+ let index;
648
+ if (Number.isFinite(callPart.index)) {
649
+ index = callPart.index;
650
+ } else if (typeof callPart.id === "string" && callPart.id) {
651
+ // Provider omitted index: a chunk carrying an id starts a new
652
+ // call, so give it its own synthetic index instead of
653
+ // collapsing every call into slot 0.
654
+ while (toolCallMap.has(nextSyntheticIndex)) nextSyntheticIndex += 1;
655
+ index = nextSyntheticIndex;
656
+ nextSyntheticIndex += 1;
657
+ lastSyntheticIndex = index;
658
+ } else if (lastSyntheticIndex >= 0) {
659
+ // No index and no id: continuation of the latest synthetic call.
660
+ index = lastSyntheticIndex;
661
+ } else {
662
+ index = 0;
663
+ }
637
664
  const previous = toolCallMap.get(index) || {
638
665
  id: "",
639
666
  type: "function",
@@ -755,7 +782,7 @@ async function runAnthropicTurn({
755
782
  } = {}) {
756
783
  const payload = {
757
784
  model,
758
- max_tokens: 131072,
785
+ max_tokens: resolveMaxTokens(DEFAULT_ANTHROPIC_MAX_TOKENS),
759
786
  messages,
760
787
  tools: buildAnthropicToolSpecs(),
761
788
  stream: true,
@@ -280,6 +280,15 @@ function compileSummary(results) {
280
280
  return summaryParts.join("\n\n");
281
281
  }
282
282
 
283
+ /**
284
+ * Quote a value for safe inclusion in a shell command (single-quote style).
285
+ * Kept local to avoid a circular dependency with agent.js.
286
+ */
287
+ function shellQuote(value = "") {
288
+ const text = String(value == null ? "" : value);
289
+ return `'${text.replace(/'/g, `'\"'\"'`)}'`;
290
+ }
291
+
283
292
  /**
284
293
  * Create a progress reporter that sends updates via bus
285
294
  */
@@ -297,10 +306,10 @@ function createBusProgressReporter(shell, publisher) {
297
306
 
298
307
  if (progress.type === "step_start") {
299
308
  const message = `⏳ ${progress.name} (${progress.current}/${progress.total})`;
300
- shell(`ufoo bus send ${publisher} ${JSON.stringify(message)}`);
309
+ shell(`ufoo bus send ${shellQuote(publisher)} ${shellQuote(JSON.stringify(message))}`);
301
310
  } else if (progress.type === "step_complete" && progress.success) {
302
311
  const message = `✅ ${progress.name} completed`;
303
- shell(`ufoo bus send ${publisher} ${JSON.stringify(message)}`);
312
+ shell(`ufoo bus send ${shellQuote(publisher)} ${shellQuote(JSON.stringify(message))}`);
304
313
  }
305
314
  };
306
315
  }
@@ -1,6 +1,8 @@
1
1
  const { spawnSync } = require("child_process");
2
2
  const { normalizeWorkspaceRoot } = require("./common");
3
3
 
4
+ const MAX_TIMEOUT_MS = 600000;
5
+
4
6
  function runBashTool(input = {}, options = {}) {
5
7
  try {
6
8
  const command = String(input.command || "").trim();
@@ -11,7 +13,9 @@ function runBashTool(input = {}, options = {}) {
11
13
  };
12
14
  }
13
15
  const workspaceRoot = normalizeWorkspaceRoot(options.workspaceRoot, options.cwd);
14
- const timeoutMs = Number.isFinite(input.timeoutMs) ? Math.max(100, Math.floor(input.timeoutMs)) : 60000;
16
+ const timeoutMs = Number.isFinite(input.timeoutMs)
17
+ ? Math.min(MAX_TIMEOUT_MS, Math.max(100, Math.floor(input.timeoutMs)))
18
+ : 60000;
15
19
  const result = spawnSync(command, {
16
20
  cwd: workspaceRoot,
17
21
  shell: true,
@@ -25,13 +29,26 @@ function runBashTool(input = {}, options = {}) {
25
29
  ok: false,
26
30
  workspaceRoot,
27
31
  code: typeof result.status === "number" ? result.status : -1,
32
+ signal: result.signal || "",
28
33
  stdout: String(result.stdout || ""),
29
34
  stderr: String(result.stderr || ""),
30
35
  error: result.error.message || "bash failed",
31
36
  };
32
37
  }
33
38
 
34
- const code = typeof result.status === "number" ? result.status : 0;
39
+ if (typeof result.status !== "number") {
40
+ return {
41
+ ok: false,
42
+ workspaceRoot,
43
+ code: -1,
44
+ signal: result.signal || "",
45
+ stdout: String(result.stdout || ""),
46
+ stderr: String(result.stderr || ""),
47
+ error: `command killed by signal ${result.signal || "unknown"}`,
48
+ };
49
+ }
50
+
51
+ const code = result.status;
35
52
  return {
36
53
  ok: code === 0,
37
54
  workspaceRoot,
@@ -1,6 +1,23 @@
1
1
  const fs = require("fs");
2
2
  const { resolveWorkspacePath } = require("./common");
3
3
 
4
+ const MAX_FULL_READ_BYTES = 4 * 1024 * 1024;
5
+
6
+ function readFileBounded(resolved) {
7
+ const stat = fs.statSync(resolved);
8
+ if (stat.size <= MAX_FULL_READ_BYTES) {
9
+ return { raw: fs.readFileSync(resolved, "utf8"), partial: false };
10
+ }
11
+ const fd = fs.openSync(resolved, "r");
12
+ try {
13
+ const buffer = Buffer.alloc(MAX_FULL_READ_BYTES);
14
+ const bytesRead = fs.readSync(fd, buffer, 0, MAX_FULL_READ_BYTES, 0);
15
+ return { raw: buffer.slice(0, bytesRead).toString("utf8"), partial: true };
16
+ } finally {
17
+ fs.closeSync(fd);
18
+ }
19
+ }
20
+
4
21
  function runReadTool(input = {}, options = {}) {
5
22
  try {
6
23
  const filePath = String(input.path || input.file || "").trim();
@@ -9,13 +26,13 @@ function runReadTool(input = {}, options = {}) {
9
26
  const endLine = Number.isFinite(input.endLine) ? Math.max(startLine, Math.floor(input.endLine)) : 0;
10
27
  const maxBytes = Number.isFinite(input.maxBytes) ? Math.max(256, Math.floor(input.maxBytes)) : 200000;
11
28
 
12
- const raw = fs.readFileSync(resolved, "utf8");
29
+ const { raw, partial } = readFileBounded(resolved);
13
30
  const lines = raw.split(/\r?\n/);
14
31
  const from = startLine - 1;
15
32
  const to = endLine > 0 ? endLine : lines.length;
16
33
  const selected = lines.slice(from, to);
17
34
  let content = selected.join("\n");
18
- let truncated = false;
35
+ let truncated = partial;
19
36
  if (Buffer.byteLength(content, "utf8") > maxBytes) {
20
37
  content = Buffer.from(content, "utf8").slice(0, maxBytes).toString("utf8");
21
38
  truncated = true;
@@ -41,4 +58,5 @@ function runReadTool(input = {}, options = {}) {
41
58
 
42
59
  module.exports = {
43
60
  runReadTool,
61
+ MAX_FULL_READ_BYTES,
44
62
  };