@oh-my-pi/pi-coding-agent 10.6.1 → 11.0.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 (86) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +80 -79
  3. package/docs/compaction.md +182 -149
  4. package/docs/config-usage.md +141 -78
  5. package/docs/custom-tools.md +45 -16
  6. package/docs/extension-loading.md +56 -954
  7. package/docs/extensions.md +192 -51
  8. package/docs/hooks.md +109 -70
  9. package/docs/python-repl.md +52 -19
  10. package/docs/rpc.md +43 -19
  11. package/docs/sdk.md +270 -211
  12. package/docs/session-tree-plan.md +60 -417
  13. package/docs/session.md +104 -39
  14. package/docs/skills.md +59 -95
  15. package/docs/theme.md +139 -110
  16. package/docs/tree.md +42 -33
  17. package/docs/tui.md +226 -80
  18. package/package.json +8 -9
  19. package/src/capability/index.ts +3 -4
  20. package/src/cli/args.ts +4 -4
  21. package/src/cli/grep-cli.ts +1 -1
  22. package/src/commit/agentic/index.ts +4 -3
  23. package/src/commit/git/index.ts +2 -3
  24. package/src/commit/map-reduce/index.ts +2 -1
  25. package/src/config/prompt-templates.ts +2 -0
  26. package/src/config/settings-schema.ts +30 -7
  27. package/src/config/settings.ts +0 -14
  28. package/src/config.ts +2 -2
  29. package/src/discovery/agents.ts +36 -0
  30. package/src/discovery/index.ts +1 -0
  31. package/src/exa/mcp-client.ts +3 -3
  32. package/src/ipy/executor.ts +5 -7
  33. package/src/ipy/gateway-coordinator.ts +1 -1
  34. package/src/ipy/kernel.ts +20 -15
  35. package/src/ipy/prelude.py +1 -1
  36. package/src/ipy/runtime.ts +7 -6
  37. package/src/lsp/lspmux.ts +3 -3
  38. package/src/main.ts +6 -8
  39. package/src/mcp/tool-bridge.ts +19 -9
  40. package/src/modes/components/assistant-message.ts +2 -2
  41. package/src/modes/components/hook-editor.ts +4 -4
  42. package/src/modes/components/settings-defs.ts +37 -2
  43. package/src/modes/components/tool-execution.ts +7 -7
  44. package/src/modes/controllers/command-controller.ts +2 -2
  45. package/src/modes/controllers/event-controller.ts +4 -7
  46. package/src/modes/controllers/input-controller.ts +4 -4
  47. package/src/modes/controllers/selector-controller.ts +1 -0
  48. package/src/modes/interactive-mode.ts +3 -5
  49. package/src/modes/rpc/rpc-mode.ts +8 -9
  50. package/src/patch/index.ts +6 -6
  51. package/src/prompts/agents/explore.md +2 -2
  52. package/src/prompts/agents/frontmatter.md +5 -5
  53. package/src/prompts/agents/plan.md +3 -2
  54. package/src/prompts/agents/reviewer.md +1 -1
  55. package/src/prompts/system/system-prompt.md +1 -3
  56. package/src/sdk.ts +13 -9
  57. package/src/session/agent-session.ts +6 -4
  58. package/src/session/compaction/compaction.ts +3 -3
  59. package/src/session/session-manager.ts +8 -9
  60. package/src/ssh/connection-manager.ts +4 -4
  61. package/src/system-prompt.ts +2 -6
  62. package/src/task/agents.ts +1 -1
  63. package/src/task/executor.ts +31 -8
  64. package/src/task/index.ts +14 -35
  65. package/src/task/omp-command.ts +3 -1
  66. package/src/task/output-manager.ts +20 -6
  67. package/src/task/parallel.ts +3 -3
  68. package/src/task/render.ts +16 -2
  69. package/src/task/types.ts +13 -20
  70. package/src/task/worktree.ts +3 -3
  71. package/src/tools/ask.ts +3 -8
  72. package/src/tools/fetch.ts +2 -2
  73. package/src/tools/gemini-image.ts +5 -6
  74. package/src/tools/grep.ts +5 -5
  75. package/src/tools/index.ts +12 -5
  76. package/src/tools/read.ts +1 -1
  77. package/src/tools/todo-write.ts +2 -3
  78. package/src/utils/frontmatter.ts +1 -1
  79. package/src/utils/image-resize.ts +1 -1
  80. package/src/utils/timings.ts +3 -2
  81. package/src/web/scrapers/github.ts +2 -2
  82. package/src/web/scrapers/utils.ts +2 -3
  83. package/src/web/scrapers/youtube.ts +2 -3
  84. package/src/web/search/auth.ts +5 -6
  85. package/src/web/search/providers/anthropic.ts +3 -2
  86. package/src/utils/terminal-notify.ts +0 -37
@@ -157,6 +157,8 @@ export interface ExecutorOptions {
157
157
  modelOverride?: string | string[];
158
158
  thinkingLevel?: ThinkingLevel;
159
159
  outputSchema?: unknown;
160
+ /** Parent task recursion depth (0 = top-level, 1 = first child, etc.) */
161
+ taskDepth?: number;
160
162
  enableLsp?: boolean;
161
163
  signal?: AbortSignal;
162
164
  onProgress?: (progress: AgentProgress) => void;
@@ -428,17 +430,25 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
428
430
  subtaskSessionFile = path.join(options.artifactsDir, `${id}.jsonl`);
429
431
  }
430
432
 
433
+ const settings = options.settings ?? Settings.isolated();
434
+ const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
435
+ const parentDepth = options.taskDepth ?? 0;
436
+ const childDepth = parentDepth + 1;
437
+ const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
438
+
431
439
  // Add tools if specified
432
440
  let toolNames: string[] | undefined;
433
441
  if (agent.tools && agent.tools.length > 0) {
434
442
  toolNames = agent.tools;
435
443
  // Auto-include task tool if spawns defined but task not in tools
436
- if (agent.spawns !== undefined && !toolNames.includes("task")) {
444
+ if (agent.spawns !== undefined && !toolNames.includes("task") && !atMaxDepth) {
437
445
  toolNames = [...toolNames, "task"];
438
446
  }
439
447
  }
440
448
 
441
- const settings = options.settings ?? Settings.isolated();
449
+ if (atMaxDepth && toolNames?.includes("task")) {
450
+ toolNames = toolNames.filter(name => name !== "task");
451
+ }
442
452
  const pythonToolMode = settings.get("python.toolMode") ?? "both";
443
453
  if (toolNames?.includes("exec")) {
444
454
  const expanded = toolNames.filter(name => name !== "exec");
@@ -454,7 +464,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
454
464
 
455
465
  const modelPatterns = normalizeModelPatterns(modelOverride ?? agent.model);
456
466
  const sessionFile = subtaskSessionFile ?? null;
457
- const spawnsEnv = agent.spawns === undefined ? "" : agent.spawns === "*" ? "*" : agent.spawns.join(",");
467
+ const spawnsEnv = atMaxDepth
468
+ ? ""
469
+ : agent.spawns === undefined
470
+ ? ""
471
+ : agent.spawns === "*"
472
+ ? "*"
473
+ : agent.spawns.join(",");
458
474
 
459
475
  const lspEnabled = enableLsp ?? true;
460
476
  const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("python");
@@ -877,6 +893,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
877
893
  sessionManager,
878
894
  hasUI: false,
879
895
  spawns: spawnsEnv,
896
+ taskDepth: childDepth,
897
+ parentTaskPrefix: id,
880
898
  enableLsp: lspEnabled,
881
899
  skipPythonPreflight,
882
900
  enableMCP,
@@ -1003,9 +1021,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1003
1021
  }
1004
1022
 
1005
1023
  const lastMessage = session.state.messages[session.state.messages.length - 1];
1006
- if (lastMessage?.role === "assistant" && lastMessage.stopReason === "aborted") {
1007
- aborted = abortReason === "signal" || abortReason === undefined;
1008
- exitCode = 1;
1024
+ if (lastMessage?.role === "assistant") {
1025
+ if (lastMessage.stopReason === "aborted") {
1026
+ aborted = abortReason === "signal" || abortReason === undefined;
1027
+ exitCode = 1;
1028
+ } else if (lastMessage.stopReason === "error") {
1029
+ exitCode = 1;
1030
+ error ??= lastMessage.errorMessage || "Subagent failed";
1031
+ }
1009
1032
  }
1010
1033
  } catch (err) {
1011
1034
  exitCode = 1;
@@ -1093,7 +1116,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1093
1116
  stderr = "";
1094
1117
  }
1095
1118
  } else {
1096
- const allowFallback = !done.aborted && !signal?.aborted;
1119
+ const allowFallback = exitCode === 0 && !done.aborted && !signal?.aborted;
1097
1120
  const { normalized: normalizedSchema, error: schemaError } = normalizeOutputSchema(outputSchema);
1098
1121
  const hasOutputSchema = normalizedSchema !== undefined && !schemaError;
1099
1122
  const fallback = allowFallback ? resolveFallbackCompletion(rawOutput, outputSchema) : null;
@@ -1110,7 +1133,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1110
1133
  } else if (!hasOutputSchema && allowFallback && rawOutput.trim().length > 0) {
1111
1134
  exitCode = 0;
1112
1135
  stderr = "";
1113
- } else {
1136
+ } else if (exitCode === 0) {
1114
1137
  const warning = "SYSTEM WARNING: Subagent exited without calling submit_result tool after 3 reminders.";
1115
1138
  rawOutput = rawOutput ? `${warning}\n\n${rawOutput}` : warning;
1116
1139
  }
package/src/task/index.ts CHANGED
@@ -17,8 +17,8 @@ import * as os from "node:os";
17
17
  import path from "node:path";
18
18
  import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Usage } from "@oh-my-pi/pi-ai";
20
+ import { $env, Snowflake } from "@oh-my-pi/pi-utils";
20
21
  import { $ } from "bun";
21
- import { nanoid } from "nanoid";
22
22
  import type { ToolSession } from "..";
23
23
  import { isDefaultModelAlias } from "../config/model-resolver";
24
24
  import { renderPromptTemplate } from "../config/prompt-templates";
@@ -35,15 +35,7 @@ import { AgentOutputManager } from "./output-manager";
35
35
  import { mapWithConcurrencyLimit } from "./parallel";
36
36
  import { renderCall, renderResult } from "./render";
37
37
  import { renderTemplate } from "./template";
38
- import {
39
- type AgentProgress,
40
- MAX_CONCURRENCY,
41
- MAX_PARALLEL_TASKS,
42
- type SingleResult,
43
- type TaskParams,
44
- type TaskToolDetails,
45
- taskSchema,
46
- } from "./types";
38
+ import { type AgentProgress, type SingleResult, type TaskParams, type TaskToolDetails, taskSchema } from "./types";
47
39
  import {
48
40
  applyBaseline,
49
41
  captureBaseline,
@@ -110,13 +102,12 @@ export { taskSchema } from "./types";
110
102
  /**
111
103
  * Build dynamic tool description listing available agents.
112
104
  */
113
- async function buildDescription(cwd: string): Promise<string> {
105
+ async function buildDescription(cwd: string, maxConcurrency: number): Promise<string> {
114
106
  const { agents } = await discoverAgents(cwd);
115
107
 
116
108
  return renderPromptTemplate(taskDescriptionTemplate, {
117
109
  agents,
118
- MAX_PARALLEL_TASKS,
119
- MAX_CONCURRENCY,
110
+ MAX_CONCURRENCY: maxConcurrency,
120
111
  });
121
112
  }
122
113
 
@@ -144,14 +135,15 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
144
135
  private constructor(session: ToolSession, description: string) {
145
136
  this.session = session;
146
137
  this.description = description;
147
- this.blockedAgent = process.env.OMP_BLOCKED_AGENT;
138
+ this.blockedAgent = $env.PI_BLOCKED_AGENT;
148
139
  }
149
140
 
150
141
  /**
151
142
  * Create a TaskTool instance with async agent discovery.
152
143
  */
153
144
  public static async create(session: ToolSession): Promise<TaskTool> {
154
- const description = await buildDescription(session.cwd);
145
+ const maxConcurrency = session.settings.get("task.maxConcurrency");
146
+ const description = await buildDescription(session.cwd, maxConcurrency);
155
147
  return new TaskTool(session, description);
156
148
  }
157
149
 
@@ -165,6 +157,8 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
165
157
  const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
166
158
  const { agent: agentName, context, schema: outputSchema, isolated } = params;
167
159
  const isIsolated = isolated === true;
160
+ const maxConcurrency = this.session.settings.get("task.maxConcurrency");
161
+ const taskDepth = this.session.taskDepth ?? 0;
168
162
 
169
163
  // Validate agent exists
170
164
  const agent = getAgent(agents, agentName);
@@ -221,23 +215,6 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
221
215
  };
222
216
  }
223
217
 
224
- // Validate task count
225
- if (params.tasks.length > MAX_PARALLEL_TASKS) {
226
- return {
227
- content: [
228
- {
229
- type: "text",
230
- text: `Too many tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
231
- },
232
- ],
233
- details: {
234
- projectAgentsDir,
235
- results: [],
236
- totalDurationMs: 0,
237
- },
238
- };
239
- }
240
-
241
218
  const tasks = params.tasks;
242
219
  const missingTaskIndexes: number[] = [];
243
220
  const idIndexes = new Map<string, number[]>();
@@ -313,7 +290,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
313
290
  // Derive artifacts directory
314
291
  const sessionFile = this.session.getSessionFile();
315
292
  const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
316
- const tempArtifactsDir = artifactsDir ? null : path.join(os.tmpdir(), `omp-task-${nanoid()}`);
293
+ const tempArtifactsDir = artifactsDir ? null : path.join(os.tmpdir(), `omp-task-${Snowflake.next()}`);
317
294
  const effectiveArtifactsDir = artifactsDir || tempArtifactsDir!;
318
295
 
319
296
  // Initialize progress tracking
@@ -471,6 +448,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
471
448
  index,
472
449
  id: task.id,
473
450
  context: undefined, // Already prepended above
451
+ taskDepth,
474
452
  modelOverride,
475
453
  thinkingLevel: thinkingLevelOverride,
476
454
  outputSchema: effectiveOutputSchema,
@@ -516,6 +494,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
516
494
  index,
517
495
  id: task.id,
518
496
  context: undefined, // Already prepended above
497
+ taskDepth,
519
498
  modelOverride,
520
499
  thinkingLevel: thinkingLevelOverride,
521
500
  outputSchema: effectiveOutputSchema,
@@ -577,7 +556,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
577
556
  // Execute in parallel with concurrency limit
578
557
  const { results: partialResults, aborted } = await mapWithConcurrencyLimit(
579
558
  tasksWithSkills,
580
- MAX_CONCURRENCY,
559
+ maxConcurrency,
581
560
  runTask,
582
561
  signal,
583
562
  );
@@ -658,7 +637,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
658
637
  if (!combinedPatch.trim()) {
659
638
  patchesApplied = true;
660
639
  } else {
661
- const combinedPatchPath = path.join(os.tmpdir(), `omp-task-combined-${nanoid()}.patch`);
640
+ const combinedPatchPath = path.join(os.tmpdir(), `omp-task-combined-${Snowflake.next()}.patch`);
662
641
  try {
663
642
  await Bun.write(combinedPatchPath, combinedPatch);
664
643
  const checkResult = await $`git apply --check --binary ${combinedPatchPath}`
@@ -1,5 +1,7 @@
1
1
  import process from "node:process";
2
2
 
3
+ import { $env } from "@oh-my-pi/pi-utils";
4
+
3
5
  interface OmpCommand {
4
6
  cmd: string;
5
7
  args: string[];
@@ -10,7 +12,7 @@ const DEFAULT_CMD = process.platform === "win32" ? "omp.cmd" : "omp";
10
12
  const DEFAULT_SHELL = process.platform === "win32";
11
13
 
12
14
  export function resolveOmpCommand(): OmpCommand {
13
- const envCmd = process.env.OMP_SUBPROCESS_CMD;
15
+ const envCmd = $env.PI_SUBPROCESS_CMD;
14
16
  if (envCmd?.trim()) {
15
17
  return { cmd: envCmd, args: [], shell: DEFAULT_SHELL };
16
18
  }
@@ -3,24 +3,33 @@
3
3
  *
4
4
  * Ensures unique output IDs across task tool invocations within a session.
5
5
  * Prefixes each ID with a sequential number (e.g., "0-AuthProvider", "1-AuthApi").
6
+ * If a parent prefix is provided, IDs are nested (e.g., "0-Auth.1-Subtask").
6
7
  *
7
8
  * This enables reliable agent:// URL resolution and prevents artifact collisions.
8
9
  */
9
10
  import * as fs from "node:fs/promises";
10
11
 
12
+ function escapeRegExp(value: string): string {
13
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ }
15
+
11
16
  /**
12
17
  * Manages agent output ID allocation to ensure uniqueness.
13
18
  *
14
19
  * Each allocated ID gets a numeric prefix based on allocation order.
20
+ * If configured with a parent prefix, the numeric prefix is appended after
21
+ * the parent (e.g., "0-Parent.0-Child").
15
22
  * On resume, scans existing files to find the next available index.
16
23
  */
17
24
  export class AgentOutputManager {
18
25
  #nextId = 0;
19
26
  #initialized = false;
20
27
  readonly #getArtifactsDir: () => string | null;
28
+ readonly #parentPrefix: string | undefined;
21
29
 
22
- constructor(getArtifactsDir: () => string | null) {
30
+ constructor(getArtifactsDir: () => string | null, options?: { parentPrefix?: string }) {
23
31
  this.#getArtifactsDir = getArtifactsDir;
32
+ this.#parentPrefix = options?.parentPrefix;
24
33
  }
25
34
 
26
35
  /**
@@ -41,12 +50,15 @@ export class AgentOutputManager {
41
50
  return; // Directory doesn't exist yet
42
51
  }
43
52
 
53
+ const pattern = this.#parentPrefix
54
+ ? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.md$`)
55
+ : /^(\d+)-.*\.md$/;
56
+
44
57
  let maxId = -1;
45
58
  for (const file of files) {
46
- // Agent outputs are named: {index}-{id}.md (e.g., "0-AuthProvider.md")
47
- const match = file.match(/^(\d+)-.*\.md$/);
59
+ const match = file.match(pattern);
48
60
  if (match) {
49
- const id = parseInt(match[1], 10);
61
+ const id = Number.parseInt(match[1], 10);
50
62
  if (id > maxId) maxId = id;
51
63
  }
52
64
  }
@@ -61,7 +73,8 @@ export class AgentOutputManager {
61
73
  */
62
74
  async allocate(id: string): Promise<string> {
63
75
  await this.#ensureInitialized();
64
- return `${this.#nextId++}-${id}`;
76
+ const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
77
+ return `${prefix}${this.#nextId++}-${id}`;
65
78
  }
66
79
 
67
80
  /**
@@ -72,7 +85,8 @@ export class AgentOutputManager {
72
85
  */
73
86
  async allocateBatch(ids: string[]): Promise<string[]> {
74
87
  await this.#ensureInitialized();
75
- return ids.map(id => `${this.#nextId++}-${id}`);
88
+ const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
89
+ return ids.map(id => `${prefix}${this.#nextId++}-${id}`);
76
90
  }
77
91
 
78
92
  /**
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * Parallel execution with concurrency control.
3
3
  */
4
- import { MAX_CONCURRENCY } from "./types";
5
-
6
4
  /** Result of parallel execution */
7
5
  export interface ParallelResult<R> {
8
6
  /** Results array - undefined entries indicate tasks that were skipped due to abort */
@@ -31,7 +29,9 @@ export async function mapWithConcurrencyLimit<T, R>(
31
29
  fn: (item: T, index: number) => Promise<R>,
32
30
  signal?: AbortSignal,
33
31
  ): Promise<ParallelResult<R>> {
34
- const limit = Math.max(1, Math.min(concurrency, items.length, MAX_CONCURRENCY));
32
+ const normalizedConcurrency = Number.isFinite(concurrency) ? Math.floor(concurrency) : items.length;
33
+ const effectiveConcurrency = normalizedConcurrency > 0 ? normalizedConcurrency : items.length;
34
+ const limit = Math.max(1, Math.min(effectiveConcurrency, items.length));
35
35
  const results: (R | undefined)[] = new Array(items.length);
36
36
  let nextIndex = 0;
37
37
 
@@ -77,6 +77,18 @@ function formatJsonScalar(value: unknown, _theme: Theme): string {
77
77
  return "";
78
78
  }
79
79
 
80
+ function formatTaskId(id: string): string {
81
+ const segments = id.split(".");
82
+ if (segments.length < 2) return id;
83
+
84
+ const parsed = segments.map(segment => segment.match(/^(\d+)-(.+)$/));
85
+ if (parsed.some(match => !match)) return id;
86
+
87
+ const indices = parsed.map(match => match![1]).join(".");
88
+ const labels = parsed.map(match => match![2]).join(">");
89
+ return `${indices} ${labels}`;
90
+ }
91
+
80
92
  const MISSING_SUBMIT_RESULT_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling submit_result tool";
81
93
 
82
94
  function extractMissingSubmitResultWarning(output: string): { warning?: string; rest: string } {
@@ -518,7 +530,8 @@ function renderAgentProgress(
518
530
 
519
531
  // Main status line: id: description [status] · stats · ⟨agent⟩
520
532
  const description = progress.description?.trim();
521
- const titlePart = description ? `${theme.bold(progress.id)}: ${description}` : progress.id;
533
+ const displayId = formatTaskId(progress.id);
534
+ const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
522
535
  let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
523
536
 
524
537
  // Only show badge for non-running states (spinner already indicates running)
@@ -748,7 +761,8 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
748
761
 
749
762
  // Main status line: id: description [status] · stats · ⟨agent⟩
750
763
  const description = result.description?.trim();
751
- const titlePart = description ? `${theme.bold(result.id)}: ${description}` : result.id;
764
+ const displayId = formatTaskId(result.id);
765
+ const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
752
766
  let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
753
767
  statusText,
754
768
  iconColor,
package/src/task/types.ts CHANGED
@@ -1,35 +1,28 @@
1
1
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Usage } from "@oh-my-pi/pi-ai";
3
+ import { $env } from "@oh-my-pi/pi-utils";
3
4
  import { type Static, Type } from "@sinclair/typebox";
4
5
 
5
6
  /** Source of an agent definition */
6
7
  export type AgentSource = "bundled" | "user" | "project";
7
8
 
8
- function getEnv(name: string, defaultValue: number): number {
9
- const value = process.env[name];
10
- if (value === undefined) {
11
- return defaultValue;
9
+ const parseNumber = (value: string | undefined, defaultValue: number): number => {
10
+ if (value) {
11
+ try {
12
+ const number = Number.parseInt(value, 10);
13
+ if (!Number.isNaN(number) && number > 0) {
14
+ return number;
15
+ }
16
+ } catch {}
12
17
  }
13
- try {
14
- const number = Number.parseInt(value, 10);
15
- if (!Number.isNaN(number) && number > 0) {
16
- return number;
17
- }
18
- } catch {}
19
18
  return defaultValue;
20
- }
21
-
22
- /** Maximum tasks per call */
23
- export const MAX_PARALLEL_TASKS = getEnv("OMP_TASK_MAX_PARALLEL", 32);
24
-
25
- /** Maximum concurrent tasks */
26
- export const MAX_CONCURRENCY = getEnv("OMP_TASK_MAX_CONCURRENCY", 16);
19
+ };
27
20
 
28
21
  /** Maximum output bytes per agent */
29
- export const MAX_OUTPUT_BYTES = getEnv("OMP_TASK_MAX_OUTPUT_BYTES", 500_000);
22
+ export const MAX_OUTPUT_BYTES = parseNumber($env.PI_TASK_MAX_OUTPUT_BYTES, 500_000);
30
23
 
31
24
  /** Maximum output lines per agent */
32
- export const MAX_OUTPUT_LINES = getEnv("OMP_TASK_MAX_OUTPUT_LINES", 5000);
25
+ export const MAX_OUTPUT_LINES = parseNumber($env.PI_TASK_MAX_OUTPUT_LINES, 5000);
33
26
 
34
27
  /** EventBus channel for raw subagent events */
35
28
  export const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event";
@@ -66,7 +59,7 @@ export const taskSchema = Type.Object({
66
59
  schema: Type.Optional(
67
60
  Type.Record(Type.String(), Type.Unknown(), { description: "JTD schema defining expected response structure" }),
68
61
  ),
69
- tasks: Type.Array(taskItemSchema, { description: "Tasks to run in parallel", maxItems: MAX_PARALLEL_TASKS }),
62
+ tasks: Type.Array(taskItemSchema, { description: "Tasks to run in parallel" }),
70
63
  });
71
64
 
72
65
  export type TaskParams = Static<typeof taskSchema>;
@@ -1,7 +1,7 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import * as fs from "node:fs/promises";
3
2
  import * as os from "node:os";
4
3
  import path from "node:path";
4
+ import { Snowflake } from "@oh-my-pi/pi-utils";
5
5
  import { $ } from "bun";
6
6
 
7
7
  export interface WorktreeBaseline {
@@ -56,7 +56,7 @@ export async function captureBaseline(repoRoot: string): Promise<WorktreeBaselin
56
56
  }
57
57
 
58
58
  async function writeTempPatchFile(patch: string): Promise<string> {
59
- const tempPath = path.join(os.tmpdir(), `omp-task-patch-${randomUUID()}.patch`);
59
+ const tempPath = path.join(os.tmpdir(), `omp-task-patch-${Snowflake.next()}.patch`);
60
60
  await Bun.write(tempPath, patch);
61
61
  return tempPath;
62
62
  }
@@ -119,7 +119,7 @@ async function listUntracked(cwd: string): Promise<string[]> {
119
119
  }
120
120
 
121
121
  export async function captureDeltaPatch(worktreeDir: string, baseline: WorktreeBaseline): Promise<string> {
122
- const tempIndex = path.join(os.tmpdir(), `omp-task-index-${randomUUID()}`);
122
+ const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
123
123
  try {
124
124
  await $`git read-tree HEAD`.cwd(worktreeDir).env({
125
125
  GIT_INDEX_FILE: tempIndex,
package/src/tools/ask.ts CHANGED
@@ -16,14 +16,13 @@
16
16
  */
17
17
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
18
18
  import type { Component } from "@oh-my-pi/pi-tui";
19
- import { Text } from "@oh-my-pi/pi-tui";
19
+ import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
20
20
  import { Type } from "@sinclair/typebox";
21
21
  import { renderPromptTemplate } from "../config/prompt-templates";
22
22
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
23
23
  import { type Theme, theme } from "../modes/theme/theme";
24
24
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
25
25
  import { renderStatusLine } from "../tui";
26
- import { detectNotificationProtocol, isNotificationSuppressed, sendNotification } from "../utils/terminal-notify";
27
26
  import type { ToolSession } from ".";
28
27
  import { ToolUIKit } from "./render-utils";
29
28
 
@@ -268,13 +267,9 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
268
267
 
269
268
  /** Send terminal notification when ask tool is waiting for input */
270
269
  private sendAskNotification(): void {
271
- if (isNotificationSuppressed()) return;
272
-
273
- const method = this.session.settings.get("ask.notification");
270
+ const method = this.session.settings.get("ask.notify");
274
271
  if (method === "off") return;
275
-
276
- const protocol = method === "auto" ? detectNotificationProtocol() : method;
277
- sendNotification(protocol, "Waiting for input");
272
+ TERMINAL.sendNotification("Waiting for input");
278
273
  }
279
274
 
280
275
  public async execute(
@@ -385,7 +385,7 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
385
385
  }
386
386
 
387
387
  /**
388
- * Render HTML to markdown using native WASM, jina, trafilatura, lynx, or html-to-markdown (in order of preference)
388
+ * Render HTML to markdown using native, jina, trafilatura, lynx (in order of preference)
389
389
  */
390
390
  async function renderHtmlToText(
391
391
  url: string,
@@ -438,7 +438,7 @@ async function renderHtmlToText(
438
438
  }
439
439
  }
440
440
 
441
- // Fall back to native WASM converter (fastest, no network/subprocess)
441
+ // Fall back to native converter (fastest, no network/subprocess)
442
442
  try {
443
443
  const content = await htmlToMarkdown(html, { cleanContent: true });
444
444
  if (content.trim().length > 100 && !isLowQualityOutput(content)) {
@@ -1,9 +1,8 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import { getEnv, getEnvApiKey, StringEnum } from "@oh-my-pi/pi-ai";
4
- import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
3
+ import { getEnvApiKey, StringEnum } from "@oh-my-pi/pi-ai";
4
+ import { $env, ptree, Snowflake, untilAborted } from "@oh-my-pi/pi-utils";
5
5
  import { type Static, Type } from "@sinclair/typebox";
6
- import { nanoid } from "nanoid";
7
6
  import type { ModelRegistry } from "../config/model-registry";
8
7
  import { renderPromptTemplate } from "../config/prompt-templates";
9
8
  import type { CustomTool } from "../extensibility/custom-tools/types";
@@ -409,7 +408,7 @@ async function findImageApiKey(modelRegistry?: ModelRegistry): Promise<ImageApiK
409
408
  if (preferredImageProvider === "gemini") {
410
409
  const geminiKey = getEnvApiKey("google");
411
410
  if (geminiKey) return { provider: "gemini", apiKey: geminiKey };
412
- const googleKey = getEnv("GOOGLE_API_KEY");
411
+ const googleKey = $env.GOOGLE_API_KEY;
413
412
  if (googleKey) return { provider: "gemini", apiKey: googleKey };
414
413
  // Fall through to auto-detect if preferred provider key not found
415
414
  } else if (preferredImageProvider === "openrouter") {
@@ -430,7 +429,7 @@ async function findImageApiKey(modelRegistry?: ModelRegistry): Promise<ImageApiK
430
429
  const geminiKey = getEnvApiKey("google");
431
430
  if (geminiKey) return { provider: "gemini", apiKey: geminiKey };
432
431
 
433
- const googleKey = getEnv("GOOGLE_API_KEY");
432
+ const googleKey = $env.GOOGLE_API_KEY;
434
433
  if (googleKey) return { provider: "gemini", apiKey: googleKey };
435
434
 
436
435
  return null;
@@ -487,7 +486,7 @@ function getExtensionForMime(mimeType: string): string {
487
486
 
488
487
  async function saveImageToTemp(image: InlineImageData): Promise<string> {
489
488
  const ext = getExtensionForMime(image.mimeType);
490
- const filename = `omp-image-${nanoid()}.${ext}`;
489
+ const filename = `omp-image-${Snowflake.next()}.${ext}`;
491
490
  const filepath = path.join(os.tmpdir(), filename);
492
491
  await Bun.write(filepath, Buffer.from(image.data, "base64"));
493
492
  return filepath;
package/src/tools/grep.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as nodePath from "node:path";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
3
 
4
- import { grep as wasmGrep } from "@oh-my-pi/pi-natives";
4
+ import { grep } from "@oh-my-pi/pi-natives";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { untilAborted } from "@oh-my-pi/pi-utils";
@@ -124,10 +124,10 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
124
124
  const effectiveOutputMode = "content";
125
125
  const effectiveLimit = normalizedLimit ?? DEFAULT_MATCH_LIMIT;
126
126
 
127
- // Run WASM grep
128
- let result: Awaited<ReturnType<typeof wasmGrep>>;
127
+ // Run grep
128
+ let result: Awaited<ReturnType<typeof grep>>;
129
129
  try {
130
- result = await wasmGrep({
130
+ result = await grep({
131
131
  pattern: normalizedPattern,
132
132
  path: searchPath,
133
133
  glob: glob?.trim() || undefined,
@@ -150,7 +150,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
150
150
  }
151
151
 
152
152
  const formatPath = (filePath: string): string => {
153
- // WASM returns paths starting with / (the virtual root)
153
+ // returns paths starting with / (the virtual root)
154
154
  const cleanPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
155
155
  if (isDirectory) {
156
156
  return cleanPath.replace(/\\/g, "/");
@@ -1,5 +1,5 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
- import { logger } from "@oh-my-pi/pi-utils";
2
+ import { $env, logger } from "@oh-my-pi/pi-utils";
3
3
  import type { PromptTemplate } from "../config/prompt-templates";
4
4
  import type { Settings } from "../config/settings";
5
5
  import type { Skill } from "../extensibility/skills";
@@ -127,6 +127,8 @@ export interface ToolSession {
127
127
  outputSchema?: unknown;
128
128
  /** Whether to include the submit_result tool by default */
129
129
  requireSubmitResultTool?: boolean;
130
+ /** Task recursion depth (0 = top-level, 1 = first child, etc.) */
131
+ taskDepth?: number;
130
132
  /** Get session file */
131
133
  getSessionFile: () => string | null;
132
134
  /** Get session ID */
@@ -192,7 +194,7 @@ export type ToolName = keyof typeof BUILTIN_TOOLS;
192
194
  export type PythonToolMode = "ipy-only" | "bash-only" | "both";
193
195
 
194
196
  /**
195
- * Parse OMP_PY environment variable to determine Python tool mode.
197
+ * Parse PI_PY environment variable to determine Python tool mode.
196
198
  * Returns null if not set or invalid.
197
199
  *
198
200
  * Values:
@@ -201,7 +203,7 @@ export type PythonToolMode = "ipy-only" | "bash-only" | "both";
201
203
  * - "mix" or "both" → both
202
204
  */
203
205
  function getPythonModeFromEnv(): PythonToolMode | null {
204
- const value = process.env.OMP_PY?.toLowerCase();
206
+ const value = $env.PI_PY?.toLowerCase();
205
207
  if (!value) return null;
206
208
 
207
209
  switch (value) {
@@ -238,7 +240,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
238
240
  pythonMode !== "bash-only" &&
239
241
  (requestedTools === undefined || requestedTools.includes("python"));
240
242
  const isTestEnv = process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test";
241
- const skipPythonWarm = isTestEnv || process.env.OMP_PYTHON_SKIP_CHECK === "1";
243
+ const skipPythonWarm = isTestEnv || $env.PI_PYTHON_SKIP_CHECK === "1";
242
244
  if (shouldCheckPython) {
243
245
  const availability = await checkPythonKernelAvailability(session.cwd);
244
246
  time("createTools:pythonCheck");
@@ -287,6 +289,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
287
289
  if (name === "lsp") return session.settings.get("lsp.enabled");
288
290
  if (name === "calc") return session.settings.get("calc.enabled");
289
291
  if (name === "browser") return session.settings.get("browser.enabled");
292
+ if (name === "task") {
293
+ const maxDepth = session.settings.get("task.maxRecursionDepth") ?? 2;
294
+ const currentDepth = session.taskDepth ?? 0;
295
+ return maxDepth < 0 || currentDepth < maxDepth;
296
+ }
290
297
  return true;
291
298
  };
292
299
  if (includeSubmitResult && requestedTools && !requestedTools.includes("submit_result")) {
@@ -317,7 +324,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
317
324
  }),
318
325
  );
319
326
  time("createTools:afterFactories");
320
- if (slowTools.length > 0 && process.env.OMP_TIMING === "1") {
327
+ if (slowTools.length > 0 && $env.PI_TIMING === "1") {
321
328
  logger.debug("Tool factory timings", { slowTools });
322
329
  }
323
330
  const tools = results.filter(r => r.tool !== null).map(r => r.tool as Tool);
package/src/tools/read.ts CHANGED
@@ -636,7 +636,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
636
636
  const base64 = Buffer.from(buffer).toString("base64");
637
637
 
638
638
  if (this.autoResizeImages) {
639
- // Resize image if needed - catch errors from WASM
639
+ // Resize image if needed - catch errors from Photon
640
640
  try {
641
641
  const resized = await resizeImage({ type: "image", data: base64, mimeType });
642
642
  const dimensionNote = formatDimensionNote(resized);