@oh-my-pi/pi-coding-agent 16.1.18 → 16.1.20

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 (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli.js +15452 -15142
  3. package/dist/types/cli/gallery-cli.d.ts +6 -0
  4. package/dist/types/commands/gallery.d.ts +1 -1
  5. package/dist/types/config/service-tier.d.ts +34 -0
  6. package/dist/types/config/settings-schema.d.ts +36 -33
  7. package/dist/types/edit/hashline/filesystem.d.ts +1 -18
  8. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +10 -0
  9. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +4 -1
  10. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +12 -0
  11. package/dist/types/internal-urls/skill-protocol.d.ts +2 -2
  12. package/dist/types/internal-urls/types.d.ts +3 -0
  13. package/dist/types/modes/components/custom-editor.d.ts +0 -2
  14. package/dist/types/modes/controllers/input-controller.d.ts +0 -1
  15. package/dist/types/session/agent-session.d.ts +2 -0
  16. package/dist/types/task/executor.d.ts +9 -1
  17. package/dist/types/task/parallel.d.ts +17 -1
  18. package/dist/types/tools/index.d.ts +3 -1
  19. package/dist/types/tools/path-utils.d.ts +3 -0
  20. package/package.json +13 -13
  21. package/scripts/build-binary.ts +20 -0
  22. package/scripts/generate-legacy-pi-bundled-registry.ts +404 -0
  23. package/src/cli/gallery-cli.ts +31 -2
  24. package/src/cli/gallery-fixtures/agentic.ts +13 -4
  25. package/src/commands/gallery.ts +11 -3
  26. package/src/config/service-tier.ts +87 -0
  27. package/src/config/settings-schema.ts +48 -23
  28. package/src/edit/hashline/filesystem.ts +14 -0
  29. package/src/eval/agent-bridge.ts +2 -0
  30. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +987 -0
  31. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +3330 -18
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +29 -14
  33. package/src/internal-urls/local-protocol.ts +116 -9
  34. package/src/internal-urls/skill-protocol.ts +3 -3
  35. package/src/internal-urls/types.ts +3 -0
  36. package/src/main.ts +1 -0
  37. package/src/mcp/transports/stdio.ts +5 -0
  38. package/src/modes/components/custom-editor.test.ts +7 -5
  39. package/src/modes/components/custom-editor.ts +6 -16
  40. package/src/modes/controllers/input-controller.ts +143 -137
  41. package/src/sdk.ts +1 -0
  42. package/src/session/agent-session.ts +72 -15
  43. package/src/session/messages.ts +70 -47
  44. package/src/session/session-history-format.ts +21 -4
  45. package/src/task/executor.ts +79 -2
  46. package/src/task/index.ts +11 -6
  47. package/src/task/parallel.ts +59 -7
  48. package/src/tools/ast-edit.ts +1 -0
  49. package/src/tools/ast-grep.ts +1 -0
  50. package/src/tools/find.ts +1 -0
  51. package/src/tools/index.ts +3 -1
  52. package/src/tools/irc.ts +16 -2
  53. package/src/tools/path-utils.ts +4 -0
  54. package/src/tools/read.ts +43 -17
  55. package/src/tools/search.ts +4 -0
  56. package/src/utils/shell-snapshot-fn-env.sh +60 -0
  57. package/src/utils/shell-snapshot.ts +77 -20
@@ -482,65 +482,88 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
482
482
  * - Custom extensions and tools
483
483
  */
484
484
  export function convertToLlm(messages: AgentMessage[]): Message[] {
485
- return messages
486
- .map((m): Message | undefined => {
487
- switch (m.role) {
488
- case "bashExecution":
489
- if (m.excludeFromContext) {
490
- return undefined;
491
- }
492
- return {
485
+ return messages.flatMap((m): Message[] => {
486
+ switch (m.role) {
487
+ case "bashExecution":
488
+ if (m.excludeFromContext) {
489
+ return [];
490
+ }
491
+ return [
492
+ {
493
493
  role: "user",
494
494
  content: [{ type: "text", text: bashExecutionToText(m) }],
495
495
  attribution: "user",
496
496
  timestamp: m.timestamp,
497
- };
498
- case "pythonExecution":
499
- if (m.excludeFromContext) {
500
- return undefined;
501
- }
502
- return {
497
+ },
498
+ ];
499
+ case "pythonExecution":
500
+ if (m.excludeFromContext) {
501
+ return [];
502
+ }
503
+ return [
504
+ {
503
505
  role: "user",
504
506
  content: [{ type: "text", text: pythonExecutionToText(m) }],
505
507
  attribution: "user",
506
508
  timestamp: m.timestamp,
507
- };
508
- case "fileMention": {
509
- const fileContents = m.files
510
- .map(file => {
511
- const inner = file.content ? `\n${file.content}\n` : "\n";
512
- return `<file path="${file.path}">${inner}</file>`;
513
- })
514
- .join("\n");
515
- const content: (TextContent | ImageContent)[] = [{ type: "text" as const, text: fileContents }];
516
- for (const file of m.files) {
517
- if (file.image) {
518
- content.push(file.image);
519
- }
520
- }
521
- return {
509
+ },
510
+ ];
511
+ case "fileMention": {
512
+ // One `fileMention` can mix `@notes.md` (text) and `@screenshot.png` (image)
513
+ // in the same turn (`generateFileMentionMessages` packs every `@…` into a
514
+ // single message). Splitting by image presence keeps text-only mentions on
515
+ // the higher-priority `developer` slot while routing image attachments
516
+ // through `user`, the only Responses content slot that legitimately accepts
517
+ // `input_image` (Codex chatgpt.com /codex/responses rejects everything else
518
+ // with `Invalid value: 'input_image'`, #3443).
519
+ const wrap = (file: FileMentionMessage["files"][number]): string => {
520
+ const inner = file.content ? `\n${file.content}\n` : "\n";
521
+ return `<file path="${file.path}">${inner}</file>`;
522
+ };
523
+ const textFiles = m.files.filter(file => !file.image);
524
+ const imageFiles = m.files.filter(file => file.image);
525
+ const out: Message[] = [];
526
+ if (textFiles.length > 0) {
527
+ out.push({
522
528
  role: "developer",
529
+ content: [{ type: "text" as const, text: textFiles.map(wrap).join("\n") }],
530
+ attribution: "user",
531
+ timestamp: m.timestamp,
532
+ });
533
+ }
534
+ if (imageFiles.length > 0) {
535
+ const content: (TextContent | ImageContent)[] = [
536
+ { type: "text" as const, text: imageFiles.map(wrap).join("\n") },
537
+ ];
538
+ for (const file of imageFiles) {
539
+ if (file.image) content.push(file.image);
540
+ }
541
+ out.push({
542
+ role: "user",
523
543
  content,
524
544
  attribution: "user",
525
545
  timestamp: m.timestamp,
526
- };
546
+ });
527
547
  }
528
- case "custom":
529
- case "hookMessage":
530
- case "branchSummary":
531
- case "compactionSummary":
532
- case "user":
533
- case "developer":
534
- case "assistant":
535
- case "toolResult":
536
- // Core roles share one transformer with agent-core —
537
- // duplicating them here is how snapcompact frames once
538
- // silently fell off the provider request.
539
- return convertMessageToLlm(m);
540
- default:
541
- m satisfies never;
542
- return undefined;
548
+ return out;
543
549
  }
544
- })
545
- .filter(m => m !== undefined);
550
+ case "custom":
551
+ case "hookMessage":
552
+ case "branchSummary":
553
+ case "compactionSummary":
554
+ case "user":
555
+ case "developer":
556
+ case "assistant":
557
+ case "toolResult": {
558
+ // Core roles share one transformer with agent-core —
559
+ // duplicating them here is how snapcompact frames once
560
+ // silently fell off the provider request.
561
+ const converted = convertMessageToLlm(m);
562
+ return converted ? [converted] : [];
563
+ }
564
+ default:
565
+ m satisfies never;
566
+ return [];
567
+ }
568
+ });
546
569
  }
@@ -86,6 +86,14 @@ function lineCount(text: string): number {
86
86
  return text.split("\n").length;
87
87
  }
88
88
 
89
+ function primaryArgValue(value: unknown): string {
90
+ if (typeof value === "string" && value.length > 0) return value;
91
+ if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === "string")) {
92
+ return value.join(", ");
93
+ }
94
+ return "";
95
+ }
96
+
89
97
  /** Pick the most informative scalar argument of a tool call. */
90
98
  function primaryArg(name: string, args: Record<string, unknown> | undefined): string {
91
99
  if (!args || typeof args !== "object") return "";
@@ -97,12 +105,21 @@ function primaryArg(name: string, args: Record<string, unknown> | undefined): st
97
105
  if (note) return oneLine(note);
98
106
  if (severity) return oneLine(severity);
99
107
  }
108
+ if (name === "search") {
109
+ const pattern = primaryArgValue(args.pattern);
110
+ const paths = primaryArgValue(args.paths);
111
+ if (pattern && paths) return oneLine(`${pattern} @ ${paths}`);
112
+ if (pattern) return oneLine(pattern);
113
+ if (paths) return oneLine(paths);
114
+ }
115
+ if (name === "find") {
116
+ const paths = primaryArgValue(args.paths);
117
+ if (paths) return oneLine(paths);
118
+ }
100
119
  for (const key of PRIMARY_ARG_KEYS) {
101
120
  const value = args[key];
102
- if (typeof value === "string" && value.length > 0) return oneLine(value);
103
- if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === "string")) {
104
- return oneLine(value.join(", "));
105
- }
121
+ const summary = primaryArgValue(value);
122
+ if (summary) return oneLine(summary);
106
123
  }
107
124
  // Fallback: first non-intent string arg, then a compact JSON of the args.
108
125
  const rest: Record<string, unknown> = {};
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
- import type { Api, Model, Usage } from "@oh-my-pi/pi-ai";
10
+ import type { Api, Model, ServiceTier, Usage } from "@oh-my-pi/pi-ai";
11
11
  import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import type { Rule } from "../capability/rule";
13
13
  import { ModelRegistry } from "../config/model-registry";
@@ -18,6 +18,7 @@ import {
18
18
  resolveModelOverrideWithAuthFallback,
19
19
  } from "../config/model-resolver";
20
20
  import type { PromptTemplate } from "../config/prompt-templates";
21
+ import { resolveSubagentServiceTier } from "../config/service-tier";
21
22
  import { Settings } from "../config/settings";
22
23
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
23
24
  import type { ToolPathWithSource } from "../extensibility/custom-tools";
@@ -50,12 +51,12 @@ import {
50
51
  type OutputValidator,
51
52
  summarizeValidationFailure,
52
53
  } from "../tools/output-schema-validator";
53
-
54
54
  import { type ReportFindingDetails, toReviewFinding } from "../tools/review";
55
55
  import { ToolAbortError } from "../tools/tool-errors";
56
56
  import type { EventBus } from "../utils/event-bus";
57
57
  import { buildNamedToolChoice } from "../utils/tool-choice";
58
58
  import type { WorkspaceTree } from "../workspace-tree";
59
+ import { Semaphore } from "./parallel";
59
60
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
60
61
  import {
61
62
  type AgentDefinition,
@@ -194,6 +195,51 @@ function installSubagentRetryFallbackChain(args: {
194
195
  return role;
195
196
  }
196
197
 
198
+ const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
199
+ "ollama-cloud": "providers.ollama-cloud.maxConcurrency",
200
+ };
201
+
202
+ interface ProviderSemaphoreEntry {
203
+ limit: number;
204
+ semaphore: Semaphore;
205
+ }
206
+
207
+ const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
208
+
209
+ /**
210
+ * Resolve the configured concurrency ceiling for a provider, or `undefined`
211
+ * when the provider has no cap concept at all. A configured value `<= 0` means
212
+ * "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
213
+ * holds a slot and a later finite resize counts work started while unlimited.
214
+ */
215
+ function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
216
+ const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
217
+ if (!settingPath) return undefined;
218
+ const raw = settings.get(settingPath);
219
+ const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
220
+ return limit > 0 ? limit : Number.POSITIVE_INFINITY;
221
+ }
222
+
223
+ function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
224
+ const limit = getProviderConcurrencyLimit(settings, provider);
225
+ if (limit === undefined) return undefined;
226
+ // Always hand out (and acquire on) the single shared limiter, even when
227
+ // unlimited (Infinity). Resizing it in place — rather than replacing it —
228
+ // keeps every in-flight slot counted, so a runtime or mixed limit change can
229
+ // never push concurrency past the cap (issue #3464 review feedback).
230
+ const existing = providerSemaphores.get(provider);
231
+ if (existing) {
232
+ if (existing.limit !== limit) {
233
+ existing.limit = limit;
234
+ existing.semaphore.resize(limit);
235
+ }
236
+ return existing.semaphore;
237
+ }
238
+ const semaphore = new Semaphore(limit);
239
+ providerSemaphores.set(provider, { limit, semaphore });
240
+ return semaphore;
241
+ }
242
+
197
243
  function renderIrcPeerRoster(selfId: string): string {
198
244
  const peers = AgentRegistry.global()
199
245
  .list()
@@ -339,6 +385,13 @@ export interface ExecutorOptions {
339
385
  authStorage?: AuthStorage;
340
386
  modelRegistry?: ModelRegistry;
341
387
  settings?: Settings;
388
+ /**
389
+ * Parent session's live effective service tier, the source of truth for a
390
+ * subagent whose `serviceTierSubagent` is `"inherit"`. `null` = the parent
391
+ * explicitly has no tier (e.g. `/fast off`); omitted = no live session, so
392
+ * inherit falls back to the configured `serviceTier` setting.
393
+ */
394
+ parentServiceTier?: ServiceTier | null;
342
395
  /** Override local:// protocol options so subagent shares parent's local:// root */
343
396
  localProtocolOptions?: LocalProtocolOptions;
344
397
  /**
@@ -732,11 +785,21 @@ export function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
732
785
  export function createSubagentSettings(
733
786
  baseSettings: Settings,
734
787
  overrides?: Partial<Record<SettingPath, unknown>>,
788
+ inheritedServiceTier?: ServiceTier | null,
735
789
  ): Settings {
736
790
  const snapshot: Partial<Record<SettingPath, unknown>> = {};
737
791
  for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
738
792
  snapshot[key] = baseSettings.get(key);
739
793
  }
794
+ // Resolve the subagent's service tier from `serviceTierSubagent` ("inherit" =
795
+ // match the parent's live tier when a live session supplied one, else the
796
+ // configured `serviceTier`). The result is stamped back onto the snapshot so
797
+ // createAgentSession's `settings.get("serviceTier")` read picks it up.
798
+ snapshot.serviceTier = resolveSubagentServiceTier(
799
+ baseSettings.get("serviceTierSubagent"),
800
+ baseSettings.get("serviceTier"),
801
+ inheritedServiceTier,
802
+ );
740
803
  return Settings.isolated({
741
804
  ...snapshot,
742
805
  "async.enabled": false,
@@ -1747,6 +1810,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1747
1810
  const subagentSettings = createSubagentSettings(
1748
1811
  settings,
1749
1812
  agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
1813
+ options.parentServiceTier,
1750
1814
  );
1751
1815
  const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
1752
1816
  // Tailored specialist identity for this spawn. `subagentRole` is the full
@@ -1889,6 +1953,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1889
1953
  let sessionOpenedAt: number | undefined;
1890
1954
  let sessionCreatedAt: number | undefined;
1891
1955
  let readyAt: number | undefined;
1956
+ let providerSemaphore: Semaphore | undefined;
1957
+ let providerSemaphoreAcquired = false;
1892
1958
 
1893
1959
  try {
1894
1960
  checkAbort();
@@ -1957,6 +2023,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1957
2023
  ? resolvedThinkingLevel
1958
2024
  : (thinkingLevel ?? resolvedThinkingLevel);
1959
2025
  resolvedAt = performance.now();
2026
+ if (model) {
2027
+ providerSemaphore = getProviderSemaphore(settings, model.provider);
2028
+ if (providerSemaphore) {
2029
+ await providerSemaphore.acquire(abortSignal);
2030
+ providerSemaphoreAcquired = true;
2031
+ }
2032
+ }
1960
2033
 
1961
2034
  const effectiveCwd = worktree ?? cwd;
1962
2035
  const sessionManager = sessionFile
@@ -2247,6 +2320,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2247
2320
  }
2248
2321
  if (exitCode === 0) exitCode = 1;
2249
2322
  }
2323
+ if (providerSemaphoreAcquired) {
2324
+ providerSemaphore?.release();
2325
+ providerSemaphoreAcquired = false;
2326
+ }
2250
2327
  sessionAbortController.abort();
2251
2328
  if (unsubscribe) {
2252
2329
  try {
package/src/task/index.ts CHANGED
@@ -798,13 +798,13 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
798
798
  onSettled?.(true);
799
799
  throw new Error("Aborted before execution");
800
800
  }
801
- markRunning();
802
- progress.status = "running";
803
- await reportProgress(
804
- `Running background task ${agentId}...`,
805
- buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
806
- );
807
801
  try {
802
+ markRunning();
803
+ progress.status = "running";
804
+ await reportProgress(
805
+ `Running background task ${agentId}...`,
806
+ buildDetails("running", ownJobId) as unknown as Record<string, unknown>,
807
+ );
808
808
  const result = await this.#executeSync(
809
809
  toolCallId,
810
810
  spawnParams,
@@ -1296,6 +1296,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1296
1296
  parentTelemetry: this.session.getTelemetry?.(),
1297
1297
  parentEvalSessionId,
1298
1298
  parentAgentId: this.session.getAgentId?.() ?? MAIN_AGENT_ID,
1299
+ // Live source of truth for `serviceTierSubagent: inherit`. When the
1300
+ // session exposes a tier accessor, pass tier-or-null (null = explicit
1301
+ // none, e.g. /fast off); otherwise leave undefined so inherit falls
1302
+ // back to the configured serviceTier setting.
1303
+ parentServiceTier: this.session.getServiceTier ? (this.session.getServiceTier() ?? null) : undefined,
1299
1304
  };
1300
1305
 
1301
1306
  const runTask = async (): Promise<SingleResult> => {
@@ -100,22 +100,74 @@ export class Semaphore {
100
100
  this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
101
101
  }
102
102
 
103
- async acquire(): Promise<void> {
103
+ /**
104
+ * Resolves when a slot is available. Pass an `AbortSignal` so callers that
105
+ * stop waiting (parent task cancelled, wall-clock budget elapsed) also stop
106
+ * occupying a queue slot — otherwise a later `release()` would resolve the
107
+ * abandoned waiter, permanently shrinking effective concurrency for the
108
+ * remaining lifetime of the process (issue #3464 review feedback).
109
+ */
110
+ async acquire(signal?: AbortSignal): Promise<void> {
111
+ if (signal?.aborted) {
112
+ throw semaphoreAbortReason(signal);
113
+ }
104
114
  if (this.#current < this.#max) {
105
115
  this.#current++;
106
116
  return;
107
117
  }
108
- const { promise, resolve } = Promise.withResolvers<void>();
109
- this.#queue.push(resolve);
118
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
119
+ const queue = this.#queue;
120
+ let waiter: () => void = resolve;
121
+ if (signal) {
122
+ const onAbort = () => {
123
+ const index = queue.indexOf(waiter);
124
+ if (index >= 0) queue.splice(index, 1);
125
+ reject(semaphoreAbortReason(signal));
126
+ };
127
+ waiter = () => {
128
+ signal.removeEventListener("abort", onAbort);
129
+ resolve();
130
+ };
131
+ signal.addEventListener("abort", onAbort, { once: true });
132
+ }
133
+ queue.push(waiter);
110
134
  return promise;
111
135
  }
112
136
 
113
137
  release(): void {
114
- const next = this.#queue.shift();
115
- if (next) {
138
+ if (this.#current > 0) this.#current--;
139
+ // Admit the next waiter only if we are under the (possibly just-lowered) ceiling.
140
+ if (this.#current < this.#max) {
141
+ const next = this.#queue.shift();
142
+ if (next) {
143
+ this.#current++;
144
+ next();
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Adjust the maximum concurrency in place. Raising the ceiling immediately
151
+ * admits queued waiters that now fit; lowering it lets in-flight holders
152
+ * drain naturally (new acquires keep blocking until `#current` falls below
153
+ * the new max). Resizing the single shared instance — instead of replacing
154
+ * it — keeps in-flight slots counted, so a runtime or mixed limit change can
155
+ * never push concurrency past the cap (issue #3464 review feedback).
156
+ */
157
+ resize(max: number): void {
158
+ const normalizedMax = Number.isFinite(max) ? Math.trunc(max) : 0;
159
+ this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
160
+ while (this.#current < this.#max) {
161
+ const next = this.#queue.shift();
162
+ if (!next) break;
163
+ this.#current++;
116
164
  next();
117
- } else {
118
- this.#current--;
119
165
  }
120
166
  }
121
167
  }
168
+
169
+ function semaphoreAbortReason(signal: AbortSignal): unknown {
170
+ const reason = signal.reason;
171
+ if (reason !== undefined) return reason;
172
+ return new Error("Semaphore acquire aborted");
173
+ }
@@ -283,6 +283,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
283
283
  settings: this.session.settings,
284
284
  signal,
285
285
  localProtocolOptions: this.session.localProtocolOptions,
286
+ skills: this.session.skills,
286
287
  });
287
288
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
288
289
 
@@ -185,6 +185,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
185
185
  settings: this.session.settings,
186
186
  signal,
187
187
  localProtocolOptions: this.session.localProtocolOptions,
188
+ skills: this.session.skills,
188
189
  });
189
190
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
190
191
 
package/src/tools/find.ts CHANGED
@@ -166,6 +166,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
166
166
  settings: this.session.settings,
167
167
  signal,
168
168
  localProtocolOptions: this.session.localProtocolOptions,
169
+ skills: this.session.skills,
169
170
  });
170
171
  if (!resource.sourcePath) {
171
172
  throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
@@ -1,6 +1,6 @@
1
1
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
- import type { FetchImpl, ImageContent, Model, ToolChoice } from "@oh-my-pi/pi-ai";
3
+ import type { FetchImpl, ImageContent, Model, ServiceTier, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
5
  import type { AsyncJobManager } from "../async/job-manager";
6
6
  import type { Rule } from "../capability/rule";
@@ -240,6 +240,8 @@ export interface ToolSession {
240
240
  getActiveModelString?: () => string | undefined;
241
241
  /** Get the current session model object (provider/api capabilities), regardless of how it was chosen. */
242
242
  getActiveModel?: () => Model | undefined;
243
+ /** Get the session's live effective service tier (undefined = none). Source of truth for subagent `serviceTierSubagent: inherit`. */
244
+ getServiceTier?: () => ServiceTier | undefined;
243
245
  /** Auth storage for passing to subagents (avoids re-discovery) */
244
246
  authStorage?: import("../session/auth-storage").AuthStorage;
245
247
  /** Model registry for passing to subagents (avoids re-discovery) */
package/src/tools/irc.ts CHANGED
@@ -514,6 +514,18 @@ function callMeta(args: IrcRenderArgs | undefined): string[] {
514
514
  return meta;
515
515
  }
516
516
 
517
+ function renderErrorResult(
518
+ result: { content: Array<{ type: string; text?: string }> },
519
+ args: IrcRenderArgs | undefined,
520
+ theme: Theme,
521
+ ): string[] {
522
+ const text = textContent(result) || "IRC call failed.";
523
+ return [
524
+ renderStatusLine({ icon: "error", title: callTitle(args, theme), meta: callMeta(args) }, theme),
525
+ formatErrorDetail(text, theme),
526
+ ];
527
+ }
528
+
517
529
  /**
518
530
  * Display-only transcript card for live IRC traffic: `irc:incoming` DMs
519
531
  * delivered to this session, `irc:autoreply` side-channel replies sent on
@@ -743,9 +755,11 @@ function buildResultLines(
743
755
  case "wait":
744
756
  return renderWaitResult(result, details, args, expanded, theme);
745
757
  case "inbox":
746
- return renderInboxResult(details, args, expanded, theme);
758
+ return result.isError
759
+ ? renderErrorResult(result, args, theme)
760
+ : renderInboxResult(details, args, expanded, theme);
747
761
  case "list":
748
- return renderListResult(details, expanded, theme);
762
+ return result.isError ? renderErrorResult(result, args, theme) : renderListResult(details, expanded, theme);
749
763
  default: {
750
764
  const text = textContent(result) || (result.isError ? "IRC call failed." : "Done.");
751
765
  return [
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
+ import type { Skill } from "../extensibility/skills";
6
7
  import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
7
8
  import { ToolError } from "./tool-errors";
8
9
 
@@ -986,6 +987,8 @@ export interface ToolScopeOptions {
986
987
  signal?: AbortSignal;
987
988
  /** Calling session's `local://` root mapping — pins resolutions to the calling session. */
988
989
  localProtocolOptions?: LocalProtocolOptions;
990
+ /** Calling session's loaded skills — lets skill:// resolve without process-global state. */
991
+ skills?: readonly Skill[];
989
992
  }
990
993
 
991
994
  export interface ToolScopeResolution {
@@ -1042,6 +1045,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
1042
1045
  settings: opts.settings,
1043
1046
  signal: opts.signal,
1044
1047
  localProtocolOptions: opts.localProtocolOptions,
1048
+ skills: opts.skills,
1045
1049
  });
1046
1050
  if (!resource.sourcePath) {
1047
1051
  throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
package/src/tools/read.ts CHANGED
@@ -2083,7 +2083,24 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2083
2083
  `Invalid selector ':${internalTarget.sel}' on '${internalTarget.path}'. Use :N, :N-M, :N+K, :N- (open-ended), a comma-separated list of ranges, :raw, or a range combined with raw (e.g. :raw:50-100).`,
2084
2084
  );
2085
2085
  }
2086
- return this.#handleInternalUrl(internalTarget.path, parsed, signal);
2086
+ const urlMeta = parseInternalUrl(internalTarget.path);
2087
+ const scheme = urlMeta.protocol.replace(/:$/, "").toLowerCase();
2088
+ if (scheme === "local") {
2089
+ const localFile = await resolveLocalUrlToFile(urlMeta, {
2090
+ cwd: this.session.cwd,
2091
+ settings: this.session.settings,
2092
+ signal,
2093
+ localProtocolOptions: this.session.localProtocolOptions,
2094
+ skills: this.session.skills,
2095
+ });
2096
+ if (localFile) {
2097
+ readPath = internalTarget.sel === undefined ? localFile.path : `${localFile.path}:${internalTarget.sel}`;
2098
+ } else {
2099
+ return this.#handleInternalUrl(internalTarget.path, parsed, signal);
2100
+ }
2101
+ } else {
2102
+ return this.#handleInternalUrl(internalTarget.path, parsed, signal);
2103
+ }
2087
2104
  }
2088
2105
 
2089
2106
  // One suffix-glob memo per read call — archive, sqlite, and plain-path
@@ -2393,23 +2410,31 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2393
2410
  // counts in `truncation` keep reflecting the source, not the trimmed
2394
2411
  // view — column truncation surfaces separately via `.limits()`.
2395
2412
  const rawSelector = isRawSelector(parsed);
2396
- // Binary sniff: NUL bytes in the collected window mean the file is
2397
- // not displayable text (binary, or UTF-16 which has NULs in the
2398
- // ASCII range) — emit a notice instead of mojibake filling the
2399
- // line budget. `:raw` stays an explicit escape hatch.
2413
+ // Binary sniff: NUL bytes mean the file is not displayable text
2414
+ // (binary, or UTF-16 which has NULs in the ASCII range) — emit a
2415
+ // notice instead of mojibake filling the line budget. `:raw`
2416
+ // stays an explicit escape hatch.
2417
+ //
2418
+ // `collectedLines` covers the common case where at least one
2419
+ // physical line terminates within the byte budget. Binary blobs
2420
+ // without newlines (videos, archives, packed JSON) leave it
2421
+ // empty; their bytes only land in `firstLinePreview`, which the
2422
+ // `firstLineExceedsLimit` branch below would otherwise emit
2423
+ // verbatim. Sniffing the preview here keeps the refusal uniform.
2400
2424
  if (!rawSelector) {
2401
- for (const line of collectedLines) {
2402
- if (line.includes("\u0000")) {
2403
- return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
2404
- .text(
2405
- prependSuffixResolutionNotice(
2406
- `[Cannot read binary file '${formatPathRelativeToCwd(absolutePath, this.session.cwd)}' (${formatBytes(fileSize)}); content contains NUL bytes (binary or UTF-16 encoded)]`,
2407
- suffixResolution,
2408
- ),
2409
- )
2410
- .sourcePath(absolutePath)
2411
- .done();
2412
- }
2425
+ const hasNul = (text: string): boolean => text.includes("\u0000");
2426
+ const binaryDetected =
2427
+ collectedLines.some(hasNul) || (firstLinePreview !== undefined && hasNul(firstLinePreview.text));
2428
+ if (binaryDetected) {
2429
+ return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
2430
+ .text(
2431
+ prependSuffixResolutionNotice(
2432
+ `[Cannot read binary file '${formatPathRelativeToCwd(absolutePath, this.session.cwd)}' (${formatBytes(fileSize)}); content contains NUL bytes (binary or UTF-16 encoded)]`,
2433
+ suffixResolution,
2434
+ ),
2435
+ )
2436
+ .sourcePath(absolutePath)
2437
+ .done();
2413
2438
  }
2414
2439
  }
2415
2440
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
@@ -2771,6 +2796,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2771
2796
  settings: this.session.settings,
2772
2797
  signal,
2773
2798
  localProtocolOptions: this.session.localProtocolOptions,
2799
+ skills: this.session.skills,
2774
2800
  });
2775
2801
  const details: ReadToolDetails = { resolvedPath: resource.sourcePath, contentType: resource.contentType };
2776
2802
 
@@ -579,6 +579,7 @@ async function resolveInternalSearchInputs(opts: {
579
579
  signal?: AbortSignal;
580
580
  archiveDisplayMap: ReadonlyMap<string, string>;
581
581
  localProtocolOptions?: LocalProtocolOptions;
582
+ skills?: ResolveContext["skills"];
582
583
  }): Promise<InternalSearchInputResolution> {
583
584
  const internalRouter = InternalUrlRouter.instance();
584
585
  const paths = opts.resolvedPaths.slice();
@@ -592,6 +593,7 @@ async function resolveInternalSearchInputs(opts: {
592
593
  settings: opts.settings,
593
594
  signal: opts.signal,
594
595
  localProtocolOptions: opts.localProtocolOptions,
596
+ skills: opts.skills,
595
597
  };
596
598
 
597
599
  for (let idx = 0; idx < paths.length; idx++) {
@@ -725,6 +727,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
725
727
  signal,
726
728
  archiveDisplayMap,
727
729
  localProtocolOptions: this.session.localProtocolOptions,
730
+ skills: this.session.skills,
728
731
  });
729
732
  const searchablePaths = internalResolution.paths;
730
733
  const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
@@ -797,6 +800,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
797
800
  settings: this.session.settings,
798
801
  signal,
799
802
  localProtocolOptions: this.session.localProtocolOptions,
803
+ skills: this.session.skills,
800
804
  });
801
805
  searchPath = scope.searchPath;
802
806
  isDirectory = scope.isDirectory;