u-foo 2.5.4 → 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.4",
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 };
@@ -1,4 +1,9 @@
1
1
  const BOX = { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘", t: "┬", b: "┴", l: "├", r: "┤", x: "┼" };
2
+ const {
3
+ classifyChatLogLine,
4
+ compactContinuationIndent,
5
+ compactDividerLabel,
6
+ } = require("../../../ui/ink/chatLogModel");
2
7
 
3
8
  function createRenderer(options = {}) {
4
9
  const {
@@ -239,49 +244,31 @@ function createRenderer(options = {}) {
239
244
  return str.slice(0, i);
240
245
  }
241
246
 
242
- function classifyLogLine(raw = "") {
243
- const clean = stripControl(raw)
244
- .replace(/\{\/?[^{}\n]+\}/g, "")
245
- .replace(/\*\*([^*]+)\*\*/g, "$1")
246
- .replace(/`([^`]+)`/g, "$1");
247
- const trimmed = clean.trim();
248
- if (!trimmed) return { kind: "spacer", text: " " };
249
- if (/^[█▀▄ ]+$/.test(trimmed) || /^ufoo chat/i.test(trimmed)) return { kind: "banner", text: clean };
250
- if (/^───.*───$/.test(trimmed)) return { kind: "divider", text: clean };
251
- if (/^(error:|✗|failed\b)/i.test(trimmed)) return { kind: "error", marker: "!", speaker: "error", body: clean.replace(/^(error:\s*)/i, "") };
252
- if (/^(✓|✔|done\b|closed\b)/i.test(trimmed)) return { kind: "success", marker: "✓", body: clean.replace(/^[✓✔]\s*/, "") };
253
- const dot = clean.match(/^([^·:\n]{1,34})\s+·\s+(.*)$/);
254
- if (dot) {
255
- const speaker = dot[1].trim();
256
- return {
257
- kind: speaker.toLowerCase() === "ufoo" ? "assistant" : "agent",
258
- marker: speaker.toLowerCase() === "ufoo" ? "◆" : "●",
259
- speaker,
260
- body: dot[2] || " ",
261
- };
262
- }
263
- const colon = clean.match(/^([A-Za-z0-9_.:@/-]{1,34}):\s+(.*)$/);
264
- if (colon) return { kind: "agent", marker: "●", speaker: colon[1], body: colon[2] || " " };
265
- return { kind: "plain", marker: "│", body: clean };
266
- }
267
-
268
247
  function formatChatLogLine(raw = "", width = 80) {
269
- const row = classifyLogLine(raw);
248
+ const row = classifyChatLogLine(stripControl(raw));
270
249
  const reset = "\x1b[0m";
271
250
  if (row.kind === "spacer") return " ".repeat(width);
272
- if (row.kind === "banner") return `\x1b[36;1m${truncateVisible(row.text, width)}${reset}`;
273
- if (row.kind === "divider") return `\x1b[90m${truncateVisible(row.text, width)}${reset}`;
251
+ if (row.kind === "banner") return `\x1b[36;1m${truncateVisible(row.body, width)}${reset}`;
252
+ if (row.kind === "divider") return `\x1b[90m${truncateVisible(` ${compactDividerLabel(row.body)}`, width)}${reset}`;
274
253
 
275
254
  const palette = {
276
255
  assistant: { marker: "\x1b[36m", speaker: "\x1b[37;1m", body: "" },
277
256
  agent: { marker: "\x1b[36m", speaker: "\x1b[36m", body: "" },
278
257
  error: { marker: "\x1b[31;1m", speaker: "\x1b[31m", body: "\x1b[31m" },
279
258
  success: { marker: "\x1b[32m", speaker: "\x1b[32m", body: "\x1b[32m" },
259
+ meta: { marker: "\x1b[90m", speaker: "\x1b[90m", body: "\x1b[90m" },
280
260
  plain: { marker: "\x1b[90m", speaker: "\x1b[90m", body: "" },
281
261
  };
282
262
  const colors = palette[row.kind] || palette.plain;
283
263
  const speaker = row.speaker ? `${colors.speaker}${row.speaker}${reset}\x1b[90m · ${reset}` : "";
284
- const line = `${colors.marker}${row.marker || ""}${reset} ${speaker}${colors.body || ""}${row.body || row.text || " "}${reset}`;
264
+ const markerGlyph = row.kind === "agent" ? "" : row.marker;
265
+ const marker = markerGlyph
266
+ ? `${colors.marker}${markerGlyph}${reset} `
267
+ : " ";
268
+ const body = row.kind === "plain"
269
+ ? compactContinuationIndent(row.body || row.text || " ")
270
+ : (row.body || row.text || " ");
271
+ const line = `${marker}${speaker}${colors.body || ""}${body}${reset}`;
285
272
  return truncateVisible(line, width);
286
273
  }
287
274
 
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
  };
@@ -23,6 +23,14 @@ const fmt = require("../format");
23
23
  const { createMultilineInput } = require("./MultilineInput");
24
24
  const { createDashboardBar } = require("./DashboardBar");
25
25
  const { reducer, createInitialState } = require("./chatReducer");
26
+ const {
27
+ stripBlessedTags,
28
+ compactDividerLabel,
29
+ classifyChatLogLine,
30
+ buildChatLogLineModel,
31
+ buildChatLogGroups,
32
+ chatLogEntryText,
33
+ } = require("./chatLogModel");
26
34
  const { restartDaemonLifecycle } = require("../../runtime/daemon/restart");
27
35
 
28
36
  function bootstrapEnvironment(projectRoot, options = {}) {
@@ -157,12 +165,20 @@ function loadChatHistory(projectRoot, cap = 200, options = {}) {
157
165
  const raw = fs.readFileSync(file, "utf8");
158
166
  const lines = raw.split(/\r?\n/).filter(Boolean);
159
167
  const out = [];
168
+ const pushLine = (line = "") => {
169
+ const value = String(line || "");
170
+ if (!value.trim()) {
171
+ if (out.length > 0 && out[out.length - 1] !== "") out.push("");
172
+ return;
173
+ }
174
+ out.push(value);
175
+ };
160
176
  for (const line of lines) {
161
177
  try {
162
178
  const entry = JSON.parse(line);
163
179
  if (!entry) continue;
164
180
  if (entry.type === "spacer") {
165
- out.push("");
181
+ pushLine("");
166
182
  continue;
167
183
  }
168
184
  const text = String(entry.text || "");
@@ -170,12 +186,18 @@ function loadChatHistory(projectRoot, cap = 200, options = {}) {
170
186
  // Strip blessed-tag markup that the legacy log writer used; ink
171
187
  // can't render those tags and we don't want them shown literally.
172
188
  const stripped = text.replace(/\{[^{}]+\}/g, "");
173
- out.push(stripped);
189
+ for (const renderedLine of normalizeInkLogLines(stripped)) {
190
+ pushLine(renderedLine);
191
+ }
174
192
  } catch {
175
193
  // ignore malformed lines
176
194
  }
177
195
  }
178
- return out.slice(-cap);
196
+ while (out.length > 0 && out[0] === "") out.shift();
197
+ while (out.length > 0 && out[out.length - 1] === "") out.pop();
198
+ const capped = out.slice(-cap);
199
+ while (capped.length > 0 && capped[0] === "") capped.shift();
200
+ return capped;
179
201
  } catch {
180
202
  return [];
181
203
  }
@@ -369,67 +391,11 @@ function buildPromptIpcRequest(text) {
369
391
  };
370
392
  }
371
393
 
372
- function stripBlessedTags(text = "") {
373
- return String(text || "")
374
- .replace(/\{\/?[^{}\n]+\}/g, "")
375
- .replace(/\r/g, "");
376
- }
377
-
378
394
  function normalizeInkLogLines(text = "") {
379
395
  const clean = stripBlessedTags(text);
380
396
  return clean.split(/\r?\n/);
381
397
  }
382
398
 
383
- function stripMarkdownDecorators(text = "") {
384
- return String(text || "")
385
- .replace(/\*\*([^*]+)\*\*/g, "$1")
386
- .replace(/`([^`]+)`/g, "$1");
387
- }
388
-
389
- function classifyChatLogLine(text = "") {
390
- const raw = stripBlessedTags(text).replace(/\r/g, "");
391
- const clean = stripMarkdownDecorators(raw);
392
- const trimmed = clean.trim();
393
- if (!trimmed) return { kind: "spacer", marker: " ", speaker: "", body: " " };
394
- if (/^[█▀▄ ]+(?:\s{2,}(?:Version|Mode|Dictionary):.*)?$/.test(trimmed) || /^ufoo chat/i.test(trimmed)) {
395
- return { kind: "banner", marker: " ", speaker: "", body: clean };
396
- }
397
- if (/^───.*───$/.test(trimmed)) {
398
- return { kind: "divider", marker: "─", speaker: "", body: clean };
399
- }
400
- if (/^(error:|✗|failed\b)/i.test(trimmed)) {
401
- return { kind: "error", marker: "!", speaker: "error", body: clean.replace(/^(error:\s*)/i, "") };
402
- }
403
- if (/^(✓|✔|done\b|closed\b)/i.test(trimmed)) {
404
- return { kind: "success", marker: "✓", speaker: "", body: clean.replace(/^[✓✔]\s*/, "") };
405
- }
406
- const dotMatch = clean.match(/^([^·:\n]{1,42})\s+·\s+(.*)$/);
407
- if (dotMatch) {
408
- const speaker = dotMatch[1].trim();
409
- const lower = speaker.toLowerCase();
410
- const kind = lower === "ufoo" ? "assistant" : "agent";
411
- return { kind, marker: kind === "assistant" ? "◆" : "•", speaker, body: dotMatch[2] || " " };
412
- }
413
- const colonMatch = clean.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
414
- if (colonMatch) {
415
- return { kind: "agent", marker: "•", speaker: colonMatch[1], body: colonMatch[2] || " " };
416
- }
417
- if (/^(CHAT|UCODE)\s+·/i.test(trimmed)) {
418
- return { kind: "meta", marker: "·", speaker: "", body: clean };
419
- }
420
- return { kind: "plain", marker: "│", speaker: "", body: clean };
421
- }
422
-
423
- function buildChatLogLineModel(text = "") {
424
- const row = classifyChatLogLine(text);
425
- const hasSpeaker = Boolean(row.speaker);
426
- return {
427
- ...row,
428
- markerText: hasSpeaker ? `${row.marker || " "} ` : `${row.marker || " "} `,
429
- bodyText: row.body || " ",
430
- };
431
- }
432
-
433
399
  function createInkStreamState({
434
400
  dispatch,
435
401
  appendHistory,
@@ -1234,7 +1200,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
1234
1200
  getChatLogLines: () => {
1235
1201
  const current = stateRef.current || {};
1236
1202
  return Array.isArray(current.logLines)
1237
- ? current.logLines.map((item) => String((item && item.text) || ""))
1203
+ ? current.logLines.map((item) => chatLogEntryText(item))
1238
1204
  : [];
1239
1205
  },
1240
1206
  getStatusText: () => {
@@ -3262,9 +3228,9 @@ function createChatApp({ React, ink, props, interactive = true }) {
3262
3228
  return null;
3263
3229
  }
3264
3230
 
3265
- const renderChatLogLine = (item) => {
3266
- const row = buildChatLogLineModel((item && item.text) || "");
3267
- const key = item && item.id ? item.id : `log-${row.body}`;
3231
+ const renderChatLogEntry = (entry, group) => {
3232
+ const row = entry && entry.row ? entry.row : buildChatLogLineModel("");
3233
+ const key = entry && entry.id ? entry.id : `log-${row.body}`;
3268
3234
  if (row.kind === "spacer") {
3269
3235
  return h(Text, { key, color: "gray" }, " ");
3270
3236
  }
@@ -3281,7 +3247,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
3281
3247
  const colors = palette[row.kind] || palette.plain;
3282
3248
  if (row.kind === "divider") {
3283
3249
  return h(Box, { key, marginBottom: 1 },
3284
- h(Text, { color: colors.body, wrap: "truncate" }, row.body),
3250
+ h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
3285
3251
  );
3286
3252
  }
3287
3253
  if (row.kind === "banner") {
@@ -3289,13 +3255,16 @@ function createChatApp({ React, ink, props, interactive = true }) {
3289
3255
  h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
3290
3256
  );
3291
3257
  }
3292
- return h(Box, { key, width: "100%", marginBottom: 1 },
3293
- h(Text, { color: colors.marker, bold: row.kind === "error" }, row.markerText),
3258
+ const markerText = entry && entry.continuation
3259
+ ? (group && (group.kind === "assistant" || group.kind === "agent") ? " " : " ")
3260
+ : row.markerText;
3261
+ return h(Box, { key, width: "100%" },
3262
+ h(Text, { color: colors.marker, bold: row.kind === "error" }, markerText),
3294
3263
  h(Text, { color: colors.body, wrap: "wrap" },
3295
- row.speaker
3264
+ row.speaker && !(entry && entry.continuation)
3296
3265
  ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3297
3266
  : null,
3298
- row.speaker
3267
+ row.speaker && !(entry && entry.continuation)
3299
3268
  ? h(Text, { color: "gray" }, " · ")
3300
3269
  : null,
3301
3270
  row.bodyText,
@@ -3303,6 +3272,27 @@ function createChatApp({ React, ink, props, interactive = true }) {
3303
3272
  );
3304
3273
  };
3305
3274
 
3275
+ const renderChatLogGroup = (group) => {
3276
+ const entries = Array.isArray(group && group.entries) ? group.entries : [];
3277
+ if (entries.length === 0) return null;
3278
+ const first = entries[0] || {};
3279
+ const row = first.row || buildChatLogLineModel("");
3280
+ if (row.kind === "spacer" || row.kind === "banner" || row.kind === "divider") {
3281
+ return renderChatLogEntry(first, group);
3282
+ }
3283
+ return h(Box, {
3284
+ key: `group-${group.id}`,
3285
+ flexDirection: "column",
3286
+ width: "100%",
3287
+ marginBottom: 1,
3288
+ },
3289
+ ...entries.map((entry) => renderChatLogEntry(entry, group)));
3290
+ };
3291
+
3292
+ const renderChatLogGroups = (items) => buildChatLogGroups(items)
3293
+ .map(renderChatLogGroup)
3294
+ .filter(Boolean);
3295
+
3306
3296
  if (state.viewingAgentId) {
3307
3297
  const maxWidth = Math.max(20, size.cols || 80);
3308
3298
  const logRows = Math.max(1, (size.rows || 24) - 5);
@@ -3379,7 +3369,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
3379
3369
 
3380
3370
  return h(Box, { flexDirection: "column", width: "100%" },
3381
3371
  h(Box, { flexDirection: "column", width: "100%" },
3382
- ...state.logLines.map(renderChatLogLine),
3372
+ ...renderChatLogGroups(state.logLines),
3383
3373
  ),
3384
3374
  state.activeMerge ? h(Box, null,
3385
3375
  h(Text, { color: state.activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
@@ -3391,10 +3381,10 @@ function createChatApp({ React, ink, props, interactive = true }) {
3391
3381
  const prefix = state.activeStream.publisher
3392
3382
  ? `${state.activeStream.publisher}: `
3393
3383
  : "";
3394
- return lines.map((line, idx) => renderChatLogLine({
3384
+ return renderChatLogGroups(lines.map((line, idx) => ({
3395
3385
  id: `s-${idx}`,
3396
3386
  text: idx === 0 ? `${prefix}${line}` : ` ${line}`,
3397
- }));
3387
+ })));
3398
3388
  })(),
3399
3389
  ) : null,
3400
3390
  h(Box, { marginTop: 1, width: "100%" },
@@ -3680,6 +3670,7 @@ module.exports = {
3680
3670
  chatHistoryOptionsForScope,
3681
3671
  classifyChatLogLine,
3682
3672
  buildChatLogLineModel,
3673
+ buildChatLogGroups,
3683
3674
  createInkMultiWindowToggle,
3684
3675
  resolveActiveAgentId,
3685
3676
  resolveInjectSockPathForAgent,
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+
3
+ function stripBlessedTags(text = "") {
4
+ return String(text || "")
5
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
6
+ .replace(/\{\/?[^{}\n]+\}/g, "")
7
+ .replace(/\r/g, "");
8
+ }
9
+
10
+ function stripMarkdownDecorators(text = "") {
11
+ return String(text || "")
12
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
13
+ .replace(/`([^`]+)`/g, "$1");
14
+ }
15
+
16
+ function compactContinuationIndent(text = "") {
17
+ return String(text || "").replace(/^\s{8,}(?=\S)/, "");
18
+ }
19
+
20
+ function compactDividerLabel(text = "") {
21
+ const label = String(text || "")
22
+ .trim()
23
+ .replace(/^─+\s*/, "")
24
+ .replace(/\s*─+$/, "")
25
+ .trim();
26
+ return label || String(text || "").trim() || "section";
27
+ }
28
+
29
+ function classifyChatLogLine(text = "") {
30
+ const raw = stripBlessedTags(text).replace(/\r/g, "");
31
+ const clean = stripMarkdownDecorators(raw);
32
+ const trimmed = clean.trim();
33
+ if (!trimmed) return { kind: "spacer", marker: " ", speaker: "", body: " " };
34
+ if (/^[█▀▄ ]+(?:\s{2,}(?:Version|Mode|Dictionary):.*)?$/.test(trimmed) || /^ufoo chat/i.test(trimmed)) {
35
+ return { kind: "banner", marker: " ", speaker: "", body: clean };
36
+ }
37
+ if (/^───.*───$/.test(trimmed)) {
38
+ return { kind: "divider", marker: "─", speaker: "", body: clean };
39
+ }
40
+ if (/^(CHAT|UCODE)\s+·/i.test(trimmed)) {
41
+ return { kind: "meta", marker: "·", speaker: "", body: clean };
42
+ }
43
+ if (/^(error:|✗|failed\b)/i.test(trimmed)) {
44
+ return { kind: "error", marker: "!", speaker: "error", body: clean.replace(/^(error:\s*)/i, "") };
45
+ }
46
+ if (/^(✓|✔|done\b|closed\b)/i.test(trimmed)) {
47
+ return { kind: "success", marker: "✓", speaker: "", body: clean.replace(/^[✓✔]\s*/, "") };
48
+ }
49
+ const dotMatch = clean.match(/^([^·\n]{1,64})\s+·\s+(.*)$/);
50
+ if (dotMatch) {
51
+ const speaker = dotMatch[1].trim();
52
+ const lower = speaker.toLowerCase();
53
+ const kind = lower === "ufoo" ? "assistant" : "agent";
54
+ return { kind, marker: kind === "assistant" ? "◆" : "•", speaker, body: dotMatch[2] || " " };
55
+ }
56
+ const colonMatch = clean.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
57
+ if (colonMatch) {
58
+ return { kind: "agent", marker: "•", speaker: colonMatch[1], body: colonMatch[2] || " " };
59
+ }
60
+ return { kind: "plain", marker: "", speaker: "", body: clean };
61
+ }
62
+
63
+ function defaultMarkerForKind(kind = "", speaker = "") {
64
+ if (kind === "assistant") return "◆";
65
+ if (kind === "agent") return "•";
66
+ if (kind === "error") return "!";
67
+ if (kind === "success") return "✓";
68
+ if (kind === "divider") return "─";
69
+ if (kind === "meta") return "·";
70
+ if (kind === "banner" || kind === "spacer") return " ";
71
+ return speaker ? "•" : "";
72
+ }
73
+
74
+ function buildChatLogLineModel(input = "") {
75
+ if (input && typeof input === "object" && !input.kind) {
76
+ return buildChatLogLineModel(chatLogEntryText(input));
77
+ }
78
+
79
+ if (input && typeof input === "object" && input.kind) {
80
+ const kind = String(input.kind || "plain");
81
+ const speaker = String(input.speaker || "");
82
+ const marker = input.marker != null ? String(input.marker) : defaultMarkerForKind(kind, speaker);
83
+ const rawBody = input.bodyText != null
84
+ ? String(input.bodyText)
85
+ : String(input.body != null ? input.body : chatLogEntryText(input));
86
+ const body = kind === "plain" ? compactContinuationIndent(rawBody || " ") : (rawBody || " ");
87
+ return {
88
+ kind,
89
+ marker,
90
+ speaker,
91
+ body: input.body != null ? String(input.body) : rawBody,
92
+ markerText: input.markerText != null
93
+ ? String(input.markerText)
94
+ : (speaker ? `${marker || " "} ` : `${marker || " "} `),
95
+ bodyText: body,
96
+ };
97
+ }
98
+
99
+ const row = classifyChatLogLine(input);
100
+ const hasSpeaker = Boolean(row.speaker);
101
+ const body = row.kind === "plain"
102
+ ? compactContinuationIndent(row.body || " ")
103
+ : (row.body || " ");
104
+ return {
105
+ ...row,
106
+ markerText: hasSpeaker ? `${row.marker || " "} ` : `${row.marker || " "} `,
107
+ bodyText: body,
108
+ };
109
+ }
110
+
111
+ function normalizeEntryInput(input) {
112
+ if (input && typeof input === "object" && !Array.isArray(input)) return input;
113
+ return { text: input };
114
+ }
115
+
116
+ function createChatLogEntry(input = "", id = "") {
117
+ const source = normalizeEntryInput(input);
118
+ const text = String(source.text != null ? source.text : chatLogEntryText(source));
119
+ const row = source.kind
120
+ ? buildChatLogLineModel({ ...source, text })
121
+ : buildChatLogLineModel(text);
122
+ const meta = source.meta && typeof source.meta === "object" && !Array.isArray(source.meta)
123
+ ? { ...source.meta }
124
+ : {};
125
+ const entry = {
126
+ id: String(id || source.id || ""),
127
+ text,
128
+ kind: row.kind,
129
+ marker: row.marker,
130
+ speaker: row.speaker,
131
+ body: row.body,
132
+ markerText: row.markerText,
133
+ bodyText: row.bodyText,
134
+ sourceType: String(source.sourceType || source.type || ""),
135
+ meta,
136
+ };
137
+ return entry;
138
+ }
139
+
140
+ function chatLogEntryText(entry = "") {
141
+ if (typeof entry === "string") return entry;
142
+ if (!entry || typeof entry !== "object") return "";
143
+ if (entry.text != null) return String(entry.text);
144
+ if (entry.speaker) return `${entry.speaker} · ${entry.bodyText || entry.body || ""}`;
145
+ return String(entry.bodyText || entry.body || "");
146
+ }
147
+
148
+ function canAppendToChatLogGroup(group, row) {
149
+ if (!group || !row) return false;
150
+ if (row.kind !== "plain" && row.kind !== "spacer") return false;
151
+ return group.kind === "assistant"
152
+ || group.kind === "agent"
153
+ || group.kind === "success"
154
+ || group.kind === "error"
155
+ || group.kind === "meta"
156
+ || group.kind === "plain";
157
+ }
158
+
159
+ function buildChatLogGroups(items = []) {
160
+ const source = Array.isArray(items) ? items : [];
161
+ const groups = [];
162
+ let current = null;
163
+ for (let index = 0; index < source.length; index += 1) {
164
+ const item = source[index] || {};
165
+ const itemId = item && typeof item === "object" && item.id ? item.id : `log-${index}`;
166
+ const row = buildChatLogLineModel(item);
167
+ const entry = {
168
+ id: itemId,
169
+ text: chatLogEntryText(item),
170
+ row,
171
+ sourceType: item && typeof item === "object" ? String(item.sourceType || item.type || "") : "",
172
+ meta: item && typeof item === "object" && item.meta ? item.meta : {},
173
+ continuation: false,
174
+ };
175
+
176
+ if (canAppendToChatLogGroup(current, row)) {
177
+ entry.continuation = true;
178
+ current.entries.push(entry);
179
+ continue;
180
+ }
181
+
182
+ current = {
183
+ id: entry.id,
184
+ kind: row.kind,
185
+ entries: [entry],
186
+ };
187
+ groups.push(current);
188
+ }
189
+ return groups;
190
+ }
191
+
192
+ module.exports = {
193
+ stripBlessedTags,
194
+ stripMarkdownDecorators,
195
+ compactContinuationIndent,
196
+ compactDividerLabel,
197
+ classifyChatLogLine,
198
+ buildChatLogLineModel,
199
+ buildChatLogGroups,
200
+ createChatLogEntry,
201
+ chatLogEntryText,
202
+ };
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  const fmt = require("../format");
38
+ const { createChatLogEntry } = require("./chatLogModel");
38
39
 
39
40
  const LOG_CAP = 1000;
40
41
  const HISTORY_CAP = 200;
@@ -100,7 +101,10 @@ function createInitialState({ banner = [], globalMode = false, globalScope = "co
100
101
  const initialAgentProvider = settings.agentProvider || "codex-cli";
101
102
  const selectedProviderIndex = Math.max(0, DEFAULT_PROVIDER_OPTIONS.findIndex((opt) => opt.value === initialAgentProvider));
102
103
  return {
103
- logLines: banner.concat([""]).map((line, idx) => ({ id: `b-${idx}`, text: line })),
104
+ logLines: banner.concat([""]).map((line, idx) => createChatLogEntry({
105
+ text: line,
106
+ sourceType: "banner",
107
+ }, `b-${idx}`)),
104
108
  lineSeq: banner.length + 1,
105
109
  draft: "",
106
110
  focusMode: "input",
@@ -144,10 +148,10 @@ function createInitialState({ banner = [], globalMode = false, globalScope = "co
144
148
  function appendLog(state, lines) {
145
149
  const incoming = Array.isArray(lines) ? lines : [lines];
146
150
  let seq = state.lineSeq;
147
- const out = state.logLines.concat(incoming.map((text) => {
151
+ const out = state.logLines.concat(incoming.map((line) => {
148
152
  const id = `l-${seq}`;
149
153
  seq += 1;
150
- return { id, text: String(text == null ? "" : text) };
154
+ return createChatLogEntry(line, id);
151
155
  }));
152
156
  return {
153
157
  ...state,