@oh-my-pi/pi-coding-agent 3.21.0 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/docs/sdk.md +47 -50
  3. package/examples/custom-tools/README.md +0 -15
  4. package/examples/hooks/custom-compaction.ts +1 -3
  5. package/examples/sdk/README.md +6 -10
  6. package/package.json +5 -5
  7. package/src/cli/args.ts +9 -6
  8. package/src/core/agent-session.ts +3 -3
  9. package/src/core/custom-commands/bundled/wt/index.ts +3 -0
  10. package/src/core/custom-tools/wrapper.ts +0 -1
  11. package/src/core/extensions/index.ts +1 -6
  12. package/src/core/extensions/wrapper.ts +0 -7
  13. package/src/core/file-mentions.ts +5 -8
  14. package/src/core/sdk.ts +48 -111
  15. package/src/core/session-manager.ts +7 -0
  16. package/src/core/system-prompt.ts +22 -33
  17. package/src/core/tools/ask.ts +14 -7
  18. package/src/core/tools/bash-interceptor.ts +4 -4
  19. package/src/core/tools/bash.ts +19 -9
  20. package/src/core/tools/complete.ts +131 -0
  21. package/src/core/tools/context.ts +7 -0
  22. package/src/core/tools/edit.ts +8 -15
  23. package/src/core/tools/exa/render.ts +4 -16
  24. package/src/core/tools/find.ts +7 -18
  25. package/src/core/tools/git.ts +13 -3
  26. package/src/core/tools/grep.ts +7 -18
  27. package/src/core/tools/index.test.ts +188 -0
  28. package/src/core/tools/index.ts +106 -236
  29. package/src/core/tools/jtd-to-json-schema.ts +274 -0
  30. package/src/core/tools/ls.ts +4 -9
  31. package/src/core/tools/lsp/index.ts +32 -29
  32. package/src/core/tools/lsp/render.ts +7 -28
  33. package/src/core/tools/notebook.ts +3 -5
  34. package/src/core/tools/output.ts +130 -31
  35. package/src/core/tools/read.ts +8 -19
  36. package/src/core/tools/review.ts +0 -18
  37. package/src/core/tools/rulebook.ts +8 -2
  38. package/src/core/tools/task/agents.ts +28 -7
  39. package/src/core/tools/task/artifacts.ts +6 -9
  40. package/src/core/tools/task/discovery.ts +0 -6
  41. package/src/core/tools/task/executor.ts +306 -257
  42. package/src/core/tools/task/index.ts +65 -235
  43. package/src/core/tools/task/name-generator.ts +247 -0
  44. package/src/core/tools/task/render.ts +158 -19
  45. package/src/core/tools/task/types.ts +13 -11
  46. package/src/core/tools/task/worker-protocol.ts +18 -0
  47. package/src/core/tools/task/worker.ts +270 -0
  48. package/src/core/tools/web-fetch.ts +4 -36
  49. package/src/core/tools/web-search/index.ts +2 -1
  50. package/src/core/tools/web-search/render.ts +1 -4
  51. package/src/core/tools/write.ts +7 -15
  52. package/src/discovery/helpers.test.ts +1 -1
  53. package/src/index.ts +5 -16
  54. package/src/main.ts +4 -4
  55. package/src/modes/interactive/theme/theme.ts +4 -4
  56. package/src/prompts/task.md +14 -57
  57. package/src/prompts/tools/output.md +4 -3
  58. package/src/prompts/tools/task.md +70 -0
  59. package/examples/custom-tools/question/index.ts +0 -84
  60. package/examples/custom-tools/subagent/README.md +0 -172
  61. package/examples/custom-tools/subagent/agents/planner.md +0 -37
  62. package/examples/custom-tools/subagent/agents/scout.md +0 -50
  63. package/examples/custom-tools/subagent/agents/worker.md +0 -24
  64. package/examples/custom-tools/subagent/agents.ts +0 -156
  65. package/examples/custom-tools/subagent/commands/implement-and-review.md +0 -10
  66. package/examples/custom-tools/subagent/commands/implement.md +0 -10
  67. package/examples/custom-tools/subagent/commands/scout-and-plan.md +0 -9
  68. package/examples/custom-tools/subagent/index.ts +0 -1002
  69. package/examples/sdk/05-tools.ts +0 -94
  70. package/examples/sdk/12-full-control.ts +0 -95
  71. package/src/prompts/browser.md +0 -71
@@ -7,6 +7,7 @@ import { Type } from "@sinclair/typebox";
7
7
  import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
8
8
  import type { RenderResultOptions } from "../custom-tools/types";
9
9
  import { untilAborted } from "../utils";
10
+ import type { ToolSession } from "./index";
10
11
  import { resolveToCwd } from "./path-utils";
11
12
  import {
12
13
  formatAge,
@@ -37,7 +38,7 @@ export interface LsToolDetails {
37
38
  entryLimitReached?: number;
38
39
  }
39
40
 
40
- export function createLsTool(cwd: string): AgentTool<typeof lsSchema> {
41
+ export function createLsTool(session: ToolSession): AgentTool<typeof lsSchema> {
41
42
  return {
42
43
  name: "ls",
43
44
  label: "Ls",
@@ -49,7 +50,7 @@ export function createLsTool(cwd: string): AgentTool<typeof lsSchema> {
49
50
  signal?: AbortSignal,
50
51
  ) => {
51
52
  return untilAborted(signal, async () => {
52
- const dirPath = resolveToCwd(path || ".", cwd);
53
+ const dirPath = resolveToCwd(path || ".", session.cwd);
53
54
  const effectiveLimit = limit ?? DEFAULT_LIMIT;
54
55
 
55
56
  // Check if path exists
@@ -159,9 +160,6 @@ export function createLsTool(cwd: string): AgentTool<typeof lsSchema> {
159
160
  };
160
161
  }
161
162
 
162
- /** Default ls tool using process.cwd() - for backwards compatibility */
163
- export const lsTool = createLsTool(process.cwd());
164
-
165
163
  // =============================================================================
166
164
  // TUI Renderer
167
165
  // =============================================================================
@@ -271,10 +269,7 @@ export const lsToolRenderer = {
271
269
  }
272
270
 
273
271
  if (hasTruncation) {
274
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
275
- "warning",
276
- `truncated: ${truncationReasons.join(", ")}`,
277
- )}`;
272
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)}`;
278
273
  }
279
274
 
280
275
  return new Text(text, 0, 0);
@@ -7,6 +7,7 @@ import { type Theme, theme } from "../../../modes/interactive/theme/theme";
7
7
  import lspDescription from "../../../prompts/tools/lsp.md" with { type: "text" };
8
8
  import { logger } from "../../logger";
9
9
  import { once, untilAborted } from "../../utils";
10
+ import type { ToolSession } from "../index";
10
11
  import { resolveToCwd } from "../path-utils";
11
12
  import {
12
13
  ensureFileOpen,
@@ -687,7 +688,7 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
687
688
  }
688
689
 
689
690
  /** Create an LSP tool */
690
- export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolDetails, Theme> {
691
+ export function createLspTool(session: ToolSession): AgentTool<typeof lspSchema, LspToolDetails, Theme> {
691
692
  return {
692
693
  name: "lsp",
693
694
  label: "LSP",
@@ -713,7 +714,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
713
714
  include_declaration,
714
715
  } = params;
715
716
 
716
- const config = await getConfig(cwd);
717
+ const config = await getConfig(session.cwd);
717
718
 
718
719
  // Status action doesn't need a file
719
720
  if (action === "status") {
@@ -730,7 +731,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
730
731
 
731
732
  // Workspace diagnostics - check entire project
732
733
  if (action === "workspace_diagnostics") {
733
- const result = await runWorkspaceDiagnostics(cwd, config);
734
+ const result = await runWorkspaceDiagnostics(session.cwd, config);
734
735
  return {
735
736
  content: [
736
737
  {
@@ -757,7 +758,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
757
758
  const allServerNames = new Set<string>();
758
759
 
759
760
  for (const target of targets) {
760
- const resolved = resolveToCwd(target, cwd);
761
+ const resolved = resolveToCwd(target, session.cwd);
761
762
  const servers = getServersForFile(config, resolved);
762
763
  if (servers.length === 0) {
763
764
  results.push(`${theme.status.error} ${target}: No language server found`);
@@ -765,7 +766,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
765
766
  }
766
767
 
767
768
  const uri = fileToUri(resolved);
768
- const relPath = path.relative(cwd, resolved);
769
+ const relPath = path.relative(session.cwd, resolved);
769
770
  const allDiagnostics: Diagnostic[] = [];
770
771
 
771
772
  // Query all applicable servers for this file
@@ -773,12 +774,12 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
773
774
  allServerNames.add(serverName);
774
775
  try {
775
776
  if (serverConfig.createClient) {
776
- const linterClient = getLinterClient(serverName, serverConfig, cwd);
777
+ const linterClient = getLinterClient(serverName, serverConfig, session.cwd);
777
778
  const diagnostics = await linterClient.lint(resolved);
778
779
  allDiagnostics.push(...diagnostics);
779
780
  continue;
780
781
  }
781
- const client = await getOrCreateClient(serverConfig, cwd);
782
+ const client = await getOrCreateClient(serverConfig, session.cwd);
782
783
  await refreshFile(client, resolved);
783
784
  const diagnostics = await waitForDiagnostics(client, uri);
784
785
  allDiagnostics.push(...diagnostics);
@@ -847,7 +848,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
847
848
  };
848
849
  }
849
850
 
850
- const resolvedFile = file ? resolveToCwd(file, cwd) : null;
851
+ const resolvedFile = file ? resolveToCwd(file, session.cwd) : null;
851
852
  const serverInfo = resolvedFile
852
853
  ? getLspServerForFile(config, resolvedFile)
853
854
  : getServerForWorkspaceAction(config, action);
@@ -862,10 +863,10 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
862
863
  const [serverName, serverConfig] = serverInfo;
863
864
 
864
865
  try {
865
- const client = await getOrCreateClient(serverConfig, cwd);
866
+ const client = await getOrCreateClient(serverConfig, session.cwd);
866
867
  let targetFile = resolvedFile;
867
868
  if (action === "runnables" && !targetFile) {
868
- targetFile = findFileForServer(cwd, serverConfig);
869
+ targetFile = findFileForServer(session.cwd, serverConfig);
869
870
  if (!targetFile) {
870
871
  return {
871
872
  content: [{ type: "text", text: "Error: no matching files found for runnables" }],
@@ -914,7 +915,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
914
915
  output = "No definition found";
915
916
  } else {
916
917
  output = `Found ${locations.length} definition(s):\n${locations
917
- .map((loc) => ` ${formatLocation(loc, cwd)}`)
918
+ .map((loc) => ` ${formatLocation(loc, session.cwd)}`)
918
919
  .join("\n")}`;
919
920
  }
920
921
  }
@@ -931,7 +932,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
931
932
  if (!result || result.length === 0) {
932
933
  output = "No references found";
933
934
  } else {
934
- const lines = result.map((loc) => ` ${formatLocation(loc, cwd)}`);
935
+ const lines = result.map((loc) => ` ${formatLocation(loc, session.cwd)}`);
935
936
  output = `Found ${result.length} reference(s):\n${lines.join("\n")}`;
936
937
  }
937
938
  break;
@@ -964,7 +965,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
964
965
  details: { action, serverName, success: false },
965
966
  };
966
967
  } else {
967
- const relPath = path.relative(cwd, targetFile);
968
+ const relPath = path.relative(session.cwd, targetFile);
968
969
  // Check if hierarchical (DocumentSymbol) or flat (SymbolInformation)
969
970
  if ("selectionRange" in result[0]) {
970
971
  // Hierarchical
@@ -998,10 +999,8 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
998
999
  if (!result || result.length === 0) {
999
1000
  output = `No symbols matching "${query}"`;
1000
1001
  } else {
1001
- const lines = result.map((s) => formatSymbolInformation(s, cwd));
1002
- output = `Found ${result.length} symbol(s) matching "${query}":\n${lines
1003
- .map((l) => ` ${l}`)
1004
- .join("\n")}`;
1002
+ const lines = result.map((s) => formatSymbolInformation(s, session.cwd));
1003
+ output = `Found ${result.length} symbol(s) matching "${query}":\n${lines.map((l) => ` ${l}`).join("\n")}`;
1005
1004
  }
1006
1005
  break;
1007
1006
  }
@@ -1025,10 +1024,10 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
1025
1024
  } else {
1026
1025
  const shouldApply = apply !== false;
1027
1026
  if (shouldApply) {
1028
- const applied = await applyWorkspaceEdit(result, cwd);
1027
+ const applied = await applyWorkspaceEdit(result, session.cwd);
1029
1028
  output = `Applied rename:\n${applied.map((a) => ` ${a}`).join("\n")}`;
1030
1029
  } else {
1031
- const preview = formatWorkspaceEdit(result, cwd);
1030
+ const preview = formatWorkspaceEdit(result, session.cwd);
1032
1031
  output = `Rename preview:\n${preview.map((p) => ` ${p}`).join("\n")}`;
1033
1032
  }
1034
1033
  }
@@ -1114,7 +1113,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
1114
1113
  }
1115
1114
 
1116
1115
  if (isCodeAction(resolvedAction) && resolvedAction.edit) {
1117
- const applied = await applyWorkspaceEdit(resolvedAction.edit, cwd);
1116
+ const applied = await applyWorkspaceEdit(resolvedAction.edit, session.cwd);
1118
1117
  output = `Applied "${codeAction.title}":\n${applied.map((a) => ` ${a}`).join("\n")}`;
1119
1118
  } else {
1120
1119
  const commandPayload = getCommandPayload(resolvedAction);
@@ -1136,9 +1135,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
1136
1135
  }
1137
1136
  return ` [${i}] ${actionItem.title}`;
1138
1137
  });
1139
- output = `Available code actions:\n${lines.join(
1140
- "\n",
1141
- )}\n\nUse action_index parameter to apply a specific action.`;
1138
+ output = `Available code actions:\n${lines.join("\n")}\n\nUse action_index parameter to apply a specific action.`;
1142
1139
  }
1143
1140
  break;
1144
1141
  }
@@ -1169,7 +1166,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
1169
1166
  const lines = calls.map((call) => {
1170
1167
  const loc = { uri: call.from.uri, range: call.from.selectionRange };
1171
1168
  const detail = call.from.detail ? ` (${call.from.detail})` : "";
1172
- return ` ${call.from.name}${detail} @ ${formatLocation(loc, cwd)}`;
1169
+ return ` ${call.from.name}${detail} @ ${formatLocation(loc, session.cwd)}`;
1173
1170
  });
1174
1171
  output = `Found ${calls.length} caller(s) of "${item.name}":\n${lines.join("\n")}`;
1175
1172
  }
@@ -1184,7 +1181,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
1184
1181
  const lines = calls.map((call) => {
1185
1182
  const loc = { uri: call.to.uri, range: call.to.selectionRange };
1186
1183
  const detail = call.to.detail ? ` (${call.to.detail})` : "";
1187
- return ` ${call.to.name}${detail} @ ${formatLocation(loc, cwd)}`;
1184
+ return ` ${call.to.name}${detail} @ ${formatLocation(loc, session.cwd)}`;
1188
1185
  });
1189
1186
  output = `"${item.name}" calls ${calls.length} function(s):\n${lines.join("\n")}`;
1190
1187
  }
@@ -1207,7 +1204,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
1207
1204
  await rustAnalyzer.flycheck(client, resolvedFile ?? undefined);
1208
1205
  const collected: Array<{ filePath: string; diagnostic: Diagnostic }> = [];
1209
1206
  for (const [diagUri, diags] of client.diagnostics.entries()) {
1210
- const relPath = path.relative(cwd, uriToFile(diagUri));
1207
+ const relPath = path.relative(session.cwd, uriToFile(diagUri));
1211
1208
  for (const diag of diags) {
1212
1209
  collected.push({ filePath: relPath, diagnostic: diag });
1213
1210
  }
@@ -1274,13 +1271,13 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
1274
1271
  const result = await rustAnalyzer.ssr(client, query, replacement, !shouldApply);
1275
1272
 
1276
1273
  if (shouldApply) {
1277
- const applied = await applyWorkspaceEdit(result, cwd);
1274
+ const applied = await applyWorkspaceEdit(result, session.cwd);
1278
1275
  output =
1279
1276
  applied.length > 0
1280
1277
  ? `Applied SSR:\n${applied.map((a) => ` ${a}`).join("\n")}`
1281
1278
  : "SSR: no matches found";
1282
1279
  } else {
1283
- const preview = formatWorkspaceEdit(result, cwd);
1280
+ const preview = formatWorkspaceEdit(result, session.cwd);
1284
1281
  output =
1285
1282
  preview.length > 0
1286
1283
  ? `SSR preview:\n${preview.map((p) => ` ${p}`).join("\n")}`
@@ -1366,4 +1363,10 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
1366
1363
  };
1367
1364
  }
1368
1365
 
1369
- export const lspTool = createLspTool(process.cwd());
1366
+ export const lspTool = createLspTool({
1367
+ cwd: process.cwd(),
1368
+ hasUI: false,
1369
+ rulebookRules: [],
1370
+ getSessionFile: () => null,
1371
+ getSessionSpawns: () => null,
1372
+ });
@@ -237,10 +237,7 @@ function renderDiagnostics(
237
237
  }
238
238
  const severityColor = severityToColor(item.severity);
239
239
  const location = formatDiagnosticLocation(item.file, item.line, item.col, theme);
240
- output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)} ${theme.fg(
241
- "dim",
242
- `[${item.severity}]`,
243
- )}`;
240
+ output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)} ${theme.fg("dim", `[${item.severity}]`)}`;
244
241
  if (item.message) {
245
242
  output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg(
246
243
  "muted",
@@ -274,10 +271,7 @@ function renderDiagnostics(
274
271
  output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`;
275
272
  }
276
273
  if (remaining > 0) {
277
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
278
- "muted",
279
- `${theme.format.ellipsis} ${remaining} more`,
280
- )}`;
274
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${remaining} more`)}`;
281
275
  }
282
276
 
283
277
  return new Text(output, 0, 0);
@@ -332,10 +326,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
332
326
  const isLastLoc = li === locsToShow.length - 1 && locs.length <= maxLocsPerFile;
333
327
  const locBranch = isLastLoc ? theme.tree.last : theme.tree.branch;
334
328
  const locCont = isLastLoc ? " " : `${theme.tree.vertical} `;
335
- output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locBranch)} ${theme.fg(
336
- "muted",
337
- `line ${line}, col ${col}`,
338
- )}`;
329
+ output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locBranch)} ${theme.fg("muted", `line ${line}, col ${col}`)}`;
339
330
  if (expanded) {
340
331
  const context = `at ${file}:${line}:${col}`;
341
332
  output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locCont)}${theme.fg(
@@ -354,10 +345,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
354
345
  }
355
346
 
356
347
  if (files.length > maxFiles) {
357
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
358
- "muted",
359
- formatMoreItems(files.length - maxFiles, "file", theme),
360
- )}`;
348
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(files.length - maxFiles, "file", theme))}`;
361
349
  }
362
350
 
363
351
  return output;
@@ -463,10 +451,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
463
451
  )}`;
464
452
  }
465
453
  if (topLevelCount > 3) {
466
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
467
- "muted",
468
- `${theme.format.ellipsis} ${topLevelCount - 3} more`,
469
- )}`;
454
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `${theme.format.ellipsis} ${topLevelCount - 3} more`)}`;
470
455
  }
471
456
 
472
457
  return new Text(output, 0, 0);
@@ -502,10 +487,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
502
487
 
503
488
  const firstLine = lines[0] || "No output";
504
489
  const expandHint = formatExpandHint(false, lines.length > 1, theme);
505
- let output = `${icon} ${theme.fg(
506
- "dim",
507
- truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis),
508
- )}${expandHint}`;
490
+ let output = `${icon} ${theme.fg("dim", truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis))}${expandHint}`;
509
491
 
510
492
  if (lines.length > 1) {
511
493
  const previewLines = lines.slice(1, 4);
@@ -518,10 +500,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
518
500
  )}`;
519
501
  }
520
502
  if (lines.length > 4) {
521
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
522
- "muted",
523
- formatMoreItems(lines.length - 4, "line", theme),
524
- )}`;
503
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", formatMoreItems(lines.length - 4, "line", theme))}`;
525
504
  }
526
505
  }
527
506
 
@@ -4,6 +4,7 @@ import { Text } from "@oh-my-pi/pi-tui";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import type { Theme } from "../../modes/interactive/theme/theme";
6
6
  import type { RenderResultOptions } from "../custom-tools/types";
7
+ import type { ToolSession } from "../sdk";
7
8
  import { untilAborted } from "../utils";
8
9
  import { resolveToCwd } from "./path-utils";
9
10
  import {
@@ -61,7 +62,7 @@ function splitIntoLines(content: string): string[] {
61
62
  return content.split("\n").map((line, i, arr) => (i < arr.length - 1 ? `${line}\n` : line));
62
63
  }
63
64
 
64
- export function createNotebookTool(cwd: string): AgentTool<typeof notebookSchema> {
65
+ export function createNotebookTool(session: ToolSession): AgentTool<typeof notebookSchema> {
65
66
  return {
66
67
  name: "notebook",
67
68
  label: "Notebook",
@@ -79,7 +80,7 @@ export function createNotebookTool(cwd: string): AgentTool<typeof notebookSchema
79
80
  }: { action: string; notebook_path: string; cell_index: number; content?: string; cell_type?: string },
80
81
  signal?: AbortSignal,
81
82
  ) => {
82
- const absolutePath = resolveToCwd(notebook_path, cwd);
83
+ const absolutePath = resolveToCwd(notebook_path, session.cwd);
83
84
 
84
85
  return untilAborted(signal, async () => {
85
86
  // Check if file exists
@@ -190,9 +191,6 @@ export function createNotebookTool(cwd: string): AgentTool<typeof notebookSchema
190
191
  };
191
192
  }
192
193
 
193
- /** Default notebook tool using process.cwd() */
194
- export const notebookTool = createNotebookTool(process.cwd());
195
-
196
194
  // =============================================================================
197
195
  // TUI Renderer
198
196
  // =============================================================================
@@ -14,7 +14,7 @@ import { Type } from "@sinclair/typebox";
14
14
  import type { Theme } from "../../modes/interactive/theme/theme";
15
15
  import outputDescription from "../../prompts/tools/output.md" with { type: "text" };
16
16
  import type { RenderResultOptions } from "../custom-tools/types";
17
- import type { SessionContext } from "./index";
17
+ import type { ToolSession } from "./index";
18
18
  import {
19
19
  formatCount,
20
20
  formatEmptyMessage,
@@ -36,6 +36,11 @@ const outputSchema = Type.Object({
36
36
  description: "Output format: raw (default), json (structured), stripped (no ANSI)",
37
37
  }),
38
38
  ),
39
+ query: Type.Optional(
40
+ Type.String({
41
+ description: "jq-like query for JSON outputs (e.g., .result.items[0].name). Requires JSON output.",
42
+ }),
43
+ ),
39
44
  offset: Type.Optional(
40
45
  Type.Number({
41
46
  description: "Line number to start reading from (1-indexed)",
@@ -70,6 +75,7 @@ interface OutputEntry {
70
75
  provenance?: OutputProvenance;
71
76
  previewLines?: string[];
72
77
  range?: OutputRange;
78
+ query?: string;
73
79
  }
74
80
 
75
81
  export interface OutputToolDetails {
@@ -83,6 +89,77 @@ function stripAnsi(text: string): string {
83
89
  return text.replace(/\x1b\[[0-9;]*m/g, "");
84
90
  }
85
91
 
92
+ function parseQuery(query: string): Array<string | number> {
93
+ let input = query.trim();
94
+ if (!input) return [];
95
+ if (input.startsWith(".")) input = input.slice(1);
96
+ if (!input) return [];
97
+
98
+ const tokens: Array<string | number> = [];
99
+ let i = 0;
100
+
101
+ const isIdentChar = (ch: string) => /[A-Za-z0-9_-]/.test(ch);
102
+
103
+ while (i < input.length) {
104
+ const ch = input[i];
105
+ if (ch === ".") {
106
+ i++;
107
+ continue;
108
+ }
109
+ if (ch === "[") {
110
+ const closeIndex = input.indexOf("]", i + 1);
111
+ if (closeIndex === -1) {
112
+ throw new Error(`Invalid query: missing ] in ${query}`);
113
+ }
114
+ const raw = input.slice(i + 1, closeIndex).trim();
115
+ if (!raw) {
116
+ throw new Error(`Invalid query: empty [] in ${query}`);
117
+ }
118
+ const quote = raw[0];
119
+ if ((quote === '"' || quote === "'") && raw.endsWith(quote)) {
120
+ let inner = raw.slice(1, -1);
121
+ inner = inner.replace(/\\(["'\\])/g, "$1");
122
+ tokens.push(inner);
123
+ } else if (/^\d+$/.test(raw)) {
124
+ tokens.push(Number(raw));
125
+ } else {
126
+ tokens.push(raw);
127
+ }
128
+ i = closeIndex + 1;
129
+ continue;
130
+ }
131
+
132
+ const start = i;
133
+ while (i < input.length && isIdentChar(input[i])) {
134
+ i++;
135
+ }
136
+ if (start === i) {
137
+ throw new Error(`Invalid query: unexpected token '${input[i]}' in ${query}`);
138
+ }
139
+ const ident = input.slice(start, i);
140
+ tokens.push(ident);
141
+ }
142
+
143
+ return tokens;
144
+ }
145
+
146
+ function applyQuery(data: unknown, query: string): unknown {
147
+ const tokens = parseQuery(query);
148
+ let current: unknown = data;
149
+ for (const token of tokens) {
150
+ if (current === null || current === undefined) return undefined;
151
+ if (typeof token === "number") {
152
+ if (!Array.isArray(current)) return undefined;
153
+ current = current[token];
154
+ continue;
155
+ }
156
+ if (typeof current !== "object") return undefined;
157
+ const record = current as Record<string, unknown>;
158
+ current = record[token];
159
+ }
160
+ return current;
161
+ }
162
+
86
163
  /** List available output IDs in artifacts directory */
87
164
  function listAvailableOutputs(artifactsDir: string): string[] {
88
165
  try {
@@ -120,10 +197,7 @@ function extractPreviewLines(content: string, maxLines: number): string[] {
120
197
  return preview;
121
198
  }
122
199
 
123
- export function createOutputTool(
124
- _cwd: string,
125
- sessionContext?: SessionContext,
126
- ): AgentTool<typeof outputSchema, OutputToolDetails> {
200
+ export function createOutputTool(session: ToolSession): AgentTool<typeof outputSchema, OutputToolDetails> {
127
201
  return {
128
202
  name: "output",
129
203
  label: "Output",
@@ -131,9 +205,15 @@ export function createOutputTool(
131
205
  parameters: outputSchema,
132
206
  execute: async (
133
207
  _toolCallId: string,
134
- params: { ids: string[]; format?: "raw" | "json" | "stripped"; offset?: number; limit?: number },
208
+ params: {
209
+ ids: string[];
210
+ format?: "raw" | "json" | "stripped";
211
+ query?: string;
212
+ offset?: number;
213
+ limit?: number;
214
+ },
135
215
  ): Promise<{ content: TextContent[]; details: OutputToolDetails }> => {
136
- const sessionFile = sessionContext?.getSessionFile();
216
+ const sessionFile = session.getSessionFile();
137
217
 
138
218
  if (!sessionFile) {
139
219
  return {
@@ -153,7 +233,15 @@ export function createOutputTool(
153
233
  const outputs: OutputEntry[] = [];
154
234
  const notFound: string[] = [];
155
235
  const outputContentById = new Map<string, string>();
156
- const format = params.format ?? "raw";
236
+ const query = params.query?.trim();
237
+ const wantsQuery = query !== undefined && query.length > 0;
238
+ const format = params.format ?? (wantsQuery ? "json" : "raw");
239
+
240
+ if (wantsQuery && (params.offset !== undefined || params.limit !== undefined)) {
241
+ throw new Error("query cannot be combined with offset/limit");
242
+ }
243
+
244
+ const queryResults: Array<{ id: string; value: unknown }> = [];
157
245
 
158
246
  for (const id of params.ids) {
159
247
  const outputPath = path.join(artifactsDir, `${id}.out.md`);
@@ -171,7 +259,22 @@ export function createOutputTool(
171
259
  let selectedContent = rawContent;
172
260
  let range: OutputRange | undefined;
173
261
 
174
- if (params.offset !== undefined || params.limit !== undefined) {
262
+ if (wantsQuery && query) {
263
+ let jsonValue: unknown;
264
+ try {
265
+ jsonValue = JSON.parse(rawContent);
266
+ } catch (err) {
267
+ const message = err instanceof Error ? err.message : String(err);
268
+ throw new Error(`Output ${id} is not valid JSON: ${message}`);
269
+ }
270
+ const value = applyQuery(jsonValue, query);
271
+ queryResults.push({ id, value });
272
+ try {
273
+ selectedContent = JSON.stringify(value, null, 2) ?? "null";
274
+ } catch {
275
+ selectedContent = String(value);
276
+ }
277
+ } else if (params.offset !== undefined || params.limit !== undefined) {
175
278
  const startLine = Math.max(1, params.offset ?? 1);
176
279
  if (startLine > totalLines) {
177
280
  throw new Error(
@@ -189,11 +292,12 @@ export function createOutputTool(
189
292
  outputs.push({
190
293
  id,
191
294
  path: outputPath,
192
- lineCount: totalLines,
193
- charCount: totalChars,
295
+ lineCount: wantsQuery ? selectedContent.split("\n").length : totalLines,
296
+ charCount: wantsQuery ? selectedContent.length : totalChars,
194
297
  provenance: parseOutputProvenance(id),
195
298
  previewLines: extractPreviewLines(selectedContent, 4),
196
299
  range,
300
+ query: query,
197
301
  });
198
302
  }
199
303
 
@@ -215,15 +319,17 @@ export function createOutputTool(
215
319
  let contentText: string;
216
320
 
217
321
  if (format === "json") {
218
- const jsonData = outputs.map((o) => ({
219
- id: o.id,
220
- lineCount: o.lineCount,
221
- charCount: o.charCount,
222
- provenance: o.provenance,
223
- previewLines: o.previewLines,
224
- range: o.range,
225
- content: outputContentById.get(o.id) ?? "",
226
- }));
322
+ const jsonData = wantsQuery
323
+ ? queryResults
324
+ : outputs.map((o) => ({
325
+ id: o.id,
326
+ lineCount: o.lineCount,
327
+ charCount: o.charCount,
328
+ provenance: o.provenance,
329
+ previewLines: o.previewLines,
330
+ range: o.range,
331
+ content: outputContentById.get(o.id) ?? "",
332
+ }));
227
333
  contentText = JSON.stringify(jsonData, null, 2);
228
334
  } else {
229
335
  // raw or stripped
@@ -253,9 +359,6 @@ export function createOutputTool(
253
359
  };
254
360
  }
255
361
 
256
- /** Default output tool using process.cwd() - for backwards compatibility */
257
- export const outputTool = createOutputTool(process.cwd());
258
-
259
362
  // =============================================================================
260
363
  // TUI Renderer
261
364
  // =============================================================================
@@ -263,6 +366,7 @@ export const outputTool = createOutputTool(process.cwd());
263
366
  interface OutputRenderArgs {
264
367
  ids: string[];
265
368
  format?: "raw" | "json" | "stripped";
369
+ query?: string;
266
370
  offset?: number;
267
371
  limit?: number;
268
372
  }
@@ -291,6 +395,7 @@ export const outputToolRenderer = {
291
395
 
292
396
  const meta: string[] = [];
293
397
  if (args.format && args.format !== "raw") meta.push(`format:${args.format}`);
398
+ if (args.query) meta.push(`query:${args.query}`);
294
399
  if (args.offset !== undefined) meta.push(`offset:${args.offset}`);
295
400
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
296
401
  text += formatMeta(meta, uiTheme);
@@ -309,15 +414,9 @@ export const outputToolRenderer = {
309
414
  const icon = uiTheme.styledSymbol("status.error", "error");
310
415
  let text = `${icon} ${uiTheme.fg("error", `Error: Not found: ${details.notFound.join(", ")}`)}`;
311
416
  if (details.availableIds?.length) {
312
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
313
- "muted",
314
- `Available: ${details.availableIds.join(", ")}`,
315
- )}`;
417
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", `Available: ${details.availableIds.join(", ")}`)}`;
316
418
  } else {
317
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
318
- "muted",
319
- "No outputs available in current session",
320
- )}`;
419
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No outputs available in current session")}`;
321
420
  }
322
421
  return new Text(text, 0, 0);
323
422
  }