@oh-my-pi/pi-coding-agent 14.5.8 → 14.5.9

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 (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +7 -7
  3. package/src/config/settings-schema.ts +3 -3
  4. package/src/edit/modes/atom.lark +7 -5
  5. package/src/edit/modes/atom.ts +462 -56
  6. package/src/edit/modes/hashline.ts +21 -1
  7. package/src/lsp/index.ts +2 -4
  8. package/src/lsp/render.ts +0 -3
  9. package/src/lsp/types.ts +1 -4
  10. package/src/lsp/utils.ts +18 -14
  11. package/src/modes/controllers/command-controller.ts +17 -0
  12. package/src/modes/controllers/input-controller.ts +7 -1
  13. package/src/modes/interactive-mode.ts +30 -23
  14. package/src/modes/types.ts +4 -2
  15. package/src/modes/utils/context-usage.ts +294 -0
  16. package/src/prompts/tools/atom.md +99 -44
  17. package/src/prompts/tools/exit-plan-mode.md +5 -39
  18. package/src/prompts/tools/lsp.md +2 -3
  19. package/src/prompts/tools/{run-command.md → recipe.md} +1 -1
  20. package/src/prompts/tools/task.md +34 -147
  21. package/src/prompts/tools/todo-write.md +22 -64
  22. package/src/session/compaction/compaction.ts +35 -22
  23. package/src/session/session-dump-format.ts +1 -0
  24. package/src/slash-commands/builtin-registry.ts +12 -5
  25. package/src/tools/debug.ts +57 -70
  26. package/src/tools/index.ts +7 -7
  27. package/src/tools/{run-command → recipe}/index.ts +19 -19
  28. package/src/tools/recipe/render.ts +19 -0
  29. package/src/tools/{run-command → recipe}/runner.ts +28 -7
  30. package/src/tools/{run-command → recipe}/runners/pkg.ts +23 -53
  31. package/src/tools/renderers.ts +2 -2
  32. package/src/tools/run-command/render.ts +0 -18
  33. /package/src/tools/{run-command → recipe}/runners/cargo.ts +0 -0
  34. /package/src/tools/{run-command → recipe}/runners/index.ts +0 -0
  35. /package/src/tools/{run-command → recipe}/runners/just.ts +0 -0
  36. /package/src/tools/{run-command → recipe}/runners/make.ts +0 -0
  37. /package/src/tools/{run-command → recipe}/runners/task.ts +0 -0
@@ -26,6 +26,7 @@ import {
26
26
  getOpenAIResponsesHistoryPayload,
27
27
  normalizeResponsesToolCallId,
28
28
  } from "@oh-my-pi/pi-ai/utils";
29
+ import { countTokens } from "@oh-my-pi/pi-natives";
29
30
  import { logger, prompt } from "@oh-my-pi/pi-utils";
30
31
  import compactionShortSummaryPrompt from "../../prompts/compaction/compaction-short-summary.md" with { type: "text" };
31
32
  import compactionSummaryPrompt from "../../prompts/compaction/compaction-summary.md" with { type: "text" };
@@ -218,7 +219,7 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett
218
219
  return contextTokens > thresholdTokens;
219
220
  }
220
221
 
221
- function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
222
+ export function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
222
223
  // Fixed token limit takes priority over percentage
223
224
  const thresholdTokens = settings.thresholdTokens;
224
225
  if (typeof thresholdTokens === "number" && Number.isFinite(thresholdTokens) && thresholdTokens > 0) {
@@ -240,67 +241,79 @@ function resolveThresholdTokens(contextWindow: number, settings: CompactionSetti
240
241
  // ============================================================================
241
242
 
242
243
  /**
243
- * Estimate token count for a message using chars/4 heuristic.
244
- * This is conservative (overestimates tokens).
244
+ * Image content has no tokenizer representation; charge a fixed estimate
245
+ * matching what providers typically bill for inline images.
246
+ */
247
+ const IMAGE_TOKEN_ESTIMATE = 1200;
248
+
249
+ /**
250
+ * Estimate token count for a message using cl100k_base via the native
251
+ * tokenizer. This is not Claude's first-party tokenizer (Anthropic doesn't
252
+ * publish one) but is within ~5–10% across English/code text.
245
253
  */
246
254
  export function estimateTokens(message: AgentMessage): number {
247
- let chars = 0;
255
+ const fragments: string[] = [];
256
+ let extra = 0;
248
257
 
249
258
  switch (message.role) {
250
259
  case "user": {
251
260
  const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
252
261
  if (typeof content === "string") {
253
- chars = content.length;
262
+ fragments.push(content);
254
263
  } else if (Array.isArray(content)) {
255
264
  for (const block of content) {
256
265
  if (block.type === "text" && block.text) {
257
- chars += block.text.length;
266
+ fragments.push(block.text);
258
267
  }
259
268
  }
260
269
  }
261
- return Math.ceil(chars / 4);
270
+ break;
262
271
  }
263
272
  case "assistant": {
264
273
  const assistant = message as AssistantMessage;
265
274
  for (const block of assistant.content) {
266
275
  if (block.type === "text") {
267
- chars += block.text.length;
276
+ fragments.push(block.text);
268
277
  } else if (block.type === "thinking") {
269
- chars += block.thinking.length;
278
+ fragments.push(block.thinking);
270
279
  } else if (block.type === "toolCall") {
271
- chars += block.name.length + JSON.stringify(block.arguments).length;
280
+ fragments.push(block.name);
281
+ fragments.push(JSON.stringify(block.arguments));
272
282
  }
273
283
  }
274
- return Math.ceil(chars / 4);
284
+ break;
275
285
  }
276
286
  case "hookMessage":
277
287
  case "toolResult": {
278
288
  if (typeof message.content === "string") {
279
- chars = message.content.length;
289
+ fragments.push(message.content);
280
290
  } else {
281
291
  for (const block of message.content) {
282
292
  if (block.type === "text" && block.text) {
283
- chars += block.text.length;
284
- }
285
- if (block.type === "image") {
286
- chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
293
+ fragments.push(block.text);
294
+ } else if (block.type === "image") {
295
+ extra += IMAGE_TOKEN_ESTIMATE;
287
296
  }
288
297
  }
289
298
  }
290
- return Math.ceil(chars / 4);
299
+ break;
291
300
  }
292
301
  case "bashExecution": {
293
- chars = message.command.length + message.output.length;
294
- return Math.ceil(chars / 4);
302
+ fragments.push(message.command);
303
+ fragments.push(message.output);
304
+ break;
295
305
  }
296
306
  case "branchSummary":
297
307
  case "compactionSummary": {
298
- chars = message.summary.length;
299
- return Math.ceil(chars / 4);
308
+ fragments.push(message.summary);
309
+ break;
300
310
  }
311
+ default:
312
+ return 0;
301
313
  }
302
314
 
303
- return 0;
315
+ if (fragments.length === 0) return extra;
316
+ return extra + countTokens(fragments);
304
317
  }
305
318
 
306
319
  function estimateEntriesTokens(entries: SessionEntry[], startIndex: number, endIndex: number): number {
@@ -114,6 +114,7 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
114
114
  if (c.type === "text") {
115
115
  lines.push(c.text);
116
116
  } else if (c.type === "thinking") {
117
+ if (c.thinking.trim().length === 0) continue;
117
118
  lines.push("<thinking>");
118
119
  lines.push(c.thinking);
119
120
  lines.push("</thinking>\n");
@@ -123,11 +123,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
123
123
  },
124
124
  {
125
125
  name: "loop",
126
- description: "Loop the agent: re-submit the same prompt every time it yields (Esc to stop)",
127
- inlineHint: "<prompt>",
128
- allowArgs: true,
129
- handle: async (command, runtime) => {
130
- await runtime.ctx.handleLoopCommand(command.args || undefined);
126
+ description:
127
+ "Toggle loop mode. While enabled, the next prompt you send re-submits after every yield. Esc cancels the current iteration; /loop again to disable.",
128
+ handle: async (_command, runtime) => {
129
+ await runtime.ctx.handleLoopCommand();
131
130
  runtime.ctx.editor.setText("");
132
131
  },
133
132
  },
@@ -356,6 +355,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
356
355
  runtime.ctx.editor.setText("");
357
356
  },
358
357
  },
358
+ {
359
+ name: "context",
360
+ description: "Show estimated context usage breakdown",
361
+ handle: (_command, runtime) => {
362
+ runtime.ctx.handleContextCommand();
363
+ runtime.ctx.editor.setText("");
364
+ },
365
+ },
359
366
  {
360
367
  name: "extensions",
361
368
  aliases: ["status"],
@@ -52,85 +52,72 @@ import { toolResult } from "./tool-result";
52
52
  import { clampTimeout } from "./tool-timeouts";
53
53
 
54
54
  const debugSchema = Type.Object({
55
- action: StringEnum(
56
- [
57
- "launch",
58
- "attach",
59
- "set_breakpoint",
60
- "remove_breakpoint",
61
- "set_instruction_breakpoint",
62
- "remove_instruction_breakpoint",
63
- "data_breakpoint_info",
64
- "set_data_breakpoint",
65
- "remove_data_breakpoint",
66
- "continue",
67
- "step_over",
68
- "step_in",
69
- "step_out",
70
- "pause",
71
- "evaluate",
72
- "stack_trace",
73
- "threads",
74
- "scopes",
75
- "variables",
76
- "disassemble",
77
- "read_memory",
78
- "write_memory",
79
- "modules",
80
- "loaded_sources",
81
- "custom_request",
82
- "output",
83
- "terminate",
84
- "sessions",
85
- ],
86
- { description: "dap debugger action" },
87
- ),
88
- program: Type.Optional(Type.String({ description: "program path", examples: ["./my_app", "src/main.py"] })),
89
- args: Type.Optional(Type.Array(Type.String(), { description: "program arguments", examples: [["--verbose"]] })),
90
- adapter: Type.Optional(
91
- Type.String({ description: "debugger adapter", examples: ["gdb", "lldb-dap", "debugpy", "dlv"] }),
92
- ),
93
- cwd: Type.Optional(Type.String({ description: "working directory", examples: ["src/"] })),
94
- file: Type.Optional(Type.String({ description: "source file", examples: ["src/main.c"] })),
95
- line: Type.Optional(Type.Number({ description: "source line", examples: [42] })),
96
- function: Type.Optional(Type.String({ description: "function name", examples: ["main", "handle_request"] })),
97
- name: Type.Optional(Type.String({ description: "variable or data name", examples: ["counter", "buffer"] })),
98
- condition: Type.Optional(Type.String({ description: "breakpoint condition", examples: ["i == 10", "x > 0"] })),
99
- hit_condition: Type.Optional(Type.String({ description: "hit condition" })),
100
- expression: Type.Optional(Type.String({ description: "expression to evaluate", examples: ["x + 1", "obj.field"] })),
55
+ action: StringEnum([
56
+ "launch",
57
+ "attach",
58
+ "set_breakpoint",
59
+ "remove_breakpoint",
60
+ "set_instruction_breakpoint",
61
+ "remove_instruction_breakpoint",
62
+ "data_breakpoint_info",
63
+ "set_data_breakpoint",
64
+ "remove_data_breakpoint",
65
+ "continue",
66
+ "step_over",
67
+ "step_in",
68
+ "step_out",
69
+ "pause",
70
+ "evaluate",
71
+ "stack_trace",
72
+ "threads",
73
+ "scopes",
74
+ "variables",
75
+ "disassemble",
76
+ "read_memory",
77
+ "write_memory",
78
+ "modules",
79
+ "loaded_sources",
80
+ "custom_request",
81
+ "output",
82
+ "terminate",
83
+ "sessions",
84
+ ]),
85
+ program: Type.Optional(Type.String({ description: "program path" })),
86
+ args: Type.Optional(Type.Array(Type.String(), { description: "program arguments" })),
87
+ adapter: Type.Optional(Type.String({ description: "debugger adapter (gdb, lldb-dap, debugpy, dlv)" })),
88
+ cwd: Type.Optional(Type.String()),
89
+ file: Type.Optional(Type.String({ description: "source file" })),
90
+ line: Type.Optional(Type.Number({ description: "source line" })),
91
+ function: Type.Optional(Type.String({ description: "function name" })),
92
+ name: Type.Optional(Type.String({ description: "variable or data name" })),
93
+ condition: Type.Optional(Type.String({ description: "breakpoint condition" })),
94
+ hit_condition: Type.Optional(Type.String()),
95
+ expression: Type.Optional(Type.String({ description: "expression to evaluate" })),
101
96
  context: Type.Optional(
102
- Type.String({ description: "evaluate context", examples: ["watch", "repl", "hover", "variables", "clipboard"] }),
97
+ Type.String({ description: "evaluate context: watch | repl | hover | variables | clipboard" }),
103
98
  ),
104
- frame_id: Type.Optional(Type.Number({ description: "stack frame id" })),
99
+ frame_id: Type.Optional(Type.Number()),
105
100
  scope_id: Type.Optional(Type.Number({ description: "scope variables reference" })),
106
101
  variable_ref: Type.Optional(Type.Number({ description: "variable reference" })),
107
- pid: Type.Optional(Type.Number({ description: "process id for attach", examples: [12345] })),
108
- port: Type.Optional(Type.Number({ description: "remote attach port", examples: [4711] })),
109
- host: Type.Optional(Type.String({ description: "remote attach host", examples: ["127.0.0.1"] })),
102
+ pid: Type.Optional(Type.Number({ description: "process id for attach" })),
103
+ port: Type.Optional(Type.Number({ description: "remote attach port" })),
104
+ host: Type.Optional(Type.String({ description: "remote attach host" })),
110
105
  levels: Type.Optional(Type.Number({ description: "max stack frames" })),
111
- memory_reference: Type.Optional(
112
- Type.String({ description: "memory reference or address", examples: ["0x7ffd1234"] }),
113
- ),
114
- instruction_reference: Type.Optional(Type.String({ description: "instruction address or reference" })),
115
- instruction_count: Type.Optional(Type.Number({ description: "instructions to disassemble" })),
116
- instruction_offset: Type.Optional(Type.Number({ description: "instruction offset" })),
106
+ memory_reference: Type.Optional(Type.String({ description: "memory reference or address" })),
107
+ instruction_reference: Type.Optional(Type.String()),
108
+ instruction_count: Type.Optional(Type.Number()),
109
+ instruction_offset: Type.Optional(Type.Number()),
117
110
  count: Type.Optional(Type.Number({ description: "bytes to read" })),
118
111
  data: Type.Optional(Type.String({ description: "base64 memory payload" })),
119
112
  data_id: Type.Optional(Type.String({ description: "data breakpoint id" })),
120
- access_type: Type.Optional(
121
- StringEnum(["read", "write", "readWrite"], { description: "data breakpoint access type" }),
122
- ),
113
+ access_type: Type.Optional(StringEnum(["read", "write", "readWrite"])),
123
114
  command: Type.Optional(Type.String({ description: "custom dap request command" })),
124
- arguments: Type.Optional(
125
- Type.Record(Type.String(), Type.Any(), {
126
- description: "custom request arguments",
127
- }),
128
- ),
129
- offset: Type.Optional(Type.Number({ description: "memory or instruction offset" })),
130
- resolve_symbols: Type.Optional(Type.Boolean({ description: "resolve symbols during disassembly" })),
131
- allow_partial: Type.Optional(Type.Boolean({ description: "allow partial writes" })),
132
- start_module: Type.Optional(Type.Number({ description: "modules start index" })),
133
- module_count: Type.Optional(Type.Number({ description: "max modules to fetch" })),
115
+ arguments: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "custom request arguments" })),
116
+ offset: Type.Optional(Type.Number()),
117
+ resolve_symbols: Type.Optional(Type.Boolean()),
118
+ allow_partial: Type.Optional(Type.Boolean()),
119
+ start_module: Type.Optional(Type.Number()),
120
+ module_count: Type.Optional(Type.Number()),
134
121
  timeout: Type.Optional(Type.Number({ description: "per-request timeout seconds" })),
135
122
  });
136
123
 
@@ -37,11 +37,11 @@ import { NotebookTool } from "./notebook";
37
37
  import { wrapToolWithMetaNotice } from "./output-meta";
38
38
  import { PythonTool } from "./python";
39
39
  import { ReadTool } from "./read";
40
+ import { RecipeTool } from "./recipe";
40
41
  import { RenderMermaidTool } from "./render-mermaid";
41
42
  import { createReportToolIssueTool, isAutoQaEnabled } from "./report-tool-issue";
42
43
  import { ResolveTool } from "./resolve";
43
44
  import { reportFindingTool } from "./review";
44
- import { RunCommandTool } from "./run-command";
45
45
  import { SearchTool } from "./search";
46
46
  import { SearchToolBm25Tool } from "./search-tool-bm25";
47
47
  import { loadSshTool } from "./ssh";
@@ -76,11 +76,11 @@ export * from "./job";
76
76
  export * from "./notebook";
77
77
  export * from "./python";
78
78
  export * from "./read";
79
+ export * from "./recipe";
79
80
  export * from "./render-mermaid";
80
81
  export * from "./report-tool-issue";
81
82
  export * from "./resolve";
82
83
  export * from "./review";
83
- export * from "./run-command";
84
84
  export * from "./search";
85
85
  export * from "./search-tool-bm25";
86
86
  export * from "./ssh";
@@ -226,7 +226,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
226
226
  rewind: RewindTool.createIf,
227
227
  task: TaskTool.create,
228
228
  job: JobTool.createIf,
229
- run_command: RunCommandTool.createIf,
229
+ recipe: RecipeTool.createIf,
230
230
  irc: IrcTool.createIf,
231
231
  todo_write: s => new TodoWriteTool(s),
232
232
  web_search: s => new WebSearchTool(s),
@@ -375,10 +375,10 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
375
375
  }
376
376
  if (
377
377
  requestedTools.includes("bash") &&
378
- !requestedTools.includes("run_command") &&
379
- session.settings.get("runCommand.enabled")
378
+ !requestedTools.includes("recipe") &&
379
+ session.settings.get("recipe.enabled")
380
380
  ) {
381
- requestedTools.push("run_command");
381
+ requestedTools.push("recipe");
382
382
  }
383
383
  }
384
384
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
@@ -402,7 +402,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
402
402
  if (name === "browser") return session.settings.get("browser.enabled");
403
403
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
404
404
  if (name === "irc") return session.settings.get("irc.enabled");
405
- if (name === "run_command") return session.settings.get("runCommand.enabled");
405
+ if (name === "recipe") return session.settings.get("recipe.enabled");
406
406
  if (name === "task") {
407
407
  const maxDepth = session.settings.get("task.maxRecursionDepth") ?? 2;
408
408
  const currentDepth = session.taskDepth ?? 0;
@@ -4,43 +4,43 @@ import { prompt } from "@oh-my-pi/pi-utils";
4
4
  import { type Static, Type } from "@sinclair/typebox";
5
5
  import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
6
6
  import type { Theme } from "../../modes/theme/theme";
7
- import runCommandDescription from "../../prompts/tools/run-command.md" with { type: "text" };
7
+ import recipeDescription from "../../prompts/tools/recipe.md" with { type: "text" };
8
8
  import type { ToolSession } from "..";
9
9
  import { type BashRenderContext, BashTool, type BashToolDetails } from "../bash";
10
- import { createRunCommandToolRenderer, type RunCommandRenderArgs } from "./render";
10
+ import { createRecipeToolRenderer, type RecipeRenderArgs } from "./render";
11
11
  import { buildPromptModel, type DetectedRunner, resolveCommand } from "./runner";
12
12
  import { RUNNERS } from "./runners";
13
13
 
14
- const runCommandSchema = Type.Object({
14
+ const recipeSchema = Type.Object({
15
15
  op: Type.String({
16
16
  description: 'task name and args, e.g. "test" or "build --release"',
17
17
  examples: ["test", "build --release", "pkg:test --watch"],
18
18
  }),
19
19
  });
20
20
 
21
- type RunCommandParams = Static<typeof runCommandSchema>;
21
+ type RecipeParams = Static<typeof recipeSchema>;
22
22
 
23
- type RunCommandRenderResult = {
23
+ type RecipeRenderResult = {
24
24
  content: Array<{ type: string; text?: string }>;
25
25
  details?: BashToolDetails;
26
26
  isError?: boolean;
27
27
  };
28
28
 
29
- export class RunCommandTool implements AgentTool<typeof runCommandSchema, BashToolDetails, Theme> {
30
- readonly name = "run_command";
29
+ export class RecipeTool implements AgentTool<typeof recipeSchema, BashToolDetails, Theme> {
30
+ readonly name = "recipe";
31
31
  readonly label = "Run";
32
32
  readonly description: string;
33
- readonly parameters = runCommandSchema;
33
+ readonly parameters = recipeSchema;
34
34
  readonly strict = true;
35
35
  readonly concurrency = "exclusive";
36
36
  readonly mergeCallAndResult = true;
37
37
  readonly inline = true;
38
- readonly renderCall: (args: RunCommandRenderArgs, options: RenderResultOptions, uiTheme: Theme) => Component;
38
+ readonly renderCall: (args: RecipeRenderArgs, options: RenderResultOptions, uiTheme: Theme) => Component;
39
39
  readonly renderResult: (
40
- result: RunCommandRenderResult,
40
+ result: RecipeRenderResult,
41
41
  options: RenderResultOptions & { renderContext?: BashRenderContext },
42
42
  uiTheme: Theme,
43
- args?: RunCommandRenderArgs,
43
+ args?: RecipeRenderArgs,
44
44
  ) => Component;
45
45
 
46
46
  readonly #bash: BashTool;
@@ -49,30 +49,30 @@ export class RunCommandTool implements AgentTool<typeof runCommandSchema, BashTo
49
49
  constructor(session: ToolSession, runners: DetectedRunner[]) {
50
50
  this.#runners = runners;
51
51
  this.#bash = new BashTool(session);
52
- this.description = prompt.render(runCommandDescription, buildPromptModel(runners));
53
- const renderer = createRunCommandToolRenderer(runners);
52
+ this.description = prompt.render(recipeDescription, buildPromptModel(runners));
53
+ const renderer = createRecipeToolRenderer(runners);
54
54
  this.renderCall = renderer.renderCall;
55
55
  this.renderResult = renderer.renderResult;
56
56
  }
57
57
 
58
- static async createIf(session: ToolSession): Promise<RunCommandTool | null> {
59
- if (!session.settings.get("runCommand.enabled")) return null;
58
+ static async createIf(session: ToolSession): Promise<RecipeTool | null> {
59
+ if (!session.settings.get("recipe.enabled")) return null;
60
60
  const detected = (await Promise.all(RUNNERS.map(runner => runner.detect(session.cwd)))).filter(
61
61
  (runner): runner is DetectedRunner => runner !== null && runner.tasks.length > 0,
62
62
  );
63
63
  if (detected.length === 0) return null;
64
- return new RunCommandTool(session, detected);
64
+ return new RecipeTool(session, detected);
65
65
  }
66
66
 
67
67
  async execute(
68
68
  toolCallId: string,
69
- { op }: RunCommandParams,
69
+ { op }: RecipeParams,
70
70
  signal?: AbortSignal,
71
71
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>,
72
72
  ctx?: AgentToolContext,
73
73
  ): Promise<AgentToolResult<BashToolDetails>> {
74
- const command = resolveCommand(op, this.#runners);
75
- return await this.#bash.execute(toolCallId, { command }, signal, onUpdate, ctx);
74
+ const { command, cwd } = resolveCommand(op, this.#runners);
75
+ return await this.#bash.execute(toolCallId, { command, cwd }, signal, onUpdate, ctx);
76
76
  }
77
77
  }
78
78
 
@@ -0,0 +1,19 @@
1
+ import { createShellRenderer } from "../bash";
2
+ import type { DetectedRunner } from "./runner";
3
+ import { commandFromOp, cwdFromOp, titleFromOp } from "./runner";
4
+
5
+ export interface RecipeRenderArgs {
6
+ op?: string;
7
+ __partialJson?: string;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ export function createRecipeToolRenderer(runners: DetectedRunner[]) {
12
+ return createShellRenderer<RecipeRenderArgs>({
13
+ resolveTitle: args => titleFromOp(args?.op, runners),
14
+ resolveCommand: args => commandFromOp(args?.op, runners),
15
+ resolveCwd: args => cwdFromOp(args?.op, runners),
16
+ });
17
+ }
18
+
19
+ export const recipeToolRenderer = createRecipeToolRenderer([]);
@@ -9,6 +9,8 @@ export interface RunnerTask {
9
9
  commandPrefix?: string;
10
10
  /** Token passed to the runner command; defaults to `name`. Used when display names are namespaced. */
11
11
  commandName?: string;
12
+ /** Working directory for the task, relative to the session cwd; absent means the runner's root cwd. */
13
+ cwd?: string;
12
14
  }
13
15
 
14
16
  export interface DetectedRunner {
@@ -39,16 +41,20 @@ interface PromptTaskModel {
39
41
  paramSig?: string;
40
42
  command?: string;
41
43
  doc?: string;
44
+ cwd?: string;
42
45
  }
43
46
 
47
+ const PROMPT_TASK_LIMIT = 20;
48
+
44
49
  interface PromptRunnerModel {
45
50
  id: string;
46
51
  label: string;
47
52
  commandPrefix: string;
48
53
  tasks: PromptTaskModel[];
54
+ hiddenTaskCount?: number;
49
55
  }
50
56
 
51
- export interface RunCommandPromptModel {
57
+ export interface RecipePromptModel {
52
58
  [key: string]: unknown;
53
59
  hasMultipleRunners: boolean;
54
60
  ambiguityExampleRunner?: string;
@@ -101,7 +107,7 @@ function resolveRunnerAndTask(
101
107
  ): { runner: DetectedRunner; task: RunnerTask; tail: string } {
102
108
  const { head, tail } = parseOp(op);
103
109
  if (!head) {
104
- throw new ToolError(`run_command op is empty. Available tasks:\n${formatAvailableTasks(runners)}`);
110
+ throw new ToolError(`recipe op is empty. Available tasks:\n${formatAvailableTasks(runners)}`);
105
111
  }
106
112
 
107
113
  const colonIndex = head.indexOf(":");
@@ -136,12 +142,18 @@ function resolveRunnerAndTask(
136
142
  );
137
143
  }
138
144
 
139
- export function resolveCommand(op: string, runners: DetectedRunner[]): string {
145
+ export interface ResolvedTask {
146
+ command: string;
147
+ cwd?: string;
148
+ }
149
+
150
+ export function resolveCommand(op: string, runners: DetectedRunner[]): ResolvedTask {
140
151
  const { runner, task, tail } = resolveRunnerAndTask(op, runners);
141
- return buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, tail);
152
+ const command = buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, tail);
153
+ return task.cwd ? { command, cwd: task.cwd } : { command };
142
154
  }
143
155
 
144
- export function commandFromOp(op: string | undefined, runners: DetectedRunner[]): string | undefined {
156
+ export function resolveTaskFromOp(op: string | undefined, runners: DetectedRunner[]): ResolvedTask | undefined {
145
157
  if (!op) return undefined;
146
158
  try {
147
159
  return resolveCommand(op, runners);
@@ -150,6 +162,14 @@ export function commandFromOp(op: string | undefined, runners: DetectedRunner[])
150
162
  }
151
163
  }
152
164
 
165
+ export function commandFromOp(op: string | undefined, runners: DetectedRunner[]): string | undefined {
166
+ return resolveTaskFromOp(op, runners)?.command;
167
+ }
168
+
169
+ export function cwdFromOp(op: string | undefined, runners: DetectedRunner[]): string | undefined {
170
+ return resolveTaskFromOp(op, runners)?.cwd;
171
+ }
172
+
153
173
  export function titleFromOp(op: string | undefined, runners: DetectedRunner[]): string {
154
174
  if (!op) return "Run";
155
175
  const { head } = parseOp(op);
@@ -177,7 +197,7 @@ function findAmbiguityExample(runners: DetectedRunner[]): { runner: string; task
177
197
  return firstRunner && firstTask ? { runner: firstRunner.id, task: firstTask.name } : undefined;
178
198
  }
179
199
 
180
- export function buildPromptModel(runners: DetectedRunner[]): RunCommandPromptModel {
200
+ export function buildPromptModel(runners: DetectedRunner[]): RecipePromptModel {
181
201
  const ambiguityExample = findAmbiguityExample(runners);
182
202
  return {
183
203
  hasMultipleRunners: runners.length > 1,
@@ -187,11 +207,12 @@ export function buildPromptModel(runners: DetectedRunner[]): RunCommandPromptMod
187
207
  id: runner.id,
188
208
  label: runner.label,
189
209
  commandPrefix: runner.commandPrefix,
190
- tasks: runner.tasks.map(task => ({
210
+ tasks: runner.tasks.slice(0, PROMPT_TASK_LIMIT).map(task => ({
191
211
  name: task.name,
192
212
  paramSig: task.parameters.length > 0 ? task.parameters.join(" ") : undefined,
193
213
  command: buildCommand(task.commandPrefix ?? runner.commandPrefix, task.commandName ?? task.name, ""),
194
214
  doc: task.doc,
215
+ cwd: task.cwd,
195
216
  })),
196
217
  })),
197
218
  };