@oh-my-pi/pi-coding-agent 14.9.3 โ†’ 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 (79) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/package.json +7 -7
  3. package/src/async/job-manager.ts +66 -9
  4. package/src/capability/rule.ts +20 -0
  5. package/src/config/model-registry.ts +13 -0
  6. package/src/config/model-resolver.ts +8 -2
  7. package/src/config/settings-schema.ts +1 -1
  8. package/src/edit/index.ts +8 -0
  9. package/src/edit/renderer.ts +6 -1
  10. package/src/edit/streaming.ts +53 -2
  11. package/src/eval/js/context-manager.ts +1 -38
  12. package/src/eval/js/prelude.txt +0 -2
  13. package/src/eval/py/executor.ts +24 -8
  14. package/src/eval/py/index.ts +1 -0
  15. package/src/eval/py/prelude.py +11 -80
  16. package/src/export/html/template.css +12 -0
  17. package/src/export/html/template.generated.ts +1 -1
  18. package/src/export/html/template.js +20 -2
  19. package/src/extensibility/plugins/loader.ts +31 -6
  20. package/src/extensibility/skills.ts +20 -0
  21. package/src/internal-urls/agent-protocol.ts +63 -52
  22. package/src/internal-urls/artifact-protocol.ts +51 -51
  23. package/src/internal-urls/docs-index.generated.ts +33 -1
  24. package/src/internal-urls/index.ts +6 -19
  25. package/src/internal-urls/local-protocol.ts +49 -7
  26. package/src/internal-urls/mcp-protocol.ts +2 -8
  27. package/src/internal-urls/memory-protocol.ts +89 -59
  28. package/src/internal-urls/router.ts +38 -22
  29. package/src/internal-urls/rule-protocol.ts +2 -20
  30. package/src/internal-urls/skill-protocol.ts +4 -27
  31. package/src/main.ts +1 -1
  32. package/src/mcp/manager.ts +17 -0
  33. package/src/modes/components/session-observer-overlay.ts +2 -2
  34. package/src/modes/components/tool-execution.ts +6 -0
  35. package/src/modes/components/tree-selector.ts +4 -0
  36. package/src/modes/controllers/event-controller.ts +23 -2
  37. package/src/modes/controllers/mcp-command-controller.ts +7 -10
  38. package/src/modes/interactive-mode.ts +2 -2
  39. package/src/modes/theme/theme.ts +27 -27
  40. package/src/modes/types.ts +1 -1
  41. package/src/modes/utils/ui-helpers.ts +14 -9
  42. package/src/prompts/commands/orchestrate.md +1 -0
  43. package/src/prompts/system/project-prompt.md +10 -2
  44. package/src/prompts/system/subagent-system-prompt.md +8 -8
  45. package/src/prompts/system/system-prompt.md +13 -7
  46. package/src/prompts/tools/ask.md +0 -1
  47. package/src/prompts/tools/bash.md +0 -10
  48. package/src/prompts/tools/eval.md +1 -3
  49. package/src/prompts/tools/github.md +6 -5
  50. package/src/prompts/tools/hashline.md +1 -0
  51. package/src/prompts/tools/job.md +14 -6
  52. package/src/prompts/tools/task.md +20 -3
  53. package/src/registry/agent-registry.ts +2 -1
  54. package/src/sdk.ts +87 -89
  55. package/src/session/agent-session.ts +58 -20
  56. package/src/session/artifacts.ts +7 -4
  57. package/src/session/session-manager.ts +30 -1
  58. package/src/ssh/connection-manager.ts +32 -16
  59. package/src/ssh/sshfs-mount.ts +10 -7
  60. package/src/system-prompt.ts +0 -5
  61. package/src/task/executor.ts +14 -2
  62. package/src/task/index.ts +19 -5
  63. package/src/tool-discovery/tool-index.ts +21 -8
  64. package/src/tools/ast-edit.ts +3 -2
  65. package/src/tools/ast-grep.ts +3 -2
  66. package/src/tools/bash.ts +15 -9
  67. package/src/tools/browser/tab-supervisor.ts +12 -2
  68. package/src/tools/eval.ts +48 -10
  69. package/src/tools/fetch.ts +1 -1
  70. package/src/tools/gh.ts +140 -4
  71. package/src/tools/index.ts +12 -11
  72. package/src/tools/job.ts +48 -12
  73. package/src/tools/read.ts +5 -4
  74. package/src/tools/search.ts +3 -2
  75. package/src/tools/todo-write.ts +1 -1
  76. package/src/web/scrapers/mastodon.ts +1 -1
  77. package/src/web/scrapers/repology.ts +7 -7
  78. package/src/internal-urls/jobs-protocol.ts +0 -120
  79. package/src/prompts/system/now-prompt.md +0 -7
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";
package/src/tools/read.ts CHANGED
@@ -12,6 +12,7 @@ import { getFileReadCache } from "../edit/file-read-cache";
12
12
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
13
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
14
  import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash";
15
+ import { InternalUrlRouter } from "../internal-urls";
15
16
  import { parseInternalUrl } from "../internal-urls/parse";
16
17
  import type { InternalUrl } from "../internal-urls/types";
17
18
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -431,7 +432,7 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
431
432
  const readSchema = Type.Object({
432
433
  path: Type.String({
433
434
  description: 'path or url; append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")',
434
- examples: ["src/foo.ts", "src/foo.ts:50-100", "https://example.com:L1-L40"],
435
+ examples: ["src/foo.ts", "src/foo.ts:50-100", "https://example.com/:1-40"],
435
436
  }),
436
437
  });
437
438
 
@@ -1181,8 +1182,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1181
1182
 
1182
1183
  // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
1183
1184
  const internalTarget = splitPathAndSel(readPath);
1184
- const internalRouter = this.session.internalRouter;
1185
- if (internalRouter?.canHandle(internalTarget.path)) {
1185
+ const internalRouter = InternalUrlRouter.instance();
1186
+ if (internalRouter.canHandle(internalTarget.path)) {
1186
1187
  const parsed = parseSel(internalTarget.sel);
1187
1188
  const { offset, limit } = selToOffsetLimit(parsed);
1188
1189
  return this.#handleInternalUrl(internalTarget.path, offset, limit, { raw: isRawSelector(parsed) });
@@ -1551,7 +1552,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1551
1552
  limit?: number,
1552
1553
  options?: { raw?: boolean },
1553
1554
  ): Promise<AgentToolResult<ReadToolDetails>> {
1554
- const internalRouter = this.session.internalRouter!;
1555
+ const internalRouter = InternalUrlRouter.instance();
1555
1556
 
1556
1557
  // Check if URL has query extraction (agent:// only).
1557
1558
  // Use parseInternalUrl which handles colons in host (namespaced skills).
@@ -8,6 +8,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
9
  import { getFileReadCache } from "../edit/file-read-cache";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
+ import { InternalUrlRouter } from "../internal-urls";
11
12
  import type { Theme } from "../modes/theme/theme";
12
13
  import searchDescription from "../prompts/tools/search.md" with { type: "text" };
13
14
  import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
@@ -131,14 +132,14 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
131
132
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
132
133
  throw new ToolError("`paths` must contain non-empty paths or globs");
133
134
  }
134
- const internalRouter = this.session.internalRouter;
135
+ const internalRouter = InternalUrlRouter.instance();
135
136
  const resolvedPathInputs: string[] = [];
136
137
  // Absolute filesystem paths whose source is immutable (e.g. artifact://,
137
138
  // pi://, skill://). Hashline anchors are suppressed for these on a
138
139
  // per-file basis, leaving editable mixed-in files untouched.
139
140
  const immutableSourcePaths = new Set<string>();
140
141
  for (const rawPath of rawPaths) {
141
- if (!internalRouter?.canHandle(rawPath)) {
142
+ if (!internalRouter.canHandle(rawPath)) {
142
143
  resolvedPathInputs.push(rawPath);
143
144
  continue;
144
145
  }
@@ -631,7 +631,7 @@ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
631
631
  for (const task of phase.tasks) {
632
632
  if (task.status !== "in_progress" || !task.notes || task.notes.length === 0) continue;
633
633
  const bar = uiTheme.fg("dim", uiTheme.tree.vertical);
634
- const title = uiTheme.fg("dim", chalk.italic(`\u00a7 notes \u2014 ${task.content}`));
634
+ const title = uiTheme.fg("dim", chalk.italic(`ยง notes โ€” ${task.content}`));
635
635
  lines.push("");
636
636
  lines.push(` ${title}`);
637
637
  for (let j = 0; j < task.notes.length; j++) {
@@ -273,7 +273,7 @@ export const handleMastodon: SpecialHandler = async (
273
273
  md += `### ${formatDate(status.created_at)}\n\n`;
274
274
  const content = await htmlToBasicMarkdown(status.content);
275
275
  md += `${content}\n\n`;
276
- md += `\uD83D\uDCAC ${status.replies_count} \u00B7 \uD83D\uDD01 ${status.reblogs_count} \u00B7 \u2B50 ${status.favourites_count}\n\n`;
276
+ md += `๐Ÿ’ฌ ${status.replies_count} ยท ๐Ÿ” ${status.reblogs_count} ยท โญ ${status.favourites_count}\n\n`;
277
277
  }
278
278
  }
279
279
  }
@@ -32,19 +32,19 @@ interface RepologyPackage {
32
32
  function statusIndicator(status: string): string {
33
33
  switch (status) {
34
34
  case "newest":
35
- return "\u2705"; // green check
35
+ return "โœ…"; // green check
36
36
  case "devel":
37
- return "\uD83D\uDEA7"; // construction
37
+ return "๐Ÿšง"; // construction
38
38
  case "unique":
39
- return "\uD83D\uDD35"; // blue circle
39
+ return "๐Ÿ”ต"; // blue circle
40
40
  case "outdated":
41
- return "\uD83D\uDD34"; // red circle
41
+ return "๐Ÿ”ด"; // red circle
42
42
  case "legacy":
43
- return "\u26A0\uFE0F"; // warning
43
+ return "โš \uFE0F"; // warning
44
44
  case "rolling":
45
- return "\uD83D\uDD04"; // arrows
45
+ return "๐Ÿ”„"; // arrows
46
46
  default:
47
- return "\u2796"; // minus
47
+ return "โž–"; // minus
48
48
  }
49
49
  }
50
50
 
@@ -1,120 +0,0 @@
1
- import type { AsyncJobManager } from "../async";
2
- import { formatDuration } from "../tools/render-utils";
3
- import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
4
-
5
- export interface JobsProtocolOptions {
6
- getAsyncJobManager: () => AsyncJobManager | undefined;
7
- }
8
-
9
- function formatJobTime(startTime: number): string {
10
- return new Date(startTime).toISOString();
11
- }
12
-
13
- function formatJobDuration(startTime: number): string {
14
- return formatDuration(Math.max(0, Date.now() - startTime));
15
- }
16
-
17
- function normalizeJobId(url: InternalUrl): string {
18
- const host = url.rawHost || url.hostname;
19
- const pathname = (url.rawPathname ?? url.pathname).replace(/^\/+/, "").trim();
20
- if (host && pathname) return `${host}/${pathname}`;
21
- if (host) return host;
22
- return pathname;
23
- }
24
-
25
- export class JobsProtocolHandler implements ProtocolHandler {
26
- readonly scheme = "jobs";
27
- readonly immutable = true;
28
-
29
- constructor(private readonly options: JobsProtocolOptions) {}
30
-
31
- async resolve(url: InternalUrl): Promise<InternalResource> {
32
- const manager = this.options.getAsyncJobManager();
33
- if (!manager) {
34
- const content =
35
- "# Jobs\n\nBackground job support is disabled. Enable `async.enabled` or `bash.autoBackground.enabled` to use jobs://.";
36
- return {
37
- url: url.href,
38
- content,
39
- contentType: "text/markdown",
40
- size: Buffer.byteLength(content, "utf-8"),
41
- };
42
- }
43
-
44
- const jobId = normalizeJobId(url);
45
- if (!jobId) {
46
- return this.#listJobs(url, manager);
47
- }
48
-
49
- return this.#getJob(url, manager, jobId);
50
- }
51
-
52
- #listJobs(url: InternalUrl, manager: AsyncJobManager): InternalResource {
53
- const jobs = manager.getAllJobs();
54
- const running = jobs.filter(job => job.status === "running").sort((a, b) => a.startTime - b.startTime);
55
- const done = jobs.filter(job => job.status !== "running").sort((a, b) => b.startTime - a.startTime);
56
- const ordered = [...running, ...done];
57
-
58
- if (ordered.length === 0) {
59
- const content = "# Jobs\n\nNo background jobs found.";
60
- return {
61
- url: url.href,
62
- content,
63
- contentType: "text/markdown",
64
- size: Buffer.byteLength(content, "utf-8"),
65
- };
66
- }
67
-
68
- const lines = ordered.map(job => {
69
- return `- \`${job.id}\` [${job.type}] **${job.status}** โ€” ${job.label} \n started: ${formatJobTime(job.startTime)} ยท duration: ${formatJobDuration(job.startTime)}`;
70
- });
71
- const content = `# Jobs\n\n${ordered.length} job${ordered.length === 1 ? "" : "s"}\n\n${lines.join("\n")}`;
72
- return {
73
- url: url.href,
74
- content,
75
- contentType: "text/markdown",
76
- size: Buffer.byteLength(content, "utf-8"),
77
- };
78
- }
79
-
80
- #getJob(url: InternalUrl, manager: AsyncJobManager, jobId: string): InternalResource {
81
- const job = manager.getJob(jobId);
82
- if (!job) {
83
- const content = `# Job Not Found\n\n404: No async job found with id \`${jobId}\`.`;
84
- return {
85
- url: url.href,
86
- content,
87
- contentType: "text/markdown",
88
- size: Buffer.byteLength(content, "utf-8"),
89
- };
90
- }
91
-
92
- const sections = [
93
- `# Job ${job.id}`,
94
- "",
95
- `- type: ${job.type}`,
96
- `- status: ${job.status}`,
97
- `- label: ${job.label}`,
98
- `- start: ${formatJobTime(job.startTime)}`,
99
- `- duration: ${formatJobDuration(job.startTime)}`,
100
- ];
101
-
102
- if (job.status === "completed" && job.resultText) {
103
- sections.push("", "## Result", "", "```", job.resultText, "```");
104
- }
105
- if (job.status === "failed" && job.errorText) {
106
- sections.push("", "## Error", "", "```", job.errorText, "```");
107
- }
108
- if (job.status === "cancelled" && job.errorText) {
109
- sections.push("", "## Cancellation", "", "```", job.errorText, "```");
110
- }
111
-
112
- const content = sections.join("\n");
113
- return {
114
- url: url.href,
115
- content,
116
- contentType: "text/markdown",
117
- size: Buffer.byteLength(content, "utf-8"),
118
- };
119
- }
120
- }
@@ -1,7 +0,0 @@
1
- Today is {{date}}, and the current working directory is '{{cwd}}'.
2
-
3
- <critical>
4
- - Each response **MUST** advance the task. There is no stopping condition other than completion.
5
- - You **MUST** default to informed action; do not ask for confirmation when tools or repo context can answer.
6
- - You **MUST** verify the effect of significant behavioral changes before yielding: run the specific test, command, or scenario that covers your change.
7
- </critical>