@oh-my-pi/pi-coding-agent 14.9.2 → 14.9.5

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 (97) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +3 -3
  4. package/src/async/job-manager.ts +66 -9
  5. package/src/capability/rule.ts +20 -0
  6. package/src/config/model-registry.ts +13 -0
  7. package/src/config/model-resolver.ts +8 -2
  8. package/src/config/prompt-templates.ts +0 -5
  9. package/src/config/settings-schema.ts +39 -1
  10. package/src/edit/index.ts +8 -0
  11. package/src/edit/renderer.ts +6 -1
  12. package/src/edit/streaming.ts +53 -2
  13. package/src/eval/eval.lark +10 -31
  14. package/src/eval/index.ts +1 -0
  15. package/src/eval/js/context-manager.ts +1 -38
  16. package/src/eval/js/prelude.txt +0 -2
  17. package/src/eval/parse.ts +156 -255
  18. package/src/eval/py/executor.ts +24 -8
  19. package/src/eval/py/index.ts +1 -0
  20. package/src/eval/py/prelude.py +11 -80
  21. package/src/eval/sniff.ts +28 -0
  22. package/src/export/html/template.css +50 -0
  23. package/src/export/html/template.generated.ts +1 -1
  24. package/src/export/html/template.js +229 -17
  25. package/src/extensibility/plugins/loader.ts +31 -6
  26. package/src/extensibility/skills.ts +20 -0
  27. package/src/hashline/constants.ts +20 -0
  28. package/src/hashline/grammar.lark +16 -23
  29. package/src/hashline/hash.ts +4 -34
  30. package/src/hashline/input.ts +16 -2
  31. package/src/hashline/parser.ts +12 -1
  32. package/src/internal-urls/agent-protocol.ts +64 -52
  33. package/src/internal-urls/artifact-protocol.ts +52 -51
  34. package/src/internal-urls/docs-index.generated.ts +34 -1
  35. package/src/internal-urls/index.ts +6 -19
  36. package/src/internal-urls/local-protocol.ts +50 -7
  37. package/src/internal-urls/mcp-protocol.ts +3 -8
  38. package/src/internal-urls/memory-protocol.ts +90 -59
  39. package/src/internal-urls/pi-protocol.ts +1 -0
  40. package/src/internal-urls/router.ts +40 -23
  41. package/src/internal-urls/rule-protocol.ts +3 -20
  42. package/src/internal-urls/skill-protocol.ts +5 -27
  43. package/src/internal-urls/types.ts +18 -2
  44. package/src/main.ts +1 -1
  45. package/src/mcp/manager.ts +17 -0
  46. package/src/modes/components/session-observer-overlay.ts +2 -2
  47. package/src/modes/components/tool-execution.ts +6 -0
  48. package/src/modes/components/tree-selector.ts +4 -0
  49. package/src/modes/controllers/event-controller.ts +23 -2
  50. package/src/modes/controllers/mcp-command-controller.ts +7 -10
  51. package/src/modes/interactive-mode.ts +2 -2
  52. package/src/modes/theme/theme.ts +27 -27
  53. package/src/modes/types.ts +1 -1
  54. package/src/modes/utils/ui-helpers.ts +14 -9
  55. package/src/prompts/commands/orchestrate.md +1 -0
  56. package/src/prompts/system/custom-system-prompt.md +0 -2
  57. package/src/prompts/system/project-prompt.md +10 -0
  58. package/src/prompts/system/subagent-system-prompt.md +18 -9
  59. package/src/prompts/system/subagent-user-prompt.md +1 -10
  60. package/src/prompts/system/system-prompt.md +159 -232
  61. package/src/prompts/tools/ask.md +0 -1
  62. package/src/prompts/tools/bash.md +0 -34
  63. package/src/prompts/tools/eval.md +27 -16
  64. package/src/prompts/tools/github.md +6 -5
  65. package/src/prompts/tools/hashline.md +1 -0
  66. package/src/prompts/tools/job.md +14 -6
  67. package/src/prompts/tools/task.md +20 -3
  68. package/src/registry/agent-registry.ts +2 -1
  69. package/src/sdk.ts +87 -89
  70. package/src/session/agent-session.ts +107 -37
  71. package/src/session/artifacts.ts +7 -4
  72. package/src/session/session-manager.ts +30 -1
  73. package/src/ssh/connection-manager.ts +32 -16
  74. package/src/ssh/sshfs-mount.ts +10 -7
  75. package/src/system-prompt.ts +3 -9
  76. package/src/task/executor.ts +23 -7
  77. package/src/task/index.ts +57 -36
  78. package/src/tool-discovery/tool-index.ts +21 -8
  79. package/src/tools/ast-edit.ts +3 -2
  80. package/src/tools/ast-grep.ts +3 -2
  81. package/src/tools/bash.ts +30 -50
  82. package/src/tools/browser/tab-supervisor.ts +12 -2
  83. package/src/tools/eval.ts +59 -44
  84. package/src/tools/fetch.ts +1 -1
  85. package/src/tools/gh.ts +140 -4
  86. package/src/tools/index.ts +12 -11
  87. package/src/tools/job.ts +48 -12
  88. package/src/tools/path-utils.ts +21 -1
  89. package/src/tools/read.ts +74 -31
  90. package/src/tools/search.ts +16 -3
  91. package/src/tools/todo-write.ts +1 -1
  92. package/src/utils/file-display-mode.ts +11 -5
  93. package/src/web/scrapers/mastodon.ts +1 -1
  94. package/src/web/scrapers/repology.ts +7 -7
  95. package/src/internal-urls/jobs-protocol.ts +0 -119
  96. package/src/task/template.ts +0 -47
  97. package/src/tools/bash-normalize.ts +0 -107
@@ -16,6 +16,17 @@ import type {
16
16
  WorkerInitPayload,
17
17
  WorkerOutbound,
18
18
  } from "./tab-protocol";
19
+ // Imported with `type: "file"` so Bun's bundler statically discovers the
20
+ // worker entry and embeds it inside `bun build --compile` single-file
21
+ // binaries. Without this attribute the bundler cannot reach the entry through
22
+ // a `new URL(..., import.meta.url)` literal stored in a local variable, and
23
+ // the prebuilt binary surfaces `Timed out initializing browser tab worker`
24
+ // (issue #1011) because `/$bunfs/root/tab-worker-entry.ts` is missing.
25
+ // tsgo doesn't recognize Bun's `with { type: "file" }` attribute and treats
26
+ // this as a normal TS source import, raising TS1192/TS5097. Bun's bundler
27
+ // (and runtime) honors the attribute and returns the embedded file URL.
28
+ // @ts-expect-error -- Bun file-URL import (see comment above).
29
+ import tabWorkerEntryUrl from "./tab-worker-entry.ts" with { type: "file" };
19
30
 
20
31
  interface WorkerHandle {
21
32
  send(msg: WorkerInbound, transferList?: Transferable[]): void;
@@ -364,8 +375,7 @@ async function raceWithTimeout<T>(
364
375
 
365
376
  async function spawnTabWorker(): Promise<WorkerHandle> {
366
377
  try {
367
- const url = new URL("./tab-worker-entry.ts", import.meta.url);
368
- const worker = new Worker(url.href, { type: "module" });
378
+ const worker = new Worker(tabWorkerEntryUrl, { type: "module" });
369
379
  return wrapBunWorker(worker);
370
380
  } catch (err) {
371
381
  logger.warn("Bun Worker spawn failed; using inline tab worker (no sync-loop guard)", {
package/src/tools/eval.ts CHANGED
@@ -4,11 +4,11 @@ import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { Markdown, Text } from "@oh-my-pi/pi-tui";
5
5
  import { prompt } from "@oh-my-pi/pi-utils";
6
6
  import { type Static, Type } from "@sinclair/typebox";
7
- import { jsBackend, parseEvalInput, pythonBackend } from "../eval";
7
+ import { jsBackend, parseEvalInput, pythonBackend, sniffEvalLanguage } from "../eval";
8
8
  import type { ExecutorBackend } from "../eval/backend";
9
9
  import evalGrammar from "../eval/eval.lark" with { type: "text" };
10
- import type { ParsedEvalCell } from "../eval/parse";
11
- import type { EvalCellResult, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
10
+ import { ABORT_WARNING, type ParsedEvalCell } from "../eval/parse";
11
+ import type { EvalCellResult, EvalDisplayOutput, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
14
14
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
@@ -26,7 +26,7 @@ export const EVAL_DEFAULT_PREVIEW_LINES = 10;
26
26
 
27
27
  export const evalSchema = Type.Object({
28
28
  input: Type.String({
29
- description: "eval input as a sequence of `===== <info> =====` cell headers followed by code",
29
+ description: "eval input as a sequence of `*** Begin <LANG>` cell headers followed by code",
30
30
  }),
31
31
  });
32
32
  export type EvalToolParams = Static<typeof evalSchema>;
@@ -47,6 +47,38 @@ function formatJsonScalar(value: unknown): string {
47
47
  return "[object]";
48
48
  }
49
49
 
50
+ /** Cap per `display()` value sent back to the model. */
51
+ const MAX_DISPLAY_TEXT_BYTES = 8000;
52
+
53
+ function formatDisplayJsonForText(value: unknown): string {
54
+ let text: string;
55
+ try {
56
+ text = JSON.stringify(value, null, 2) ?? String(value);
57
+ } catch {
58
+ text = String(value);
59
+ }
60
+ if (text.length > MAX_DISPLAY_TEXT_BYTES) {
61
+ text = `${text.slice(0, MAX_DISPLAY_TEXT_BYTES)}\n… (${text.length - MAX_DISPLAY_TEXT_BYTES} chars truncated)`;
62
+ }
63
+ return text;
64
+ }
65
+
66
+ /**
67
+ * Format display() JSON values into text the model can see. Images are surfaced
68
+ * separately as ImageContent so the model can actually inspect them; this helper
69
+ * intentionally does not touch images.
70
+ */
71
+ function formatDisplayOutputsForText(outputs: EvalDisplayOutput[]): string {
72
+ const chunks: string[] = [];
73
+ let displayIndex = 0;
74
+ for (const output of outputs) {
75
+ if (output.type !== "json") continue;
76
+ displayIndex++;
77
+ chunks.push(`display[${displayIndex}]:\n${formatDisplayJsonForText(output.data)}`);
78
+ }
79
+ return chunks.join("\n\n");
80
+ }
81
+
50
82
  function renderJsonTree(value: unknown, theme: Theme, expanded: boolean, maxDepth = expanded ? 6 : 2): string[] {
51
83
  const maxItems = expanded ? 20 : 5;
52
84
 
@@ -131,33 +163,6 @@ function timeoutSecondsFromMs(timeoutMs: number): number {
131
163
  return clampTimeout("eval", timeoutMs / 1000);
132
164
  }
133
165
 
134
- /**
135
- * Best-effort language sniff for cells with no explicit `language`.
136
- *
137
- * Order:
138
- * 1. Shebang on first line (`#!/usr/bin/env python`, `#!/usr/bin/env node`, etc.)
139
- * 2. Strong syntactic markers unique to one language. We bias false negatives over
140
- * false positives — anything ambiguous returns `undefined` and the caller falls
141
- * back to the default-backend rules.
142
- */
143
- function sniffLanguage(code: string): EvalLanguage | undefined {
144
- const stripped = code.replace(/^\s+/, "");
145
- if (stripped.startsWith("#!")) {
146
- const firstLine = stripped.split("\n", 1)[0]!.toLowerCase();
147
- if (/(\bpython\d?\b|\bipython\b)/.test(firstLine)) return "python";
148
- if (/(\bnode\b|\bbun\b|\bdeno\b|\bjavascript\b|\bjs\b)/.test(firstLine)) return "js";
149
- }
150
- const jsMarkers =
151
- /(^|\n)\s*(const|let|var|async\s+function|function\s*\*?\s*[\w$]*\s*\(|import\s+[^\n]+\sfrom\s|export\s+(default|const|let|function|class|async)|require\s*\(|console\.\w+\s*\(|=>|;\s*$)/m;
152
- const pyMarkers =
153
- /(^|\n)\s*(def\s+\w+\s*\(|from\s+[\w.]+\s+import|import\s+\w+(\s+as\s+\w+)?\s*$|class\s+\w+\s*[(:]|print\s*\(|elif\s+[^\n]*:|with\s+[^\n]+:\s*$|@[\w.]+\s*$)/m;
154
- const hasJs = jsMarkers.test(code);
155
- const hasPy = pyMarkers.test(code);
156
- if (hasJs && !hasPy) return "js";
157
- if (hasPy && !hasJs) return "python";
158
- return undefined;
159
- }
160
-
161
166
  async function resolveBackend(
162
167
  session: ToolSession,
163
168
  requested: EvalLanguage | undefined,
@@ -180,7 +185,7 @@ async function resolveBackend(
180
185
  return { backend: jsBackend, fallback: false };
181
186
  }
182
187
  // Auto-detect.
183
- const sniffed = sniffLanguage(code);
188
+ const sniffed = sniffEvalLanguage(code);
184
189
  if (sniffed === "python" && allowPy && (await pythonBackend.isAvailable(session))) {
185
190
  return { backend: pythonBackend, fallback: false };
186
191
  }
@@ -397,13 +402,16 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
397
402
  const durationMs = Date.now() - startTime;
398
403
 
399
404
  const cellStatusEvents: EvalStatusEvent[] = [];
405
+ const cellDisplayOutputs: EvalDisplayOutput[] = [];
400
406
  let cellHasMarkdown = false;
401
407
  for (const output of result.displayOutputs) {
402
408
  if (output.type === "json") {
403
409
  jsonOutputs.push(output.data);
410
+ cellDisplayOutputs.push(output);
404
411
  }
405
412
  if (output.type === "image") {
406
413
  images.push({ type: "image", data: output.data, mimeType: output.mimeType });
414
+ cellDisplayOutputs.push(output);
407
415
  }
408
416
  if (output.type === "status") {
409
417
  statusEvents.push(output.event);
@@ -414,7 +422,10 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
414
422
  }
415
423
  }
416
424
 
417
- const cellOutput = result.output.trim();
425
+ const stdoutTrimmed = result.output.trim();
426
+ const displayText = formatDisplayOutputsForText(cellDisplayOutputs);
427
+ const cellOutput =
428
+ stdoutTrimmed && displayText ? `${stdoutTrimmed}\n\n${displayText}` : stdoutTrimmed || displayText;
418
429
  cellResult.output = cellOutput;
419
430
  cellResult.exitCode = result.exitCode;
420
431
  cellResult.durationMs = durationMs;
@@ -446,10 +457,11 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
446
457
  pushUpdate();
447
458
  const errorMsg = result.output || "Command aborted";
448
459
  const combinedOutput = cellOutputs.join("\n\n");
460
+ const abortSuffix = parsedInput.aborted ? `\n\n${ABORT_WARNING}` : "";
449
461
  const outputText =
450
- cells.length > 1
462
+ (cells.length > 1
451
463
  ? `${combinedOutput}\n\nCell ${i + 1} aborted: ${errorMsg}`
452
- : combinedOutput || errorMsg;
464
+ : combinedOutput || errorMsg) + abortSuffix;
453
465
 
454
466
  const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
455
467
  const details: EvalToolDetails = {
@@ -457,14 +469,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
457
469
  languages,
458
470
  cells: cellResults,
459
471
  jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
460
- images: images.length > 0 ? images : undefined,
461
472
  statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
462
473
  isError: true,
463
474
  };
464
475
  if (notice) details.notice = notice;
465
476
 
466
477
  return toolResult(details)
467
- .text(outputText)
478
+ .content([{ type: "text", text: outputText }, ...images])
468
479
  .truncationFromSummary(summaryForMeta, { direction: "tail" })
469
480
  .done();
470
481
  }
@@ -473,12 +484,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
473
484
  cellResult.status = "error";
474
485
  pushUpdate();
475
486
  const combinedOutput = cellOutputs.join("\n\n");
487
+ const abortSuffix = parsedInput.aborted ? `\n\n${ABORT_WARNING}` : "";
476
488
  const outputText =
477
- cells.length > 1
489
+ (cells.length > 1
478
490
  ? `${combinedOutput}\n\nCell ${i + 1} failed (exit code ${result.exitCode}). Earlier cells succeeded—their state persists. Fix only cell ${i + 1}.`
479
491
  : combinedOutput
480
492
  ? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`
481
- : `Command exited with code ${result.exitCode}`;
493
+ : `Command exited with code ${result.exitCode}`) + abortSuffix;
482
494
 
483
495
  const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
484
496
  const details: EvalToolDetails = {
@@ -486,14 +498,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
486
498
  languages,
487
499
  cells: cellResults,
488
500
  jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
489
- images: images.length > 0 ? images : undefined,
490
501
  statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
491
502
  isError: true,
492
503
  };
493
504
  if (notice) details.notice = notice;
494
505
 
495
506
  return toolResult(details)
496
- .text(outputText)
507
+ .content([{ type: "text", text: outputText }, ...images])
497
508
  .truncationFromSummary(summaryForMeta, { direction: "tail" })
498
509
  .done();
499
510
  }
@@ -503,8 +514,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
503
514
  }
504
515
 
505
516
  const combinedOutput = cellOutputs.join("\n\n");
517
+ const abortSuffix = parsedInput.aborted ? `\n\n${ABORT_WARNING}` : "";
518
+ const hasImages = images.length > 0;
506
519
  const outputText =
507
- combinedOutput || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
520
+ (combinedOutput ||
521
+ (hasImages
522
+ ? `(displayed ${images.length} image${images.length === 1 ? "" : "s"}; no text output)`
523
+ : "(no output)")) + abortSuffix;
508
524
  const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
509
525
 
510
526
  const details: EvalToolDetails = {
@@ -512,13 +528,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
512
528
  languages,
513
529
  cells: cellResults,
514
530
  jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
515
- images: images.length > 0 ? images : undefined,
516
531
  statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
517
532
  };
518
533
  if (notice) details.notice = notice;
519
534
 
520
535
  return toolResult(details)
521
- .text(outputText)
536
+ .content([{ type: "text", text: outputText }, ...images])
522
537
  .truncationFromSummary(summaryForMeta, { direction: "tail" })
523
538
  .done();
524
539
  } finally {
@@ -1352,7 +1352,7 @@ export function renderReadUrlCall(
1352
1352
  ): Component {
1353
1353
  const url = args.path ?? args.url ?? "";
1354
1354
  const domain = getDomain(url);
1355
- const path = truncate(url.replace(/^https?:\/\/[^/]+/, ""), 50, "\u2026");
1355
+ const path = truncate(url.replace(/^https?:\/\/[^/]+/, ""), 50, "");
1356
1356
  const description = `${domain}${path ? ` ${path}` : ""}`.trim();
1357
1357
  const meta: string[] = [];
1358
1358
  if (args.raw) meta.push("raw");
package/src/tools/gh.ts CHANGED
@@ -260,6 +260,27 @@ const githubSchema = Type.Object({
260
260
  examples: ["is:open label:bug"],
261
261
  }),
262
262
  ),
263
+ since: Type.Optional(
264
+ Type.String({
265
+ description:
266
+ "lower-bound date for search_issues/search_prs/search_commits/search_repos. Accepts a relative duration (`<n><unit>` with unit `m`/`h`/`d`/`w`/`mo`/`y`, e.g. `3d`, `12h`, `2w`) or an ISO date (`YYYY-MM-DD`) / datetime. Translated to a `created:>=…` (or `committer-date:`/`pushed:`) qualifier; not supported by search_code.",
267
+ examples: ["3d", "2w", "2026-05-01"],
268
+ }),
269
+ ),
270
+ until: Type.Optional(
271
+ Type.String({
272
+ description:
273
+ "upper-bound date in the same format as `since`. With both, builds a `field:since..until` range qualifier.",
274
+ examples: ["1d", "2026-05-09"],
275
+ }),
276
+ ),
277
+ dateField: Type.Optional(
278
+ StringEnum(["created", "updated"], {
279
+ description:
280
+ "date field used by `since`/`until`. issues/prs: `created` (default) or `updated`. repos: `created` (default) or `updated` (mapped to GitHub's `pushed:`). commits: ignored — always uses `committer-date`.",
281
+ default: "created",
282
+ }),
283
+ ),
263
284
  limit: Type.Optional(
264
285
  Type.Number({
265
286
  description: "max results (search_issues, search_prs, search_code, search_commits, search_repos)",
@@ -686,6 +707,110 @@ const SEARCH_FIELDS_BY_COMMAND: Record<"issues" | "prs" | "code" | "commits" | "
686
707
  repos: GH_SEARCH_REPOS_FIELDS,
687
708
  };
688
709
 
710
+ const RELATIVE_DURATION_PATTERN = /^(\d+)\s*(m|h|d|w|mo|y)$/i;
711
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
712
+ const FIXED_UNIT_MS: Record<string, number> = {
713
+ m: 60_000,
714
+ h: 3_600_000,
715
+ d: 86_400_000,
716
+ w: 7 * 86_400_000,
717
+ };
718
+
719
+ /**
720
+ * Resolve a search date bound to a GitHub-search-compatible literal. Returns
721
+ * either a `YYYY-MM-DD` date (relative durations and date-only inputs) or a
722
+ * full ISO 8601 datetime string (datetime inputs), so the caller can drop it
723
+ * straight into a qualifier like `created:>=<value>`.
724
+ */
725
+ export function parseSearchDateBound(raw: string, now: Date = new Date()): string {
726
+ const trimmed = raw.trim();
727
+ if (!trimmed) {
728
+ throw new ToolError("date bound must not be empty");
729
+ }
730
+
731
+ const relMatch = trimmed.match(RELATIVE_DURATION_PATTERN);
732
+ if (relMatch) {
733
+ const count = Number(relMatch[1]);
734
+ const unit = relMatch[2].toLowerCase();
735
+ const fixedMs = FIXED_UNIT_MS[unit];
736
+ let bound: Date;
737
+ if (fixedMs !== undefined) {
738
+ bound = new Date(now.getTime() - count * fixedMs);
739
+ } else {
740
+ bound = new Date(now);
741
+ if (unit === "mo") {
742
+ bound.setUTCMonth(bound.getUTCMonth() - count);
743
+ } else {
744
+ bound.setUTCFullYear(bound.getUTCFullYear() - count);
745
+ }
746
+ }
747
+ return bound.toISOString().slice(0, 10);
748
+ }
749
+
750
+ if (ISO_DATE_PATTERN.test(trimmed)) {
751
+ return trimmed;
752
+ }
753
+
754
+ const parsedMs = Date.parse(trimmed);
755
+ if (!Number.isNaN(parsedMs)) {
756
+ return new Date(parsedMs).toISOString();
757
+ }
758
+
759
+ throw new ToolError(
760
+ `invalid date bound: ${raw}. Expected a relative duration like "3d", "12h", "2w", an ISO date "YYYY-MM-DD", or an ISO datetime.`,
761
+ );
762
+ }
763
+
764
+ /**
765
+ * Build the GitHub-search qualifier (e.g. `created:>=2026-05-09`) for the
766
+ * provided bounds, or `undefined` if neither bound is set.
767
+ */
768
+ export function buildSearchDateQualifier(
769
+ field: string,
770
+ since: string | undefined,
771
+ until: string | undefined,
772
+ now?: Date,
773
+ ): string | undefined {
774
+ const sinceVal = since ? parseSearchDateBound(since, now) : undefined;
775
+ const untilVal = until ? parseSearchDateBound(until, now) : undefined;
776
+ if (sinceVal && untilVal) {
777
+ return `${field}:${sinceVal}..${untilVal}`;
778
+ }
779
+ if (sinceVal) {
780
+ return `${field}:>=${sinceVal}`;
781
+ }
782
+ if (untilVal) {
783
+ return `${field}:<=${untilVal}`;
784
+ }
785
+ return undefined;
786
+ }
787
+
788
+ function resolveSearchDateField(
789
+ command: "issues" | "prs" | "commits" | "repos",
790
+ requested: "created" | "updated" | undefined,
791
+ ): string {
792
+ if (command === "commits") {
793
+ return "committer-date";
794
+ }
795
+ const dateField = requested ?? "created";
796
+ if (command === "repos" && dateField === "updated") {
797
+ return "pushed";
798
+ }
799
+ return dateField;
800
+ }
801
+
802
+ function composeSearchQuery(parts: ReadonlyArray<string | undefined>): string {
803
+ const cleaned: string[] = [];
804
+ for (const part of parts) {
805
+ const trimmed = part?.trim();
806
+ if (trimmed) cleaned.push(trimmed);
807
+ }
808
+ if (cleaned.length === 0) {
809
+ throw new ToolError("query is required (or pass since/until to filter by date)");
810
+ }
811
+ return cleaned.join(" ");
812
+ }
813
+
689
814
  function buildGhSearchArgs(
690
815
  command: "issues" | "prs" | "code" | "commits" | "repos",
691
816
  query: string,
@@ -2636,9 +2761,11 @@ async function executeSearchIssues(
2636
2761
  params: GithubInput,
2637
2762
  signal: AbortSignal | undefined,
2638
2763
  ): Promise<AgentToolResult<GhToolDetails>> {
2639
- const query = requireNonEmpty(params.query, "query");
2640
2764
  const repo = normalizeOptionalString(params.repo);
2641
2765
  const limit = resolveSearchLimit(params.limit);
2766
+ const dateField = resolveSearchDateField("issues", params.dateField);
2767
+ const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2768
+ const query = composeSearchQuery([params.query, dateQualifier]);
2642
2769
  const args = buildGhSearchArgs("issues", query, limit, repo);
2643
2770
 
2644
2771
  const items = await git.github.json<GhSearchResult[]>(session.cwd, args, signal, {
@@ -2652,9 +2779,11 @@ async function executeSearchPrs(
2652
2779
  params: GithubInput,
2653
2780
  signal: AbortSignal | undefined,
2654
2781
  ): Promise<AgentToolResult<GhToolDetails>> {
2655
- const query = requireNonEmpty(params.query, "query");
2656
2782
  const repo = normalizeOptionalString(params.repo);
2657
2783
  const limit = resolveSearchLimit(params.limit);
2784
+ const dateField = resolveSearchDateField("prs", params.dateField);
2785
+ const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2786
+ const query = composeSearchQuery([params.query, dateQualifier]);
2658
2787
  const args = buildGhSearchArgs("prs", query, limit, repo);
2659
2788
 
2660
2789
  const items = await git.github.json<GhSearchResult[]>(session.cwd, args, signal, {
@@ -2669,6 +2798,9 @@ async function executeSearchCode(
2669
2798
  signal: AbortSignal | undefined,
2670
2799
  ): Promise<AgentToolResult<GhToolDetails>> {
2671
2800
  const query = requireNonEmpty(params.query, "query");
2801
+ if (params.since !== undefined || params.until !== undefined) {
2802
+ throw new ToolError("search_code does not support since/until; GitHub code search has no date qualifier.");
2803
+ }
2672
2804
  const repo = normalizeOptionalString(params.repo);
2673
2805
  const limit = resolveSearchLimit(params.limit);
2674
2806
  const args = buildGhSearchArgs("code", query, limit, repo);
@@ -2684,9 +2816,11 @@ async function executeSearchCommits(
2684
2816
  params: GithubInput,
2685
2817
  signal: AbortSignal | undefined,
2686
2818
  ): Promise<AgentToolResult<GhToolDetails>> {
2687
- const query = requireNonEmpty(params.query, "query");
2688
2819
  const repo = normalizeOptionalString(params.repo);
2689
2820
  const limit = resolveSearchLimit(params.limit);
2821
+ const dateField = resolveSearchDateField("commits", params.dateField);
2822
+ const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2823
+ const query = composeSearchQuery([params.query, dateQualifier]);
2690
2824
  const args = buildGhSearchArgs("commits", query, limit, repo);
2691
2825
 
2692
2826
  const items = await git.github.json<GhSearchCommitResult[]>(session.cwd, args, signal, {
@@ -2700,8 +2834,10 @@ async function executeSearchRepos(
2700
2834
  params: GithubInput,
2701
2835
  signal: AbortSignal | undefined,
2702
2836
  ): Promise<AgentToolResult<GhToolDetails>> {
2703
- const query = requireNonEmpty(params.query, "query");
2704
2837
  const limit = resolveSearchLimit(params.limit);
2838
+ const dateField = resolveSearchDateField("repos", params.dateField);
2839
+ const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
2840
+ const query = composeSearchQuery([params.query, dateQualifier]);
2705
2841
  const args = buildGhSearchArgs("repos", query, limit, undefined);
2706
2842
 
2707
2843
  const items = await git.github.json<GhSearchRepoResult[]>(session.cwd, args, signal);
@@ -1,17 +1,16 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
2
  import type { ToolChoice } from "@oh-my-pi/pi-ai";
3
3
  import { $env, $flag, logger } from "@oh-my-pi/pi-utils";
4
- import type { AsyncJobManager } from "../async";
5
4
  import type { PromptTemplate } from "../config/prompt-templates";
6
5
  import type { Settings } from "../config/settings";
7
6
  import { EditTool } from "../edit";
8
7
  import { checkPythonKernelAvailability } from "../eval/py/kernel";
9
8
  import type { Skill } from "../extensibility/skills";
10
9
  import type { HindsightSessionState } from "../hindsight/state";
11
- import type { InternalUrlRouter } from "../internal-urls";
12
10
  import { LspTool } from "../lsp";
13
11
  import type { PlanModeState } from "../plan-mode/state";
14
- import type { AgentRegistry } from "../registry/agent-registry";
12
+ import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
13
+ import type { ArtifactManager } from "../session/artifacts";
15
14
  import type { CustomMessage } from "../session/messages";
16
15
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
17
16
  import { TaskTool } from "../task";
@@ -159,6 +158,8 @@ export interface ToolSession {
159
158
  agentRegistry?: AgentRegistry;
160
159
  /** Get artifacts directory for artifact:// URLs */
161
160
  getArtifactsDir?: () => string | null;
161
+ /** Get the ArtifactManager backing this session (shared across parent + subagents). */
162
+ getArtifactManager?: () => ArtifactManager | null;
162
163
  /** Allocate a new artifact path and ID for session-scoped truncated output. */
163
164
  allocateOutputArtifact?: (toolType: string) => Promise<{ id?: string; path?: string }>;
164
165
  /** Get session spawns */
@@ -171,14 +172,8 @@ export interface ToolSession {
171
172
  authStorage?: import("../session/auth-storage").AuthStorage;
172
173
  /** Model registry for passing to subagents (avoids re-discovery) */
173
174
  modelRegistry?: import("../config/model-registry").ModelRegistry;
174
- /** MCP manager for proxying MCP calls through parent */
175
- mcpManager?: import("../mcp/manager").MCPManager;
176
- /** Internal URL router for protocols like agent://, skill://, and mcp:// */
177
- internalRouter?: InternalUrlRouter;
178
175
  /** Agent output manager for unique agent:// IDs across task invocations */
179
176
  agentOutputManager?: AgentOutputManager;
180
- /** Async background job manager for bash/task async execution */
181
- asyncJobManager?: AsyncJobManager;
182
177
  /** Settings instance for passing to subagents */
183
178
  settings: Settings;
184
179
  /** Plan mode state (if active) */
@@ -282,7 +277,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
282
277
  browser: s => new BrowserTool(s),
283
278
  checkpoint: CheckpointTool.createIf,
284
279
  rewind: RewindTool.createIf,
285
- task: TaskTool.create,
280
+ task: s => TaskTool.create(s),
286
281
  job: JobTool.createIf,
287
282
  recipe: RecipeTool.createIf,
288
283
  irc: IrcTool.createIf,
@@ -443,7 +438,13 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
443
438
  if (name === "calc") return session.settings.get("calc.enabled");
444
439
  if (name === "browser") return session.settings.get("browser.enabled");
445
440
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
446
- if (name === "irc") return session.settings.get("irc.enabled");
441
+ if (name === "irc") {
442
+ if (!session.settings.get("irc.enabled")) return false;
443
+ // Main agent only needs `irc` when subagents may run concurrently (async).
444
+ // In sync mode main blocks on `task`, so peer messaging from main is dead weight.
445
+ if (!session.settings.get("async.enabled") && session.getAgentId?.() === MAIN_AGENT_ID) return false;
446
+ return true;
447
+ }
447
448
  if (name === "recipe") return session.settings.get("recipe.enabled");
448
449
  if (name === "retain" || name === "recall" || name === "reflect") {
449
450
  return session.settings.get("memory.backend") === "hindsight";
package/src/tools/job.ts CHANGED
@@ -3,7 +3,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Text } from "@oh-my-pi/pi-tui";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import { type Static, Type } from "@sinclair/typebox";
6
- import { isBackgroundJobSupportEnabled } from "../async";
6
+ import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
7
7
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
8
  import type { Theme } from "../modes/theme/theme";
9
9
  import jobDescription from "../prompts/tools/job.md" with { type: "text" };
@@ -20,6 +20,7 @@ import {
20
20
  type ToolUIColor,
21
21
  type ToolUIStatus,
22
22
  } from "./render-utils";
23
+ import { ToolError } from "./tool-errors";
23
24
 
24
25
  const jobSchema = Type.Object({
25
26
  poll: Type.Optional(
@@ -34,6 +35,12 @@ const jobSchema = Type.Object({
34
35
  examples: [["job-1234"]],
35
36
  }),
36
37
  ),
38
+ list: Type.Optional(
39
+ Type.Boolean({
40
+ description:
41
+ "Return an immediate snapshot of every job spawned by this agent (running + completed within retention). Read-only \u2014 cannot be combined with `poll` or `cancel`.",
42
+ }),
43
+ ),
37
44
  });
38
45
 
39
46
  type JobParams = Static<typeof jobSchema>;
@@ -97,7 +104,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
97
104
  onUpdate?: AgentToolUpdateCallback<JobToolDetails>,
98
105
  _context?: AgentToolContext,
99
106
  ): Promise<AgentToolResult<JobToolDetails>> {
100
- const manager = this.session.asyncJobManager;
107
+ const manager = AsyncJobManager.instance();
101
108
  if (!manager) {
102
109
  return {
103
110
  content: [{ type: "text", text: "Async execution is disabled; no background jobs are available." }],
@@ -105,11 +112,24 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
105
112
  };
106
113
  }
107
114
 
115
+ // Scope every visible operation to the calling agent. Tests / SDK
116
+ // consumers without an agent id see everything (legacy behavior).
117
+ const ownerId = this.session.getAgentId?.() ?? undefined;
118
+ const ownerFilter = ownerId ? { ownerId } : undefined;
119
+
120
+ // `list` is a read-only snapshot mode. Replaces the legacy `jobs://` URL.
121
+ if (params.list) {
122
+ if (params.cancel?.length || params.poll?.length) {
123
+ throw new ToolError("`list` cannot be combined with `poll` or `cancel`.");
124
+ }
125
+ return this.#buildResult(manager, manager.getAllJobs(ownerFilter), []);
126
+ }
127
+
108
128
  const cancelIds = params.cancel ?? [];
109
129
  const cancelOutcomes: CancelOutcome[] = [];
110
130
  for (const id of cancelIds) {
111
131
  const existing = manager.getJob(id);
112
- if (!existing) {
132
+ if (!existing || (ownerId && existing.ownerId !== ownerId)) {
113
133
  cancelOutcomes.push({ id, status: "not_found", message: `Background job not found: ${id}` });
114
134
  continue;
115
135
  }
@@ -121,7 +141,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
121
141
  });
122
142
  continue;
123
143
  }
124
- const cancelled = manager.cancel(id);
144
+ const cancelled = manager.cancel(id, ownerFilter);
125
145
  cancelOutcomes.push(
126
146
  cancelled
127
147
  ? { id, status: "cancelled", message: `Cancelled background job ${id}.` }
@@ -130,11 +150,11 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
130
150
  }
131
151
 
132
152
  const requestedPollIds = params.poll;
133
- // If only `cancel` was provided (no `poll`), don't wait return immediately.
153
+ // If only `cancel` was provided (no `poll`), don't wait \u2014 return immediately.
134
154
  const shouldPoll = requestedPollIds !== undefined || cancelIds.length === 0;
135
155
 
136
156
  if (!shouldPoll) {
137
- const cancelledJobs = cancelIds.map(id => manager.getJob(id)).filter(j => j != null);
157
+ const cancelledJobs = this.#visibleJobs(manager, cancelIds, ownerId);
138
158
  return this.#buildResult(manager, cancelledJobs, cancelOutcomes);
139
159
  }
140
160
 
@@ -142,12 +162,12 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
142
162
  // - If `poll` was passed explicitly, watch exactly those (filtered to existing).
143
163
  // - If `poll` was omitted (and so was `cancel`), default to all running jobs.
144
164
  const jobsToWatch = requestedPollIds
145
- ? requestedPollIds.map(id => manager.getJob(id)).filter(j => j != null)
146
- : manager.getRunningJobs();
165
+ ? this.#visibleJobs(manager, requestedPollIds, ownerId)
166
+ : manager.getRunningJobs(ownerFilter);
147
167
 
148
168
  if (jobsToWatch.length === 0) {
149
169
  if (cancelOutcomes.length > 0) {
150
- const cancelledJobs = cancelIds.map(id => manager.getJob(id)).filter(j => j != null);
170
+ const cancelledJobs = this.#visibleJobs(manager, cancelIds, ownerId);
151
171
  return this.#buildResult(manager, cancelledJobs, cancelOutcomes);
152
172
  }
153
173
  const message = requestedPollIds?.length
@@ -176,7 +196,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
176
196
  const watchedJobIds = runningJobs.map(job => job.id);
177
197
  manager.watchJobs(watchedJobIds);
178
198
 
179
- const cancelledJobs = cancelIds.map(id => manager.getJob(id)).filter(j => j != null);
199
+ const cancelledJobs = this.#visibleJobs(manager, cancelIds, ownerId);
180
200
  const allTrackedJobs = [...cancelledJobs, ...jobsToWatch];
181
201
 
182
202
  const PROGRESS_INTERVAL_MS = 500;
@@ -219,6 +239,22 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
219
239
  return this.#buildResult(manager, allTrackedJobs, cancelOutcomes);
220
240
  }
221
241
 
242
+ /**
243
+ * Resolve a list of job ids to job records visible to the calling agent.
244
+ * Drops missing ids and ids owned by other agents, so cross-agent inspection
245
+ * via the `job` tool is impossible.
246
+ */
247
+ #visibleJobs(manager: AsyncJobManager, ids: string[], ownerId: string | undefined): AsyncJob[] {
248
+ const out: AsyncJob[] = [];
249
+ for (const id of ids) {
250
+ const job = manager.getJob(id);
251
+ if (!job) continue;
252
+ if (ownerId && job.ownerId !== ownerId) continue;
253
+ out.push(job);
254
+ }
255
+ return out;
256
+ }
257
+
222
258
  #snapshotJobs(
223
259
  jobs: {
224
260
  id: string;
@@ -232,7 +268,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
232
268
  ): JobSnapshot[] {
233
269
  const now = Date.now();
234
270
  return jobs.map(j => {
235
- const current = this.session.asyncJobManager?.getJob(j.id);
271
+ const current = AsyncJobManager.instance()?.getJob(j.id);
236
272
  const latest = current ?? j;
237
273
  return {
238
274
  id: latest.id,
@@ -247,7 +283,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
247
283
  }
248
284
 
249
285
  #buildResult(
250
- manager: NonNullable<ToolSession["asyncJobManager"]>,
286
+ manager: AsyncJobManager,
251
287
  jobs: {
252
288
  id: string;
253
289
  type: "bash" | "task";