topchester-ai 0.10.0 → 0.12.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.
package/dist/cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { parse } from "yaml";
15
15
  import pino from "pino";
16
16
  import picomatch from "picomatch";
17
17
  import { uuidv7 } from "uuidv7";
18
- import { Input, Markdown, ProcessTerminal, TUI, isKeyRelease, isKeyRepeat, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
18
+ import { CURSOR_MARKER, Markdown, ProcessTerminal, TUI, decodeKittyPrintable, isKeyRelease, isKeyRepeat, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
19
19
  import { highlight, supportsLanguage } from "cli-highlight";
20
20
  //#region src/agent/tools/ai-sdk-tools.ts
21
21
  function toAiSdkToolSet(definitions) {
@@ -97,23 +97,26 @@ async function enqueueFileMutation(path, mutate) {
97
97
  }
98
98
  //#endregion
99
99
  //#region src/agent/tools/types.ts
100
+ function isToolErrorResult(result) {
101
+ return "error" in result && typeof result.error === "string";
102
+ }
100
103
  function defineTool(definition) {
101
104
  return definition;
102
105
  }
103
106
  //#endregion
104
107
  //#region src/agent/tools/edit-file.ts
105
108
  const editFileEditSchema = z.object({
106
- old_text: z.string(),
107
- new_text: z.string()
109
+ old_text: z.string().describe("Exact current file text to replace; include whitespace exactly."),
110
+ new_text: z.string().describe("Replacement text for old_text.")
108
111
  });
109
112
  const editFileTool = defineTool({
110
113
  name: "edit_file",
111
- description: "Edit an existing UTF-8 file inside the workspace with exact text replacements.",
112
- prompt: "edit_file: edit an existing UTF-8 file inside the workspace with exact old_text/new_text replacements; read the file first, keep old_text small but unique, and make multiple disjoint edits for one file in one call. To use it, reply with only JSON: {\"tool\":\"edit_file\",\"args\":{\"path\":\"src/example.ts\",\"expected_hash\":\"sha256:optional-current-file-hash\",\"edits\":[{\"old_text\":\"const enabled = false;\\n\",\"new_text\":\"const enabled = true;\\n\"}]}}",
114
+ description: "Edit an existing UTF-8 file inside the workspace with exact text replacements. Use expected_current_hash only as the current/pre-edit hash from read_file, never as a predicted post-edit hash.",
115
+ prompt: "edit_file: edit an existing UTF-8 file inside the workspace with exact old_text/new_text replacements; read the file first, keep old_text small but unique, and make multiple disjoint edits for one file in one call. expected_current_hash is optional and must be the current/pre-edit hash returned by the latest read_file for that file; never invent it or use a predicted after-edit hash. To use it, reply with only JSON: {\"tool\":\"edit_file\",\"args\":{\"path\":\"src/example.ts\",\"expected_current_hash\":\"sha256:current-file-hash-from-read_file\",\"edits\":[{\"old_text\":\"const enabled = false;\\n\",\"new_text\":\"const enabled = true;\\n\"}]}}",
113
116
  argsSchema: z.object({
114
- path: z.string(),
115
- expected_hash: z.string().optional(),
116
- edits: z.array(editFileEditSchema).min(1)
117
+ path: z.string().describe("Workspace-relative path to the existing UTF-8 file to edit."),
118
+ expected_current_hash: z.string().optional().describe("Optional current file hash returned by the latest read_file result for this file. This is checked before editing to catch stale reads; it is not the hash after the edit."),
119
+ edits: z.array(editFileEditSchema).min(1).describe("Exact text replacements to apply to the original file.")
117
120
  }),
118
121
  execute: (context, args) => editWorkspaceFile(context.workspaceRoot, args, { logger: context.logger })
119
122
  });
@@ -123,7 +126,7 @@ async function editWorkspaceFile(workspaceRoot, args, options = {}) {
123
126
  const fileStat = await statExistingFile(scopedPath.path, args.path);
124
127
  const beforeBytes = await readFile(scopedPath.path);
125
128
  const beforeHash = hashBytes$1(beforeBytes);
126
- if (args.expected_hash && args.expected_hash !== beforeHash) throw new Error(`edit_file expected_hash did not match ${scopedPath.relativePath}.`);
129
+ if (args.expected_current_hash && args.expected_current_hash !== beforeHash) throw new Error(`edit_file expected_current_hash did not match ${scopedPath.relativePath}.`);
127
130
  const result = applyExactEdits(decodeUtf8$1(scopedPath.relativePath, beforeBytes), args.edits, scopedPath.relativePath);
128
131
  const afterBytes = Buffer.from(result.newContent, "utf8");
129
132
  const afterHash = hashBytes$1(afterBytes);
@@ -376,9 +379,12 @@ const ignoredDirectories = new Set([
376
379
  ]);
377
380
  const findFileTool = defineTool({
378
381
  name: "find_file",
379
- description: "Find files by fuzzy name inside the workspace.",
382
+ description: "Find files by fuzzy name inside the workspace. Results are file paths, not file contents; use read_file next when the user needs contents.",
380
383
  prompt: "find_file: find existing files by fuzzy path or filename inside the workspace; matches may appear in the middle of a filename, and results are file paths, not file contents. To use it, reply with only JSON: {\"tool\":\"find_file\",\"args\":{\"query\":\"runtime\"}}",
381
384
  argsSchema: findFileArgsSchema,
385
+ parallelSafe: true,
386
+ mutatesWorkspace: false,
387
+ resourceKeys: (args) => [`find:${args.path}`],
382
388
  execute: (context, args) => findWorkspaceFilesByName(context.workspaceRoot, args, {
383
389
  pathEnv: context.pathEnv,
384
390
  logger: context.logger
@@ -829,7 +835,7 @@ function parseGitLog(output) {
829
835
  };
830
836
  });
831
837
  }
832
- function truncateText(content, maxBytes) {
838
+ function truncateText$1(content, maxBytes) {
833
839
  if (Buffer.byteLength(content) <= maxBytes) return {
834
840
  content,
835
841
  truncated: false
@@ -915,6 +921,9 @@ const gitStatusTool = defineTool({
915
921
  description: "Inspect structured Git branch and changed-file status inside the workspace.",
916
922
  prompt: "git_status: inspect branch, head, clean state, staged, unstaged, and untracked files without parsing shell output. To use it, reply with only JSON: {\"tool\":\"git_status\",\"args\":{\"path\":\".\",\"include_untracked\":true}}",
917
923
  argsSchema: gitStatusArgsSchema,
924
+ parallelSafe: true,
925
+ mutatesWorkspace: false,
926
+ resourceKeys: (args) => [`git-status:${args.path}`],
918
927
  execute: (context, args) => inspectGitStatus(context, args)
919
928
  });
920
929
  const gitDiffTool = defineTool({
@@ -922,6 +931,9 @@ const gitDiffTool = defineTool({
922
931
  description: "Inspect bounded Git diffs for staged, unstaged, and optionally untracked files.",
923
932
  prompt: "git_diff: inspect a bounded Git diff; use scope \"all\", \"unstaged\", or \"staged\", and include_untracked:true only when untracked file patches are needed. To use it, reply with only JSON: {\"tool\":\"git_diff\",\"args\":{\"scope\":\"all\",\"include_untracked\":true}}",
924
933
  argsSchema: gitDiffArgsSchema,
934
+ parallelSafe: true,
935
+ mutatesWorkspace: false,
936
+ resourceKeys: (args) => [`git-diff:${args.path ?? "."}:${args.scope}`],
925
937
  execute: (context, args) => inspectGitDiff(context, args)
926
938
  });
927
939
  const gitLogTool = defineTool({
@@ -929,6 +941,9 @@ const gitLogTool = defineTool({
929
941
  description: "Inspect recent Git commits as bounded structured summaries.",
930
942
  prompt: "git_log: inspect recent commits without parsing shell output. To use it, reply with only JSON: {\"tool\":\"git_log\",\"args\":{\"limit\":10,\"path\":\"src/agent/runtime.ts\"}}",
931
943
  argsSchema: gitLogArgsSchema,
944
+ parallelSafe: true,
945
+ mutatesWorkspace: false,
946
+ resourceKeys: (args) => [`git-log:${args.path ?? "."}`],
932
947
  execute: (context, args) => inspectGitLog(context, args)
933
948
  });
934
949
  const gitAddTool = defineTool({
@@ -1056,7 +1071,7 @@ async function inspectGitDiff(context, args) {
1056
1071
  changedFiles.add(file);
1057
1072
  }
1058
1073
  }
1059
- const bounded = truncateText(sections.join("\n").trimEnd() || "No diff.", args.max_bytes);
1074
+ const bounded = truncateText$1(sections.join("\n").trimEnd() || "No diff.", args.max_bytes);
1060
1075
  return {
1061
1076
  tool: "git_diff",
1062
1077
  path: path ?? void 0,
@@ -1343,6 +1358,9 @@ const grepTool = defineTool({
1343
1358
  pattern: z.string(),
1344
1359
  path: z.string().optional()
1345
1360
  }),
1361
+ parallelSafe: true,
1362
+ mutatesWorkspace: false,
1363
+ resourceKeys: (args) => [`grep:${args.path ?? "."}`],
1346
1364
  execute: (context, args) => grepWorkspace(context.workspaceRoot, args, {
1347
1365
  pathEnv: context.pathEnv,
1348
1366
  logger: context.logger
@@ -1644,6 +1662,8 @@ const READ_ONLY_COMMANDS = new Set([
1644
1662
  "find",
1645
1663
  "fd",
1646
1664
  "cat",
1665
+ "sed",
1666
+ "sort",
1647
1667
  "head",
1648
1668
  "tail",
1649
1669
  "wc",
@@ -1851,6 +1871,7 @@ function validateSimpleCommand(command, context) {
1851
1871
  case "git": return validateGitCommand(command, context);
1852
1872
  case "find": return validateFindCommand(command, context);
1853
1873
  case "fd": return validateGenericCommandArgs(command, context, FD_OPTIONS_WITH_VALUES);
1874
+ case "sed": return validateSedCommand(command, context);
1854
1875
  default: return validateGenericCommandArgs(command, context, COMMON_OPTIONS_WITH_VALUES);
1855
1876
  }
1856
1877
  }
@@ -1873,6 +1894,88 @@ function validateGitCommand(command, context) {
1873
1894
  function validateFindCommand(command, context) {
1874
1895
  return validateGenericCommandArgs(command, context, FIND_OPTIONS_WITH_VALUES);
1875
1896
  }
1897
+ function validateSedCommand(command, context) {
1898
+ let sawScript = false;
1899
+ for (let index = 0; index < command.args.length; index += 1) {
1900
+ const arg = command.args[index] ?? "";
1901
+ if (arg === "-i" || arg.startsWith("-i") || arg === "--in-place" || arg.startsWith("--in-place=")) return {
1902
+ allowed: false,
1903
+ reason: "inspect_command rejected 'sed' because in-place edits are unsafe."
1904
+ };
1905
+ if (arg === "-e" || arg === "--expression") {
1906
+ const script = command.args[index + 1];
1907
+ if (!script) return {
1908
+ allowed: false,
1909
+ reason: "inspect_command rejected 'sed' because -e requires a script."
1910
+ };
1911
+ const scriptResult = validateSedScript(script);
1912
+ if (!scriptResult.allowed) return scriptResult;
1913
+ sawScript = true;
1914
+ index += 1;
1915
+ continue;
1916
+ }
1917
+ if (arg === "-n" || arg === "--quiet" || arg === "--silent" || /^-[Erun]+$/.test(arg)) continue;
1918
+ if (arg.startsWith("-")) return {
1919
+ allowed: false,
1920
+ reason: `inspect_command rejected 'sed' because '${arg}' is not allowed.`
1921
+ };
1922
+ if (!sawScript && looksLikeSedScript(arg)) {
1923
+ const scriptResult = validateSedScript(arg);
1924
+ if (!scriptResult.allowed) return scriptResult;
1925
+ sawScript = true;
1926
+ continue;
1927
+ }
1928
+ const scoped = resolveWorkspacePath$1(context.workspaceRoot, arg, context.cwd);
1929
+ if (!scoped.allowed) return {
1930
+ allowed: false,
1931
+ reason: scoped.reason
1932
+ };
1933
+ }
1934
+ return sawScript ? { allowed: true } : {
1935
+ allowed: false,
1936
+ reason: "inspect_command rejected 'sed' because it requires an inline script."
1937
+ };
1938
+ }
1939
+ function looksLikeSedScript(arg) {
1940
+ return arg.startsWith("s") || /^(\d+|\$)?(,(\d+|\$))?[pdq]$/.test(arg);
1941
+ }
1942
+ function validateSedScript(script) {
1943
+ if (script.includes("\n")) return {
1944
+ allowed: false,
1945
+ reason: "inspect_command rejected 'sed' because multiline scripts are unsafe."
1946
+ };
1947
+ if (/^(\d+|\$)?(,(\d+|\$))?[pdq]$/.test(script)) return { allowed: true };
1948
+ if (!script.startsWith("s") || script.length < 4) return {
1949
+ allowed: false,
1950
+ reason: "inspect_command rejected 'sed' because only simple read-only scripts are allowed."
1951
+ };
1952
+ const delimiter = script[1] ?? "";
1953
+ if (!delimiter || /[A-Za-z0-9\\\n]/.test(delimiter)) return {
1954
+ allowed: false,
1955
+ reason: "inspect_command rejected 'sed' because the substitution delimiter is invalid."
1956
+ };
1957
+ const delimiters = findUnescapedDelimiterIndexes(script, delimiter);
1958
+ if (delimiters.length < 3) return {
1959
+ allowed: false,
1960
+ reason: "inspect_command rejected 'sed' because the substitution script is incomplete."
1961
+ };
1962
+ const flags = script.slice(delimiters[2] + 1);
1963
+ if (/[^gIpM0-9]/.test(flags) || /[ew]/.test(flags)) return {
1964
+ allowed: false,
1965
+ reason: "inspect_command rejected 'sed' because substitution flags are unsafe."
1966
+ };
1967
+ return { allowed: true };
1968
+ }
1969
+ function findUnescapedDelimiterIndexes(script, delimiter) {
1970
+ const indexes = [];
1971
+ for (let index = 0; index < script.length; index += 1) {
1972
+ if (script[index] !== delimiter) continue;
1973
+ let slashCount = 0;
1974
+ for (let back = index - 1; back >= 0 && script[back] === "\\"; back -= 1) slashCount += 1;
1975
+ if (slashCount % 2 === 0) indexes.push(index);
1976
+ }
1977
+ return indexes;
1978
+ }
1876
1979
  function validateGenericCommandArgs(command, context, optionsWithValues, knownPathlessWords = /* @__PURE__ */ new Set()) {
1877
1980
  for (let index = 0; index < command.args.length; index += 1) {
1878
1981
  const arg = command.args[index] ?? "";
@@ -2232,6 +2335,9 @@ const listFilesTool = defineTool({
2232
2335
  recursive: z.boolean().optional().default(false),
2233
2336
  limit: z.number().int().min(1).max(2e3).optional().default(500)
2234
2337
  }),
2338
+ parallelSafe: true,
2339
+ mutatesWorkspace: false,
2340
+ resourceKeys: (args) => [`dir:${args.path}`],
2235
2341
  execute: (context, args) => listWorkspaceFiles(context.workspaceRoot, args)
2236
2342
  });
2237
2343
  async function listWorkspaceFiles(workspaceRoot, args) {
@@ -2310,11 +2416,264 @@ function resolveWorkspaceScopedPath$1(workspaceRoot, path) {
2310
2416
  relativePath: relativePath || "."
2311
2417
  };
2312
2418
  }
2419
+ //#endregion
2420
+ //#region src/cli/ui.ts
2421
+ const colors = {
2422
+ bgSoftGray: "\x1B[48;5;236m",
2423
+ blue: "\x1B[34m",
2424
+ cyan: "\x1B[36m",
2425
+ darkGray: "\x1B[90m",
2426
+ dim: "\x1B[2m",
2427
+ green: "\x1B[32m",
2428
+ orange: "\x1B[38;5;208m",
2429
+ purple: "\x1B[35m",
2430
+ red: "\x1B[31m",
2431
+ reset: "\x1B[0m",
2432
+ resetForeground: "\x1B[39m",
2433
+ yellow: "\x1B[33m"
2434
+ };
2435
+ const ui = {
2436
+ heading(text) {
2437
+ return color(`Topchester ${text}`, "cyan");
2438
+ },
2439
+ label(text) {
2440
+ return color(text, "dim");
2441
+ },
2442
+ muted(text) {
2443
+ return color(text, "darkGray");
2444
+ },
2445
+ model(text) {
2446
+ return color(text, "blue");
2447
+ },
2448
+ modelInline(text) {
2449
+ if (!shouldUseColor()) return text;
2450
+ return `${colors.blue}${text}${colors.resetForeground}`;
2451
+ },
2452
+ ok(text) {
2453
+ return color(text, "green");
2454
+ },
2455
+ warn(text) {
2456
+ return color(text, "yellow");
2457
+ },
2458
+ error(text) {
2459
+ return color(text, "red");
2460
+ },
2461
+ softBackground(text) {
2462
+ return color(text, "bgSoftGray");
2463
+ },
2464
+ async spinner(text, action) {
2465
+ return withStatusLine(text, action, void 0, 80, false);
2466
+ },
2467
+ async progress(text, action) {
2468
+ let latest = text;
2469
+ return withStatusLine(text, () => action((message) => {
2470
+ latest = message;
2471
+ }), () => latest, 80, true);
2472
+ }
2473
+ };
2474
+ async function withStatusLine(text, action, getText = () => text, progressEveryMs = 80, emitPlainProgress = false) {
2475
+ if (!shouldUseColor()) {
2476
+ if (!emitPlainProgress) return action();
2477
+ const timer = setInterval(() => {
2478
+ stderr.write(`${getText()}\n`);
2479
+ }, Math.max(progressEveryMs, 5e3));
2480
+ try {
2481
+ return await action();
2482
+ } finally {
2483
+ clearInterval(timer);
2484
+ }
2485
+ }
2486
+ const frames = [
2487
+ "⠋",
2488
+ "⠙",
2489
+ "⠹",
2490
+ "⠸",
2491
+ "⠼",
2492
+ "⠴",
2493
+ "⠦",
2494
+ "⠧",
2495
+ "⠇",
2496
+ "⠏"
2497
+ ];
2498
+ let index = 0;
2499
+ stderr.write(`${color(frames[index], "cyan")} ${getText()}`);
2500
+ const timer = setInterval(() => {
2501
+ index = (index + 1) % frames.length;
2502
+ stderr.write(`\r\u001b[2K${color(frames[index], "cyan")} ${getText()}`);
2503
+ }, progressEveryMs);
2504
+ try {
2505
+ return await action();
2506
+ } finally {
2507
+ clearInterval(timer);
2508
+ stderr.write(`\r\u001b[2K`);
2509
+ }
2510
+ }
2511
+ function color(text, colorName) {
2512
+ if (!shouldUseColor()) return text;
2513
+ return `${colors[colorName]}${text}${colors.reset}`;
2514
+ }
2515
+ function shouldUseColor() {
2516
+ if (process.env.NO_COLOR) return false;
2517
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
2518
+ return stdout.isTTY === true;
2519
+ }
2520
+ //#endregion
2521
+ //#region src/agent/task-plan.ts
2522
+ const planTodoStatusSchema = z.enum([
2523
+ "pending",
2524
+ "in_progress",
2525
+ "completed"
2526
+ ]);
2527
+ const taskPlanItemSchema = z.object({
2528
+ text: z.string().trim().min(1, "Plan item text cannot be empty."),
2529
+ status: planTodoStatusSchema
2530
+ });
2531
+ const planTodoArgsSchema = z.object({ items: z.array(taskPlanItemSchema).max(20, "Plan updates are limited to 20 items.") }).superRefine((args, context) => {
2532
+ const seen = /* @__PURE__ */ new Set();
2533
+ let inProgressCount = 0;
2534
+ let incompleteCount = 0;
2535
+ args.items.forEach((item, index) => {
2536
+ const key = item.text.toLocaleLowerCase("en");
2537
+ if (seen.has(key)) context.addIssue({
2538
+ code: "custom",
2539
+ message: "Plan item text must be unique.",
2540
+ path: [
2541
+ "items",
2542
+ index,
2543
+ "text"
2544
+ ]
2545
+ });
2546
+ seen.add(key);
2547
+ if (item.status === "in_progress") inProgressCount += 1;
2548
+ if (item.status !== "completed") incompleteCount += 1;
2549
+ });
2550
+ if (inProgressCount > 1) context.addIssue({
2551
+ code: "custom",
2552
+ message: "At most one plan item can be in_progress.",
2553
+ path: ["items"]
2554
+ });
2555
+ if (args.items.length > 0 && incompleteCount > 0 && inProgressCount === 0) context.addIssue({
2556
+ code: "custom",
2557
+ message: "A non-completed plan must have exactly one in_progress item.",
2558
+ path: ["items"]
2559
+ });
2560
+ });
2561
+ function createEmptyTaskPlanState(now = /* @__PURE__ */ new Date()) {
2562
+ return {
2563
+ items: [],
2564
+ updatedAt: now.toISOString()
2565
+ };
2566
+ }
2567
+ function applyTaskPlanUpdate(_previous, args, now = /* @__PURE__ */ new Date()) {
2568
+ return {
2569
+ items: planTodoArgsSchema.parse(args).items.map((item) => ({
2570
+ text: item.text,
2571
+ status: item.status
2572
+ })),
2573
+ updatedAt: now.toISOString()
2574
+ };
2575
+ }
2576
+ function createTaskPlanController(initialState = createEmptyTaskPlanState(), now = () => /* @__PURE__ */ new Date()) {
2577
+ let state = initialState;
2578
+ return {
2579
+ update(args) {
2580
+ state = applyTaskPlanUpdate(state, args, now());
2581
+ return state;
2582
+ },
2583
+ get() {
2584
+ return state;
2585
+ }
2586
+ };
2587
+ }
2588
+ function summarizeTaskPlan(state) {
2589
+ const pendingCount = state.items.filter((item) => item.status === "pending").length;
2590
+ const inProgressCount = state.items.filter((item) => item.status === "in_progress").length;
2591
+ const completedCount = state.items.filter((item) => item.status === "completed").length;
2592
+ const currentItem = state.items.find((item) => item.status === "in_progress")?.text;
2593
+ return {
2594
+ pendingCount,
2595
+ inProgressCount,
2596
+ completedCount,
2597
+ ...currentItem === void 0 ? {} : { currentItem }
2598
+ };
2599
+ }
2600
+ function hasOpenTaskPlan(state) {
2601
+ return Boolean(state && state.items.some((item) => item.status !== "completed"));
2602
+ }
2603
+ function formatTaskPlanForPrompt(state) {
2604
+ const summary = summarizeTaskPlan(state);
2605
+ return [
2606
+ "Plan updated",
2607
+ `pending: ${summary.pendingCount}`,
2608
+ `in_progress: ${summary.inProgressCount}`,
2609
+ `completed: ${summary.completedCount}`,
2610
+ summary.currentItem ? `current: ${summary.currentItem}` : ""
2611
+ ].filter(Boolean).join("\n");
2612
+ }
2613
+ function detectTaskPlanChange(previous, next) {
2614
+ const hadPlan = Boolean(previous && previous.items.length > 0);
2615
+ const hasPlan = Boolean(next && next.items.length > 0);
2616
+ if (!hadPlan && hasPlan) return "created";
2617
+ if (hadPlan && !hasPlan) return "cleared";
2618
+ if (hadPlan && hasPlan) return "updated";
2619
+ return "unchanged";
2620
+ }
2621
+ function formatTaskPlanNotice(change, state) {
2622
+ if (change === "unchanged") return;
2623
+ if (change === "cleared" || state.items.length === 0) return "todo plan cleared";
2624
+ const summary = summarizeTaskPlan(state);
2625
+ if (summary.inProgressCount === 0 && summary.pendingCount === 0) return "todo plan completed";
2626
+ const prefix = change === "created" ? "todo plan created" : "todo plan updated";
2627
+ return summary.currentItem ? `${prefix}: ${summary.currentItem}` : prefix;
2628
+ }
2629
+ function formatTaskPlanForTui(state, width, visibleLimit = 6) {
2630
+ if (state.items.length === 0) return [];
2631
+ const itemWidth = Math.max(1, Math.max(12, width) - 6);
2632
+ const visibleItems = state.items.slice(0, visibleLimit);
2633
+ const lines = visibleItems.map((item) => formatTaskPlanTuiLine(item, truncateText(item.text, itemWidth)));
2634
+ const remaining = state.items.length - visibleItems.length;
2635
+ if (remaining > 0) lines.push(ui.muted(` +${remaining} more`));
2636
+ return lines;
2637
+ }
2638
+ function formatTaskPlanTuiLine(item, text) {
2639
+ switch (item.status) {
2640
+ case "completed": return ` ${ui.ok("[x]")} ${ui.muted(text)}`;
2641
+ case "in_progress": return ` ${ui.ok("[>]")} ${ui.ok(text)}`;
2642
+ case "pending": return ` ${ui.muted("[ ]")} ${text}`;
2643
+ }
2644
+ }
2645
+ function truncateText(text, width) {
2646
+ if (text.length <= width) return text;
2647
+ if (width <= 3) return ".".repeat(Math.max(0, width));
2648
+ return `${text.slice(0, width - 3)}...`;
2649
+ }
2650
+ //#endregion
2651
+ //#region src/agent/tools/plan-todo.ts
2652
+ const planTodoTool = defineTool({
2653
+ name: "plan_todo",
2654
+ description: "Replace the visible session task plan for multi-step work.",
2655
+ prompt: "plan_todo: replace the visible session task plan for non-trivial multi-step work; keep 2-6 short items, exactly one in_progress item while work remains, and use [] only to clear. Do not use plan_todo just to report completed work before a final answer. To use it, reply with only JSON: {\"tool\":\"plan_todo\",\"args\":{\"items\":[{\"text\":\"Inspect relevant files\",\"status\":\"in_progress\"},{\"text\":\"Implement focused change\",\"status\":\"pending\"}]}}",
2656
+ argsSchema: planTodoArgsSchema,
2657
+ async execute(context, args) {
2658
+ if (!context.taskPlan) throw new Error("plan_todo requires runtime task-plan state.");
2659
+ const plan = context.taskPlan.update(args);
2660
+ const summary = summarizeTaskPlan(plan);
2661
+ return {
2662
+ tool: "plan_todo",
2663
+ content: formatTaskPlanForPrompt(plan),
2664
+ plan,
2665
+ ...summary
2666
+ };
2667
+ }
2668
+ });
2313
2669
  const readFileTool = defineTool({
2314
2670
  name: "read_file",
2315
2671
  description: "Read a UTF-8 file inside the workspace.",
2316
2672
  prompt: "read_file: read a UTF-8 file inside the workspace. To use it, reply with only JSON: {\"tool\":\"read_file\",\"args\":{\"path\":\"package.json\"}}",
2317
2673
  argsSchema: z.object({ path: z.string() }),
2674
+ parallelSafe: true,
2675
+ mutatesWorkspace: false,
2676
+ resourceKeys: (args) => [`file:${args.path}`],
2318
2677
  execute: (context, args) => readWorkspaceFile(context.workspaceRoot, args.path)
2319
2678
  });
2320
2679
  async function readWorkspaceFile(workspaceRoot, path) {
@@ -2331,16 +2690,52 @@ async function readWorkspaceFile(workspaceRoot, path) {
2331
2690
  hash: `sha256:${createHash("sha256").update(bytes).digest("hex")}`
2332
2691
  };
2333
2692
  }
2693
+ const taskTool = defineTool({
2694
+ name: "task",
2695
+ description: "Delegate a focused prompt to a constrained child agent session.",
2696
+ prompt: "task: delegate focused read-only research or isolated analysis to a child agent session. Use it when parallel context gathering would help. To use it, reply with only JSON: {\"tool\":\"task\",\"args\":{\"description\":\"Inspect runtime event flow\",\"prompt\":\"Read the runtime and summarize how events are emitted.\",\"subagent_type\":\"explore\"}}",
2697
+ argsSchema: z.object({
2698
+ description: z.string().min(1),
2699
+ prompt: z.string().min(1),
2700
+ subagent_type: z.string().optional(),
2701
+ task_id: z.string().optional()
2702
+ }),
2703
+ async execute(context, args) {
2704
+ if (!context.subagents) throw new Error("task requires a runtime subagent manager.");
2705
+ const result = await context.subagents.runTask({
2706
+ description: args.description,
2707
+ prompt: args.prompt,
2708
+ subagentType: args.subagent_type,
2709
+ taskId: args.task_id,
2710
+ parentToolCallId: context.toolCallId ?? args.task_id ?? "task",
2711
+ eventSink: context.eventSink,
2712
+ abortSignal: context.abortSignal
2713
+ });
2714
+ return {
2715
+ tool: "task",
2716
+ childSessionId: result.sessionId,
2717
+ status: result.status,
2718
+ profileId: result.profileId,
2719
+ content: [
2720
+ `Task ${result.status}: ${args.description}`,
2721
+ `child_session: ${result.sessionId}`,
2722
+ `profile: ${result.profileId}`,
2723
+ "",
2724
+ result.result
2725
+ ].join("\n")
2726
+ };
2727
+ }
2728
+ });
2334
2729
  const writeFileTool = defineTool({
2335
2730
  name: "write_file",
2336
- description: "Create a new UTF-8 file inside the workspace, or explicitly replace one with an expected hash.",
2337
- prompt: "write_file: create a new UTF-8 file inside the workspace by default; use edit_file for targeted changes to existing files, pass create_parent_dirs:true only when creating the folder path is intended, and replace an existing whole file only with overwrite:true and expected_hash from read_file. To use it, reply with only JSON: {\"tool\":\"write_file\",\"args\":{\"path\":\"test/example.test.ts\",\"content\":\"import { it, expect } from \\\"vitest\\\";\\n\\nit(\\\"works\\\", () => {\\n expect(true).toBe(true);\\n});\\n\",\"create_parent_dirs\":true}}",
2731
+ description: "Create a new UTF-8 file inside the workspace, or explicitly replace one. For overwrite:true, expected_current_hash must be the current/pre-write hash from read_file, never a predicted post-write hash.",
2732
+ prompt: "write_file: create a new UTF-8 file inside the workspace by default; use edit_file for targeted changes to existing files; pass create_parent_dirs:true only when creating the folder path is intended. Replace an existing whole file only with overwrite:true and expected_current_hash set to the current/pre-write hash returned by the latest read_file for that file; never invent it or use a predicted after-write hash. To create a file, reply with only JSON: {\"tool\":\"write_file\",\"args\":{\"path\":\"test/example.test.ts\",\"content\":\"import { it, expect } from \\\"vitest\\\";\\n\\nit(\\\"works\\\", () => {\\n expect(true).toBe(true);\\n});\\n\",\"create_parent_dirs\":true}}",
2338
2733
  argsSchema: z.object({
2339
- path: z.string(),
2340
- content: z.string(),
2341
- create_parent_dirs: z.boolean().optional(),
2342
- overwrite: z.boolean().optional(),
2343
- expected_hash: z.string().optional()
2734
+ path: z.string().describe("Workspace-relative path to write."),
2735
+ content: z.string().describe("Complete UTF-8 file content to write."),
2736
+ create_parent_dirs: z.boolean().optional().describe("Create missing parent directories only when that is explicitly intended."),
2737
+ overwrite: z.boolean().optional().describe("Set true only for intentional whole-file replacement."),
2738
+ expected_current_hash: z.string().optional().describe("Required with overwrite:true. Use the current file hash returned by the latest read_file result for this file. This is checked before writing and is not the hash after the write.")
2344
2739
  }),
2345
2740
  execute: (context, args) => writeWorkspaceFile(context.workspaceRoot, args, { logger: context.logger })
2346
2741
  });
@@ -2396,12 +2791,12 @@ async function writeWorkspaceFile(workspaceRoot, args, options = {}) {
2396
2791
  });
2397
2792
  }
2398
2793
  async function overwriteExistingFile(workspaceRoot, scopedPath, args, existingTarget, options) {
2399
- if (!args.expected_hash) throw new Error(`write_file overwrite requires expected_hash for ${scopedPath.relativePath}.`);
2794
+ if (!args.expected_current_hash) throw new Error(`write_file overwrite requires expected_current_hash for ${scopedPath.relativePath}.`);
2400
2795
  if (!existingTarget) throw new Error(`write_file overwrite requires an existing file: ${scopedPath.relativePath}`);
2401
2796
  if (!existingTarget.isFile()) throw new Error(`write_file overwrite requires a regular file: ${scopedPath.relativePath}`);
2402
2797
  const beforeBytes = await readFile(scopedPath.path);
2403
2798
  const beforeHash = hashBytes(beforeBytes);
2404
- if (args.expected_hash !== beforeHash) throw new Error(`write_file expected_hash did not match ${scopedPath.relativePath}.`);
2799
+ if (args.expected_current_hash !== beforeHash) throw new Error(`write_file expected_current_hash did not match ${scopedPath.relativePath}.`);
2405
2800
  const beforeLineCount = countLogicalLines$1(decodeUtf8(scopedPath.relativePath, beforeBytes));
2406
2801
  const afterBytes = encodeUtf8Text(args.content);
2407
2802
  const afterHash = hashBytes(afterBytes);
@@ -2543,6 +2938,8 @@ function isNodeError$2(error) {
2543
2938
  //#endregion
2544
2939
  //#region src/agent/tools/registry.ts
2545
2940
  const toolRegistry = {
2941
+ [taskTool.name]: taskTool,
2942
+ [planTodoTool.name]: planTodoTool,
2546
2943
  [readFileTool.name]: readFileTool,
2547
2944
  [listFilesTool.name]: listFilesTool,
2548
2945
  [grepTool.name]: grepTool,
@@ -2562,8 +2959,16 @@ function isToolName(name) {
2562
2959
  function getToolDefinition(name) {
2563
2960
  return toolRegistry[name];
2564
2961
  }
2565
- function getToolPromptLines() {
2566
- return Object.values(toolRegistry).map((tool) => tool.prompt);
2962
+ function getToolPromptLines(filter) {
2963
+ return getToolDefinitionsForPermissions(filter).map((tool) => tool.prompt);
2964
+ }
2965
+ function getToolDefinitionsForPermissions(filter) {
2966
+ return Object.entries(toolRegistry).filter(([name]) => filter?.(name) ?? true).map(([, tool]) => tool);
2967
+ }
2968
+ function isParallelSafeToolName(name) {
2969
+ if (!isToolName(name)) return false;
2970
+ const definition = toolRegistry[name];
2971
+ return Boolean(definition.parallelSafe && !definition.mutatesWorkspace && !definition.requiresExclusiveWorkspace);
2567
2972
  }
2568
2973
  //#endregion
2569
2974
  //#region src/agent/tools/xml-parser.ts
@@ -2652,7 +3057,7 @@ function parseToolCallWithSource(text, allowedSources = ["text-json", "text-xml"
2652
3057
  if (allowedSources.includes("text-json")) {
2653
3058
  const json = parseJsonToolCall(text);
2654
3059
  if (json) return {
2655
- call: json,
3060
+ ...json,
2656
3061
  source: "text-json"
2657
3062
  };
2658
3063
  }
@@ -2660,7 +3065,8 @@ function parseToolCallWithSource(text, allowedSources = ["text-json", "text-xml"
2660
3065
  const xml = parseXmlToolCall(text);
2661
3066
  if (xml) return {
2662
3067
  call: xml,
2663
- source: "text-xml"
3068
+ source: "text-xml",
3069
+ remainder: ""
2664
3070
  };
2665
3071
  }
2666
3072
  }
@@ -2675,12 +3081,16 @@ function parseNativeToolCall(toolName, args) {
2675
3081
  };
2676
3082
  }
2677
3083
  function parseJsonToolCall(text) {
2678
- const trimmed = extractToolJsonCandidate(stripJsonFence(text.trim()));
3084
+ const { json, remainder } = extractToolJsonCandidate(stripJsonFence(text.trim()));
2679
3085
  let value;
2680
3086
  try {
2681
- value = JSON.parse(trimmed);
3087
+ value = JSON.parse(json);
2682
3088
  } catch {
2683
- return;
3089
+ try {
3090
+ value = JSON.parse(escapeControlCharactersInJsonStrings(json));
3091
+ } catch {
3092
+ return;
3093
+ }
2684
3094
  }
2685
3095
  if (!isRecord$1(value) || typeof value.tool !== "string") return;
2686
3096
  if (!isToolName(value.tool)) return;
@@ -2688,17 +3098,29 @@ function parseJsonToolCall(text) {
2688
3098
  const parsed = definition.argsSchema.safeParse(value.args);
2689
3099
  if (!parsed.success) return;
2690
3100
  return {
2691
- tool: definition.name,
2692
- args: parsed.data
3101
+ call: {
3102
+ tool: definition.name,
3103
+ args: parsed.data
3104
+ },
3105
+ remainder: remainder.trim()
2693
3106
  };
2694
3107
  }
2695
3108
  function stripJsonFence(text) {
2696
3109
  return text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1] ?? text;
2697
3110
  }
2698
3111
  function extractToolJsonCandidate(text) {
2699
- if (!text.startsWith("{")) return text;
3112
+ if (!text.startsWith("{")) return {
3113
+ json: text,
3114
+ remainder: ""
3115
+ };
2700
3116
  const endIndex = findJsonObjectEnd(text);
2701
- return endIndex === void 0 ? text : text.slice(0, endIndex + 1);
3117
+ return endIndex === void 0 ? {
3118
+ json: text,
3119
+ remainder: ""
3120
+ } : {
3121
+ json: text.slice(0, endIndex + 1),
3122
+ remainder: text.slice(endIndex + 1)
3123
+ };
2702
3124
  }
2703
3125
  function findJsonObjectEnd(text) {
2704
3126
  let depth = 0;
@@ -2720,6 +3142,39 @@ function findJsonObjectEnd(text) {
2720
3142
  }
2721
3143
  }
2722
3144
  }
3145
+ function escapeControlCharactersInJsonStrings(text) {
3146
+ let result = "";
3147
+ let inString = false;
3148
+ let escaped = false;
3149
+ for (let index = 0; index < text.length; index += 1) {
3150
+ const char = text[index];
3151
+ if (!inString) {
3152
+ result += char;
3153
+ if (char === "\"") inString = true;
3154
+ continue;
3155
+ }
3156
+ if (escaped) {
3157
+ result += char;
3158
+ escaped = false;
3159
+ continue;
3160
+ }
3161
+ if (char === "\\") {
3162
+ result += char;
3163
+ escaped = true;
3164
+ continue;
3165
+ }
3166
+ if (char === "\"") {
3167
+ result += char;
3168
+ inString = false;
3169
+ continue;
3170
+ }
3171
+ if (char === "\n") result += "\\n";
3172
+ else if (char === "\r") result += "\\r";
3173
+ else if (char === " ") result += "\\t";
3174
+ else result += char;
3175
+ }
3176
+ return result;
3177
+ }
2723
3178
  function isRecord$1(value) {
2724
3179
  return typeof value === "object" && value !== null;
2725
3180
  }
@@ -2769,25 +3224,41 @@ var ModelGateway = class ModelGateway {
2769
3224
  }
2770
3225
  async generateText(request) {
2771
3226
  const resolved = this.resolveModel(request.purpose);
3227
+ const result = await generateText({
3228
+ model: resolved.model,
3229
+ system: request.system,
3230
+ prompt: request.prompt,
3231
+ providerOptions: buildProviderOptions(resolved.providerId, resolved.providerConfig),
3232
+ abortSignal: request.abortSignal
3233
+ });
3234
+ const usage = normalizeUsage(result.usage, {
3235
+ providerId: resolved.providerId,
3236
+ providerConfig: resolved.providerConfig,
3237
+ responseBody: result.response.body
3238
+ });
2772
3239
  return {
2773
- text: (await generateText({
2774
- model: resolved.model,
2775
- system: request.system,
2776
- prompt: request.prompt,
2777
- abortSignal: request.abortSignal
2778
- })).text,
3240
+ text: result.text,
2779
3241
  providerId: resolved.providerId,
2780
3242
  modelId: resolved.modelId,
2781
- purpose: resolved.purpose
3243
+ purpose: resolved.purpose,
3244
+ ...usage ? { usage } : {}
2782
3245
  };
2783
3246
  }
2784
3247
  async generateAgentStep(request) {
2785
3248
  const resolved = this.resolveModel(request.purpose ?? "agent.primary");
2786
3249
  const override = request.toolProtocol ?? resolved.modelConfig.toolProtocol ?? resolved.providerConfig.toolProtocol ?? "auto";
2787
3250
  const attempts = [];
3251
+ if (override === "auto" && shouldUseTextProtocolForOpenRouterStreaming(request, resolved)) {
3252
+ attempts.push({
3253
+ protocol: "native-openai-compatible",
3254
+ status: "skipped",
3255
+ reason: "openrouter streaming auto uses text-json"
3256
+ });
3257
+ return this.generateTextAgentStep(request, resolved, attempts, "openrouter streaming auto uses text JSON protocol", false, ["text-json"]);
3258
+ }
2788
3259
  if (override === "native" || override === "auto") try {
2789
3260
  const result = await this.generateNativeAgentStep(request, resolved, attempts);
2790
- if (result.toolCalls.length > 0 || override === "native") return result;
3261
+ if (result.toolCalls.length > 0) return result;
2791
3262
  const parsedTextCall = parseToolCallWithSource(result.text);
2792
3263
  if (parsedTextCall) {
2793
3264
  const fallbackProtocol = parsedTextCall.source === "text-xml" ? "text-xml" : "text-json";
@@ -2811,7 +3282,7 @@ var ModelGateway = class ModelGateway {
2811
3282
  }
2812
3283
  return result;
2813
3284
  } catch (error) {
2814
- const reason = formatErrorMessage$1(error);
3285
+ const reason = formatErrorMessage$2(error);
2815
3286
  attempts.push({
2816
3287
  protocol: "native-openai-compatible",
2817
3288
  status: "failed",
@@ -2828,17 +3299,19 @@ var ModelGateway = class ModelGateway {
2828
3299
  return this.generateTextAgentStep(request, resolved, attempts, override === "text-xml" ? "forced text XML protocol" : "forced text JSON protocol", false, override === "text-xml" ? ["text-xml"] : ["text-json"]);
2829
3300
  }
2830
3301
  async *streamText(request) {
3302
+ const resolved = this.resolveModel(request.purpose);
2831
3303
  yield* streamText({
2832
- model: this.resolveModel(request.purpose).model,
3304
+ model: resolved.model,
2833
3305
  system: request.system,
2834
3306
  prompt: request.prompt,
3307
+ providerOptions: buildProviderOptions(resolved.providerId, resolved.providerConfig),
2835
3308
  abortSignal: request.abortSignal
2836
3309
  }).textStream;
2837
3310
  }
2838
3311
  async generateNativeAgentStep(request, resolved, attempts) {
2839
3312
  const providerOptions = buildNativeProviderOptions(resolved.providerId, resolved.providerConfig);
2840
3313
  const openRouterRoutingApplied = hasOpenRouterRoutingOptions(providerOptions, resolved.providerId);
2841
- const result = await generateText({
3314
+ const result = request.onReasoning ? await this.streamNativeAgentStep(request, resolved, providerOptions) : await generateText({
2842
3315
  model: resolved.model,
2843
3316
  system: request.system,
2844
3317
  prompt: request.prompt,
@@ -2847,6 +3320,11 @@ var ModelGateway = class ModelGateway {
2847
3320
  providerOptions,
2848
3321
  abortSignal: request.abortSignal
2849
3322
  });
3323
+ const usage = normalizeUsage(result.usage, {
3324
+ providerId: resolved.providerId,
3325
+ providerConfig: resolved.providerConfig,
3326
+ responseBody: result.response.body
3327
+ });
2850
3328
  const toolCalls = result.toolCalls.map((call, index) => {
2851
3329
  const parsed = parseNativeToolCall(call.toolName, call.input);
2852
3330
  if (!parsed) throw new Error(`Native tool call for ${call.toolName} did not match the registered schema.`);
@@ -2866,6 +3344,7 @@ var ModelGateway = class ModelGateway {
2866
3344
  providerId: resolved.providerId,
2867
3345
  modelId: resolved.modelId,
2868
3346
  purpose: resolved.purpose,
3347
+ ...usage ? { usage } : {},
2869
3348
  toolCalls,
2870
3349
  toolProtocol: "native-openai-compatible",
2871
3350
  protocolAttempts: attempts,
@@ -2875,12 +3354,18 @@ var ModelGateway = class ModelGateway {
2875
3354
  };
2876
3355
  }
2877
3356
  async generateTextAgentStep(request, resolved, attempts, fallbackReason, providerRejectedTools, allowedSources) {
2878
- const result = await generateText({
3357
+ const result = request.onReasoning ? await this.streamTextAgentStep(request, resolved) : await generateText({
2879
3358
  model: resolved.model,
2880
3359
  system: request.system,
2881
3360
  prompt: request.prompt,
3361
+ providerOptions: buildProviderOptions(resolved.providerId, resolved.providerConfig),
2882
3362
  abortSignal: request.abortSignal
2883
3363
  });
3364
+ const usage = normalizeUsage(result.usage, {
3365
+ providerId: resolved.providerId,
3366
+ providerConfig: resolved.providerConfig,
3367
+ responseBody: result.response.body
3368
+ });
2884
3369
  const parsed = parseToolCallWithSource(result.text, allowedSources);
2885
3370
  const defaultProtocol = allowedSources.length === 1 && allowedSources[0] === "text-xml" ? "text-xml" : "text-json";
2886
3371
  const toolProtocol = parsed?.source === "text-xml" ? "text-xml" : parsed ? "text-json" : defaultProtocol;
@@ -2894,6 +3379,7 @@ var ModelGateway = class ModelGateway {
2894
3379
  providerId: resolved.providerId,
2895
3380
  modelId: resolved.modelId,
2896
3381
  purpose: resolved.purpose,
3382
+ ...usage ? { usage } : {},
2897
3383
  toolCalls: parsed ? [{
2898
3384
  id: `${parsed.source}-0`,
2899
3385
  tool: parsed.call.tool,
@@ -2908,35 +3394,209 @@ var ModelGateway = class ModelGateway {
2908
3394
  openRouterRoutingApplied: false
2909
3395
  };
2910
3396
  }
2911
- };
2912
- function resolveApiKey(config) {
2913
- if (config.apiKey !== void 0) return config.apiKey;
2914
- if (config.apiKeyEnv === void 0) return;
2915
- return process.env[config.apiKeyEnv];
2916
- }
2917
- function buildNativeProviderOptions(providerId, config) {
2918
- const options = { parallel_tool_calls: false };
2919
- if (shouldApplyOpenRouterRoutingOptions(providerId, config)) options.provider = { require_parameters: true };
2920
- return { [providerId]: options };
2921
- }
2922
- function shouldApplyOpenRouterRoutingOptions(providerId, config) {
2923
- if (config.openRouterToolRouting === "force") return true;
2924
- if (config.openRouterToolRouting === "off") return false;
2925
- return providerId.toLowerCase().includes("openrouter") || config.baseURL.toLowerCase().includes("openrouter.ai");
2926
- }
2927
- function hasOpenRouterRoutingOptions(providerOptions, providerId) {
2928
- const options = providerOptions[providerId];
2929
- return Boolean(options && typeof options === "object" && "provider" in options);
3397
+ async streamNativeAgentStep(request, resolved, providerOptions) {
3398
+ const result = streamText({
3399
+ model: resolved.model,
3400
+ system: request.system,
3401
+ prompt: request.prompt,
3402
+ tools: toAiSdkToolSet(request.tools),
3403
+ toolChoice: "auto",
3404
+ providerOptions,
3405
+ abortSignal: request.abortSignal,
3406
+ includeRawChunks: true
3407
+ });
3408
+ guardStreamTextResultRejections(result);
3409
+ let rawUsageBody;
3410
+ let sawReasoningDelta = false;
3411
+ let reasoningText;
3412
+ let text;
3413
+ let toolCalls;
3414
+ let usage;
3415
+ let warnings;
3416
+ let response;
3417
+ try {
3418
+ ({rawUsageBody, sawReasoningDelta} = await consumeReasoningStream(result.fullStream, request.onReasoning));
3419
+ [text, toolCalls, usage, warnings, response, reasoningText] = await Promise.all([
3420
+ result.text,
3421
+ result.toolCalls,
3422
+ result.usage,
3423
+ result.warnings,
3424
+ result.response,
3425
+ result.reasoningText
3426
+ ]);
3427
+ } catch (error) {
3428
+ await settleRejectedStreamTextResult(result);
3429
+ throw error;
3430
+ }
3431
+ await emitReasoningSummary(request.onReasoning, sawReasoningDelta, reasoningText);
3432
+ return {
3433
+ text,
3434
+ toolCalls,
3435
+ usage,
3436
+ warnings,
3437
+ response: withRawUsageBody(response, rawUsageBody)
3438
+ };
3439
+ }
3440
+ async streamTextAgentStep(request, resolved) {
3441
+ const result = streamText({
3442
+ model: resolved.model,
3443
+ system: request.system,
3444
+ prompt: request.prompt,
3445
+ providerOptions: buildProviderOptions(resolved.providerId, resolved.providerConfig),
3446
+ abortSignal: request.abortSignal,
3447
+ includeRawChunks: true
3448
+ });
3449
+ guardStreamTextResultRejections(result);
3450
+ let rawUsageBody;
3451
+ let sawReasoningDelta = false;
3452
+ let reasoningText;
3453
+ let text;
3454
+ let usage;
3455
+ let warnings;
3456
+ let response;
3457
+ try {
3458
+ ({rawUsageBody, sawReasoningDelta} = await consumeReasoningStream(result.fullStream, request.onReasoning));
3459
+ [text, usage, warnings, response, reasoningText] = await Promise.all([
3460
+ result.text,
3461
+ result.usage,
3462
+ result.warnings,
3463
+ result.response,
3464
+ result.reasoningText
3465
+ ]);
3466
+ } catch (error) {
3467
+ await settleRejectedStreamTextResult(result);
3468
+ throw error;
3469
+ }
3470
+ await emitReasoningSummary(request.onReasoning, sawReasoningDelta, reasoningText);
3471
+ return {
3472
+ text,
3473
+ usage,
3474
+ warnings,
3475
+ response: withRawUsageBody(response, rawUsageBody)
3476
+ };
3477
+ }
3478
+ };
3479
+ function guardStreamTextResultRejections(result) {
3480
+ for (const promise of [
3481
+ result.text,
3482
+ result.toolCalls,
3483
+ result.usage,
3484
+ result.warnings,
3485
+ result.response,
3486
+ result.reasoningText
3487
+ ]) if (promise) Promise.resolve(promise).catch(() => {});
3488
+ }
3489
+ async function settleRejectedStreamTextResult(result) {
3490
+ await Promise.allSettled([
3491
+ result.text,
3492
+ result.toolCalls,
3493
+ result.usage,
3494
+ result.warnings,
3495
+ result.response,
3496
+ result.reasoningText
3497
+ ].filter((promise) => promise !== void 0).map((promise) => Promise.resolve(promise)));
3498
+ }
3499
+ async function consumeReasoningStream(stream, onReasoning) {
3500
+ let sawReasoningDelta = false;
3501
+ let rawUsageBody;
3502
+ for await (const part of stream) {
3503
+ if (!part || typeof part !== "object") continue;
3504
+ const typedPart = part;
3505
+ if (typedPart.type === "error") throw typedPart.error;
3506
+ if (typedPart.type === "raw" && hasUsageCostBody(typedPart.rawValue)) {
3507
+ rawUsageBody = typedPart.rawValue;
3508
+ continue;
3509
+ }
3510
+ if (typedPart.type !== "reasoning-delta") continue;
3511
+ const text = typeof typedPart.text === "string" ? typedPart.text : typedPart.delta;
3512
+ if (typeof text !== "string" || text.trim().length === 0) continue;
3513
+ sawReasoningDelta = true;
3514
+ await onReasoning?.({
3515
+ type: "delta",
3516
+ text
3517
+ });
3518
+ }
3519
+ return rawUsageBody === void 0 ? { sawReasoningDelta } : {
3520
+ sawReasoningDelta,
3521
+ rawUsageBody
3522
+ };
3523
+ }
3524
+ async function emitReasoningSummary(onReasoning, sawReasoningDelta, reasoningText) {
3525
+ if (sawReasoningDelta || !reasoningText || reasoningText.trim().length === 0) return;
3526
+ await onReasoning?.({
3527
+ type: "summary",
3528
+ text: reasoningText
3529
+ });
3530
+ }
3531
+ function hasUsageCostBody(value) {
3532
+ return Boolean(value && typeof value === "object" && "usage" in value && value.usage && typeof value.usage === "object");
3533
+ }
3534
+ function withRawUsageBody(response, rawUsageBody) {
3535
+ return rawUsageBody === void 0 ? response : {
3536
+ ...response,
3537
+ body: rawUsageBody
3538
+ };
3539
+ }
3540
+ function resolveApiKey(config) {
3541
+ if (config.apiKey !== void 0) return config.apiKey;
3542
+ if (config.apiKeyEnv === void 0) return;
3543
+ return process.env[config.apiKeyEnv];
3544
+ }
3545
+ function buildProviderOptions(providerId, config) {
3546
+ const options = {};
3547
+ if (config.service_tier !== void 0) options.service_tier = config.service_tier;
3548
+ return { [providerId]: options };
3549
+ }
3550
+ function buildNativeProviderOptions(providerId, config) {
3551
+ const options = {
3552
+ ...buildProviderOptions(providerId, config)[providerId],
3553
+ parallel_tool_calls: false
3554
+ };
3555
+ if (shouldApplyOpenRouterRoutingOptions(providerId, config)) options.provider = { require_parameters: true };
3556
+ return { [providerId]: options };
3557
+ }
3558
+ function shouldApplyOpenRouterRoutingOptions(providerId, config) {
3559
+ if (config.openRouterToolRouting === "force") return true;
3560
+ if (config.openRouterToolRouting === "off") return false;
3561
+ return isOpenRouterProvider$1(providerId, config);
3562
+ }
3563
+ function isOpenRouterProvider$1(providerId, config) {
3564
+ return providerId.toLowerCase().includes("openrouter") || config.baseURL.toLowerCase().includes("openrouter.ai");
3565
+ }
3566
+ function shouldUseTextProtocolForOpenRouterStreaming(request, resolved) {
3567
+ return request.onReasoning !== void 0 && isOpenRouterProvider$1(resolved.providerId, resolved.providerConfig);
3568
+ }
3569
+ function hasOpenRouterRoutingOptions(providerOptions, providerId) {
3570
+ const options = providerOptions[providerId];
3571
+ return Boolean(options && typeof options === "object" && "provider" in options);
2930
3572
  }
2931
3573
  function isNativeToolFallbackError(error) {
2932
- const message = formatErrorMessage$1(error).toLowerCase();
3574
+ const message = formatErrorMessage$2(error).toLowerCase();
2933
3575
  return message.includes("tool") || message.includes("function") || message.includes("parallel_tool_calls") || message.includes("tool_choice") || message.includes("requested parameters") || message.includes("provider routing") || message.includes("provider-selection") || message.includes("invalid request");
2934
3576
  }
2935
3577
  function extractWarningMessages(warnings) {
2936
3578
  if (!Array.isArray(warnings)) return [];
2937
- return warnings.map((warning) => formatErrorMessage$1(warning));
3579
+ return warnings.map((warning) => formatErrorMessage$2(warning));
3580
+ }
3581
+ function normalizeUsage(usage, context) {
3582
+ const costUsd = context && isOpenRouterProvider$1(context.providerId, context.providerConfig) ? extractOpenRouterCost(context.responseBody) : void 0;
3583
+ if (!usage) return costUsd === void 0 ? void 0 : { costUsd };
3584
+ const normalized = {
3585
+ ...typeof usage.inputTokens === "number" ? { inputTokens: usage.inputTokens } : {},
3586
+ ...typeof usage.outputTokens === "number" ? { outputTokens: usage.outputTokens } : {},
3587
+ ...typeof usage.totalTokens === "number" ? { totalTokens: usage.totalTokens } : {},
3588
+ ...costUsd === void 0 ? {} : { costUsd }
3589
+ };
3590
+ return Object.keys(normalized).length > 0 ? normalized : void 0;
2938
3591
  }
2939
- function formatErrorMessage$1(error) {
3592
+ function extractOpenRouterCost(responseBody) {
3593
+ if (!responseBody || typeof responseBody !== "object") return;
3594
+ const usage = responseBody.usage;
3595
+ if (!usage || typeof usage !== "object") return;
3596
+ const cost = usage.cost;
3597
+ return typeof cost === "number" && Number.isFinite(cost) ? cost : void 0;
3598
+ }
3599
+ function formatErrorMessage$2(error) {
2940
3600
  return error instanceof Error ? error.message : String(error);
2941
3601
  }
2942
3602
  //#endregion
@@ -2946,8 +3606,6 @@ const modelPurposeSchema = z.enum([
2946
3606
  "agent.fast",
2947
3607
  "kb.scan",
2948
3608
  "kb.summarize",
2949
- "kb.extract",
2950
- "kb.embed",
2951
3609
  "fallback"
2952
3610
  ]);
2953
3611
  const modelPurposes = modelPurposeSchema.options;
@@ -2957,6 +3615,10 @@ const toolProtocolSchema = z.enum([
2957
3615
  "text-json",
2958
3616
  "text-xml"
2959
3617
  ]);
3618
+ const openRouterAttributionHeaders = {
3619
+ "HTTP-Referer": "https://topchester.com",
3620
+ "X-Title": "Topchester"
3621
+ };
2960
3622
  const providerSchema = z.object({
2961
3623
  type: z.literal("openai-compatible"),
2962
3624
  baseURL: z.string().url(),
@@ -2964,6 +3626,7 @@ const providerSchema = z.object({
2964
3626
  apiKey: z.string().optional(),
2965
3627
  headers: z.record(z.string(), z.string()).optional(),
2966
3628
  supportsStructuredOutputs: z.boolean().optional(),
3629
+ service_tier: z.enum(["flex", "priority"]).optional(),
2967
3630
  toolProtocol: toolProtocolSchema.optional(),
2968
3631
  openRouterToolRouting: z.enum([
2969
3632
  "auto",
@@ -3042,7 +3705,7 @@ function readConfigFile(path) {
3042
3705
  try {
3043
3706
  return parse(readFileSync(path, "utf8"));
3044
3707
  } catch (error) {
3045
- throw new Error(`Invalid Topchester config at ${path}: ${formatErrorMessage(error)}`);
3708
+ throw new Error(`Invalid Topchester config at ${path}: ${formatErrorMessage$1(error)}`);
3046
3709
  }
3047
3710
  }
3048
3711
  function parseConfigFile(path, value) {
@@ -3078,6 +3741,7 @@ function normalizeConfigInput(value) {
3078
3741
  ensureKnownProvider(providers, kbSummarizeModelRef.provider);
3079
3742
  delete models["kb.summarize"];
3080
3743
  }
3744
+ applyKnownProviderDefaults(providers);
3081
3745
  return {
3082
3746
  ...value,
3083
3747
  models: {
@@ -3128,12 +3792,30 @@ function ensureKnownProvider(providers, provider) {
3128
3792
  baseURL: "https://openrouter.ai/api/v1",
3129
3793
  apiKeyEnv: "OPENROUTER_API_KEY",
3130
3794
  supportsStructuredOutputs: true,
3131
- headers: {
3132
- "HTTP-Referer": "https://topchester.com",
3133
- "X-Title": "Topchester"
3134
- }
3795
+ headers: { ...openRouterAttributionHeaders }
3135
3796
  };
3136
3797
  }
3798
+ function applyKnownProviderDefaults(providers) {
3799
+ for (const [providerId, provider] of Object.entries(providers)) {
3800
+ if (!isPlainObject(provider) || provider.type !== "openai-compatible" || typeof provider.baseURL !== "string") continue;
3801
+ if (isOpenAIProvider(providerId, provider.baseURL)) {
3802
+ provider.supportsStructuredOutputs ??= true;
3803
+ provider.toolProtocol ??= "native";
3804
+ }
3805
+ if (isOpenRouterProvider(providerId, provider.baseURL)) provider.headers = {
3806
+ ...openRouterAttributionHeaders,
3807
+ ...isPlainObject(provider.headers) ? provider.headers : {}
3808
+ };
3809
+ }
3810
+ }
3811
+ function isOpenRouterProvider(providerId, baseURL) {
3812
+ return providerId.toLowerCase().includes("openrouter") || baseURL.toLowerCase().includes("openrouter.ai");
3813
+ }
3814
+ function isOpenAIProvider(providerId, baseURL) {
3815
+ const normalizedProvider = providerId.toLowerCase();
3816
+ const normalizedBaseURL = baseURL.toLowerCase();
3817
+ return normalizedProvider === "openai" || normalizedProvider === "gpt" || normalizedProvider.includes("openai") || normalizedBaseURL.includes("api.openai.com");
3818
+ }
3137
3819
  function deepMerge(base, override, path = []) {
3138
3820
  if (Array.isArray(base) && Array.isArray(override)) return path.join(".") === "ignore.paths" ? [...base, ...override] : override;
3139
3821
  if (!isPlainObject(base) || !isPlainObject(override)) return override;
@@ -3147,7 +3829,7 @@ function isPlainObject(value) {
3147
3829
  function formatZodIssue(issue) {
3148
3830
  return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
3149
3831
  }
3150
- function formatErrorMessage(error) {
3832
+ function formatErrorMessage$1(error) {
3151
3833
  return error instanceof Error ? error.message : String(error);
3152
3834
  }
3153
3835
  //#endregion
@@ -3241,107 +3923,6 @@ function normalizeModelGatewayConfig(config) {
3241
3923
  };
3242
3924
  }
3243
3925
  //#endregion
3244
- //#region src/cli/ui.ts
3245
- const colors = {
3246
- bgSoftGray: "\x1B[48;5;236m",
3247
- blue: "\x1B[34m",
3248
- cyan: "\x1B[36m",
3249
- darkGray: "\x1B[90m",
3250
- dim: "\x1B[2m",
3251
- green: "\x1B[32m",
3252
- orange: "\x1B[38;5;208m",
3253
- purple: "\x1B[35m",
3254
- red: "\x1B[31m",
3255
- reset: "\x1B[0m",
3256
- resetForeground: "\x1B[39m",
3257
- yellow: "\x1B[33m"
3258
- };
3259
- const ui = {
3260
- heading(text) {
3261
- return color(`Topchester ${text}`, "cyan");
3262
- },
3263
- label(text) {
3264
- return color(text, "dim");
3265
- },
3266
- muted(text) {
3267
- return color(text, "darkGray");
3268
- },
3269
- model(text) {
3270
- return color(text, "blue");
3271
- },
3272
- modelInline(text) {
3273
- if (!shouldUseColor()) return text;
3274
- return `${colors.blue}${text}${colors.resetForeground}`;
3275
- },
3276
- ok(text) {
3277
- return color(text, "green");
3278
- },
3279
- warn(text) {
3280
- return color(text, "yellow");
3281
- },
3282
- error(text) {
3283
- return color(text, "red");
3284
- },
3285
- softBackground(text) {
3286
- return color(text, "bgSoftGray");
3287
- },
3288
- async spinner(text, action) {
3289
- return withStatusLine(text, action, void 0, 80, false);
3290
- },
3291
- async progress(text, action) {
3292
- let latest = text;
3293
- return withStatusLine(text, () => action((message) => {
3294
- latest = message;
3295
- }), () => latest, 80, true);
3296
- }
3297
- };
3298
- async function withStatusLine(text, action, getText = () => text, progressEveryMs = 80, emitPlainProgress = false) {
3299
- if (!shouldUseColor()) {
3300
- if (!emitPlainProgress) return action();
3301
- const timer = setInterval(() => {
3302
- stderr.write(`${getText()}\n`);
3303
- }, Math.max(progressEveryMs, 5e3));
3304
- try {
3305
- return await action();
3306
- } finally {
3307
- clearInterval(timer);
3308
- }
3309
- }
3310
- const frames = [
3311
- "⠋",
3312
- "⠙",
3313
- "⠹",
3314
- "⠸",
3315
- "⠼",
3316
- "⠴",
3317
- "⠦",
3318
- "⠧",
3319
- "⠇",
3320
- "⠏"
3321
- ];
3322
- let index = 0;
3323
- stderr.write(`${color(frames[index], "cyan")} ${getText()}`);
3324
- const timer = setInterval(() => {
3325
- index = (index + 1) % frames.length;
3326
- stderr.write(`\r\u001b[2K${color(frames[index], "cyan")} ${getText()}`);
3327
- }, progressEveryMs);
3328
- try {
3329
- return await action();
3330
- } finally {
3331
- clearInterval(timer);
3332
- stderr.write(`\r\u001b[2K`);
3333
- }
3334
- }
3335
- function color(text, colorName) {
3336
- if (!shouldUseColor()) return text;
3337
- return `${colors[colorName]}${text}${colors.reset}`;
3338
- }
3339
- function shouldUseColor() {
3340
- if (process.env.NO_COLOR) return false;
3341
- if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
3342
- return stdout.isTTY === true;
3343
- }
3344
- //#endregion
3345
3926
  //#region src/knowledge/status.ts
3346
3927
  function getKnowledgeStatus(workspaceRoot) {
3347
3928
  const kbPathSource = process.env.TOPCHESTER_KB_DIR ? "env" : "default";
@@ -3674,6 +4255,14 @@ const l1ConfidenceLevels = [
3674
4255
  "medium",
3675
4256
  "high"
3676
4257
  ];
4258
+ const l1FileRoles = [
4259
+ "source",
4260
+ "test",
4261
+ "config",
4262
+ "doc",
4263
+ "script",
4264
+ "unknown"
4265
+ ];
3677
4266
  const nonEmptyStringSchema = z.string().min(1);
3678
4267
  const sha256HashSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
3679
4268
  const isoUtcTimestampSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/).refine((value) => !Number.isNaN(Date.parse(value)), { message: "Expected a valid UTC ISO timestamp" });
@@ -3685,7 +4274,7 @@ const l1FileSymbolSchema = z.object({
3685
4274
  kind: nonEmptyStringSchema,
3686
4275
  name: nonEmptyStringSchema,
3687
4276
  exported: z.boolean(),
3688
- summary: nonEmptyStringSchema
4277
+ summary: nonEmptyStringSchema.optional()
3689
4278
  }).strict();
3690
4279
  const l1FileEvidenceSchema = z.object({
3691
4280
  kind: nonEmptyStringSchema,
@@ -3702,6 +4291,7 @@ const l1FileEntrySchema = z.object({
3702
4291
  size_bytes: z.number().int().nonnegative(),
3703
4292
  last_scanned_at: isoUtcTimestampSchema,
3704
4293
  scan_status: z.enum(l1FileScanStatuses),
4294
+ file_role: z.enum(l1FileRoles).default("unknown"),
3705
4295
  summary: nonEmptyStringSchema,
3706
4296
  responsibilities: z.array(nonEmptyStringSchema),
3707
4297
  symbols: z.array(l1FileSymbolSchema),
@@ -3710,6 +4300,9 @@ const l1FileEntrySchema = z.object({
3710
4300
  module_ids: z.array(l1ModuleIdSchema),
3711
4301
  feature_ids: z.array(l1FeatureIdSchema),
3712
4302
  test_ids: z.array(l1FileIdSchema),
4303
+ declared_test_targets: z.array(l1FileIdSchema).default([]),
4304
+ likely_test_targets: z.array(l1FileIdSchema).default([]),
4305
+ tested_by: z.array(l1FileIdSchema).default([]),
3713
4306
  evidence: z.array(l1FileEvidenceSchema),
3714
4307
  confidence: z.enum(l1ConfidenceLevels)
3715
4308
  }).strict().refine((entry) => entry.id === `file:${entry.path}`, {
@@ -3777,6 +4370,123 @@ function formatCountProgress(label, completed, total, detail) {
3777
4370
  return `${label} [${formatProgressBar(safeCompleted, safeTotal)}] ${safeCompleted}/${safeTotal} (${percent}%)${suffix}`;
3778
4371
  }
3779
4372
  //#endregion
4373
+ //#region src/knowledge/compiler/l1-postprocess.ts
4374
+ async function postProcessL1Entries(kbPath) {
4375
+ const entries = await loadL1Entries(kbPath);
4376
+ const entriesById = new Map(entries.map(({ entry }) => [entry.id, entry]));
4377
+ const entriesByPath = new Map(entries.map(({ entry }) => [entry.path, entry]));
4378
+ const testTargetsById = /* @__PURE__ */ new Map();
4379
+ for (const { entry } of entries) {
4380
+ if (inferL1FileRole(entry.path) !== "test") {
4381
+ testTargetsById.set(entry.id, {
4382
+ declared: [],
4383
+ likely: []
4384
+ });
4385
+ continue;
4386
+ }
4387
+ const declared = dedupeStrings$1([...entry.declared_test_targets.filter((id) => isExistingNonSelfFileId(id, entry.id, entriesById)), ...entry.imports.filter((id) => isExistingNonSelfFileId(id, entry.id, entriesById))]);
4388
+ const likely = dedupeStrings$1([...entry.likely_test_targets.filter((id) => isExistingNonSelfFileId(id, entry.id, entriesById)), ...inferLikelyTestTargets(entry.path, entriesByPath)]);
4389
+ testTargetsById.set(entry.id, {
4390
+ declared,
4391
+ likely
4392
+ });
4393
+ }
4394
+ const testedBy = /* @__PURE__ */ new Map();
4395
+ for (const [testId, links] of testTargetsById) for (const targetId of dedupeStrings$1([...links.declared, ...links.likely])) {
4396
+ const list = testedBy.get(targetId) ?? [];
4397
+ list.push(testId);
4398
+ testedBy.set(targetId, list);
4399
+ }
4400
+ let entriesUpdated = 0;
4401
+ let testLinksAdded = 0;
4402
+ for (const { entry, entryPath } of entries) {
4403
+ const fileRole = inferL1FileRole(entry.path);
4404
+ const links = testTargetsById.get(entry.id) ?? {
4405
+ declared: [],
4406
+ likely: []
4407
+ };
4408
+ const nextEntry = parseL1FileEntry({
4409
+ ...entry,
4410
+ file_role: fileRole,
4411
+ declared_test_targets: links.declared,
4412
+ likely_test_targets: links.likely,
4413
+ tested_by: dedupeStrings$1(testedBy.get(entry.id) ?? []).sort()
4414
+ });
4415
+ testLinksAdded += nextEntry.declared_test_targets.length + nextEntry.likely_test_targets.length + nextEntry.tested_by.length;
4416
+ if (JSON.stringify(nextEntry) !== JSON.stringify(entry)) {
4417
+ await writeFile(entryPath, `${JSON.stringify(nextEntry, null, 2)}\n`);
4418
+ entriesUpdated += 1;
4419
+ }
4420
+ }
4421
+ return {
4422
+ entriesRead: entries.length,
4423
+ entriesUpdated,
4424
+ testLinksAdded
4425
+ };
4426
+ }
4427
+ function inferL1FileRole(path) {
4428
+ const lowerPath = path.toLowerCase();
4429
+ const name = basename(lowerPath);
4430
+ if (isTestPath(lowerPath)) return "test";
4431
+ if (lowerPath.startsWith("scripts/") || lowerPath.startsWith("script/") || lowerPath.endsWith(".sh")) return "script";
4432
+ if (lowerPath.endsWith(".md") || lowerPath.endsWith(".mdx") || lowerPath.startsWith("docs/")) return "doc";
4433
+ if (name === "package.json" || name.endsWith("lock.json") || name.endsWith("-lock.yaml") || name.endsWith(".config.ts") || name.endsWith(".config.js") || name.endsWith(".config.mjs") || name.endsWith(".config.cjs") || name === "tsconfig.json" || name.startsWith(".")) return "config";
4434
+ if (/\.(ts|tsx|js|jsx|mts|cts)$/.test(lowerPath)) return "source";
4435
+ return "unknown";
4436
+ }
4437
+ async function loadL1Entries(kbPath) {
4438
+ const entryPaths = await listJsonFiles$1(join(kbPath, "l1-files")).catch((error) => {
4439
+ if (isFileNotFoundError$3(error)) return [];
4440
+ throw error;
4441
+ });
4442
+ const entries = [];
4443
+ for (const entryPath of entryPaths) entries.push({
4444
+ entryPath,
4445
+ entry: parseL1FileEntry(JSON.parse(await readFile(entryPath, "utf8")))
4446
+ });
4447
+ return entries.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
4448
+ }
4449
+ async function listJsonFiles$1(directory) {
4450
+ const entries = await readdir(directory, { withFileTypes: true });
4451
+ const files = [];
4452
+ for (const entry of entries) {
4453
+ const entryPath = join(directory, entry.name);
4454
+ if (entry.isDirectory()) files.push(...await listJsonFiles$1(entryPath));
4455
+ else if (entry.isFile() && entry.name.endsWith(".json")) files.push(entryPath);
4456
+ }
4457
+ return files.sort();
4458
+ }
4459
+ function inferLikelyTestTargets(testPath, entriesByPath) {
4460
+ const candidates = /* @__PURE__ */ new Set();
4461
+ const sourceLikePath = removeTestSuffix(testPath);
4462
+ candidates.add(sourceLikePath);
4463
+ for (const prefix of [
4464
+ "test/",
4465
+ "tests/",
4466
+ "__tests__/"
4467
+ ]) if (testPath.startsWith(prefix)) candidates.add(`src/${removeTestSuffix(testPath.slice(prefix.length))}`);
4468
+ if (testPath.includes("/__tests__/")) candidates.add(removeTestSuffix(testPath.replace("/__tests__/", "/")));
4469
+ return [...candidates].filter((candidate) => candidate !== testPath).flatMap((candidate) => {
4470
+ const entry = entriesByPath.get(candidate);
4471
+ return entry ? [entry.id] : [];
4472
+ });
4473
+ }
4474
+ function removeTestSuffix(path) {
4475
+ return path.replace(/\.(test|spec)(\.[^./]+)$/i, "$2");
4476
+ }
4477
+ function isTestPath(path) {
4478
+ return /\.(test|spec)\.(ts|tsx|js|jsx|mts|cts)$/.test(path) || path.startsWith("test/") || path.startsWith("tests/") || path.startsWith("__tests__/") || path.includes("/__tests__/");
4479
+ }
4480
+ function isExistingNonSelfFileId(id, selfId, entriesById) {
4481
+ return id !== selfId && entriesById.has(id);
4482
+ }
4483
+ function dedupeStrings$1(values) {
4484
+ return [...new Set(values)];
4485
+ }
4486
+ function isFileNotFoundError$3(error) {
4487
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
4488
+ }
4489
+ //#endregion
3780
4490
  //#region src/knowledge/compiler/manifest.ts
3781
4491
  const knowledgeCompilerIdentity = {
3782
4492
  name: "topchester-knowledge-compiler",
@@ -3819,6 +4529,8 @@ async function processL1Queue(options) {
3819
4529
  await persistQueue(options.queuePath, queuedFiles, now().toISOString());
3820
4530
  options.onProgress?.({ message: formatL1ProgressMessage("Processing L1 files", index + 1, queuedFiles.length, item.path) });
3821
4531
  }
4532
+ options.onProgress?.({ message: "Linking L1 file relationships..." });
4533
+ await postProcessL1Entries(options.kbPath);
3822
4534
  const summary = await summarizeL1Queue(options.kbPath, queuedFiles);
3823
4535
  await writeManifest(options, summary, now().toISOString());
3824
4536
  return {
@@ -3878,7 +4590,11 @@ async function processL1QueueItem(options) {
3878
4590
  }
3879
4591
  function buildL1FileEntrySystemPrompt() {
3880
4592
  return [
3881
- "You summarize one repository file for Topchester's L1 knowledge base.",
4593
+ "You create concise, structured repository knowledge for one file.",
4594
+ "Prefer concrete facts visible in the file over generic descriptions.",
4595
+ "Do not invent modules, features, tests, routes, or dependencies.",
4596
+ "If uncertain, leave arrays empty and use lower confidence.",
4597
+ "Avoid filler such as \"This file contains code\" or \"Symbol named X\".",
3882
4598
  "Return exactly one JSON object and no markdown.",
3883
4599
  "Do not include secrets, credentials, or raw provider payloads."
3884
4600
  ].join("\n");
@@ -3887,6 +4603,35 @@ function buildL1FileEntryPrompt(input) {
3887
4603
  return [
3888
4604
  "Create an L1 file entry for this workspace-relative path.",
3889
4605
  "The compiler will overwrite id, path, content_hash, size_bytes, last_scanned_at, and scan_status.",
4606
+ "",
4607
+ "Extraction rules:",
4608
+ "- summary: one specific sentence about the file's role in this project.",
4609
+ "- responsibilities: 2-6 concrete responsibilities, no duplicates, no generic boilerplate.",
4610
+ "- symbols: important declared or exported interfaces, types, classes, functions, constants, schemas, commands, routes, React components, tests, or config objects.",
4611
+ " For each symbol, set:",
4612
+ " - kind: interface | type | class | function | const | component | schema | command | route | test | config | symbol",
4613
+ " - name: exact identifier or stable label",
4614
+ " - exported: true only when exported from this file",
4615
+ " - summary: include only if it adds useful meaning beyond the name",
4616
+ "- imports: only workspace-local file dependencies as file:<path>; omit packages and built-ins.",
4617
+ "- exports: exact exported names from this file as strings.",
4618
+ "- test_ids: only file:<path> when this file is clearly a test or clearly references a test target.",
4619
+ "- file_role: source | test | config | doc | script | unknown.",
4620
+ "- declared_test_targets: for test files, file:<path> entries that this test directly imports or names.",
4621
+ "- likely_test_targets: for test files, file:<path> entries likely covered by path/name convention.",
4622
+ "- tested_by: leave empty; the compiler fills reverse test links after all files are processed.",
4623
+ "- module_ids and feature_ids: leave empty unless there is strong evidence.",
4624
+ "- evidence: include at least { \"kind\": \"path\", \"value\": \"<path>\" } and any high-signal local evidence.",
4625
+ "- confidence: high for simple files with clear structure, medium for normal files, low for vague/generated/config-heavy files.",
4626
+ "",
4627
+ "Quality rules:",
4628
+ "- Return valid JSON only.",
4629
+ "- Keep arrays concise.",
4630
+ "- Deduplicate all arrays.",
4631
+ "- Prefer exact names from source.",
4632
+ "- Do not copy large code snippets.",
4633
+ "- Do not include secrets or raw credentials.",
4634
+ "",
3890
4635
  "Use this JSON shape:",
3891
4636
  JSON.stringify({
3892
4637
  $schema: l1FileEntrySchemaPath,
@@ -3899,6 +4644,7 @@ function buildL1FileEntryPrompt(input) {
3899
4644
  size_bytes: 0,
3900
4645
  last_scanned_at: "2026-05-11T00:00:00Z",
3901
4646
  scan_status: "current",
4647
+ file_role: "source",
3902
4648
  summary: "One clear sentence.",
3903
4649
  responsibilities: ["What this file owns or does."],
3904
4650
  symbols: [],
@@ -3907,6 +4653,9 @@ function buildL1FileEntryPrompt(input) {
3907
4653
  module_ids: [],
3908
4654
  feature_ids: [],
3909
4655
  test_ids: [],
4656
+ declared_test_targets: [],
4657
+ likely_test_targets: [],
4658
+ tested_by: [],
3910
4659
  evidence: [{
3911
4660
  kind: "path",
3912
4661
  value: "<path>"
@@ -3944,41 +4693,49 @@ function normalizeL1FileEntry(value, deterministic) {
3944
4693
  }
3945
4694
  function normalizeModelOwnedL1Fields(value, path) {
3946
4695
  const record = value;
4696
+ const exports = normalizeStringArray(record.exports);
3947
4697
  return {
3948
4698
  ...record,
4699
+ file_role: inferL1FileRole(path),
3949
4700
  responsibilities: normalizeStringArray(record.responsibilities),
3950
- symbols: normalizeSymbols(record.symbols, path),
4701
+ symbols: normalizeSymbols(record.symbols, path, exports),
3951
4702
  imports: normalizePrefixedIds(record.imports, "file:"),
3952
- exports: normalizeStringArray(record.exports),
4703
+ exports,
3953
4704
  module_ids: normalizePrefixedIds(record.module_ids, "module:"),
3954
4705
  feature_ids: normalizePrefixedIds(record.feature_ids, "feature:"),
3955
4706
  test_ids: normalizePrefixedIds(record.test_ids, "file:"),
4707
+ declared_test_targets: normalizePrefixedIds(record.declared_test_targets, "file:"),
4708
+ likely_test_targets: normalizePrefixedIds(record.likely_test_targets, "file:"),
4709
+ tested_by: normalizePrefixedIds(record.tested_by, "file:"),
3956
4710
  evidence: normalizeEvidence(record.evidence)
3957
4711
  };
3958
4712
  }
3959
4713
  function normalizeStringArray(value) {
3960
4714
  if (!Array.isArray(value)) return [];
3961
- return value.filter((item) => typeof item === "string" && item.trim().length > 0);
4715
+ return dedupeStrings(value.filter((item) => typeof item === "string").map((item) => item.trim()));
3962
4716
  }
3963
4717
  function normalizePrefixedIds(value, prefix) {
3964
4718
  return normalizeStringArray(value).filter((item) => item.startsWith(prefix));
3965
4719
  }
3966
4720
  function normalizeEvidence(value) {
3967
4721
  if (!Array.isArray(value)) return [];
3968
- return value.flatMap((item) => {
4722
+ return dedupeRecords(value.flatMap((item) => {
3969
4723
  if (!item || typeof item !== "object" || Array.isArray(item)) return [];
3970
4724
  const record = item;
3971
- if (typeof record.kind !== "string" || record.kind.trim().length === 0) return [];
3972
- if (typeof record.value !== "string" || record.value.trim().length === 0) return [];
4725
+ const kind = typeof record.kind === "string" ? record.kind.trim() : "";
4726
+ const recordValue = typeof record.value === "string" ? record.value.trim() : "";
4727
+ if (kind.length === 0) return [];
4728
+ if (recordValue.length === 0) return [];
3973
4729
  return [{
3974
- kind: record.kind,
3975
- value: record.value
4730
+ kind,
4731
+ value: recordValue
3976
4732
  }];
3977
- });
4733
+ }), (item) => `${item.kind}\0${item.value}`);
3978
4734
  }
3979
- function normalizeSymbols(value, path) {
4735
+ function normalizeSymbols(value, path, exports) {
3980
4736
  if (!Array.isArray(value)) return [];
3981
- return value.flatMap((item) => {
4737
+ const exportedNames = new Set(exports);
4738
+ return dedupeRecords(value.flatMap((item) => {
3982
4739
  if (typeof item === "string") {
3983
4740
  const name = item.trim();
3984
4741
  if (!name || !path) return [];
@@ -3986,8 +4743,7 @@ function normalizeSymbols(value, path) {
3986
4743
  id: `symbol:${path}#${name}`,
3987
4744
  kind: "symbol",
3988
4745
  name,
3989
- exported: false,
3990
- summary: `Symbol named ${name}.`
4746
+ exported: exportedNames.has(name)
3991
4747
  }];
3992
4748
  }
3993
4749
  if (!item || typeof item !== "object" || Array.isArray(item)) return [];
@@ -3995,14 +4751,36 @@ function normalizeSymbols(value, path) {
3995
4751
  const rawId = typeof record.id === "string" && record.id.startsWith("symbol:") ? record.id : void 0;
3996
4752
  const name = typeof record.name === "string" && record.name.trim().length > 0 ? record.name : rawId?.slice(rawId.lastIndexOf("#") + 1);
3997
4753
  if (!name || !path) return [];
3998
- return [{
4754
+ const summary = typeof record.summary === "string" && record.summary.trim().length > 0 ? record.summary.trim() : "";
4755
+ return [removeUndefinedValues({
3999
4756
  id: rawId ?? `symbol:${path}#${name}`,
4000
- kind: typeof record.kind === "string" && record.kind.trim().length > 0 ? record.kind : "symbol",
4757
+ kind: typeof record.kind === "string" && record.kind.trim().length > 0 ? record.kind.trim() : "symbol",
4001
4758
  name,
4002
- exported: typeof record.exported === "boolean" ? record.exported : false,
4003
- summary: typeof record.summary === "string" && record.summary.trim().length > 0 ? record.summary : `Symbol named ${name}.`
4004
- }];
4005
- });
4759
+ exported: exportedNames.has(name) || (typeof record.exported === "boolean" ? record.exported : false),
4760
+ summary: summary && !isGenericSymbolSummary$1(summary, name) ? summary : void 0
4761
+ })];
4762
+ }), (item) => String(item.id));
4763
+ }
4764
+ function dedupeStrings(values) {
4765
+ return [...new Set(values.filter((value) => value.length > 0))];
4766
+ }
4767
+ function dedupeRecords(values, keyFor) {
4768
+ const seen = /* @__PURE__ */ new Set();
4769
+ const deduped = [];
4770
+ for (const value of values) {
4771
+ const key = keyFor(value);
4772
+ if (seen.has(key)) continue;
4773
+ seen.add(key);
4774
+ deduped.push(value);
4775
+ }
4776
+ return deduped;
4777
+ }
4778
+ function removeUndefinedValues(record) {
4779
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== void 0));
4780
+ }
4781
+ function isGenericSymbolSummary$1(summary, name) {
4782
+ const normalizedSummary = summary.trim().replace(/\s+/g, " ");
4783
+ return normalizedSummary === `Symbol named ${name}.` || normalizedSummary === `Symbol named ${name}` || normalizedSummary === name;
4006
4784
  }
4007
4785
  function extractTopLevelJsonObjects(text) {
4008
4786
  const objects = [];
@@ -4404,11 +5182,11 @@ async function getL1SyncStatus(kbPath, kbReady, file) {
4404
5182
  if (entry.path !== file.path || entry.size_bytes !== file.sizeBytes || entry.content_hash !== file.hash) return "changed";
4405
5183
  return entry.scan_status;
4406
5184
  } catch (error) {
4407
- if (isFileNotFoundError$1(error)) return "missing_entry";
5185
+ if (isFileNotFoundError$2(error)) return "missing_entry";
4408
5186
  return "invalid";
4409
5187
  }
4410
5188
  }
4411
- function isFileNotFoundError$1(error) {
5189
+ function isFileNotFoundError$2(error) {
4412
5190
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
4413
5191
  }
4414
5192
  function assertKbSummarizeModelConfigured(model) {
@@ -4522,25 +5300,449 @@ function isNodeError(error) {
4522
5300
  return error instanceof Error && "code" in error;
4523
5301
  }
4524
5302
  //#endregion
4525
- //#region src/tui/markdown.ts
4526
- const codeFenceSentinel = "topchester-code-fence";
4527
- function renderMarkdown(text, width) {
4528
- const lines = new Markdown(unwrapMarkdownCodeFences(text), 0, 0, getMarkdownTheme()).render(width);
4529
- const rendered = [];
4530
- let inCodeBlock = false;
4531
- for (const line of lines) {
4532
- if (line.includes(codeFenceSentinel)) {
4533
- inCodeBlock = !inCodeBlock;
4534
- continue;
5303
+ //#region src/knowledge/search.ts
5304
+ var L1InMemoryIndex = class {
5305
+ entriesById = /* @__PURE__ */ new Map();
5306
+ postingsByToken = /* @__PURE__ */ new Map();
5307
+ prefixTokensByPrefix = /* @__PURE__ */ new Map();
5308
+ constructor(entries) {
5309
+ for (const entry of entries) {
5310
+ this.entriesById.set(entry.id, entry);
5311
+ this.indexEntry(entry);
5312
+ }
5313
+ this.indexPrefixTokens();
5314
+ }
5315
+ get size() {
5316
+ return this.entriesById.size;
5317
+ }
5318
+ search(query, options = {}) {
5319
+ const tokens = tokenizeQuery(query);
5320
+ const scoresByEntryId = /* @__PURE__ */ new Map();
5321
+ const reasonsByEntryId = /* @__PURE__ */ new Map();
5322
+ for (const token of tokens) {
5323
+ this.addMatches(token, 1, scoresByEntryId, reasonsByEntryId);
5324
+ if (token.length >= 4) this.addPrefixMatches(token, scoresByEntryId, reasonsByEntryId);
5325
+ }
5326
+ const limit = options.limit ?? 10;
5327
+ return [...scoresByEntryId.entries()].map(([entryId, score]) => {
5328
+ const entry = this.entriesById.get(entryId);
5329
+ if (!entry) return;
5330
+ return {
5331
+ id: entry.id,
5332
+ path: entry.path,
5333
+ score: Math.round(score * 100) / 100,
5334
+ summary: entry.summary,
5335
+ contentHash: entry.content_hash,
5336
+ scanStatus: entry.scan_status,
5337
+ reasons: [...(reasonsByEntryId.get(entryId) ?? /* @__PURE__ */ new Map()).entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([reason]) => reason).slice(0, 6)
5338
+ };
5339
+ }).filter((match) => Boolean(match)).sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)).slice(0, limit);
5340
+ }
5341
+ getEntry(id) {
5342
+ return this.entriesById.get(id);
5343
+ }
5344
+ indexEntry(entry) {
5345
+ this.addField(entry, "path", [entry.path, basename(entry.path)], 6);
5346
+ this.addField(entry, "symbol", entry.symbols.flatMap((symbol) => [
5347
+ symbol.name,
5348
+ symbol.kind,
5349
+ symbol.summary
5350
+ ].filter(isString)), 10);
5351
+ this.addField(entry, "export", entry.exports, 9);
5352
+ this.addField(entry, "responsibility", entry.responsibilities, 6);
5353
+ this.addField(entry, "summary", [entry.summary], 5);
5354
+ this.addField(entry, "import", entry.imports, 4);
5355
+ this.addField(entry, "test", entry.test_ids, 4);
5356
+ this.addField(entry, "relationship", [...entry.module_ids, ...entry.feature_ids], 3);
5357
+ this.addField(entry, "evidence", entry.evidence.map((evidence) => evidence.value), 3);
5358
+ }
5359
+ addField(entry, field, values, weight) {
5360
+ const tokens = new Set(values.flatMap(tokenizeText));
5361
+ for (const token of tokens) {
5362
+ const postings = this.postingsByToken.get(token) ?? [];
5363
+ postings.push({
5364
+ entryId: entry.id,
5365
+ weight,
5366
+ reason: `${formatField(field)} matched ${token}`
5367
+ });
5368
+ this.postingsByToken.set(token, postings);
4535
5369
  }
4536
- rendered.push(inCodeBlock ? applyCodeBlockBackground(line) : line);
4537
5370
  }
4538
- return rendered;
4539
- }
4540
- function unwrapMarkdownCodeFences(text) {
4541
- return text.replace(/^```(?:markdown|md)\s*\n([\s\S]*?)\n```$/gim, "$1");
5371
+ addMatches(token, multiplier, scoresByEntryId, reasonsByEntryId) {
5372
+ for (const posting of this.postingsByToken.get(token) ?? []) {
5373
+ scoresByEntryId.set(posting.entryId, (scoresByEntryId.get(posting.entryId) ?? 0) + posting.weight * multiplier);
5374
+ const reasons = reasonsByEntryId.get(posting.entryId) ?? /* @__PURE__ */ new Map();
5375
+ reasons.set(posting.reason, Math.max(reasons.get(posting.reason) ?? 0, posting.weight * multiplier));
5376
+ reasonsByEntryId.set(posting.entryId, reasons);
5377
+ }
5378
+ }
5379
+ addPrefixMatches(token, scoresByEntryId, reasonsByEntryId) {
5380
+ const matchedTokens = /* @__PURE__ */ new Set();
5381
+ for (const indexedToken of this.prefixTokensByPrefix.get(token) ?? []) matchedTokens.add(indexedToken);
5382
+ for (let prefixLength = 1; prefixLength < token.length; prefixLength += 1) {
5383
+ const prefix = token.slice(0, prefixLength);
5384
+ if (this.postingsByToken.has(prefix)) matchedTokens.add(prefix);
5385
+ }
5386
+ for (const indexedToken of matchedTokens) {
5387
+ if (indexedToken === token) continue;
5388
+ this.addMatches(indexedToken, .6, scoresByEntryId, reasonsByEntryId);
5389
+ }
5390
+ }
5391
+ indexPrefixTokens() {
5392
+ for (const indexedToken of this.postingsByToken.keys()) for (let prefixLength = 4; prefixLength < indexedToken.length; prefixLength += 1) {
5393
+ const prefix = indexedToken.slice(0, prefixLength);
5394
+ const tokens = this.prefixTokensByPrefix.get(prefix) ?? [];
5395
+ tokens.push(indexedToken);
5396
+ this.prefixTokensByPrefix.set(prefix, tokens);
5397
+ }
5398
+ }
5399
+ };
5400
+ function buildL1InMemoryIndex(entries) {
5401
+ return new L1InMemoryIndex(entries);
4542
5402
  }
4543
- function getMarkdownTheme() {
5403
+ async function searchL1Knowledge(workspaceRoot, query, options = {}) {
5404
+ const status = getKnowledgeStatus(workspaceRoot);
5405
+ if (!status.kbExists || !status.kbIsDirectory) throw new Error("Run `topchester kb init` and `topchester kb compile` before searching the knowledge base.");
5406
+ const loadResult = await loadL1FileEntries(status.kbPath);
5407
+ const index = buildL1InMemoryIndex(loadResult.entries);
5408
+ return {
5409
+ workspaceRoot,
5410
+ kbPath: status.kbPath,
5411
+ query,
5412
+ entryCount: index.size,
5413
+ invalidEntryCount: loadResult.invalidEntryCount,
5414
+ matches: index.search(query, options)
5415
+ };
5416
+ }
5417
+ function createL1ContextPackFromIndex(source, query, options = {}) {
5418
+ const limit = options.limit ?? 8;
5419
+ const minScore = options.minScore ?? 12;
5420
+ const relevantFiles = source.index.search(query, { limit: Math.max(limit * 3, limit) }).filter((match) => match.score >= minScore).slice(0, limit).map((match) => {
5421
+ const entry = source.index.getEntry(match.id);
5422
+ if (!entry) return;
5423
+ return {
5424
+ id: match.id,
5425
+ path: match.path,
5426
+ score: match.score,
5427
+ reasons: match.reasons,
5428
+ contentHash: match.contentHash,
5429
+ scanStatus: match.scanStatus,
5430
+ l1: compactL1Entry(entry),
5431
+ fullL1: options.includeFullL1 ? entry : void 0
5432
+ };
5433
+ }).filter((file) => Boolean(file));
5434
+ const warnings = relevantFiles.length === 0 ? ["No L1 entries met the context pack score threshold."] : [];
5435
+ return {
5436
+ workspaceRoot: source.workspaceRoot,
5437
+ kbPath: source.kbPath,
5438
+ query,
5439
+ entryCount: source.index.size,
5440
+ invalidEntryCount: source.invalidEntryCount,
5441
+ selection: {
5442
+ limit,
5443
+ minScore
5444
+ },
5445
+ drift: {
5446
+ status: "unchecked",
5447
+ warnings: ["L1 context pack includes stored scan statuses; exact file-hash drift check has not run yet."]
5448
+ },
5449
+ summary: summarizeContextPack(query, relevantFiles),
5450
+ warnings,
5451
+ relevantFiles
5452
+ };
5453
+ }
5454
+ async function createL1ContextPack(workspaceRoot, query, options = {}) {
5455
+ const limit = options.limit ?? 8;
5456
+ const minScore = options.minScore ?? 12;
5457
+ const status = getKnowledgeStatus(workspaceRoot);
5458
+ if (!status.kbExists || !status.kbIsDirectory) throw new Error("Run `topchester kb init` and `topchester kb compile` before creating a context pack.");
5459
+ const loadResult = await loadL1FileEntries(status.kbPath);
5460
+ const index = buildL1InMemoryIndex(loadResult.entries);
5461
+ return createL1ContextPackFromIndex({
5462
+ workspaceRoot,
5463
+ kbPath: status.kbPath,
5464
+ index,
5465
+ invalidEntryCount: loadResult.invalidEntryCount
5466
+ }, query, {
5467
+ limit,
5468
+ minScore,
5469
+ includeFullL1: options.includeFullL1
5470
+ });
5471
+ }
5472
+ function formatL1KnowledgeSearchResult(result) {
5473
+ return [
5474
+ "KB search",
5475
+ `workspace: ${result.workspaceRoot}`,
5476
+ `knowledge folder: ${result.kbPath} [ok]`,
5477
+ `query: ${result.query}`,
5478
+ `entries indexed: ${result.entryCount}`,
5479
+ `invalid L1 entries skipped: ${result.invalidEntryCount}`,
5480
+ `matches: ${result.matches.length}`,
5481
+ ...result.matches.length === 0 ? ["state: no L1 matches found"] : [""],
5482
+ ...result.matches.flatMap((match) => [
5483
+ `${match.score}\t${match.path}\t${match.scanStatus}\t${match.contentHash}`,
5484
+ ` reasons: ${match.reasons.join("; ") || "score match"}`,
5485
+ ` summary: ${match.summary}`
5486
+ ]),
5487
+ "----",
5488
+ `total matches: ${result.matches.length}`
5489
+ ];
5490
+ }
5491
+ function formatL1ContextPackResult(result) {
5492
+ return [
5493
+ "KB context",
5494
+ `workspace: ${result.workspaceRoot}`,
5495
+ `knowledge folder: ${result.kbPath} [ok]`,
5496
+ `query: ${result.query}`,
5497
+ `entries indexed: ${result.entryCount}`,
5498
+ `invalid L1 entries skipped: ${result.invalidEntryCount}`,
5499
+ `selection: top ${result.selection.limit}, min score ${result.selection.minScore}`,
5500
+ `drift: ${result.drift.status}`,
5501
+ `relevant files: ${result.relevantFiles.length}`,
5502
+ `summary: ${result.summary}`,
5503
+ ...result.warnings.map((warning) => `warning: ${warning}`),
5504
+ "",
5505
+ ...result.relevantFiles.flatMap((file) => [
5506
+ `${file.score}\t${file.path}\t${file.scanStatus}\t${file.contentHash}`,
5507
+ ` reasons: ${file.reasons.join("; ") || "score match"}`,
5508
+ ` responsibilities: ${(file.l1.responsibilities ?? []).join("; ") || "(none)"}`,
5509
+ ` symbols: ${(file.l1.symbols ?? []).map((symbol) => symbol.name).join(", ") || "(none)"}`,
5510
+ ` imports: ${(file.l1.imports ?? []).join(", ") || "(none)"}`,
5511
+ ` exports: ${(file.l1.exports ?? []).join(", ") || "(none)"}`,
5512
+ ` tests: ${(file.l1.test_ids ?? []).join(", ") || "(none)"}`
5513
+ ]),
5514
+ "----",
5515
+ `total relevant files: ${result.relevantFiles.length}`
5516
+ ];
5517
+ }
5518
+ function formatL1ContextPackForPrompt(result) {
5519
+ return [
5520
+ "Topchester KB context pack:",
5521
+ "Use this as orientation only. For task-critical facts, read current source files before editing or making exact claims.",
5522
+ "## -- kb summary start",
5523
+ "```json",
5524
+ JSON.stringify(stripEmptyContainers({
5525
+ query: result.query,
5526
+ summary: result.summary,
5527
+ drift: result.drift,
5528
+ warnings: result.warnings,
5529
+ relevantFiles: result.relevantFiles.map((file) => ({
5530
+ id: file.id,
5531
+ path: file.path,
5532
+ score: file.score,
5533
+ reasons: file.reasons,
5534
+ contentHash: file.contentHash,
5535
+ scanStatus: file.scanStatus,
5536
+ l1: {
5537
+ summary: file.l1.summary,
5538
+ file_role: file.l1.file_role,
5539
+ responsibilities: file.l1.responsibilities,
5540
+ symbols: file.l1.symbols,
5541
+ imports: file.l1.imports,
5542
+ exports: file.l1.exports,
5543
+ module_ids: file.l1.module_ids,
5544
+ feature_ids: file.l1.feature_ids,
5545
+ test_ids: file.l1.test_ids,
5546
+ declared_test_targets: file.l1.declared_test_targets,
5547
+ likely_test_targets: file.l1.likely_test_targets,
5548
+ tested_by: file.l1.tested_by,
5549
+ confidence: file.l1.confidence
5550
+ }
5551
+ }))
5552
+ })),
5553
+ "```",
5554
+ "## -- kb summary end"
5555
+ ].join("\n");
5556
+ }
5557
+ async function loadL1FileEntries(kbPath) {
5558
+ const entryPaths = await listJsonFiles(join(kbPath, "l1-files")).catch((error) => {
5559
+ if (isFileNotFoundError$1(error)) return [];
5560
+ throw error;
5561
+ });
5562
+ const parsedEntries = await Promise.all(entryPaths.map(loadL1FileEntry));
5563
+ const entries = parsedEntries.filter((entry) => Boolean(entry));
5564
+ return {
5565
+ entries,
5566
+ invalidEntryCount: parsedEntries.length - entries.length
5567
+ };
5568
+ }
5569
+ async function loadL1FileEntry(entryPath) {
5570
+ try {
5571
+ return parseL1FileEntry(JSON.parse(await readFile(entryPath, "utf8")));
5572
+ } catch {
5573
+ return;
5574
+ }
5575
+ }
5576
+ function summarizeContextPack(query, files) {
5577
+ if (files.length === 0) return `No strong L1 matches were found for "${query}".`;
5578
+ const paths = files.slice(0, 5).map((file) => file.path);
5579
+ return `Likely relevant L1 files for "${query}": ${paths.join(", ")}${files.length > paths.length ? ", ..." : ""}.`;
5580
+ }
5581
+ function compactL1Entry(entry) {
5582
+ const responsibilities = take(entry.responsibilities, 5);
5583
+ const symbols = take(entry.symbols, 12).map(compactSymbol);
5584
+ const imports = take(entry.imports, 20);
5585
+ const exports = take(entry.exports, 20);
5586
+ const moduleIds = take(entry.module_ids, 10);
5587
+ const featureIds = take(entry.feature_ids, 10);
5588
+ const testIds = take(entry.test_ids, 10);
5589
+ const declaredTestTargets = take(entry.declared_test_targets, 10);
5590
+ const likelyTestTargets = take(entry.likely_test_targets, 10);
5591
+ const testedBy = take(entry.tested_by, 10);
5592
+ return stripUndefinedProperties({
5593
+ file_role: entry.file_role,
5594
+ summary: entry.summary,
5595
+ responsibilities: nonEmptyArray(responsibilities),
5596
+ symbols: nonEmptyArray(symbols),
5597
+ imports: nonEmptyArray(imports),
5598
+ exports: nonEmptyArray(exports),
5599
+ module_ids: nonEmptyArray(moduleIds),
5600
+ feature_ids: nonEmptyArray(featureIds),
5601
+ test_ids: nonEmptyArray(testIds),
5602
+ declared_test_targets: nonEmptyArray(declaredTestTargets),
5603
+ likely_test_targets: nonEmptyArray(likelyTestTargets),
5604
+ tested_by: nonEmptyArray(testedBy),
5605
+ confidence: entry.confidence
5606
+ });
5607
+ }
5608
+ function take(items, count) {
5609
+ return items.slice(0, count);
5610
+ }
5611
+ function compactSymbol(symbol) {
5612
+ const compacted = {
5613
+ name: symbol.name,
5614
+ exported: symbol.exported,
5615
+ kind: symbol.kind === "symbol" ? void 0 : symbol.kind,
5616
+ summary: symbol.summary && !isGenericSymbolSummary(symbol.summary, symbol.name) ? symbol.summary : void 0
5617
+ };
5618
+ return compacted.kind || compacted.summary ? compacted : {
5619
+ name: compacted.name,
5620
+ exported: compacted.exported
5621
+ };
5622
+ }
5623
+ function stripEmptyContainers(value) {
5624
+ if (Array.isArray(value)) {
5625
+ const stripped = value.map(stripEmptyContainers).filter((item) => item !== void 0);
5626
+ return stripped.length > 0 ? stripped : void 0;
5627
+ }
5628
+ if (value && typeof value === "object") {
5629
+ const entries = Object.entries(value).map(([key, item]) => [key, stripEmptyContainers(item)]).filter(([, item]) => item !== void 0);
5630
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
5631
+ }
5632
+ return value;
5633
+ }
5634
+ function stripUndefinedProperties(value) {
5635
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
5636
+ }
5637
+ function nonEmptyArray(items) {
5638
+ return items.length > 0 ? items : void 0;
5639
+ }
5640
+ function isGenericSymbolSummary(summary, name) {
5641
+ const normalizedSummary = summary.trim().replace(/\s+/g, " ");
5642
+ return normalizedSummary === `Symbol named ${name}.` || normalizedSummary === `Symbol named ${name}` || normalizedSummary === name;
5643
+ }
5644
+ async function listJsonFiles(directory) {
5645
+ const entries = await readdir(directory, { withFileTypes: true });
5646
+ const files = [];
5647
+ for (const entry of entries) {
5648
+ const entryPath = join(directory, entry.name);
5649
+ if (entry.isDirectory()) files.push(...await listJsonFiles(entryPath));
5650
+ else if (entry.isFile() && entry.name.endsWith(".json")) files.push(entryPath);
5651
+ }
5652
+ return files.sort();
5653
+ }
5654
+ function tokenizeQuery(text) {
5655
+ return [...new Set(tokenizeText(text).filter((token) => !queryStopWords.has(token)))];
5656
+ }
5657
+ function tokenizeText(text) {
5658
+ const rawTokens = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().match(/[a-z0-9_]+/g) ?? [];
5659
+ const tokens = [];
5660
+ for (const rawToken of rawTokens) {
5661
+ const token = rawToken.replace(/^_+|_+$/g, "");
5662
+ if (!token || indexStopWords.has(token)) continue;
5663
+ tokens.push(token);
5664
+ const singular = singularizeToken(token);
5665
+ if (singular !== token) tokens.push(singular);
5666
+ }
5667
+ return tokens;
5668
+ }
5669
+ function singularizeToken(token) {
5670
+ if (token.length > 3 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
5671
+ if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss") && !token.endsWith("us")) return token.slice(0, -1);
5672
+ return token;
5673
+ }
5674
+ function formatField(field) {
5675
+ switch (field) {
5676
+ case "path": return "path";
5677
+ case "symbol": return "symbol";
5678
+ case "export": return "export";
5679
+ case "responsibility": return "responsibility";
5680
+ case "summary": return "summary";
5681
+ case "import": return "import";
5682
+ case "test": return "test";
5683
+ case "relationship": return "relationship";
5684
+ case "evidence": return "evidence";
5685
+ }
5686
+ }
5687
+ function isFileNotFoundError$1(error) {
5688
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
5689
+ }
5690
+ function isString(value) {
5691
+ return typeof value === "string";
5692
+ }
5693
+ const indexStopWords = new Set([
5694
+ "a",
5695
+ "an",
5696
+ "and",
5697
+ "are",
5698
+ "as",
5699
+ "at",
5700
+ "be",
5701
+ "by",
5702
+ "for",
5703
+ "from",
5704
+ "in",
5705
+ "is",
5706
+ "it",
5707
+ "of",
5708
+ "on",
5709
+ "or",
5710
+ "the",
5711
+ "to",
5712
+ "with"
5713
+ ]);
5714
+ const queryStopWords = new Set([
5715
+ ...indexStopWords,
5716
+ "error",
5717
+ "here",
5718
+ "log",
5719
+ "see",
5720
+ "se",
5721
+ "tries",
5722
+ "trying",
5723
+ "user",
5724
+ "when"
5725
+ ]);
5726
+ //#endregion
5727
+ //#region src/tui/markdown.ts
5728
+ const codeFenceSentinel = "topchester-code-fence";
5729
+ function renderMarkdown(text, width) {
5730
+ const lines = new Markdown(unwrapMarkdownCodeFences(text), 0, 0, getMarkdownTheme()).render(width);
5731
+ const rendered = [];
5732
+ let inCodeBlock = false;
5733
+ for (const line of lines) {
5734
+ if (line.includes(codeFenceSentinel)) {
5735
+ inCodeBlock = !inCodeBlock;
5736
+ continue;
5737
+ }
5738
+ rendered.push(inCodeBlock ? applyCodeBlockBackground(line) : line);
5739
+ }
5740
+ return rendered;
5741
+ }
5742
+ function unwrapMarkdownCodeFences(text) {
5743
+ return text.replace(/^```(?:markdown|md)\s*\n([\s\S]*?)\n```$/gim, "$1");
5744
+ }
5745
+ function getMarkdownTheme() {
4544
5746
  return {
4545
5747
  heading: ui.label,
4546
5748
  link: ui.label,
@@ -4607,6 +5809,12 @@ function agentMessage(text, meta) {
4607
5809
  meta
4608
5810
  };
4609
5811
  }
5812
+ function thinkingMessage(text) {
5813
+ return {
5814
+ kind: "thinking",
5815
+ text
5816
+ };
5817
+ }
4610
5818
  function toolCallMessage(call, label, resultSummary) {
4611
5819
  return resultSummary === void 0 ? {
4612
5820
  kind: "tool_call",
@@ -4619,6 +5827,12 @@ function toolCallMessage(call, label, resultSummary) {
4619
5827
  resultSummary
4620
5828
  };
4621
5829
  }
5830
+ function subagentMessage(message) {
5831
+ return {
5832
+ kind: "subagent",
5833
+ ...message
5834
+ };
5835
+ }
4622
5836
  function modalMessage(message) {
4623
5837
  return {
4624
5838
  kind: "modal",
@@ -4628,6 +5842,8 @@ function modalMessage(message) {
4628
5842
  function renderChatMessage(message, options = {}) {
4629
5843
  if (message.kind === "modal") return renderChatModal(message, options.selectedActionIndex);
4630
5844
  if (message.kind === "tool_call") return renderToolCallMessage(message);
5845
+ if (message.kind === "subagent") return renderSubagentMessage(message);
5846
+ if (message.kind === "thinking") return message.text.split("\n").map((line) => ui.muted(line));
4631
5847
  if (message.text.length === 0) return [""];
4632
5848
  const lines = message.kind === "agent" && options.width !== void 0 ? renderMarkdown(message.text, Math.max(1, options.width - getPrefix(message.kind).length)) : message.text.split("\n");
4633
5849
  if (message.kind === "user") return renderUserMessage(lines);
@@ -4660,6 +5876,18 @@ function renderToolCallMessage(message) {
4660
5876
  const visibleLabel = message.resultSummary && !message.label.includes(message.resultSummary) ? `${message.label} ${message.resultSummary}` : message.label;
4661
5877
  return [` ${ui.muted(expandTabs(visibleLabel))}`];
4662
5878
  }
5879
+ function renderSubagentMessage(message) {
5880
+ const label = message.title ?? shortSessionId(message.sessionId);
5881
+ switch (message.status) {
5882
+ case "running": return [` ${ui.muted(`↳ task: ${label} (running)`)}`];
5883
+ case "event": return message.text ? [` ${ui.muted(`↳ task: ${label}: ${message.text}`)}`] : [];
5884
+ case "completed": return [` ${ui.muted(`↳ task: ${label} (completed)`)}`, ...message.text ? [` ${message.text}`] : []];
5885
+ case "failed": return [` ${ui.warn(`↳ task: ${label} (failed)`)}`, ...message.text ? [` ${message.text}`] : []];
5886
+ }
5887
+ }
5888
+ function shortSessionId(sessionId) {
5889
+ return sessionId.length <= 8 ? sessionId : sessionId.slice(0, 8);
5890
+ }
4663
5891
  function expandTabs(line) {
4664
5892
  let column = 0;
4665
5893
  let expanded = "";
@@ -4728,11 +5956,21 @@ const jsonValueSchema = z.lazy(() => z.union([
4728
5956
  const sessionMetadataSchema = z.object({
4729
5957
  version: z.literal(1),
4730
5958
  sessionId: z.string(),
5959
+ rootSessionId: z.string().optional(),
5960
+ parentSessionId: z.string().optional(),
5961
+ parentToolCallId: z.string().optional(),
5962
+ source: z.enum(["user", "subagent"]).optional(),
5963
+ agentProfileId: z.string().optional(),
5964
+ title: z.string().optional(),
4731
5965
  workspaceRoot: z.string().min(1),
4732
5966
  createdAt: isoTimestampSchema,
4733
5967
  updatedAt: isoTimestampSchema,
4734
5968
  lastEventId: z.number().int().min(0)
4735
- });
5969
+ }).transform((metadata) => ({
5970
+ ...metadata,
5971
+ rootSessionId: metadata.rootSessionId ?? metadata.sessionId,
5972
+ source: metadata.source ?? "user"
5973
+ }));
4736
5974
  const eventEnvelopeSchema = z.object({
4737
5975
  version: z.literal(1),
4738
5976
  id: z.number().int().positive(),
@@ -4753,6 +5991,19 @@ const toolCallPayloadSchema = z.object({
4753
5991
  label: z.string(),
4754
5992
  call: z.record(z.string(), jsonValueSchema)
4755
5993
  });
5994
+ const taskPlanItemPayloadSchema = z.object({
5995
+ text: z.string(),
5996
+ status: z.enum([
5997
+ "pending",
5998
+ "in_progress",
5999
+ "completed"
6000
+ ])
6001
+ });
6002
+ const taskPlanPayloadSchema = z.object({
6003
+ kind: z.literal("task_plan"),
6004
+ items: z.array(taskPlanItemPayloadSchema),
6005
+ updatedAt: isoTimestampSchema
6006
+ });
4756
6007
  const statusPayloadSchema = z.object({
4757
6008
  kind: z.literal("status"),
4758
6009
  status: z.string()
@@ -4771,14 +6022,74 @@ const choicePayloadSchema = z.object({
4771
6022
  value: z.string().optional()
4772
6023
  }))
4773
6024
  });
6025
+ const subagentLifecycleBasePayloadSchema = z.object({
6026
+ sessionId: z.string(),
6027
+ parentSessionId: z.string(),
6028
+ parentToolCallId: z.string()
6029
+ });
6030
+ const subagentStartedPayloadSchema = subagentLifecycleBasePayloadSchema.extend({
6031
+ kind: z.literal("subagent_started"),
6032
+ agentProfileId: z.string().optional(),
6033
+ title: z.string().optional()
6034
+ });
6035
+ const subagentEventPayloadSchema = subagentLifecycleBasePayloadSchema.extend({
6036
+ kind: z.literal("subagent_event"),
6037
+ event: z.record(z.string(), jsonValueSchema)
6038
+ });
6039
+ const subagentCompletedPayloadSchema = subagentLifecycleBasePayloadSchema.extend({
6040
+ kind: z.literal("subagent_completed"),
6041
+ result: z.string().optional()
6042
+ });
6043
+ const subagentFailedPayloadSchema = subagentLifecycleBasePayloadSchema.extend({
6044
+ kind: z.literal("subagent_failed"),
6045
+ error: z.string()
6046
+ });
4774
6047
  const sessionEventPayloadSchema = z.discriminatedUnion("kind", [
4775
6048
  messagePayloadSchema,
4776
6049
  toolCallPayloadSchema,
6050
+ taskPlanPayloadSchema,
4777
6051
  statusPayloadSchema,
4778
6052
  knowledgeStatusPayloadSchema,
4779
- choicePayloadSchema
6053
+ choicePayloadSchema,
6054
+ subagentStartedPayloadSchema,
6055
+ subagentEventPayloadSchema,
6056
+ subagentCompletedPayloadSchema,
6057
+ subagentFailedPayloadSchema
4780
6058
  ]);
4781
6059
  const sessionEventSchema = z.intersection(eventEnvelopeSchema, sessionEventPayloadSchema);
6060
+ const sessionEventPayload = {
6061
+ subagentStarted(reference, options = {}) {
6062
+ return {
6063
+ kind: "subagent_started",
6064
+ ...reference,
6065
+ ...options
6066
+ };
6067
+ },
6068
+ subagentEvent(reference, event) {
6069
+ return {
6070
+ kind: "subagent_event",
6071
+ ...reference,
6072
+ event
6073
+ };
6074
+ },
6075
+ subagentCompleted(reference, result) {
6076
+ return result === void 0 ? {
6077
+ kind: "subagent_completed",
6078
+ ...reference
6079
+ } : {
6080
+ kind: "subagent_completed",
6081
+ ...reference,
6082
+ result
6083
+ };
6084
+ },
6085
+ subagentFailed(reference, error) {
6086
+ return {
6087
+ kind: "subagent_failed",
6088
+ ...reference,
6089
+ error
6090
+ };
6091
+ }
6092
+ };
4782
6093
  //#endregion
4783
6094
  //#region src/session/store.ts
4784
6095
  const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
@@ -4794,6 +6105,8 @@ async function createSession(workspaceRoot) {
4794
6105
  const metadata = {
4795
6106
  version: 1,
4796
6107
  sessionId,
6108
+ rootSessionId: sessionId,
6109
+ source: "user",
4797
6110
  workspaceRoot,
4798
6111
  createdAt,
4799
6112
  updatedAt: createdAt,
@@ -4804,6 +6117,41 @@ async function createSession(workspaceRoot) {
4804
6117
  await writeFile(eventsPath, "", { flag: "wx" });
4805
6118
  return buildHandle(sessionDir, metadata);
4806
6119
  }
6120
+ async function createChildSession(workspaceRoot, options) {
6121
+ validateSessionId(options.parent.sessionId);
6122
+ const sessionId = generateSessionId();
6123
+ const sessionDir = join(getTopchesterSessionsPath(workspaceRoot), sessionId);
6124
+ const metadataPath = join(sessionDir, "metadata.json");
6125
+ const eventsPath = join(sessionDir, "events.jsonl");
6126
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
6127
+ const metadata = {
6128
+ version: 1,
6129
+ sessionId,
6130
+ rootSessionId: options.parent.metadata.rootSessionId,
6131
+ parentSessionId: options.parent.sessionId,
6132
+ parentToolCallId: options.parentToolCallId,
6133
+ source: "subagent",
6134
+ ...options.agentProfileId === void 0 ? {} : { agentProfileId: options.agentProfileId },
6135
+ ...options.title === void 0 ? {} : { title: options.title },
6136
+ workspaceRoot,
6137
+ createdAt,
6138
+ updatedAt: createdAt,
6139
+ lastEventId: 0
6140
+ };
6141
+ await mkdir(sessionDir, { recursive: true });
6142
+ await writeMetadata(metadataPath, metadata);
6143
+ await writeFile(eventsPath, "", { flag: "wx" });
6144
+ const child = buildHandle(sessionDir, metadata);
6145
+ if (options.recordParentEvent ?? true) await options.parent.append(sessionEventPayload.subagentStarted({
6146
+ sessionId: child.sessionId,
6147
+ parentSessionId: options.parent.sessionId,
6148
+ parentToolCallId: options.parentToolCallId
6149
+ }, {
6150
+ ...options.agentProfileId === void 0 ? {} : { agentProfileId: options.agentProfileId },
6151
+ ...options.title === void 0 ? {} : { title: options.title }
6152
+ }));
6153
+ return child;
6154
+ }
4807
6155
  async function loadSessionForAppend(workspaceRoot, sessionId) {
4808
6156
  const loaded = await loadSession(workspaceRoot, sessionId);
4809
6157
  return buildHandle(loaded.sessionDir, loaded.metadata);
@@ -4854,6 +6202,7 @@ async function resolveLatestSessionId(workspaceRoot) {
4854
6202
  function rehydrateSession(events) {
4855
6203
  const messages = [];
4856
6204
  let status;
6205
+ let taskPlan;
4857
6206
  let visibleOnlyActionValues = /* @__PURE__ */ new Set();
4858
6207
  for (const event of events) switch (event.kind) {
4859
6208
  case "message":
@@ -4869,7 +6218,17 @@ function rehydrateSession(events) {
4869
6218
  case "tool_call":
4870
6219
  messages.push(toolCallMessage(event.call, event.label));
4871
6220
  break;
6221
+ case "task_plan":
6222
+ taskPlan = {
6223
+ items: event.items,
6224
+ updatedAt: event.updatedAt
6225
+ };
6226
+ break;
4872
6227
  case "knowledge_status": break;
6228
+ case "subagent_started":
6229
+ case "subagent_event":
6230
+ case "subagent_completed":
6231
+ case "subagent_failed": break;
4873
6232
  case "choice":
4874
6233
  messages.push({
4875
6234
  kind: "modal",
@@ -4886,7 +6245,8 @@ function rehydrateSession(events) {
4886
6245
  }
4887
6246
  return {
4888
6247
  messages,
4889
- status
6248
+ status,
6249
+ ...taskPlan === void 0 ? {} : { taskPlan }
4890
6250
  };
4891
6251
  }
4892
6252
  function buildHandle(sessionDir, metadata) {
@@ -5167,19 +6527,25 @@ var BusyIndicator = class {
5167
6527
  this.tui.requestRender();
5168
6528
  }, 80);
5169
6529
  }
5170
- stop() {
6530
+ stop(options = {}) {
5171
6531
  if (this.timer) {
5172
6532
  clearInterval(this.timer);
5173
6533
  this.timer = void 0;
5174
6534
  }
5175
6535
  this.app.setPromptHint(void 0);
5176
- this.app.setEphemeralLine(void 0);
6536
+ if (options.clearEphemeralLine ?? true) this.app.setEphemeralLine(void 0);
5177
6537
  }
5178
6538
  setActivity(activity) {
5179
6539
  this.activityOverride = activity;
5180
6540
  this.render();
5181
6541
  this.tui.requestRender();
5182
6542
  }
6543
+ clearActivity() {
6544
+ if (!this.activityOverride) return;
6545
+ this.activityOverride = void 0;
6546
+ this.render();
6547
+ this.tui.requestRender();
6548
+ }
5183
6549
  render() {
5184
6550
  if (this.activityOverride) {
5185
6551
  this.app.setEphemeralLine(`${this.frames[this.index]} ${this.activityOverride}`);
@@ -5190,6 +6556,33 @@ var BusyIndicator = class {
5190
6556
  this.app.setEphemeralLine(`${this.frames[this.index]} ${this.options.activities[activityIndex]}`);
5191
6557
  }
5192
6558
  };
6559
+ var ReasoningTailBuffer = class {
6560
+ text = "";
6561
+ get hasText() {
6562
+ return this.text.length > 0;
6563
+ }
6564
+ get value() {
6565
+ return this.text;
6566
+ }
6567
+ append(delta) {
6568
+ const normalized = normalizeReasoningText(`${this.text}${delta}`);
6569
+ if (!normalized) return;
6570
+ this.text = normalized;
6571
+ return this.text;
6572
+ }
6573
+ replace(summary) {
6574
+ const normalized = normalizeReasoningText(summary);
6575
+ if (!normalized) return;
6576
+ this.text = normalized;
6577
+ return this.text;
6578
+ }
6579
+ clear() {
6580
+ this.text = "";
6581
+ }
6582
+ };
6583
+ function normalizeReasoningText(text) {
6584
+ return text.replace(/\s+/gu, " ").trim();
6585
+ }
5193
6586
  //#endregion
5194
6587
  //#region src/agent/commands.ts
5195
6588
  const slashCommandSuggestions = [
@@ -5264,6 +6657,96 @@ async function executeKbCommand(args, context) {
5264
6657
  return { messages: ["Usage: /kb init, /kb compile, /kb sync, /kb reset, or /kb status"] };
5265
6658
  }
5266
6659
  //#endregion
6660
+ //#region src/agent/events.ts
6661
+ const ABORT_CHOICE_VALUE = "__topchester_abort__";
6662
+ const agentEvent = {
6663
+ status(status) {
6664
+ return {
6665
+ type: "status",
6666
+ status
6667
+ };
6668
+ },
6669
+ systemMessage(text) {
6670
+ return {
6671
+ type: "message",
6672
+ role: "system",
6673
+ text
6674
+ };
6675
+ },
6676
+ assistantMessage(text, meta) {
6677
+ return meta === void 0 ? {
6678
+ type: "message",
6679
+ role: "assistant",
6680
+ text
6681
+ } : {
6682
+ type: "message",
6683
+ role: "assistant",
6684
+ text,
6685
+ meta
6686
+ };
6687
+ },
6688
+ toolCall(call, label) {
6689
+ return {
6690
+ type: "tool_call",
6691
+ call,
6692
+ label
6693
+ };
6694
+ },
6695
+ taskPlan(plan) {
6696
+ return {
6697
+ type: "task_plan",
6698
+ plan
6699
+ };
6700
+ },
6701
+ knowledgeStatus(status, guidance) {
6702
+ return guidance === void 0 ? {
6703
+ type: "knowledge_status",
6704
+ status
6705
+ } : {
6706
+ type: "knowledge_status",
6707
+ status,
6708
+ guidance
6709
+ };
6710
+ },
6711
+ choice(options) {
6712
+ return {
6713
+ type: "choice",
6714
+ ...options
6715
+ };
6716
+ },
6717
+ subagentStarted(options) {
6718
+ return {
6719
+ type: "subagent_started",
6720
+ ...options
6721
+ };
6722
+ },
6723
+ subagentEvent(options, event) {
6724
+ return {
6725
+ type: "subagent_event",
6726
+ ...options,
6727
+ event
6728
+ };
6729
+ },
6730
+ subagentCompleted(options) {
6731
+ return {
6732
+ type: "subagent_completed",
6733
+ ...options
6734
+ };
6735
+ },
6736
+ subagentFailed(options) {
6737
+ return {
6738
+ type: "subagent_failed",
6739
+ ...options
6740
+ };
6741
+ }
6742
+ };
6743
+ function choiceAction(label, value) {
6744
+ return value === void 0 ? { label } : {
6745
+ label,
6746
+ value
6747
+ };
6748
+ }
6749
+ //#endregion
5267
6750
  //#region src/tui/keys.ts
5268
6751
  function isUpKey(data) {
5269
6752
  return matchesKey(data, "up") || data === "\x1B[A";
@@ -5274,6 +6757,9 @@ function isDownKey(data) {
5274
6757
  function isEnterKey(data) {
5275
6758
  return matchesKey(data, "enter") || data === "\n" || data === "\r";
5276
6759
  }
6760
+ function isNewLineKey(data) {
6761
+ return matchesKey(data, "shift+enter") || matchesKey(data, "alt+enter") || matchesKey(data, "ctrl+enter") || data === "\x1B\r" || data === "\x1B[13;2~";
6762
+ }
5277
6763
  function isTabKey(data) {
5278
6764
  return matchesKey(data, "tab") || data === " ";
5279
6765
  }
@@ -5380,12 +6866,14 @@ function getStartupThreadMessages(context) {
5380
6866
  lines.push("Ask Topchester what you want to change.");
5381
6867
  return [systemMessage(lines.join("\n"))];
5382
6868
  }
5383
- function renderStaticLayout(messages, folderName = "", modelLabel = "") {
6869
+ function renderStaticLayout(messages, folderName = "", modelLabel = "", taskPlan) {
5384
6870
  const threadLines = messages.flatMap((message) => renderChatMessage(message));
5385
6871
  const status = formatStatusLine(folderName, modelLabel);
6872
+ const planLines = taskPlan && taskPlan.items.length > 0 ? [...formatTaskPlanForTui(taskPlan, 72), ""] : [];
5386
6873
  return [
5387
6874
  ...threadLines,
5388
6875
  "",
6876
+ ...planLines,
5389
6877
  "┌──────────────────────────────────────────────────────────────────────┐",
5390
6878
  "│ > │",
5391
6879
  "└──────────────────────────────────────────────────────────────────────┘",
@@ -5467,23 +6955,35 @@ function stripAnsi(text) {
5467
6955
  }
5468
6956
  //#endregion
5469
6957
  //#region src/tui/layout.ts
6958
+ const PROMPT_VISIBLE_CONTENT_LINES = 5;
6959
+ const PASTE_PREVIEW_MIN_LINES = 6;
6960
+ const PASTE_PREVIEW_MIN_CHARS = 500;
6961
+ const BRACKETED_PASTE_START = "\x1B[200~";
6962
+ const BRACKETED_PASTE_END = "\x1B[201~";
5470
6963
  var ChatLayout = class {
5471
6964
  terminal;
5472
6965
  messages;
5473
6966
  folderName;
5474
6967
  modelLabel;
5475
- input = new Input();
6968
+ inputFocused = false;
6969
+ promptValue = "";
6970
+ promptCursor = 0;
5476
6971
  status = "ready";
5477
6972
  knowledgeStatus;
5478
6973
  ephemeralLine;
6974
+ taskPlanNoticeLine;
5479
6975
  noticeLine;
5480
6976
  promptHint;
6977
+ taskPlan;
5481
6978
  cancelPending;
5482
6979
  submitMessage;
5483
6980
  submitCommand;
5484
6981
  activeModalActionIndex = 0;
5485
6982
  activeSlashSuggestionIndex = 0;
5486
6983
  threadScrollOffset = 0;
6984
+ pasteBuffer;
6985
+ pasteCounter = 0;
6986
+ pastedContent = /* @__PURE__ */ new Map();
5487
6987
  promptHistory = new PromptHistory();
5488
6988
  exitAgent;
5489
6989
  transcriptMode;
@@ -5494,14 +6994,6 @@ var ChatLayout = class {
5494
6994
  this.modelLabel = modelLabel;
5495
6995
  this.exitAgent = typeof options === "function" ? options : options.exitAgent ?? (() => {});
5496
6996
  this.transcriptMode = typeof options === "function" ? "viewport" : options.transcriptMode ?? "viewport";
5497
- this.input.onSubmit = (value) => {
5498
- if (value.trim().length > 0) {
5499
- const message = value.trim();
5500
- this.addMessage(userMessage(message));
5501
- this.input.setValue("");
5502
- this.submitUserInput(message);
5503
- }
5504
- };
5505
6997
  }
5506
6998
  addMessage(message) {
5507
6999
  this.messages.push(message);
@@ -5514,6 +7006,24 @@ var ChatLayout = class {
5514
7006
  setKnowledgeStatus(status) {
5515
7007
  this.knowledgeStatus = formatKnowledgeFooterStatus(status);
5516
7008
  }
7009
+ setTaskPlan(plan) {
7010
+ const change = detectTaskPlanChange(this.taskPlan, plan);
7011
+ this.taskPlan = plan && plan.items.length > 0 ? plan : void 0;
7012
+ return change;
7013
+ }
7014
+ setTaskPlanNotice(line) {
7015
+ this.taskPlanNoticeLine = line;
7016
+ }
7017
+ clearTaskPlan(now = /* @__PURE__ */ new Date()) {
7018
+ if (!this.taskPlan) return;
7019
+ const cleared = {
7020
+ items: [],
7021
+ updatedAt: now.toISOString()
7022
+ };
7023
+ this.taskPlan = void 0;
7024
+ this.taskPlanNoticeLine = void 0;
7025
+ return cleared;
7026
+ }
5517
7027
  isReady() {
5518
7028
  return this.status === "ready";
5519
7029
  }
@@ -5536,7 +7046,10 @@ var ChatLayout = class {
5536
7046
  this.submitCommand = submit;
5537
7047
  }
5538
7048
  setInputValue(value) {
5539
- this.input.setValue(value);
7049
+ this.promptValue = value;
7050
+ this.promptCursor = value.length;
7051
+ this.pastedContent.clear();
7052
+ this.pasteCounter = 0;
5540
7053
  }
5541
7054
  getConversationTurns() {
5542
7055
  return this.messages.flatMap((message) => {
@@ -5550,16 +7063,18 @@ var ChatLayout = class {
5550
7063
  text: message.text
5551
7064
  }];
5552
7065
  case "system":
7066
+ case "thinking":
5553
7067
  case "tool_call":
7068
+ case "subagent":
5554
7069
  case "modal": return [];
5555
7070
  }
5556
7071
  });
5557
7072
  }
5558
7073
  get focused() {
5559
- return this.input.focused;
7074
+ return this.inputFocused;
5560
7075
  }
5561
7076
  set focused(value) {
5562
- this.input.focused = value;
7077
+ this.inputFocused = value;
5563
7078
  }
5564
7079
  handleInput(data) {
5565
7080
  if (this.cancelPending && matchesKey(data, "escape")) {
@@ -5568,15 +7083,15 @@ var ChatLayout = class {
5568
7083
  }
5569
7084
  if (this.handleModalInput(data)) return;
5570
7085
  if (this.handleSlashSuggestionInput(data)) return;
7086
+ if (this.handlePromptPasteInput(data)) return;
7087
+ if (this.handlePromptNewLineInput(data)) return;
7088
+ if (this.handlePromptSubmitInput(data)) return;
5571
7089
  if (this.handleThreadScrollInput(data)) return;
7090
+ if (this.handlePromptVerticalCursorInput(data)) return;
5572
7091
  if (this.handlePromptHistoryInput(data)) return;
5573
- const previousInput = this.input.getValue();
5574
- this.input.handleInput(data);
5575
- if (this.input.getValue() !== previousInput) this.promptHistory.resetBrowsing();
5576
- }
5577
- invalidate() {
5578
- this.input.invalidate();
7092
+ this.handlePromptEditInput(data);
5579
7093
  }
7094
+ invalidate() {}
5580
7095
  render(width) {
5581
7096
  const safeWidth = Math.max(20, width);
5582
7097
  const footerLines = this.getActiveModal() ? this.renderModalHelp(safeWidth) : this.renderPrompt(safeWidth);
@@ -5604,6 +7119,7 @@ var ChatLayout = class {
5604
7119
  return [...this.renderThreadMessageLines(messageLines, innerWidth, width, message.kind === "user"), ...spacer];
5605
7120
  });
5606
7121
  if (this.ephemeralLine) lines.push(...this.renderThreadMessageLines([` ${this.ephemeralLine}`], innerWidth, width, false));
7122
+ if (this.taskPlanNoticeLine) lines.push(...this.renderThreadMessageLines([` ${this.taskPlanNoticeLine}`], innerWidth, width, false));
5607
7123
  if (this.noticeLine) lines.push(...this.renderThreadMessageLines([` ${this.noticeLine}`], innerWidth, width, false));
5608
7124
  return lines;
5609
7125
  }
@@ -5619,17 +7135,69 @@ var ChatLayout = class {
5619
7135
  const bottom = `└${"─".repeat(Math.max(0, width - 2))}┘`;
5620
7136
  const prefix = "> ";
5621
7137
  const innerWidth = Math.max(1, width - 4 - 2);
5622
- const inputLine = this.promptHint ? truncateToWidth(ui.label(this.promptHint), innerWidth, "…", true) : truncateToWidth(renderInputWithoutPrompt(this.input, innerWidth), innerWidth, "…", true);
7138
+ const inputLines = this.promptHint ? [truncateToWidth(ui.label(this.promptHint), innerWidth, "…", true)] : this.renderPromptInputLines(innerWidth);
5623
7139
  const statusInnerWidth = Math.max(1, width - 2);
5624
7140
  const status = truncateToWidth(` ${formatStatusLine(this.folderName, this.modelLabel, this.status, this.knowledgeStatus, statusInnerWidth)} `, width, "…", true);
5625
7141
  return [
5626
7142
  ...this.renderSlashSuggestions(width),
7143
+ ...this.renderTaskPlan(width),
5627
7144
  top,
5628
- `│ ${prefix}${inputLine} │`,
7145
+ ...inputLines.map((line, index) => `│ ${index === 0 ? prefix : " "}${padPromptInputLine(line, innerWidth)} │`),
5629
7146
  bottom,
5630
7147
  status
5631
7148
  ];
5632
7149
  }
7150
+ renderPromptInputLines(innerWidth) {
7151
+ const value = this.promptValue;
7152
+ if (!value.includes("\n")) return [this.renderPromptLineWithCursor(value, this.promptCursor, innerWidth)];
7153
+ const rows = this.getPromptRows(innerWidth);
7154
+ const cursorRowIndex = rows.findIndex((row) => this.promptCursor >= row.start && this.promptCursor <= row.end);
7155
+ const latestStart = Math.max(0, rows.length - PROMPT_VISIBLE_CONTENT_LINES);
7156
+ const visibleStart = cursorRowIndex === -1 ? latestStart : Math.min(Math.max(0, cursorRowIndex - 2), latestStart);
7157
+ return rows.slice(visibleStart, visibleStart + PROMPT_VISIBLE_CONTENT_LINES).map((row) => {
7158
+ if (this.promptCursor >= row.start && this.promptCursor <= row.end) return this.renderPromptLineWithCursor(row.text, this.promptCursor - row.start, innerWidth);
7159
+ return truncateToWidth(row.text.length === 0 ? " " : row.text, innerWidth, "…", true);
7160
+ });
7161
+ }
7162
+ getPromptRows(width) {
7163
+ const rows = [];
7164
+ let offset = 0;
7165
+ for (const line of this.promptValue.split("\n")) {
7166
+ if (line.length === 0) rows.push({
7167
+ text: "",
7168
+ start: offset,
7169
+ end: offset
7170
+ });
7171
+ else for (let index = 0; index < line.length; index += width) {
7172
+ const text = line.slice(index, index + width);
7173
+ rows.push({
7174
+ text,
7175
+ start: offset + index,
7176
+ end: offset + index + text.length
7177
+ });
7178
+ }
7179
+ offset += line.length + 1;
7180
+ }
7181
+ return rows.length > 0 ? rows : [{
7182
+ text: "",
7183
+ start: 0,
7184
+ end: 0
7185
+ }];
7186
+ }
7187
+ renderPromptLineWithCursor(text, cursor, width) {
7188
+ const safeCursor = Math.max(0, Math.min(cursor, text.length));
7189
+ const windowStart = safeCursor >= width ? safeCursor - width + 1 : 0;
7190
+ const visibleText = text.slice(windowStart, windowStart + width);
7191
+ const visibleCursor = safeCursor - windowStart;
7192
+ const beforeCursor = visibleText.slice(0, visibleCursor);
7193
+ const cursorChar = visibleText[visibleCursor] ?? " ";
7194
+ const afterCursor = visibleText.slice(visibleCursor + cursorChar.length);
7195
+ return truncateToWidth(`${beforeCursor}${this.inputFocused ? CURSOR_MARKER : ""}\u001b[7m${cursorChar}\u001b[27m${afterCursor}`, width, "…", true);
7196
+ }
7197
+ renderTaskPlan(width) {
7198
+ if (!this.taskPlan) return [];
7199
+ return formatTaskPlanForTui(this.taskPlan, Math.max(1, width));
7200
+ }
5633
7201
  renderSlashSuggestions(width) {
5634
7202
  const suggestions = this.getSlashSuggestions();
5635
7203
  if (suggestions.length === 0 || this.promptHint) return [];
@@ -5676,6 +7244,14 @@ var ChatLayout = class {
5676
7244
  this.exitAgent();
5677
7245
  return true;
5678
7246
  }
7247
+ if (action.value === "__topchester_abort__") {
7248
+ this.addMessage({
7249
+ kind: "user",
7250
+ text: action.label,
7251
+ modelContext: false
7252
+ });
7253
+ return true;
7254
+ }
5679
7255
  this.submitModalAction(action.value ?? action.label);
5680
7256
  return true;
5681
7257
  }
@@ -5718,17 +7294,196 @@ var ChatLayout = class {
5718
7294
  handlePromptHistoryInput(data) {
5719
7295
  if (this.promptHint) return false;
5720
7296
  if (isUpKey(data)) {
5721
- const prompt = this.promptHistory.previous(this.input.getValue());
5722
- if (prompt !== void 0) this.input.setValue(prompt);
7297
+ const prompt = this.promptHistory.previous(this.promptValue);
7298
+ if (prompt !== void 0) {
7299
+ this.promptValue = prompt;
7300
+ this.promptCursor = prompt.length;
7301
+ }
5723
7302
  return true;
5724
7303
  }
5725
7304
  if (isDownKey(data)) {
5726
7305
  const prompt = this.promptHistory.next();
5727
- if (prompt !== void 0) this.input.setValue(prompt);
7306
+ if (prompt !== void 0) {
7307
+ this.promptValue = prompt;
7308
+ this.promptCursor = prompt.length;
7309
+ }
5728
7310
  return true;
5729
7311
  }
5730
7312
  return false;
5731
7313
  }
7314
+ handlePromptVerticalCursorInput(data) {
7315
+ if (this.promptHint || !this.promptValue.includes("\n")) return false;
7316
+ if (isUpKey(data)) {
7317
+ if (this.canMovePromptCursorVertically(-1)) {
7318
+ this.movePromptCursorVertically(-1);
7319
+ return true;
7320
+ }
7321
+ if (this.promptCursor > 0) {
7322
+ this.promptCursor = this.getCurrentPromptLineStart();
7323
+ return true;
7324
+ }
7325
+ return false;
7326
+ }
7327
+ if (isDownKey(data)) {
7328
+ if (this.canMovePromptCursorVertically(1)) {
7329
+ this.movePromptCursorVertically(1);
7330
+ return true;
7331
+ }
7332
+ if (this.promptCursor < this.promptValue.length) {
7333
+ this.promptCursor = this.getCurrentPromptLineEnd();
7334
+ return true;
7335
+ }
7336
+ return false;
7337
+ }
7338
+ return false;
7339
+ }
7340
+ canMovePromptCursorVertically(delta) {
7341
+ const lines = this.promptValue.split("\n");
7342
+ const current = this.getPromptLineCursor(lines);
7343
+ if (delta === -1) return current.line > 0;
7344
+ return current.line < lines.length - 1;
7345
+ }
7346
+ handlePromptNewLineInput(data) {
7347
+ if (this.promptHint || !isNewLineKey(data)) return false;
7348
+ this.insertPromptText("\n");
7349
+ this.promptHistory.resetBrowsing();
7350
+ return true;
7351
+ }
7352
+ handlePromptSubmitInput(data) {
7353
+ if (this.promptHint || !isEnterKey(data)) return false;
7354
+ this.submitPromptValue();
7355
+ return true;
7356
+ }
7357
+ handlePromptPasteInput(data) {
7358
+ if (this.promptHint) return false;
7359
+ if (this.pasteBuffer !== void 0) {
7360
+ this.pasteBuffer += data;
7361
+ this.flushPromptPasteBuffer();
7362
+ return true;
7363
+ }
7364
+ const startIndex = data.indexOf(BRACKETED_PASTE_START);
7365
+ if (startIndex === -1) return false;
7366
+ const beforePaste = data.slice(0, startIndex);
7367
+ if (beforePaste.length > 0) this.insertPromptText(beforePaste);
7368
+ this.pasteBuffer = data.slice(startIndex + 6);
7369
+ this.flushPromptPasteBuffer();
7370
+ this.promptHistory.resetBrowsing();
7371
+ return true;
7372
+ }
7373
+ flushPromptPasteBuffer() {
7374
+ if (this.pasteBuffer === void 0) return;
7375
+ const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
7376
+ if (endIndex === -1) return;
7377
+ const pasted = this.pasteBuffer.slice(0, endIndex);
7378
+ const remaining = this.pasteBuffer.slice(endIndex + 6);
7379
+ this.pasteBuffer = void 0;
7380
+ this.insertPastedText(pasted);
7381
+ if (remaining.length > 0) this.handleInput(remaining);
7382
+ }
7383
+ insertPastedText(text) {
7384
+ const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
7385
+ const trimmedText = normalizedText.trim();
7386
+ if (trimmedText.length === 0) return;
7387
+ const lineCount = trimmedText.split("\n").length;
7388
+ if (lineCount >= PASTE_PREVIEW_MIN_LINES || trimmedText.length >= PASTE_PREVIEW_MIN_CHARS) {
7389
+ this.pasteCounter += 1;
7390
+ const marker = `[Pasted #${this.pasteCounter} ${lineCount} lines ${trimmedText.length} chars]`;
7391
+ this.pastedContent.set(marker, trimmedText);
7392
+ this.insertPromptText(marker);
7393
+ return;
7394
+ }
7395
+ this.insertPromptText(normalizedText);
7396
+ }
7397
+ insertPromptText(text) {
7398
+ this.promptValue = `${this.promptValue.slice(0, this.promptCursor)}${text}${this.promptValue.slice(this.promptCursor)}`;
7399
+ this.promptCursor += text.length;
7400
+ }
7401
+ expandPastedContent(value) {
7402
+ let expanded = value;
7403
+ for (const [marker, content] of this.pastedContent) expanded = expanded.split(marker).join(content);
7404
+ return expanded;
7405
+ }
7406
+ submitPromptValue() {
7407
+ if (this.promptValue.trim().length === 0) return;
7408
+ const message = this.expandPastedContent(this.promptValue).trim();
7409
+ this.addMessage(userMessage(message));
7410
+ this.promptValue = "";
7411
+ this.promptCursor = 0;
7412
+ this.pastedContent.clear();
7413
+ this.pasteCounter = 0;
7414
+ this.submitUserInput(message);
7415
+ }
7416
+ handlePromptEditInput(data) {
7417
+ if (this.promptHint) return false;
7418
+ if (matchesKey(data, "left") || data === "\x1B[D") {
7419
+ this.promptCursor = Math.max(0, this.promptCursor - 1);
7420
+ return true;
7421
+ }
7422
+ if (matchesKey(data, "right") || data === "\x1B[C") {
7423
+ this.promptCursor = Math.min(this.promptValue.length, this.promptCursor + 1);
7424
+ return true;
7425
+ }
7426
+ if (isHomeKey(data)) {
7427
+ this.promptCursor = this.getCurrentPromptLineStart();
7428
+ return true;
7429
+ }
7430
+ if (isEndKey(data)) {
7431
+ this.promptCursor = this.getCurrentPromptLineEnd();
7432
+ return true;
7433
+ }
7434
+ if (matchesKey(data, "backspace") || data === "" || data === "\b") {
7435
+ if (this.promptCursor > 0) {
7436
+ this.promptValue = `${this.promptValue.slice(0, this.promptCursor - 1)}${this.promptValue.slice(this.promptCursor)}`;
7437
+ this.promptCursor -= 1;
7438
+ this.promptHistory.resetBrowsing();
7439
+ }
7440
+ return true;
7441
+ }
7442
+ if (matchesKey(data, "delete") || data === "\x1B[3~") {
7443
+ if (this.promptCursor < this.promptValue.length) {
7444
+ this.promptValue = `${this.promptValue.slice(0, this.promptCursor)}${this.promptValue.slice(this.promptCursor + 1)}`;
7445
+ this.promptHistory.resetBrowsing();
7446
+ }
7447
+ return true;
7448
+ }
7449
+ const printable = decodeKittyPrintable(data) ?? (isPrintableInput(data) ? data : void 0);
7450
+ if (printable !== void 0) {
7451
+ this.insertPromptText(printable);
7452
+ this.promptHistory.resetBrowsing();
7453
+ return true;
7454
+ }
7455
+ return false;
7456
+ }
7457
+ movePromptCursorVertically(delta) {
7458
+ const lines = this.promptValue.split("\n");
7459
+ const current = this.getPromptLineCursor(lines);
7460
+ const targetLine = Math.max(0, Math.min(lines.length - 1, current.line + delta));
7461
+ const targetColumn = Math.min(current.column, lines[targetLine]?.length ?? 0);
7462
+ this.promptCursor = lines.slice(0, targetLine).reduce((total, line) => total + line.length + 1, 0) + targetColumn;
7463
+ }
7464
+ getPromptLineCursor(lines = this.promptValue.split("\n")) {
7465
+ let offset = 0;
7466
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
7467
+ const line = lines[lineIndex] ?? "";
7468
+ const end = offset + line.length;
7469
+ if (this.promptCursor <= end || lineIndex === lines.length - 1) return {
7470
+ line: lineIndex,
7471
+ column: Math.max(0, this.promptCursor - offset)
7472
+ };
7473
+ offset = end + 1;
7474
+ }
7475
+ return {
7476
+ line: 0,
7477
+ column: 0
7478
+ };
7479
+ }
7480
+ getCurrentPromptLineStart() {
7481
+ return this.promptValue.lastIndexOf("\n", Math.max(0, this.promptCursor - 1)) + 1;
7482
+ }
7483
+ getCurrentPromptLineEnd() {
7484
+ const end = this.promptValue.indexOf("\n", this.promptCursor);
7485
+ return end === -1 ? this.promptValue.length : end;
7486
+ }
5732
7487
  handleSlashSuggestionInput(data) {
5733
7488
  const suggestions = this.getSlashSuggestions();
5734
7489
  if (suggestions.length === 0) {
@@ -5747,19 +7502,19 @@ var ChatLayout = class {
5747
7502
  this.completeSlashSuggestion(suggestions);
5748
7503
  return true;
5749
7504
  }
5750
- if (isEnterKey(data) && this.input.getValue().trim() !== suggestions[this.activeSlashSuggestionIndex]?.value) {
7505
+ if (isEnterKey(data) && this.promptValue.trim() !== suggestions[this.activeSlashSuggestionIndex]?.value) {
5751
7506
  this.completeSlashSuggestion(suggestions);
5752
7507
  return true;
5753
7508
  }
5754
7509
  return false;
5755
7510
  }
5756
7511
  completeSlashSuggestion(suggestions) {
5757
- this.input.setValue(suggestions[this.activeSlashSuggestionIndex]?.value ?? this.input.getValue());
5758
- this.input.handleInput("\x1B[F");
7512
+ this.promptValue = suggestions[this.activeSlashSuggestionIndex]?.value ?? this.promptValue;
7513
+ this.promptCursor = this.promptValue.length;
5759
7514
  this.promptHistory.resetBrowsing();
5760
7515
  }
5761
7516
  getSlashSuggestions() {
5762
- return getSlashCommandSuggestions(this.input.getValue());
7517
+ return getSlashCommandSuggestions(this.promptValue);
5763
7518
  }
5764
7519
  getActiveModal() {
5765
7520
  return this.messages[this.getActiveModalIndex()];
@@ -5772,6 +7527,7 @@ var ChatLayout = class {
5772
7527
  this.submitUserInput(message);
5773
7528
  }
5774
7529
  submitUserInput(message) {
7530
+ this.setTaskPlanNotice(void 0);
5775
7531
  this.promptHistory.add(message);
5776
7532
  if (message.startsWith("/")) this.submitCommand?.(message);
5777
7533
  else this.submitMessage?.(message);
@@ -5780,8 +7536,15 @@ var ChatLayout = class {
5780
7536
  function colorUserMessageBorder(line) {
5781
7537
  return line.replace("▌", ui.modelInline("▌"));
5782
7538
  }
5783
- function renderInputWithoutPrompt(input, width) {
5784
- return (input.render(width + 2)[0] ?? "").replace(/^> /, "");
7539
+ function padPromptInputLine(line, width) {
7540
+ return `${line}${" ".repeat(Math.max(0, width - stripAnsi(line).length))}`;
7541
+ }
7542
+ function isPrintableInput(data) {
7543
+ if (data.length === 0) return false;
7544
+ return [...data].every((char) => {
7545
+ const code = char.charCodeAt(0);
7546
+ return code >= 32 && code !== 127 && (code < 128 || code > 159);
7547
+ });
5785
7548
  }
5786
7549
  //#endregion
5787
7550
  //#region src/agent/conversation.ts
@@ -5793,58 +7556,6 @@ function buildConversationPrompt(turns, latestMessage) {
5793
7556
  return lines.join("\n\n");
5794
7557
  }
5795
7558
  //#endregion
5796
- //#region src/agent/events.ts
5797
- const agentEvent = {
5798
- status(status) {
5799
- return {
5800
- type: "status",
5801
- status
5802
- };
5803
- },
5804
- systemMessage(text) {
5805
- return {
5806
- type: "message",
5807
- role: "system",
5808
- text
5809
- };
5810
- },
5811
- assistantMessage(text, meta) {
5812
- return meta === void 0 ? {
5813
- type: "message",
5814
- role: "assistant",
5815
- text
5816
- } : {
5817
- type: "message",
5818
- role: "assistant",
5819
- text,
5820
- meta
5821
- };
5822
- },
5823
- toolCall(call, label) {
5824
- return {
5825
- type: "tool_call",
5826
- call,
5827
- label
5828
- };
5829
- },
5830
- knowledgeStatus(status, guidance) {
5831
- return guidance === void 0 ? {
5832
- type: "knowledge_status",
5833
- status
5834
- } : {
5835
- type: "knowledge_status",
5836
- status,
5837
- guidance
5838
- };
5839
- },
5840
- choice(options) {
5841
- return {
5842
- type: "choice",
5843
- ...options
5844
- };
5845
- }
5846
- };
5847
- //#endregion
5848
7559
  //#region src/agent/health.ts
5849
7560
  async function checkAgentReady(modelGateway, abortSignal) {
5850
7561
  const abortController = new AbortController();
@@ -5875,22 +7586,102 @@ function isAbortError(error) {
5875
7586
  return error.name === "AbortError" || error.message.toLowerCase().includes("aborted");
5876
7587
  }
5877
7588
  //#endregion
7589
+ //#region src/agent/profiles.ts
7590
+ const READ_ONLY_TOOLS = [
7591
+ "read_file",
7592
+ "list_files",
7593
+ "grep",
7594
+ "find_file",
7595
+ "git_status",
7596
+ "git_diff",
7597
+ "git_log"
7598
+ ];
7599
+ const PRIMARY_AGENT_PROFILE = {
7600
+ id: "primary",
7601
+ displayName: "Primary",
7602
+ mode: "primary",
7603
+ promptAdditions: [],
7604
+ modelPurpose: "agent.primary",
7605
+ toolPermissionDefault: "allow",
7606
+ allowedTools: [],
7607
+ deniedTools: []
7608
+ };
7609
+ const AGENT_PROFILES = [PRIMARY_AGENT_PROFILE, ...[{
7610
+ id: "explore",
7611
+ displayName: "Explore",
7612
+ mode: "subagent",
7613
+ promptAdditions: ["You are running as a read-only exploration subagent. Inspect the workspace and return concise findings to the parent agent."],
7614
+ modelPurpose: "agent.fast",
7615
+ toolPermissionDefault: "deny",
7616
+ allowedTools: READ_ONLY_TOOLS,
7617
+ deniedTools: ["task", "plan_todo"]
7618
+ }, {
7619
+ id: "general",
7620
+ displayName: "General",
7621
+ mode: "subagent",
7622
+ promptAdditions: ["You are running as a constrained subagent. Work only on the delegated prompt and return a concise result."],
7623
+ modelPurpose: "agent.primary",
7624
+ toolPermissionDefault: "allow",
7625
+ allowedTools: [],
7626
+ deniedTools: ["task", "plan_todo"]
7627
+ }]];
7628
+ function resolveAgentProfile(profileId = PRIMARY_AGENT_PROFILE.id) {
7629
+ const profile = AGENT_PROFILES.find((candidate) => candidate.id === profileId);
7630
+ if (!profile) throw new Error(`Unknown agent profile "${profileId}".`);
7631
+ return profile;
7632
+ }
7633
+ function createToolPermissionView(profile, parent = {}) {
7634
+ const deniedTools = new Set(profile.deniedTools);
7635
+ for (const tool of parent.deniedTools ?? []) deniedTools.add(tool);
7636
+ return {
7637
+ profileId: profile.id,
7638
+ defaultPermission: profile.toolPermissionDefault,
7639
+ allowedTools: new Set(profile.allowedTools),
7640
+ deniedTools
7641
+ };
7642
+ }
7643
+ function isToolAllowed(permissionView, toolName) {
7644
+ if (!isRegisteredToolName(toolName)) return false;
7645
+ if (permissionView.deniedTools.has(toolName)) return false;
7646
+ if (permissionView.defaultPermission === "deny") return permissionView.allowedTools.has(toolName);
7647
+ return true;
7648
+ }
7649
+ function getProfileToolDefinitions(permissionView) {
7650
+ return getToolDefinitionsForPermissions((toolName) => isToolAllowed(permissionView, toolName));
7651
+ }
7652
+ function isRegisteredToolName(toolName) {
7653
+ return toolName in toolRegistry;
7654
+ }
7655
+ //#endregion
5878
7656
  //#region src/agent/tools/executor.ts
5879
7657
  async function executeToolCall(workspaceRoot, call, options = {}) {
5880
- const definition = getToolDefinition(call.tool);
5881
7658
  const startedAt = Date.now();
5882
7659
  const context = {
5883
7660
  workspaceRoot,
5884
7661
  pathEnv: options.pathEnv,
5885
- logger: options.logger
7662
+ logger: options.logger,
7663
+ taskPlan: options.taskPlan,
7664
+ profile: options.profile,
7665
+ permissions: options.permissions,
7666
+ subagents: options.subagents,
7667
+ eventSink: options.eventSink,
7668
+ abortSignal: options.abortSignal,
7669
+ toolCallId: options.toolCallId
5886
7670
  };
5887
- options.logger?.debug({
5888
- event: "tool_call",
5889
- tool: call.tool,
5890
- args: summarizeToolArgs(call)
5891
- }, "tool call");
5892
7671
  try {
5893
- const result = await definition.execute(context, call.args);
7672
+ if (!isToolName(call.tool)) throw new Error(`Unknown tool "${call.tool}".`);
7673
+ if (options.permissions && !isToolAllowed(options.permissions, call.tool)) throw new Error(`Tool "${call.tool}" is not allowed for agent profile "${options.permissions.profileId}".`);
7674
+ const definition = getToolDefinition(call.tool);
7675
+ const parsedCall = {
7676
+ ...call,
7677
+ args: definition.argsSchema.parse(call.args)
7678
+ };
7679
+ options.logger?.debug({
7680
+ event: "tool_call",
7681
+ tool: parsedCall.tool,
7682
+ args: summarizeToolArgs(parsedCall)
7683
+ }, "tool call");
7684
+ const result = await definition.execute(context, parsedCall.args);
5894
7685
  const durationMs = Date.now() - startedAt;
5895
7686
  options.logger?.debug({
5896
7687
  event: "tool_result",
@@ -5910,23 +7701,40 @@ async function executeToolCall(workspaceRoot, call, options = {}) {
5910
7701
  }, "tool result content");
5911
7702
  return result;
5912
7703
  } catch (error) {
5913
- options.logger?.error({
5914
- event: "tool_error",
7704
+ const message = formatErrorMessage(error);
7705
+ const logPayload = {
7706
+ event: "tool_result",
5915
7707
  tool: call.tool,
5916
7708
  durationMs: Date.now() - startedAt,
7709
+ error: message,
5917
7710
  err: error
5918
- }, "tool failed");
5919
- throw error;
7711
+ };
7712
+ if (typeof options.logger?.warn === "function") options.logger.warn(logPayload, "tool returned error");
7713
+ else options.logger?.debug(logPayload, "tool returned error");
7714
+ return {
7715
+ tool: call.tool,
7716
+ content: `Tool ${call.tool} failed: ${message}`,
7717
+ error: message,
7718
+ warning: message
7719
+ };
5920
7720
  }
5921
7721
  }
5922
7722
  function summarizeToolArgs(call) {
7723
+ if (call.tool === "plan_todo") {
7724
+ const activeItem = call.args.items.find((item) => item.status === "in_progress")?.text;
7725
+ return {
7726
+ itemCount: call.args.items.length,
7727
+ activeItem,
7728
+ completedCount: call.args.items.filter((item) => item.status === "completed").length
7729
+ };
7730
+ }
5923
7731
  if (call.tool === "write_file") return {
5924
7732
  path: call.args.path,
5925
7733
  contentLength: call.args.content.length,
5926
7734
  lineCount: countLogicalLines(call.args.content),
5927
7735
  createParentDirs: Boolean(call.args.create_parent_dirs),
5928
7736
  overwrite: Boolean(call.args.overwrite),
5929
- expectedHashProvided: Boolean(call.args.expected_hash)
7737
+ expectedCurrentHashProvided: Boolean(call.args.expected_current_hash)
5930
7738
  };
5931
7739
  if (call.tool !== "edit_file") return call.args;
5932
7740
  return {
@@ -5934,10 +7742,15 @@ function summarizeToolArgs(call) {
5934
7742
  editCount: call.args.edits.length,
5935
7743
  oldTextLengths: call.args.edits.map((edit) => edit.old_text.length),
5936
7744
  newTextLengths: call.args.edits.map((edit) => edit.new_text.length),
5937
- expectedHashProvided: Boolean(call.args.expected_hash)
7745
+ expectedCurrentHashProvided: Boolean(call.args.expected_current_hash)
5938
7746
  };
5939
7747
  }
5940
7748
  function summarizeToolResult(result) {
7749
+ if (result.tool === "plan_todo") return {
7750
+ itemCount: result.plan.items.length,
7751
+ activeItem: result.currentItem,
7752
+ completedCount: result.completedCount
7753
+ };
5941
7754
  if (result.tool === "inspect_command") return {
5942
7755
  cwd: result.cwd,
5943
7756
  exitCode: result.exitCode,
@@ -6006,13 +7819,22 @@ function countLogicalLines(content) {
6006
7819
  const withoutTrailingLineEnding = content.replace(/\r?\n$/u, "");
6007
7820
  return withoutTrailingLineEnding.length === 0 ? 1 : withoutTrailingLineEnding.split(/\r?\n/u).length;
6008
7821
  }
7822
+ function formatErrorMessage(error) {
7823
+ return error instanceof Error ? error.message : String(error);
7824
+ }
6009
7825
  //#endregion
6010
7826
  //#region src/agent/prompts.ts
6011
- function getChatSystemPrompt() {
7827
+ function getChatSystemPrompt(options = {}) {
7828
+ const profile = options.profile ?? PRIMARY_AGENT_PROFILE;
7829
+ const canUseTool = (toolName) => options.permissions ? isToolAllowed(options.permissions, toolName) : true;
7830
+ const toolPromptLines = options.permissions ? getToolPromptLines((toolName) => canUseTool(toolName)) : getToolPromptLines();
6012
7831
  return [
6013
7832
  "You are Topchester, a plain-spoken terminal coding agent for software engineering work.",
6014
7833
  "Your job is to turn ordinary user requests into concrete repository work: inspect the codebase, make focused changes when tools allow it, verify the result when possible, and report the outcome clearly.",
6015
7834
  "",
7835
+ `Agent profile: ${profile.displayName} (${profile.id}).`,
7836
+ ...profile.promptAdditions,
7837
+ "",
6016
7838
  "Working style:",
6017
7839
  "- Start by understanding the user's intent and the surrounding code before proposing or changing anything non-trivial.",
6018
7840
  "- Prefer local project evidence over assumptions. Use search and read tools to find relevant files, examples, tests, commands, and conventions.",
@@ -6025,67 +7847,281 @@ function getChatSystemPrompt() {
6025
7847
  "- Ask a clarifying question only when the missing information blocks useful progress or the safe interpretation is genuinely unclear.",
6026
7848
  "",
6027
7849
  "You have these tools available:",
6028
- ...getToolPromptLines(),
7850
+ ...toolPromptLines,
6029
7851
  "",
6030
7852
  "Tool use:",
6031
7853
  "- When using a tool, output exactly one tool JSON object and no prose, markdown, or additional JSON. After the tool result, either output the next single tool JSON object or a final plain-text answer.",
6032
- "- Use read/search tools when the user asks about files, code, symbols, usages, tests, or project behavior.",
6033
- "- Use find_file for path or filename lookup. Use grep for text inside files. If grep output mentions another path, treat that mentioned path as content until find_file or read_file confirms it exists.",
6034
- "- Use list_files, grep, find_file, and read_file for exact file listing, search, lookup, and reading tasks.",
6035
- "- Use git_status, git_diff, and git_log for Git state, diffs, and history. Prefer these over inspect_command for Git workflow inspection.",
6036
- "- Use git_add and git_commit only when the user explicitly asks to stage or commit. Never stage unrelated files, never stage '.', and never commit unless staged paths exactly match the user's request.",
6037
- "- Use inspect_command only for quick read-only repo orientation when a short familiar command chain is clearer than several dedicated tool calls.",
6038
- "- inspect_command is not a shell. Unsafe commands, shell expansion, scripts, installs, builds, tests, network access, and file mutation are not available through it.",
6039
- "- Use read_file before editing a file so your edit is based on current file content and hash metadata.",
6040
- "- Use edit_file for targeted edits to existing files. Make multiple disjoint edits for the same file in one call when possible.",
6041
- "- Use write_file to create new files by default. It fails when the file already exists unless you are replacing the whole file with overwrite:true and expected_hash from read_file.",
6042
- "- Pass write_file create_parent_dirs:true only when the user intent clearly includes creating that folder path.",
6043
- "- Do not use inspect_command for file creation or file mutation.",
6044
- "- Keep edit_file old_text small but unique. Do not include line labels or grep prefixes in old_text; use exact file text only.",
6045
- "- Use edit/write tools when they are available and the user asks you to implement, fix, add, update, or refactor code.",
6046
- "- Use command/test tools when they are available and you need to inspect the environment, run tests, format, lint, typecheck, or verify behavior.",
7854
+ "- You already have permission to use the available tools to handle the user's request. Do not ask the user to provide tool results or permission to use an available tool.",
7855
+ "- Do not claim to have read, created, edited, staged, committed, or run anything unless a tool result in this turn confirms it.",
7856
+ ...canUseTool("plan_todo") ? [
7857
+ "- Use plan_todo for non-trivial multi-step work before the first substantive repository tool call.",
7858
+ "- Keep plan_todo items short, user-safe, and usually 2 to 6 items. Maintain exactly one in_progress item while work remains, update it after major progress changes, and clear it only when abandoning the plan or when no visible plan is useful.",
7859
+ "- Do not use plan_todo for simple one-step answers, tiny reads, or trivial edits.",
7860
+ "- Do not call plan_todo only to summarize completed work before a final answer. If no visible plan is active and the work is done, answer directly."
7861
+ ] : [],
7862
+ ...canUseTool("read_file") || canUseTool("grep") || canUseTool("find_file") || canUseTool("list_files") ? ["- Use read/search tools when the user asks about files, code, symbols, usages, tests, or project behavior."] : [],
7863
+ ...canUseTool("find_file") && canUseTool("grep") && canUseTool("read_file") ? ["- Use find_file for path or filename lookup. Use grep for text inside files. If grep output mentions another path, treat that mentioned path as content until find_file or read_file confirms it exists."] : [],
7864
+ ...canUseTool("list_files") && canUseTool("grep") && canUseTool("find_file") && canUseTool("read_file") ? ["- Use list_files, grep, find_file, and read_file for exact file listing, search, lookup, and reading tasks."] : [],
7865
+ ...canUseTool("git_status") && canUseTool("git_diff") && canUseTool("git_log") ? ["- Use git_status, git_diff, and git_log for Git state, diffs, and history. Prefer these over inspect_command for Git workflow inspection."] : [],
7866
+ ...canUseTool("git_add") && canUseTool("git_commit") ? ["- Use git_add and git_commit only when the user explicitly asks to stage or commit. Never stage unrelated files, never stage '.', and never commit unless staged paths exactly match the user's request."] : [],
7867
+ ...canUseTool("inspect_command") ? ["- Use inspect_command only for quick read-only repo orientation when a short familiar command chain is clearer than several dedicated tool calls.", "- inspect_command is not a shell. Unsafe commands, shell expansion, scripts, installs, builds, tests, network access, and file mutation are not available through it."] : [],
7868
+ ...canUseTool("edit_file") && canUseTool("read_file") ? ["- Use read_file before editing a file so your edit is based on current file content and hash metadata."] : [],
7869
+ ...canUseTool("read_file") && (canUseTool("edit_file") || canUseTool("write_file")) ? ["- When passing expected_current_hash to edit_file or write_file, use the current pre-edit/pre-write hash from the latest read_file result for that exact file. Never invent it and never use a predicted after-edit or after-write hash."] : [],
7870
+ ...canUseTool("edit_file") ? ["- Use edit_file for targeted edits to existing files. Make multiple disjoint edits for the same file in one call when possible."] : [],
7871
+ ...canUseTool("write_file") && canUseTool("read_file") ? [
7872
+ "- Use write_file to create new files by default. It fails when the file already exists unless you are replacing the whole file with overwrite:true and expected_current_hash from read_file.",
7873
+ "- When the user asks you to create a new file, call write_file. Do not answer that the file was created until write_file succeeds.",
7874
+ "- Pass write_file create_parent_dirs:true only when the user intent clearly includes creating that folder path."
7875
+ ] : [],
7876
+ ...canUseTool("write_file") && !canUseTool("read_file") ? [
7877
+ "- Use write_file to create new files by default. It fails when the file already exists unless overwrite:true is available with verified current content.",
7878
+ "- When the user asks you to create a new file, call write_file. Do not answer that the file was created until write_file succeeds.",
7879
+ "- Pass write_file create_parent_dirs:true only when the user intent clearly includes creating that folder path."
7880
+ ] : [],
7881
+ ...canUseTool("inspect_command") ? ["- Do not use inspect_command for file creation or file mutation."] : [],
7882
+ ...canUseTool("edit_file") ? ["- Keep edit_file old_text small but unique. Do not include line labels or grep prefixes in old_text; use exact file text only."] : [],
7883
+ ...canUseTool("edit_file") || canUseTool("write_file") ? ["- Use edit/write tools when they are available and the user asks you to implement, fix, add, update, or refactor code."] : [],
7884
+ ...canUseTool("inspect_command") ? ["- Use command/test tools when they are available and you need to inspect the environment, run tests, format, lint, typecheck, or verify behavior."] : [],
6047
7885
  "- After each tool result, decide the next useful action from the new evidence. Continue until the request is handled or blocked.",
6048
7886
  "Do not make up file contents or search results."
6049
7887
  ].join("\n");
6050
7888
  }
6051
7889
  //#endregion
7890
+ //#region src/session/runtime-payloads.ts
7891
+ function runtimeEventToSessionPayload(event) {
7892
+ switch (event.type) {
7893
+ case "message": return {
7894
+ kind: "message",
7895
+ role: event.role,
7896
+ text: event.text,
7897
+ ...event.meta === void 0 ? {} : { meta: event.meta }
7898
+ };
7899
+ case "tool_call": return {
7900
+ kind: "tool_call",
7901
+ label: event.label,
7902
+ call: event.call
7903
+ };
7904
+ case "task_plan": return {
7905
+ kind: "task_plan",
7906
+ items: event.plan.items,
7907
+ updatedAt: event.plan.updatedAt
7908
+ };
7909
+ case "knowledge_status": return;
7910
+ case "choice": return {
7911
+ kind: "choice",
7912
+ tone: event.tone,
7913
+ title: event.title,
7914
+ ...event.body === void 0 ? {} : { body: event.body },
7915
+ actions: event.actions
7916
+ };
7917
+ case "subagent_started": return {
7918
+ kind: "subagent_started",
7919
+ sessionId: event.sessionId,
7920
+ parentSessionId: event.parentSessionId,
7921
+ parentToolCallId: event.parentToolCallId,
7922
+ ...event.agentProfileId === void 0 ? {} : { agentProfileId: event.agentProfileId },
7923
+ ...event.title === void 0 ? {} : { title: event.title }
7924
+ };
7925
+ case "subagent_event": return {
7926
+ kind: "subagent_event",
7927
+ sessionId: event.sessionId,
7928
+ parentSessionId: event.parentSessionId,
7929
+ parentToolCallId: event.parentToolCallId,
7930
+ event: event.event
7931
+ };
7932
+ case "subagent_completed": return {
7933
+ kind: "subagent_completed",
7934
+ sessionId: event.sessionId,
7935
+ parentSessionId: event.parentSessionId,
7936
+ parentToolCallId: event.parentToolCallId,
7937
+ ...event.result === void 0 ? {} : { result: event.result }
7938
+ };
7939
+ case "subagent_failed": return {
7940
+ kind: "subagent_failed",
7941
+ sessionId: event.sessionId,
7942
+ parentSessionId: event.parentSessionId,
7943
+ parentToolCallId: event.parentToolCallId,
7944
+ error: event.error
7945
+ };
7946
+ case "status": return {
7947
+ kind: "status",
7948
+ status: event.status
7949
+ };
7950
+ }
7951
+ }
7952
+ //#endregion
7953
+ //#region src/agent/subagents.ts
7954
+ var SubagentManager = class {
7955
+ options;
7956
+ constructor(options) {
7957
+ this.options = options;
7958
+ }
7959
+ async runTask(options) {
7960
+ const parentSession = this.options.parentSession;
7961
+ if (!parentSession) throw new Error("task requires an active persisted session.");
7962
+ const profile = resolveAgentProfile(options.subagentType ?? "explore");
7963
+ if (profile.mode !== "subagent" && profile.mode !== "all") throw new Error(`Agent profile "${profile.id}" cannot be used for subagent tasks.`);
7964
+ const child = await createChildSession(this.options.context.workspaceRoot, {
7965
+ parent: parentSession,
7966
+ parentToolCallId: options.parentToolCallId,
7967
+ agentProfileId: profile.id,
7968
+ title: options.description,
7969
+ recordParentEvent: false
7970
+ });
7971
+ const reference = {
7972
+ sessionId: child.sessionId,
7973
+ parentSessionId: parentSession.sessionId,
7974
+ parentToolCallId: options.parentToolCallId
7975
+ };
7976
+ await options.eventSink?.(agentEvent.subagentStarted({
7977
+ ...reference,
7978
+ agentProfileId: profile.id,
7979
+ title: options.description
7980
+ }));
7981
+ const childRuntime = this.options.createRuntime({
7982
+ profile,
7983
+ parentPermissions: this.options.parentPermissions,
7984
+ session: child
7985
+ });
7986
+ let finalResponse = "";
7987
+ try {
7988
+ for await (const childEvent of childRuntime.submitMessageStream([], options.prompt, options.abortSignal, { session: child })) {
7989
+ const payload = runtimeEventToSessionPayload(childEvent);
7990
+ if (payload) await child.append(payload);
7991
+ if (childEvent.type === "message" && childEvent.role === "assistant") finalResponse = childEvent.text;
7992
+ await options.eventSink?.(agentEvent.subagentEvent(reference, childEvent));
7993
+ }
7994
+ const result = finalResponse.trim() || "Subagent completed without an assistant response.";
7995
+ await options.eventSink?.(agentEvent.subagentCompleted({
7996
+ ...reference,
7997
+ result
7998
+ }));
7999
+ return {
8000
+ sessionId: child.sessionId,
8001
+ status: "completed",
8002
+ result,
8003
+ profileId: profile.id
8004
+ };
8005
+ } catch (error) {
8006
+ const message = error instanceof Error ? error.message : String(error);
8007
+ await options.eventSink?.(agentEvent.subagentFailed({
8008
+ ...reference,
8009
+ error: message
8010
+ }));
8011
+ return {
8012
+ sessionId: child.sessionId,
8013
+ status: "failed",
8014
+ result: message,
8015
+ profileId: profile.id
8016
+ };
8017
+ }
8018
+ }
8019
+ };
8020
+ //#endregion
6052
8021
  //#region src/agent/runtime.ts
6053
- const MAX_TOOL_CALLS_PER_TURN = 8;
6054
- var TopchesterAgentRuntime = class {
8022
+ const MAX_TOOL_CALLS_PER_TURN = 75;
8023
+ const DEFAULT_TASK_CONCURRENCY = 3;
8024
+ var TopchesterAgentRuntime = class TopchesterAgentRuntime {
6055
8025
  context;
6056
- constructor(context) {
8026
+ options;
8027
+ taskPlan = createTaskPlanController();
8028
+ /**
8029
+ * Holds the shared application context for one runtime instance.
8030
+ * The runtime does not own those dependencies; it coordinates the
8031
+ * workspace, model gateway, logger, config, and task-plan state that
8032
+ * are passed in by the CLI or TUI layer.
8033
+ */
8034
+ constructor(context, options = {}) {
6057
8035
  this.context = context;
8036
+ this.options = options;
6058
8037
  }
8038
+ /**
8039
+ * Performs the lightweight startup model check used by the interactive
8040
+ * agent before accepting work. The check is intentionally non-blocking
8041
+ * from the user's point of view: timeout and failure both produce a
8042
+ * visible status message, but the runtime still moves to ready so the
8043
+ * user can continue.
8044
+ */
6059
8045
  async checkAgent(abortSignal) {
6060
8046
  const result = await checkAgentReady(this.context.modelGateway, abortSignal);
6061
8047
  if (result === "ready") return [agentEvent.status("ready")];
6062
8048
  if (result === "timed-out") return [agentEvent.systemMessage("Agent is taking a while, so I skipped the startup check."), agentEvent.status("ready")];
6063
8049
  return [agentEvent.systemMessage("Agent did not say it was ready."), agentEvent.status("ready")];
6064
8050
  }
8051
+ /**
8052
+ * Builds the initial knowledge-base status events shown by the TUI.
8053
+ * This wraps the raw filesystem status with the same non-clean file count
8054
+ * used by `/kb status`, so startup messaging reflects whether project
8055
+ * knowledge is ready, missing, stale, or waiting for a sync.
8056
+ */
6065
8057
  async checkKnowledgeBase() {
6066
8058
  return getKnowledgeStatusEvents(await this.getKnowledgeStatusWithNonCleanFileCount());
6067
8059
  }
6068
- async submitMessage(conversation, message, abortSignal) {
6069
- const prompt = buildConversationPrompt(conversation, message);
6070
- const events = [];
6071
- let nextPrompt = prompt;
8060
+ /**
8061
+ * Streams one user chat turn through the agent loop. It builds the model
8062
+ * prompt with relevant KB context, calls the model, executes any requested
8063
+ * tools, feeds tool results back into the next prompt, and repeats until
8064
+ * the model returns a normal assistant message or the loop hits its safety
8065
+ * limit.
8066
+ *
8067
+ * This is the primary runtime execution contract. Compatibility wrappers
8068
+ * can collect the stream, but the runtime's own turn loop only knows about
8069
+ * ordered events.
8070
+ */
8071
+ async *submitMessageStream(conversation, message, abortSignal, options = {}) {
8072
+ let nextPrompt = await this.buildPromptWithKnowledgeContext(buildConversationPrompt(conversation, message), message);
6072
8073
  let totalDurationMs = 0;
8074
+ const tokenUsageTotals = {};
8075
+ const profile = this.options.profile ?? PRIMARY_AGENT_PROFILE;
8076
+ const permissions = createToolPermissionView(profile, { deniedTools: this.options.parentPermissions?.deniedTools });
8077
+ const tools = getProfileToolDefinitions(permissions);
8078
+ const session = options.session ?? this.options.session;
8079
+ const subagents = new SubagentManager({
8080
+ context: this.context,
8081
+ parentSession: session,
8082
+ parentProfile: profile,
8083
+ parentPermissions: permissions,
8084
+ createRuntime: ({ profile: childProfile, parentPermissions, session: childSession }) => new TopchesterAgentRuntime(this.context, {
8085
+ ...this.options,
8086
+ profile: childProfile,
8087
+ parentPermissions,
8088
+ session: childSession
8089
+ })
8090
+ });
6073
8091
  let lastModelId = "model";
6074
8092
  let afterTool;
6075
8093
  let toolProtocolOverride = readToolProtocolEnvOverride();
8094
+ let requestedPlanClosure = false;
6076
8095
  for (let toolCalls = 0; toolCalls <= MAX_TOOL_CALLS_PER_TURN; toolCalls += 1) {
6077
8096
  const startedAt = Date.now();
8097
+ const system = getChatSystemPrompt({
8098
+ profile,
8099
+ permissions
8100
+ });
8101
+ this.context.logger.debug({
8102
+ event: "model_prompt",
8103
+ purpose: "agent.primary",
8104
+ afterTool,
8105
+ toolProtocol: toolProtocolOverride,
8106
+ promptLength: nextPrompt.length,
8107
+ systemLength: system.length,
8108
+ prompt: nextPrompt,
8109
+ system
8110
+ }, afterTool ? "model prompt after tool" : "model prompt");
6078
8111
  const result = await generateAgentStep(this.context, {
6079
8112
  purpose: "agent.primary",
6080
- system: getChatSystemPrompt(),
8113
+ system,
6081
8114
  prompt: nextPrompt,
6082
8115
  abortSignal,
6083
- toolProtocol: toolProtocolOverride
8116
+ toolProtocol: toolProtocolOverride,
8117
+ onReasoning: options.onReasoning,
8118
+ tools
6084
8119
  });
6085
8120
  const durationMs = Date.now() - startedAt;
6086
8121
  const toolCall = result.toolCalls[0];
6087
8122
  totalDurationMs += durationMs;
6088
8123
  lastModelId = result.modelId;
8124
+ addTokenUsageTotals(tokenUsageTotals, result.usage);
6089
8125
  this.context.logger.debug({
6090
8126
  event: "model_response",
6091
8127
  purpose: "agent.primary",
@@ -6093,6 +8129,11 @@ var TopchesterAgentRuntime = class {
6093
8129
  durationMs,
6094
8130
  totalDurationMs,
6095
8131
  textLength: result.text.length,
8132
+ usage: result.usage,
8133
+ inputTokens: result.usage?.inputTokens,
8134
+ outputTokens: result.usage?.outputTokens,
8135
+ totalTokens: result.usage?.totalTokens,
8136
+ costUsd: result.usage?.costUsd,
6096
8137
  hasToolCall: Boolean(toolCall),
6097
8138
  toolProtocol: result.toolProtocol,
6098
8139
  protocolAttempts: result.protocolAttempts,
@@ -6113,25 +8154,120 @@ var TopchesterAgentRuntime = class {
6113
8154
  if (result.providerRejectedTools && result.toolProtocol === "text-json") toolProtocolOverride = "text-json";
6114
8155
  else if (result.providerRejectedTools && result.toolProtocol === "text-xml") toolProtocolOverride = "text-xml";
6115
8156
  if (!toolCall) {
6116
- events.push(agentEvent.assistantMessage(result.text.trim() || "I got an empty response from the model.", formatAgentMessageMeta(result.modelId, totalDurationMs)), agentEvent.status("ready"));
6117
- return events;
8157
+ const plan = this.taskPlan.get();
8158
+ const finalText = stripSuppressiblePlanTodoPrefix(result.text, plan) ?? result.text;
8159
+ if (hasOpenTaskPlan(plan)) {
8160
+ if (!requestedPlanClosure) {
8161
+ requestedPlanClosure = true;
8162
+ nextPrompt = `${nextPrompt}\n\n${formatOpenPlanClosureInstruction(finalText, result.toolProtocol)}`;
8163
+ continue;
8164
+ }
8165
+ yield agentEvent.taskPlan(this.taskPlan.update({ items: [] }));
8166
+ }
8167
+ yield agentEvent.assistantMessage(finalText.trim() || "I got an empty response from the model.", formatAgentMessageMeta(result.modelId, totalDurationMs, tokenUsageTotals));
8168
+ yield agentEvent.status("ready");
8169
+ return;
6118
8170
  }
6119
8171
  if (toolCalls === MAX_TOOL_CALLS_PER_TURN) {
6120
- events.push(agentEvent.systemMessage(`Stopped after ${MAX_TOOL_CALLS_PER_TURN} tool calls in one turn.`), agentEvent.status("ready"));
6121
- return events;
8172
+ yield agentEvent.choice({
8173
+ tone: "warning",
8174
+ title: "Tool call limit reached",
8175
+ body: `Stopped after ${MAX_TOOL_CALLS_PER_TURN} tool calls in one turn. Continue starts another turn; abort leaves the call stopped.`,
8176
+ actions: [choiceAction("Continue", "Continue the previous task from where you stopped."), choiceAction("Abort", ABORT_CHOICE_VALUE)]
8177
+ });
8178
+ yield agentEvent.status("ready");
8179
+ return;
8180
+ }
8181
+ if (result.toolCalls.length > 1 && result.toolCalls.every((call) => call.tool === "task")) {
8182
+ const taskCalls = result.toolCalls.map((call) => call);
8183
+ const taskResults = [];
8184
+ for (let index = 0; index < taskCalls.length; index += DEFAULT_TASK_CONCURRENCY) {
8185
+ const batch = taskCalls.slice(index, index + DEFAULT_TASK_CONCURRENCY);
8186
+ const taskEventQueue = createRuntimeEventQueue();
8187
+ const batchResultPromise = Promise.all(batch.map((call, batchIndex) => executeToolCall(this.context.workspaceRoot, call, {
8188
+ logger: this.context.logger,
8189
+ taskPlan: this.taskPlan,
8190
+ profile,
8191
+ permissions,
8192
+ subagents,
8193
+ abortSignal,
8194
+ toolCallId: result.toolCalls[index + batchIndex]?.id,
8195
+ eventSink: (event) => taskEventQueue.push(event)
8196
+ }))).finally(() => {
8197
+ taskEventQueue.close();
8198
+ });
8199
+ for await (const event of taskEventQueue) yield event;
8200
+ taskResults.push(...await batchResultPromise);
8201
+ }
8202
+ for (let index = 0; index < taskCalls.length; index += 1) yield agentEvent.toolCall(taskCalls[index], formatToolCallMessage(taskCalls[index], taskResults[index]));
8203
+ afterTool = "task";
8204
+ nextPrompt = `${nextPrompt}\n\n${taskResults.map((toolResult) => formatToolResultForPrompt(toolResult)).join("\n\n")}\n\n${formatContinuationInstruction(result.toolProtocol, taskResults.at(-1), isToolAllowed(permissions, "plan_todo"))}`;
8205
+ continue;
8206
+ }
8207
+ if (result.toolCalls.length > 1 && result.toolCalls.every((call) => isParallelSafeToolName(call.tool))) {
8208
+ const parallelCalls = result.toolCalls.map((call) => call);
8209
+ const parallelResults = await Promise.all(parallelCalls.map((call, index) => executeToolCall(this.context.workspaceRoot, call, {
8210
+ logger: this.context.logger,
8211
+ taskPlan: this.taskPlan,
8212
+ profile,
8213
+ permissions,
8214
+ subagents,
8215
+ abortSignal,
8216
+ toolCallId: result.toolCalls[index]?.id
8217
+ })));
8218
+ for (let index = 0; index < parallelCalls.length; index += 1) yield agentEvent.toolCall(parallelCalls[index], formatToolCallMessage(parallelCalls[index], parallelResults[index]));
8219
+ afterTool = parallelCalls.at(-1)?.tool;
8220
+ nextPrompt = `${nextPrompt}\n\n${parallelResults.map((toolResult) => formatToolResultForPrompt(toolResult)).join("\n\n")}\n\n${formatContinuationInstruction(result.toolProtocol, parallelResults.at(-1), isToolAllowed(permissions, "plan_todo"))}`;
8221
+ continue;
6122
8222
  }
6123
8223
  const executableToolCall = toolCall;
6124
- const toolResult = await executeToolCall(this.context.workspaceRoot, executableToolCall, { logger: this.context.logger });
6125
- events.push(agentEvent.toolCall(executableToolCall, formatToolCallMessage(executableToolCall, toolResult)));
8224
+ const suppressiblePlanTodoAnswer = getSuppressiblePlanTodoAnswer(executableToolCall, result.text, this.taskPlan.get());
8225
+ if (suppressiblePlanTodoAnswer !== void 0) {
8226
+ yield agentEvent.assistantMessage(suppressiblePlanTodoAnswer || "I got an empty response from the model.", formatAgentMessageMeta(result.modelId, totalDurationMs, tokenUsageTotals));
8227
+ yield agentEvent.status("ready");
8228
+ return;
8229
+ }
8230
+ const toolEventQueue = createRuntimeEventQueue();
8231
+ const toolResultPromise = executeToolCall(this.context.workspaceRoot, executableToolCall, {
8232
+ logger: this.context.logger,
8233
+ taskPlan: this.taskPlan,
8234
+ profile,
8235
+ permissions,
8236
+ subagents,
8237
+ abortSignal,
8238
+ toolCallId: toolCall.id,
8239
+ eventSink: (event) => toolEventQueue.push(event)
8240
+ }).finally(() => {
8241
+ toolEventQueue.close();
8242
+ });
8243
+ for await (const event of toolEventQueue) yield event;
8244
+ const toolResult = await toolResultPromise;
8245
+ yield agentEvent.toolCall(executableToolCall, formatToolCallMessage(executableToolCall, toolResult));
8246
+ if (!isToolErrorResult(toolResult) && toolResult.tool === "plan_todo") yield agentEvent.taskPlan(toolResult.plan);
6126
8247
  afterTool = executableToolCall.tool;
6127
- nextPrompt = `${nextPrompt}\n\n${formatToolResultForPrompt(toolResult)}\n\n${formatContinuationInstruction(result.toolProtocol)}`;
8248
+ nextPrompt = `${nextPrompt}\n\n${formatToolResultForPrompt(toolResult)}\n\n${formatContinuationInstruction(result.toolProtocol, toolResult, isToolAllowed(permissions, "plan_todo"))}`;
6128
8249
  }
6129
- return [
6130
- ...events,
6131
- agentEvent.assistantMessage("I stopped because the tool loop ended unexpectedly.", formatAgentMessageMeta(lastModelId, totalDurationMs)),
6132
- agentEvent.status("ready")
6133
- ];
8250
+ yield agentEvent.assistantMessage("I stopped because the tool loop ended unexpectedly.", formatAgentMessageMeta(lastModelId, totalDurationMs, tokenUsageTotals));
8251
+ yield agentEvent.status("ready");
8252
+ }
8253
+ /**
8254
+ * Compatibility wrapper for callers that still expect a completed event
8255
+ * array or use the older `onEvent` callback shape.
8256
+ */
8257
+ async submitMessage(conversation, message, abortSignal, onEvent, options = {}) {
8258
+ const events = [];
8259
+ for await (const event of this.submitMessageStream(conversation, message, abortSignal, options)) {
8260
+ events.push(event);
8261
+ await onEvent?.(event);
8262
+ }
8263
+ return events;
6134
8264
  }
8265
+ /**
8266
+ * Executes a slash command through the shared command dispatcher and maps
8267
+ * the command output into runtime events. Commands that can change KB
8268
+ * readiness also refresh the displayed knowledge status so the TUI footer
8269
+ * and chat status stay aligned with the command result.
8270
+ */
6135
8271
  async submitSlashCommand(command, onProgress) {
6136
8272
  const result = await executeSlashCommand(command, {
6137
8273
  workspaceRoot: this.context.workspaceRoot,
@@ -6145,6 +8281,12 @@ var TopchesterAgentRuntime = class {
6145
8281
  events.push(agentEvent.status("ready"));
6146
8282
  return events;
6147
8283
  }
8284
+ /**
8285
+ * Reads the project KB status and augments it with a count of files that
8286
+ * would be touched by a dry-run compile. The dry run is only performed for
8287
+ * a ready KB directory, because missing or incomplete KB states already
8288
+ * have enough information for the startup and status messages.
8289
+ */
6148
8290
  async getKnowledgeStatusWithNonCleanFileCount() {
6149
8291
  const status = getKnowledgeStatus(this.context.workspaceRoot);
6150
8292
  if (!status.kbExists || !status.kbIsDirectory || status.kbContentState !== "ready") return status;
@@ -6154,12 +8296,88 @@ var TopchesterAgentRuntime = class {
6154
8296
  nonCleanFileCount: result.files.length
6155
8297
  };
6156
8298
  }
8299
+ /**
8300
+ * Adds relevant L1 knowledge context to the conversation prompt when the
8301
+ * compiled KB is present and ready. Search failures are logged and then
8302
+ * ignored on purpose: stale or broken KB search should not prevent the
8303
+ * user's chat turn from reaching the model.
8304
+ */
8305
+ async buildPromptWithKnowledgeContext(prompt, message) {
8306
+ if (this.options.disableL1Context ?? isL1ContextDisabledByEnv()) {
8307
+ this.context.logger.debug({
8308
+ event: "kb_context_pack_skipped",
8309
+ reason: "disabled"
8310
+ }, "kb context pack skipped");
8311
+ return prompt;
8312
+ }
8313
+ const status = getKnowledgeStatus(this.context.workspaceRoot);
8314
+ if (!status.kbExists || !status.kbIsDirectory || status.kbContentState !== "ready") return prompt;
8315
+ try {
8316
+ const contextPack = await createL1ContextPack(this.context.workspaceRoot, message, {
8317
+ limit: 8,
8318
+ minScore: 12
8319
+ });
8320
+ this.context.logger.debug({
8321
+ event: "kb_context_pack",
8322
+ query: message,
8323
+ entryCount: contextPack.entryCount,
8324
+ relevantFileCount: contextPack.relevantFiles.length,
8325
+ paths: contextPack.relevantFiles.map((file) => file.path),
8326
+ warnings: contextPack.warnings
8327
+ }, "kb context pack");
8328
+ this.context.logger.trace({
8329
+ event: "kb_context_pack_payload",
8330
+ contextPack
8331
+ }, "kb context pack payload");
8332
+ if (contextPack.relevantFiles.length === 0) return prompt;
8333
+ return `${formatL1ContextPackForPrompt(contextPack)}\n\nConversation:\n${prompt}`;
8334
+ } catch (error) {
8335
+ this.context.logger.debug({
8336
+ event: "kb_context_pack_failed",
8337
+ error: error instanceof Error ? error.message : String(error)
8338
+ }, "kb context pack failed");
8339
+ return prompt;
8340
+ }
8341
+ }
6157
8342
  };
8343
+ function createRuntimeEventQueue() {
8344
+ const events = [];
8345
+ let closed = false;
8346
+ let notify;
8347
+ return {
8348
+ push(event) {
8349
+ events.push(event);
8350
+ notify?.();
8351
+ notify = void 0;
8352
+ },
8353
+ close() {
8354
+ closed = true;
8355
+ notify?.();
8356
+ notify = void 0;
8357
+ },
8358
+ async *[Symbol.asyncIterator]() {
8359
+ while (!closed || events.length > 0) {
8360
+ const event = events.shift();
8361
+ if (event) {
8362
+ yield event;
8363
+ continue;
8364
+ }
8365
+ await new Promise((resolve) => {
8366
+ notify = resolve;
8367
+ });
8368
+ }
8369
+ }
8370
+ };
8371
+ }
8372
+ /**
8373
+ * Calls the configured model gateway for a single agent step and normalizes
8374
+ * the result into the newer `ModelAgentResult` shape. Gateways that implement
8375
+ * native agent stepping receive the tool registry directly; older text-only
8376
+ * gateways fall back to parsing a JSON or XML tool call out of the model text
8377
+ * so the rest of the runtime can use the same tool loop.
8378
+ */
6158
8379
  async function generateAgentStep(context, request) {
6159
- if ("generateAgentStep" in context.modelGateway && typeof context.modelGateway.generateAgentStep === "function") return context.modelGateway.generateAgentStep({
6160
- ...request,
6161
- tools: Object.values(toolRegistry)
6162
- });
8380
+ if ("generateAgentStep" in context.modelGateway && typeof context.modelGateway.generateAgentStep === "function") return context.modelGateway.generateAgentStep({ ...request });
6163
8381
  const result = await context.modelGateway.generateText(request);
6164
8382
  const parsed = parseToolCallWithSource(result.text);
6165
8383
  const toolProtocol = parsed?.source === "text-xml" ? "text-xml" : "text-json";
@@ -6183,15 +8401,56 @@ async function generateAgentStep(context, request) {
6183
8401
  openRouterRoutingApplied: false
6184
8402
  };
6185
8403
  }
8404
+ function getSuppressiblePlanTodoAnswer(call, modelText, currentPlan) {
8405
+ if (call.tool !== "plan_todo" || hasOpenTaskPlan(currentPlan)) return;
8406
+ const items = call.args.items;
8407
+ if (!Array.isArray(items) || items.some((item) => !isCompletedPlanTodoItem(item))) return;
8408
+ const parsed = parseToolCallWithSource(modelText, ["text-json"]);
8409
+ return parsed?.remainder ? parsed.remainder : void 0;
8410
+ }
8411
+ function stripSuppressiblePlanTodoPrefix(modelText, currentPlan) {
8412
+ const parsed = parseToolCallWithSource(modelText, ["text-json"]);
8413
+ if (!parsed) return;
8414
+ return getSuppressiblePlanTodoAnswer(parsed.call, modelText, currentPlan);
8415
+ }
8416
+ function isCompletedPlanTodoItem(item) {
8417
+ return Boolean(item && typeof item === "object" && "status" in item && item.status === "completed");
8418
+ }
8419
+ /**
8420
+ * Reads the optional environment override for the tool-calling protocol.
8421
+ * Invalid values are ignored instead of failing startup, which keeps local
8422
+ * experimentation contained to supported protocol names while preserving the
8423
+ * normal automatic negotiation path by default.
8424
+ */
6186
8425
  function readToolProtocolEnvOverride() {
6187
8426
  const value = process.env.TOPCHESTER_TOOL_PROTOCOL;
6188
8427
  if (value === "auto" || value === "native" || value === "text-json" || value === "text-xml") return value;
6189
8428
  }
8429
+ function isL1ContextDisabledByEnv() {
8430
+ const value = process.env.TOPCHESTER_DISABLE_L1_CONTEXT?.trim().toLowerCase();
8431
+ return value === "1" || value === "true" || value === "yes" || value === "on";
8432
+ }
8433
+ function shouldShowTokenUsageByEnv() {
8434
+ const value = process.env.TOPCHESTER_SHOW_TOKEN_USAGE?.trim().toLowerCase();
8435
+ return value !== void 0 && value !== "" && value !== "0" && value !== "false" && value !== "no" && value !== "off";
8436
+ }
8437
+ /**
8438
+ * Applies TUI styling to per-file KB sync states. The raw scanner statuses
8439
+ * are preserved as text, but success, warning, and error categories get
8440
+ * different colors so slash-command output is readable without changing the
8441
+ * underlying command semantics.
8442
+ */
6190
8443
  function formatTuiSyncStatus(status) {
6191
8444
  if (status === "current") return ui.ok(status);
6192
8445
  if (status === "invalid" || status === "missing_file") return ui.error(status);
6193
8446
  return ui.warn(status);
6194
8447
  }
8448
+ /**
8449
+ * Decides whether a slash command should trigger a fresh KB status event.
8450
+ * Only KB subcommands that can initialize, rebuild, sync, reset, or inspect
8451
+ * the compiled knowledge state need the refresh; other commands can return
8452
+ * their output without doing extra filesystem work.
8453
+ */
6195
8454
  function shouldRefreshKnowledgeStatus(command) {
6196
8455
  const parsed = parseSlashCommand(command);
6197
8456
  return parsed?.name === "kb" && [
@@ -6202,19 +8461,44 @@ function shouldRefreshKnowledgeStatus(command) {
6202
8461
  "status"
6203
8462
  ].includes(parsed.args[0] ?? "");
6204
8463
  }
8464
+ /**
8465
+ * Converts a computed KB status into the startup event shape consumed by the
8466
+ * TUI. The event carries both the structured status and a short next-step
8467
+ * message, letting renderers show precise state while keeping user-facing
8468
+ * guidance in one place.
8469
+ */
6205
8470
  function getKnowledgeStatusEvents(status) {
6206
8471
  return [agentEvent.knowledgeStatus(status, formatStartupKnowledgeGuidance(status))];
6207
8472
  }
8473
+ /**
8474
+ * Produces the short guidance line shown with startup KB status. The message
8475
+ * is deliberately action-oriented: it points to the next command that would
8476
+ * fix the current state and returns nothing when the KB is ready and clean.
8477
+ */
6208
8478
  function formatStartupKnowledgeGuidance(status) {
6209
8479
  if (!status.kbExists) return "Next: run /kb init, then /kb compile to create project knowledge.";
6210
8480
  if (!status.kbIsDirectory) return "Fix the KB path or config, then run /kb status.";
6211
8481
  if (status.kbContentState !== "ready") return "Next: run /kb compile to build project knowledge.";
6212
8482
  if ((status.nonCleanFileCount ?? 0) > 0) return "Next: run /kb sync to update project knowledge, or /kb status to inspect the files.";
6213
8483
  }
8484
+ /**
8485
+ * Serializes a tool execution result into the text that is fed back to the
8486
+ * model after a tool call. Each tool gets the metadata the model needs for
8487
+ * the next step, such as file hashes, diffs, command exit status, truncation
8488
+ * state, or KB dirty-state signals, while errors are presented in a uniform
8489
+ * error block.
8490
+ */
6214
8491
  function formatToolResultForPrompt(result) {
6215
8492
  const path = result.path ? ` ${JSON.stringify(result.path)}` : "";
6216
8493
  const command = result.command ? ` via ${result.command}` : "";
6217
8494
  const warning = result.warning ? `\nWarning: ${result.warning}` : "";
8495
+ if (isToolErrorResult(result)) return [
8496
+ `Tool result from ${result.tool}${path}${command}:`,
8497
+ `Error: ${result.error}`,
8498
+ "```",
8499
+ result.content,
8500
+ "```"
8501
+ ].join("\n");
6218
8502
  if (result.tool === "read_file") return [
6219
8503
  `Tool result from ${result.tool}${path}${command}:${warning}`,
6220
8504
  `hash: ${result.hash}`,
@@ -6222,6 +8506,8 @@ function formatToolResultForPrompt(result) {
6222
8506
  result.content,
6223
8507
  "```"
6224
8508
  ].join("\n");
8509
+ if (result.tool === "plan_todo") return [`Tool result from ${result.tool}:`, result.content].join("\n");
8510
+ if (result.tool === "task") return [`Tool result from ${result.tool}:`, result.content].join("\n");
6225
8511
  if (result.tool === "edit_file") return [
6226
8512
  `Tool result from ${result.tool}${path}:`,
6227
8513
  `before_hash: ${result.beforeHash}`,
@@ -6319,11 +8605,50 @@ function formatToolResultForPrompt(result) {
6319
8605
  "```"
6320
8606
  ].join("\n");
6321
8607
  }
6322
- function formatContinuationInstruction(protocol) {
6323
- return `Continue the user's request using the tool result above. ${protocol === "text-xml" ? "If another tool is needed, reply with only one XML tool call." : protocol === "text-json" ? "If another tool is needed, reply with only that tool JSON." : "If another tool is needed, use the available tool calling path."} Otherwise answer the user. Do not guess.`;
8608
+ /**
8609
+ * Builds the follow-up instruction appended after each tool result. It keeps
8610
+ * the model on the active task, reminds it to maintain the visible plan, and
8611
+ * restates the current tool-call protocol so the next model step remains
8612
+ * parseable by the runtime.
8613
+ */
8614
+ function formatContinuationInstruction(protocol, result, canUsePlanTodo = true) {
8615
+ const toolInstruction = protocol === "text-xml" ? "If another tool is needed, reply with only one XML tool call." : protocol === "text-json" ? "If another tool is needed, reply with only that tool JSON." : "If another tool is needed, use the available tool calling path.";
8616
+ return [
8617
+ "Continue the user's request using the tool result above and the visible plan when one is active.",
8618
+ result.tool === "find_file" ? "find_file results are paths only; if the user asked to read or answer from file contents, call read_file on the relevant path before answering. Do not ask the user to provide the read_file result or permission." : "",
8619
+ canUsePlanTodo ? "Update plan_todo after major progress changes." : "",
8620
+ canUsePlanTodo ? "Before a final answer, close the visible plan by calling plan_todo with all finished items marked completed, or with [] if abandoning the plan." : "",
8621
+ toolInstruction,
8622
+ "Otherwise answer the user. Do not guess."
8623
+ ].filter(Boolean).join(" ");
8624
+ }
8625
+ /**
8626
+ * Creates the corrective prompt used when the model tries to answer while a
8627
+ * visible task plan is still open. The draft final answer is preserved so the
8628
+ * model can reuse it after closing the plan, but the immediate instruction is
8629
+ * to call `plan_todo` first.
8630
+ */
8631
+ function formatOpenPlanClosureInstruction(draftAnswer, protocol) {
8632
+ const toolInstruction = protocol === "text-xml" ? "Reply now with only one XML plan_todo tool call." : protocol === "text-json" ? "Reply now with only the plan_todo JSON object." : "Use the available tool calling path now to call plan_todo.";
8633
+ const trimmedDraft = draftAnswer.trim();
8634
+ return [
8635
+ "The visible plan still has unfinished items, so do not provide the final answer yet.",
8636
+ "First close the plan with plan_todo: mark completed work as completed, keep one item in_progress only if work truly remains, or use [] if abandoning the plan.",
8637
+ toolInstruction,
8638
+ trimmedDraft ? `After the plan_todo result, use this draft final answer if it is still accurate:\n${trimmedDraft}` : ""
8639
+ ].filter(Boolean).join("\n");
6324
8640
  }
8641
+ /**
8642
+ * Formats a compact, user-visible summary for a tool call event. When a
8643
+ * result is available the summary includes useful completion details, such as
8644
+ * changed-line counts, staged paths, commit subjects, or command failures,
8645
+ * instead of echoing the full tool payload.
8646
+ */
6325
8647
  function formatToolCallMessage(call, result) {
8648
+ if (result && isToolErrorResult(result)) return `${call.tool} failed: ${result.error}`;
6326
8649
  switch (call.tool) {
8650
+ case "task": return result?.tool === "task" ? `task: ${result.status} ${result.childSessionId}` : `task: ${call.args.description}`;
8651
+ case "plan_todo": return result?.tool === "plan_todo" ? `plan_todo: ${result.plan.items.length} items, ${result.inProgressCount} active` : `plan_todo: ${call.args.items.length} items`;
6327
8652
  case "read_file": return `read_file: ${call.args.path}`;
6328
8653
  case "list_files": return `list_files: ${call.args.path}${call.args.recursive ? " (recursive)" : ""}`;
6329
8654
  case "grep": return `grep: ${call.args.pattern} in ${call.args.path ?? "."}`;
@@ -6338,21 +8663,62 @@ function formatToolCallMessage(call, result) {
6338
8663
  case "inspect_command": return `inspect_command: ${call.args.command}`;
6339
8664
  }
6340
8665
  }
8666
+ /**
8667
+ * Summarizes a `git_diff` call for the TUI event list. Successful results
8668
+ * report the resolved scope, file count, and truncation marker; pending or
8669
+ * failed calls fall back to the requested scope from the tool arguments.
8670
+ */
6341
8671
  function formatGitDiffCallSummary(call, result) {
6342
- if (result?.tool === "git_diff") return `${result.scope} (${result.fileCount} files${result.truncated ? ", truncated" : ""})`;
8672
+ if (result?.tool === "git_diff" && !isToolErrorResult(result)) return `${result.scope} (${result.fileCount} files${result.truncated ? ", truncated" : ""})`;
6343
8673
  return call.args.scope;
6344
8674
  }
8675
+ /**
8676
+ * Returns the parenthesized change summary for a successful `edit_file`
8677
+ * result. Non-edit results and failed edits intentionally return an empty
8678
+ * suffix so the main tool-call formatter can keep one path for success,
8679
+ * failure, and pre-result display.
8680
+ */
6345
8681
  function formatEditFileChangeSummary(result) {
6346
- if (result?.tool !== "edit_file") return "";
8682
+ if (result?.tool !== "edit_file" || isToolErrorResult(result)) return "";
6347
8683
  return ` (changed ${result.editEvent.diffSummary})`;
6348
8684
  }
8685
+ /**
8686
+ * Returns the parenthesized write summary for a successful `write_file`
8687
+ * result. The helper mirrors the edit summary helper, keeping write-specific
8688
+ * result details out of the larger switch that formats all tool-call messages.
8689
+ */
6349
8690
  function formatWriteFileChangeSummary(result) {
6350
- if (result?.tool !== "write_file") return "";
8691
+ if (result?.tool !== "write_file" || isToolErrorResult(result)) return "";
6351
8692
  return ` (${result.writeEvent.writeSummary})`;
6352
8693
  }
6353
- function formatAgentMessageMeta(model, durationMs) {
6354
- return `${model} · ${formatDuration$1(durationMs)}`;
6355
- }
8694
+ /**
8695
+ * Formats the assistant-message metadata shown next to the final response.
8696
+ * The model identifier and cumulative turn duration are kept together here
8697
+ * so callers do not need to know how agent-loop timing should be presented.
8698
+ */
8699
+ function formatAgentMessageMeta(model, durationMs, usage) {
8700
+ const tokenUsage = shouldShowTokenUsageByEnv() ? formatTokenUsage(usage) : void 0;
8701
+ return [
8702
+ model,
8703
+ formatDuration$1(durationMs),
8704
+ tokenUsage
8705
+ ].filter(Boolean).join(" · ");
8706
+ }
8707
+ function addTokenUsageTotals(totals, usage) {
8708
+ if (!usage) return;
8709
+ if (typeof usage.inputTokens === "number") totals.inputTokens = (totals.inputTokens ?? 0) + usage.inputTokens;
8710
+ if (typeof usage.outputTokens === "number") totals.outputTokens = (totals.outputTokens ?? 0) + usage.outputTokens;
8711
+ }
8712
+ function formatTokenUsage(usage) {
8713
+ if (usage?.inputTokens === void 0 && usage?.outputTokens === void 0) return;
8714
+ return `${formatInteger(usage.inputTokens ?? 0)} input / ${formatInteger(usage.outputTokens ?? 0)} output tokens`;
8715
+ }
8716
+ /**
8717
+ * Converts elapsed milliseconds into the short human-readable duration used
8718
+ * in assistant metadata. Very short turns keep one decimal place, normal
8719
+ * sub-minute turns round to seconds, and longer turns switch to minutes plus
8720
+ * remaining seconds.
8721
+ */
6356
8722
  function formatDuration$1(durationMs) {
6357
8723
  const totalSeconds = Math.max(0, durationMs / 1e3);
6358
8724
  if (totalSeconds < 10) return `${formatNumber(totalSeconds, 1)} sec`;
@@ -6362,12 +8728,21 @@ function formatDuration$1(durationMs) {
6362
8728
  if (seconds === 0) return `${minutes} min`;
6363
8729
  return `${minutes} min ${seconds} sec`;
6364
8730
  }
8731
+ /**
8732
+ * Formats a number with a fixed number of fraction digits using the English
8733
+ * locale expected by the TUI metadata strings. Keeping this tiny wrapper
8734
+ * avoids repeating the minimum and maximum fraction-digit options at every
8735
+ * call site.
8736
+ */
6365
8737
  function formatNumber(value, fractionDigits) {
6366
8738
  return value.toLocaleString("en", {
6367
8739
  minimumFractionDigits: fractionDigits,
6368
8740
  maximumFractionDigits: fractionDigits
6369
8741
  });
6370
8742
  }
8743
+ function formatInteger(value) {
8744
+ return value.toLocaleString("en", { maximumFractionDigits: 0 });
8745
+ }
6371
8746
  //#endregion
6372
8747
  //#region src/tui/runtime-events.ts
6373
8748
  function renderRuntimeEvent(event) {
@@ -6381,9 +8756,39 @@ function renderRuntimeEvent(event) {
6381
8756
  body: event.body,
6382
8757
  actions: event.actions
6383
8758
  })];
8759
+ case "task_plan": return [];
8760
+ case "subagent_started": return [subagentMessage({
8761
+ status: "running",
8762
+ sessionId: event.sessionId,
8763
+ title: event.title
8764
+ })];
8765
+ case "subagent_event": return formatForwardedSubagentEvent(event.sessionId, event.event);
8766
+ case "subagent_completed": return [subagentMessage({
8767
+ status: "completed",
8768
+ sessionId: event.sessionId,
8769
+ text: event.result
8770
+ })];
8771
+ case "subagent_failed": return [subagentMessage({
8772
+ status: "failed",
8773
+ sessionId: event.sessionId,
8774
+ text: event.error
8775
+ })];
6384
8776
  case "status": return [];
6385
8777
  }
6386
8778
  }
8779
+ function formatForwardedSubagentEvent(sessionId, event) {
8780
+ if (event.type === "message" && event.role === "assistant") return [subagentMessage({
8781
+ status: "event",
8782
+ sessionId,
8783
+ text: event.text
8784
+ })];
8785
+ if (event.type === "tool_call") return [subagentMessage({
8786
+ status: "event",
8787
+ sessionId,
8788
+ text: event.label
8789
+ })];
8790
+ return [];
8791
+ }
6387
8792
  function formatKbPathSource(status) {
6388
8793
  return status.kbPathSource === "env" ? " (custom)" : "";
6389
8794
  }
@@ -6394,6 +8799,7 @@ var TopchesterTuiShell = class {
6394
8799
  options;
6395
8800
  runtime;
6396
8801
  session;
8802
+ taskPlanNoticeTimer;
6397
8803
  constructor(context, runtime, options = {}) {
6398
8804
  this.context = context;
6399
8805
  this.options = options;
@@ -6410,7 +8816,7 @@ var TopchesterTuiShell = class {
6410
8816
  const folderName = getFolderName(this.context.workspaceRoot);
6411
8817
  const modelLabel = getModelLabel(this.context);
6412
8818
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
6413
- console.log(renderStaticLayout(messages, folderName, modelLabel));
8819
+ console.log(renderStaticLayout(messages, folderName, modelLabel, this.options.initialTaskPlan));
6414
8820
  return;
6415
8821
  }
6416
8822
  const terminal = new ProcessTerminal();
@@ -6429,11 +8835,12 @@ var TopchesterTuiShell = class {
6429
8835
  process.exit(0);
6430
8836
  }
6431
8837
  });
8838
+ app.setTaskPlan(this.options.initialTaskPlan);
6432
8839
  app.setSubmitMessage((message) => {
6433
- this.submitChatMessage(app, tui, message);
8840
+ this.startBackgroundTask(app, tui, "Chat", () => this.submitChatMessage(app, tui, message));
6434
8841
  });
6435
8842
  app.setSubmitCommand((command) => {
6436
- this.submitSlashCommand(app, tui, command);
8843
+ this.startBackgroundTask(app, tui, "Command", () => this.submitSlashCommand(app, tui, command));
6437
8844
  });
6438
8845
  tui.addChild(app);
6439
8846
  tui.setFocus(app);
@@ -6450,7 +8857,15 @@ var TopchesterTuiShell = class {
6450
8857
  }
6451
8858
  }));
6452
8859
  tui.start();
6453
- this.checkAgent(app, tui);
8860
+ this.startBackgroundTask(app, tui, "Agent check", () => this.checkAgent(app, tui));
8861
+ }
8862
+ startBackgroundTask(app, tui, label, task) {
8863
+ task().catch((error) => {
8864
+ app.addMessage(systemMessage(`${label} failed: ${formatPlainError(error)}`));
8865
+ app.setStatus("ready");
8866
+ app.setCancelPending(void 0);
8867
+ tui.requestRender();
8868
+ });
6454
8869
  }
6455
8870
  async checkAgent(app, tui) {
6456
8871
  const busy = new BusyIndicator(app, tui, {
@@ -6471,13 +8886,13 @@ var TopchesterTuiShell = class {
6471
8886
  busy.start();
6472
8887
  tui.requestRender();
6473
8888
  try {
6474
- await this.applyRuntimeEvents(app, await this.runtime.checkAgent(abortController.signal));
8889
+ await this.applyRuntimeEvents(app, await this.runtime.checkAgent(abortController.signal), tui);
6475
8890
  } catch (error) {
6476
8891
  if (cancelled) {
6477
8892
  app.addMessage(systemMessage("Agent check stopped."));
6478
8893
  app.setStatus("ready");
6479
8894
  } else {
6480
- const message = error instanceof Error ? error.message : String(error);
8895
+ const message = formatPlainError(error);
6481
8896
  app.addMessage(systemMessage(`Agent check failed: ${message}`));
6482
8897
  app.setStatus("agent check failed");
6483
8898
  }
@@ -6485,7 +8900,7 @@ var TopchesterTuiShell = class {
6485
8900
  app.setCancelPending(void 0);
6486
8901
  busy.stop();
6487
8902
  }
6488
- if (app.isReady()) await this.applyRuntimeEvents(app, await this.runtime.checkKnowledgeBase());
8903
+ if (app.isReady()) await this.applyRuntimeEvents(app, await this.runtime.checkKnowledgeBase(), tui);
6489
8904
  tui.requestRender();
6490
8905
  }
6491
8906
  async submitChatMessage(app, tui, message) {
@@ -6499,6 +8914,7 @@ var TopchesterTuiShell = class {
6499
8914
  ]
6500
8915
  });
6501
8916
  const abortController = new AbortController();
8917
+ const reasoningDisplay = isStreamReasoningEnabledByEnv() ? createBusyReasoningSink(busy) : void 0;
6502
8918
  let cancelled = false;
6503
8919
  app.setCancelPending(() => {
6504
8920
  cancelled = true;
@@ -6507,19 +8923,29 @@ var TopchesterTuiShell = class {
6507
8923
  busy.start();
6508
8924
  tui.requestRender();
6509
8925
  try {
8926
+ await this.clearTaskPlanForNewTurn(app);
6510
8927
  await this.persistPayloadWithWarning(app, {
6511
8928
  kind: "message",
6512
8929
  role: "user",
6513
8930
  text: message
6514
8931
  });
6515
- await this.applyRuntimeEvents(app, await this.runtime.submitMessage(app.getConversationTurns(), message, abortController.signal));
8932
+ for await (const event of this.runtime.submitMessageStream(app.getConversationTurns(), message, abortController.signal, {
8933
+ onReasoning: reasoningDisplay?.sink,
8934
+ session: this.session
8935
+ })) {
8936
+ if (event.type === "message" && event.role === "assistant") {
8937
+ reasoningDisplay?.commit(app);
8938
+ busy.clearActivity();
8939
+ }
8940
+ await this.applyRuntimeEvents(app, [event], tui);
8941
+ tui.requestRender();
8942
+ }
6516
8943
  } catch (error) {
6517
8944
  if (cancelled) {
6518
8945
  app.addMessage(systemMessage("Response stopped."));
6519
8946
  app.setStatus("ready");
6520
8947
  } else {
6521
- const errorMessage = error instanceof Error ? error.message : String(error);
6522
- app.addMessage(systemMessage(`Chat failed: ${errorMessage}`));
8948
+ app.addMessage(systemMessage(`Chat failed: ${formatPlainError(error)}`));
6523
8949
  app.setStatus("chat failed");
6524
8950
  await this.persistPayloadWithWarning(app, {
6525
8951
  kind: "status",
@@ -6542,13 +8968,13 @@ var TopchesterTuiShell = class {
6542
8968
  busy.start();
6543
8969
  tui.requestRender();
6544
8970
  try {
8971
+ await this.clearTaskPlanForNewTurn(app);
6545
8972
  await this.persistPayloadWithWarning(app, slashCommandToSessionPayload(command));
6546
8973
  await this.applyRuntimeEvents(app, await this.runtime.submitSlashCommand(command, (event) => {
6547
8974
  busy.setActivity(event.message);
6548
- }));
8975
+ }), tui);
6549
8976
  } catch (error) {
6550
- const errorMessage = error instanceof Error ? error.message : String(error);
6551
- app.addMessage(systemMessage(`Command failed: ${errorMessage}`));
8977
+ app.addMessage(systemMessage(`Command failed: ${formatPlainError(error)}`));
6552
8978
  app.setStatus("command failed");
6553
8979
  await this.persistPayloadWithWarning(app, {
6554
8980
  kind: "status",
@@ -6559,14 +8985,41 @@ var TopchesterTuiShell = class {
6559
8985
  tui.requestRender();
6560
8986
  }
6561
8987
  }
6562
- async applyRuntimeEvents(app, events) {
8988
+ async applyRuntimeEvents(app, events, renderRequester) {
6563
8989
  for (const event of events) {
6564
8990
  if (event.type === "status") app.setStatus(event.status);
6565
8991
  if (event.type === "knowledge_status") app.setKnowledgeStatus(event.status);
8992
+ if (event.type === "task_plan") {
8993
+ const change = app.setTaskPlan(event.plan);
8994
+ app.setTaskPlanNotice(formatTaskPlanNotice(change, event.plan));
8995
+ this.scheduleTaskPlanNoticeClear(app, renderRequester);
8996
+ }
6566
8997
  for (const message of renderRuntimeEvent(event)) app.addMessage(message);
6567
8998
  await this.persistPayloadWithWarning(app, runtimeEventToSessionPayload(event));
6568
8999
  }
6569
9000
  }
9001
+ scheduleTaskPlanNoticeClear(app, renderRequester) {
9002
+ if (this.taskPlanNoticeTimer) {
9003
+ clearTimeout(this.taskPlanNoticeTimer);
9004
+ this.taskPlanNoticeTimer = void 0;
9005
+ }
9006
+ if (!renderRequester) return;
9007
+ this.taskPlanNoticeTimer = setTimeout(() => {
9008
+ this.taskPlanNoticeTimer = void 0;
9009
+ app.setTaskPlanNotice(void 0);
9010
+ renderRequester.requestRender();
9011
+ }, 2500);
9012
+ this.taskPlanNoticeTimer.unref?.();
9013
+ }
9014
+ async clearTaskPlanForNewTurn(app) {
9015
+ const clearedPlan = app.clearTaskPlan();
9016
+ if (!clearedPlan) return;
9017
+ await this.persistPayloadWithWarning(app, {
9018
+ kind: "task_plan",
9019
+ items: clearedPlan.items,
9020
+ updatedAt: clearedPlan.updatedAt
9021
+ });
9022
+ }
6570
9023
  async persistPayloadWithWarning(app, payload) {
6571
9024
  if (!this.session || !payload) return;
6572
9025
  try {
@@ -6600,6 +9053,8 @@ function chatMessageToSessionPayload(message) {
6600
9053
  text: message.text,
6601
9054
  ...message.meta === void 0 ? {} : { meta: message.meta }
6602
9055
  };
9056
+ if (message.kind === "thinking") return;
9057
+ if (message.kind === "subagent") return;
6603
9058
  if (message.kind === "modal") return {
6604
9059
  kind: "choice",
6605
9060
  tone: message.tone,
@@ -6613,33 +9068,6 @@ function chatMessageToSessionPayload(message) {
6613
9068
  call: message.call
6614
9069
  };
6615
9070
  }
6616
- function runtimeEventToSessionPayload(event) {
6617
- switch (event.type) {
6618
- case "message": return {
6619
- kind: "message",
6620
- role: event.role,
6621
- text: event.text,
6622
- ...event.meta === void 0 ? {} : { meta: event.meta }
6623
- };
6624
- case "tool_call": return {
6625
- kind: "tool_call",
6626
- label: event.label,
6627
- call: event.call
6628
- };
6629
- case "knowledge_status": return;
6630
- case "choice": return {
6631
- kind: "choice",
6632
- tone: event.tone,
6633
- title: event.title,
6634
- ...event.body === void 0 ? {} : { body: event.body },
6635
- actions: event.actions
6636
- };
6637
- case "status": return {
6638
- kind: "status",
6639
- status: event.status
6640
- };
6641
- }
6642
- }
6643
9071
  function slashCommandToSessionPayload(command) {
6644
9072
  return {
6645
9073
  kind: "message",
@@ -6651,8 +9079,34 @@ function slashCommandToSessionPayload(command) {
6651
9079
  }
6652
9080
  };
6653
9081
  }
9082
+ function isStreamReasoningEnabledByEnv() {
9083
+ const value = process.env.TOPCHESTER_STREAM_REASONING?.trim().toLowerCase();
9084
+ return value === "1" || value === "true" || value === "yes" || value === "on";
9085
+ }
9086
+ function createBusyReasoningSink(busy) {
9087
+ const buffer = new ReasoningTailBuffer();
9088
+ let committed = false;
9089
+ return {
9090
+ commit(app) {
9091
+ if (committed || !buffer.hasText) return;
9092
+ app.addMessage(thinkingMessage(buffer.value));
9093
+ committed = true;
9094
+ },
9095
+ async sink(event) {
9096
+ if (event.type === "clear") {
9097
+ buffer.clear();
9098
+ committed = false;
9099
+ busy.clearActivity();
9100
+ return;
9101
+ }
9102
+ const text = event.type === "summary" ? buffer.replace(event.text ?? "") : buffer.append(event.text ?? "");
9103
+ if (!text) return;
9104
+ busy.setActivity(ui.muted(text));
9105
+ }
9106
+ };
9107
+ }
6654
9108
  function formatPlainError(error) {
6655
- return error instanceof Error ? error.message : String(error);
9109
+ return (error instanceof Error ? error.message : String(error)).split(/\r?\n/u).map((line) => line.trim()).find((line) => line.length > 0) ?? "Unknown error";
6656
9110
  }
6657
9111
  function printExitBanner(sessionId, durationMs) {
6658
9112
  console.log("");
@@ -6796,8 +9250,8 @@ async function executeRunCommand(context, options) {
6796
9250
  text: options.prompt,
6797
9251
  inputType: "prompt"
6798
9252
  });
6799
- await applyRuntimeEvents({
6800
- events: await runtime.submitMessage(conversation, options.prompt, abortController.signal),
9253
+ for await (const event of runtime.submitMessageStream(conversation, options.prompt, abortController.signal, { session })) await applyRuntimeEvent({
9254
+ event,
6801
9255
  session,
6802
9256
  jsonEvents,
6803
9257
  runId,
@@ -6846,7 +9300,9 @@ async function loadConversation(workspaceRoot, resume) {
6846
9300
  text: message.text
6847
9301
  }];
6848
9302
  case "system":
9303
+ case "thinking":
6849
9304
  case "tool_call":
9305
+ case "subagent":
6850
9306
  case "modal": return [];
6851
9307
  }
6852
9308
  });
@@ -6858,12 +9314,16 @@ async function persistStartupMessages(session, context) {
6858
9314
  }
6859
9315
  }
6860
9316
  async function applyRuntimeEvents(options) {
6861
- for (const event of options.events) {
6862
- const payload = runtimeEventToSessionPayload(event);
6863
- if (payload) await options.session.append(payload);
6864
- pushJson(options.jsonEvents, options.runId, options.session.sessionId, event.type, { event });
6865
- if (options.plain) printPlainEvent(event);
6866
- }
9317
+ for (const event of options.events) await applyRuntimeEvent({
9318
+ ...options,
9319
+ event
9320
+ });
9321
+ }
9322
+ async function applyRuntimeEvent(options) {
9323
+ const payload = runtimeEventToSessionPayload(options.event);
9324
+ if (payload) await options.session.append(payload);
9325
+ pushJson(options.jsonEvents, options.runId, options.session.sessionId, options.event.type, { event: options.event });
9326
+ if (options.plain) printPlainEvent(options.event);
6867
9327
  }
6868
9328
  function printPlainEvent(event) {
6869
9329
  if (event.type === "message") {
@@ -6874,7 +9334,14 @@ function printPlainEvent(event) {
6874
9334
  console.log(event.label);
6875
9335
  return;
6876
9336
  }
6877
- if (event.type === "knowledge_status" && event.guidance) console.log(event.guidance);
9337
+ if (event.type === "knowledge_status" && event.guidance) {
9338
+ console.log(event.guidance);
9339
+ return;
9340
+ }
9341
+ if (event.type === "task_plan") {
9342
+ const notice = formatTaskPlanNotice("updated", event.plan);
9343
+ if (notice) console.log(notice);
9344
+ }
6878
9345
  }
6879
9346
  function pushJson(events, runId, sessionId, type, fields) {
6880
9347
  events.push({
@@ -6906,9 +9373,12 @@ program.action(async () => {
6906
9373
  try {
6907
9374
  if (options.resume) {
6908
9375
  const loaded = await loadSession(context.workspaceRoot, options.resume);
9376
+ const session = await loadSessionForAppend(context.workspaceRoot, loaded.sessionId);
9377
+ const rehydrated = rehydrateSession(loaded.events);
6909
9378
  await new TopchesterTuiShell(context, void 0, {
6910
- session: await loadSessionForAppend(context.workspaceRoot, loaded.sessionId),
6911
- initialMessages: rehydrateSession(loaded.events).messages
9379
+ session,
9380
+ initialMessages: rehydrated.messages,
9381
+ initialTaskPlan: rehydrated.taskPlan
6912
9382
  }).render();
6913
9383
  return;
6914
9384
  }
@@ -6940,6 +9410,9 @@ program.command("run").description("run one prompt or slash command without open
6940
9410
  process.exitCode = 1;
6941
9411
  }
6942
9412
  });
9413
+ program.command("search").description("search compiled L1 knowledge entries").argument("<query...>", "search query").option("--limit <count>", "maximum number of matches", parsePositiveInteger).option("--json", "write full JSON search result to stdout").action(async (queryParts, options) => {
9414
+ await executeKbSearchCommand(queryParts, options);
9415
+ });
6943
9416
  const kbCommand = program.command("kb").description("knowledge base commands");
6944
9417
  kbCommand.command("init").description("initialize a project knowledge base").action(async () => {
6945
9418
  const context = createContextFromOptions();
@@ -6973,6 +9446,12 @@ kbCommand.command("sync").description("sync non-clean project files into the kno
6973
9446
  console.log(formatKnowledgeSyncResult(result).join("\n"));
6974
9447
  if (isPartialKnowledgeCompileResult(result)) process.exitCode = 2;
6975
9448
  });
9449
+ kbCommand.command("search").alias("query").description("search compiled L1 knowledge entries").argument("<query...>", "search query").option("--limit <count>", "maximum number of matches", parsePositiveInteger).option("--json", "write full JSON search result to stdout").action(async (queryParts, options) => {
9450
+ await executeKbSearchCommand(queryParts, options);
9451
+ });
9452
+ kbCommand.command("context").description("create an L1 context pack for a query").argument("<query...>", "context query").option("--limit <count>", "maximum number of relevant files", parsePositiveInteger).option("--min-score <score>", "minimum match score", parseNonNegativeNumber).option("--json", "write JSON context pack to stdout").option("--full-l1", "include full raw L1 entries in JSON output").action(async (queryParts, options) => {
9453
+ await executeKbContextCommand(queryParts, options);
9454
+ });
6976
9455
  kbCommand.command("reset").description("delete the local project knowledge base and cache").action(async () => {
6977
9456
  const context = createContextFromOptions();
6978
9457
  const result = await ui.progress("Resetting project knowledge base...", (report) => resetKnowledgeBase(context.workspaceRoot, { onProgress: (event) => report(event.message) }));
@@ -7019,6 +9498,31 @@ function createContextFromOptions() {
7019
9498
  devFlags: options.dev
7020
9499
  });
7021
9500
  }
9501
+ async function executeKbSearchCommand(queryParts, options) {
9502
+ const context = createContextFromOptions();
9503
+ const query = queryParts.join(" ");
9504
+ const result = options.json ? await searchL1Knowledge(context.workspaceRoot, query, { limit: options.limit }) : await ui.spinner("Searching L1 knowledge entries...", () => searchL1Knowledge(context.workspaceRoot, query, { limit: options.limit }));
9505
+ if (options.json) {
9506
+ console.log(JSON.stringify(stripEmptyContainers(result), null, 2));
9507
+ return;
9508
+ }
9509
+ console.log(formatL1KnowledgeSearchResult(result).join("\n"));
9510
+ }
9511
+ async function executeKbContextCommand(queryParts, options) {
9512
+ const context = createContextFromOptions();
9513
+ const query = queryParts.join(" ");
9514
+ const contextPackOptions = {
9515
+ limit: options.limit,
9516
+ minScore: options.minScore,
9517
+ includeFullL1: options.fullL1
9518
+ };
9519
+ const result = options.json ? await createL1ContextPack(context.workspaceRoot, query, contextPackOptions) : await ui.spinner("Creating L1 context pack...", () => createL1ContextPack(context.workspaceRoot, query, contextPackOptions));
9520
+ if (options.json) {
9521
+ console.log(JSON.stringify(stripEmptyContainers(result), null, 2));
9522
+ return;
9523
+ }
9524
+ console.log(formatL1ContextPackResult(result).join("\n"));
9525
+ }
7022
9526
  function formatStartupError(error) {
7023
9527
  const message = error instanceof Error ? error.message : String(error);
7024
9528
  if (message.includes("Could not read session metadata") || message.includes("Could not read session event") || message.includes("ENOENT")) return message.includes("metadata.json") || message.includes("events.jsonl") ? message : "Session not found";
@@ -7032,6 +9536,11 @@ function parsePositiveInteger(value) {
7032
9536
  if (!Number.isInteger(parsed) || parsed <= 0) throw new Error("Expected a positive integer.");
7033
9537
  return parsed;
7034
9538
  }
9539
+ function parseNonNegativeNumber(value) {
9540
+ const parsed = Number(value);
9541
+ if (!Number.isFinite(parsed) || parsed < 0) throw new Error("Expected a non-negative number.");
9542
+ return parsed;
9543
+ }
7035
9544
  function formatDryRunSyncStatus(status) {
7036
9545
  if (status === "current") return ui.ok(status);
7037
9546
  if (status === "invalid" || status === "missing_file") return ui.error(status);