@oh-my-pi/pi-coding-agent 16.1.19 → 16.1.21

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 (59) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/cli.js +3795 -3760
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/cli/gallery-cli.d.ts +6 -0
  5. package/dist/types/commands/gallery.d.ts +1 -1
  6. package/dist/types/config/service-tier.d.ts +34 -0
  7. package/dist/types/config/settings-schema.d.ts +36 -33
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
  9. package/dist/types/mcp/oauth-flow.d.ts +42 -5
  10. package/dist/types/modes/components/custom-editor.d.ts +32 -0
  11. package/dist/types/modes/components/tool-execution.d.ts +5 -5
  12. package/dist/types/modes/controllers/event-controller.d.ts +10 -0
  13. package/dist/types/modes/controllers/input-controller.d.ts +2 -1
  14. package/dist/types/session/agent-session.d.ts +2 -0
  15. package/dist/types/task/executor.d.ts +9 -1
  16. package/dist/types/task/parallel.d.ts +17 -1
  17. package/dist/types/tools/index.d.ts +3 -1
  18. package/dist/types/utils/clipboard.d.ts +10 -0
  19. package/package.json +13 -13
  20. package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
  21. package/src/advisor/__tests__/advisor.test.ts +44 -0
  22. package/src/advisor/advise-tool.ts +33 -0
  23. package/src/autolearn/controller.ts +17 -2
  24. package/src/cli/gallery-cli.ts +31 -2
  25. package/src/cli/gallery-fixtures/agentic.ts +13 -4
  26. package/src/commands/gallery.ts +11 -3
  27. package/src/config/service-tier.ts +87 -0
  28. package/src/config/settings-schema.ts +48 -23
  29. package/src/eval/agent-bridge.ts +2 -0
  30. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
  31. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
  33. package/src/main.ts +1 -0
  34. package/src/mcp/manager.ts +12 -3
  35. package/src/mcp/oauth-flow.ts +121 -7
  36. package/src/mcp/transports/stdio.ts +5 -0
  37. package/src/modes/components/chat-transcript-builder.ts +31 -0
  38. package/src/modes/components/custom-editor.test.ts +80 -0
  39. package/src/modes/components/custom-editor.ts +86 -6
  40. package/src/modes/components/tool-execution.ts +50 -25
  41. package/src/modes/controllers/event-controller.ts +57 -8
  42. package/src/modes/controllers/input-controller.ts +213 -93
  43. package/src/modes/controllers/mcp-command-controller.ts +18 -2
  44. package/src/modes/utils/ui-helpers.ts +40 -0
  45. package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
  46. package/src/prompts/system/autolearn-nudge.md +4 -2
  47. package/src/prompts/tools/todo.md +1 -1
  48. package/src/sdk.ts +1 -0
  49. package/src/session/agent-session.ts +76 -15
  50. package/src/session/session-history-format.ts +21 -4
  51. package/src/task/executor.ts +79 -2
  52. package/src/task/index.ts +11 -6
  53. package/src/task/parallel.ts +59 -7
  54. package/src/tools/index.ts +3 -1
  55. package/src/tools/irc.ts +16 -2
  56. package/src/tools/todo.ts +20 -10
  57. package/src/utils/clipboard.ts +57 -0
  58. package/src/utils/shell-snapshot-fn-env.sh +60 -0
  59. package/src/utils/shell-snapshot.ts +77 -20
@@ -160,6 +160,7 @@ import {
160
160
  } from "../config/model-resolver";
161
161
  import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
162
162
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
163
+ import { resolveServiceTierSetting } from "../config/service-tier";
163
164
  import type { Settings, SkillsSettings } from "../config/settings";
164
165
  import { getDefault, onAppendOnlyModeChanged } from "../config/settings";
165
166
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
@@ -1163,6 +1164,7 @@ export class AgentSession {
1163
1164
  #advisorEnabled = false;
1164
1165
  /** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */
1165
1166
  #advisorAgent?: Agent;
1167
+ #advisorAdviseTool?: AdviseTool;
1166
1168
  #advisorReadOnlyTools?: AgentTool[];
1167
1169
  #advisorWatchdogPrompt?: string;
1168
1170
  #advisorYieldQueueUnsubscribe?: () => void;
@@ -1797,6 +1799,7 @@ export class AgentSession {
1797
1799
  this.#advisorAgentUnsubscribe?.();
1798
1800
  this.#advisorAgentUnsubscribe = undefined;
1799
1801
  this.#advisorRuntime?.reset();
1802
+ this.#advisorAdviseTool?.resetDeliveredNotes();
1800
1803
  this.#attachAdvisorRecorderFeed();
1801
1804
  this.#advisorPrimaryTurnsCompleted = 0;
1802
1805
  this.#advisorInterruptImmuneTurnStart = undefined;
@@ -1877,6 +1880,7 @@ export class AgentSession {
1877
1880
  };
1878
1881
 
1879
1882
  const adviseTool = new AdviseTool(enqueueAdvice);
1883
+ this.#advisorAdviseTool = adviseTool;
1880
1884
  const advisorReadOnlyTools = this.#advisorReadOnlyTools ?? [];
1881
1885
 
1882
1886
  const appendOnlyContext = new AppendOnlyContextManager();
@@ -1887,6 +1891,16 @@ export class AgentSession {
1887
1891
  }
1888
1892
  const advisorSessionId = this.sessionId ? `${this.sessionId}-advisor` : undefined;
1889
1893
 
1894
+ // Advisor service tier (`serviceTierAdvisor`): "none" (default) runs the
1895
+ // advisor on standard processing; "inherit" tracks the session's live tier
1896
+ // per request (like the main agent, including /fast toggles) via a resolver;
1897
+ // a concrete value pins the advisor to that tier regardless of the session.
1898
+ const advisorTierSetting = this.settings.get("serviceTierAdvisor");
1899
+ const advisorServiceTier =
1900
+ advisorTierSetting === "inherit" ? undefined : resolveServiceTierSetting(advisorTierSetting, undefined);
1901
+ const advisorServiceTierResolver =
1902
+ advisorTierSetting === "inherit" ? (model: Model) => this.#effectiveServiceTier(model) : undefined;
1903
+
1890
1904
  // Thread the primary's telemetry into the advisor loop so the advisor
1891
1905
  // model's GenAI spans + usage/cost hooks fire like every other model call,
1892
1906
  // stamped with the advisor's own identity. `conversationId` is cleared so
@@ -1916,6 +1930,8 @@ export class AgentSession {
1916
1930
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
1917
1931
  intentTracing: false,
1918
1932
  telemetry: advisorTelemetry,
1933
+ serviceTier: advisorServiceTier,
1934
+ serviceTierResolver: advisorServiceTierResolver,
1919
1935
  });
1920
1936
  advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
1921
1937
 
@@ -1991,6 +2007,7 @@ export class AgentSession {
1991
2007
  if (this.#advisorAgent) {
1992
2008
  this.#advisorAgent = undefined;
1993
2009
  }
2010
+ this.#advisorAdviseTool = undefined;
1994
2011
  this.#advisorYieldQueueUnsubscribe?.();
1995
2012
  this.#advisorYieldQueueUnsubscribe = undefined;
1996
2013
  }
@@ -2797,6 +2814,9 @@ export class AgentSession {
2797
2814
  return;
2798
2815
  }
2799
2816
 
2817
+ const successfulYieldMessage = this.#findSuccessfulYieldAssistantMessage(settledMessages);
2818
+ const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
2819
+
2800
2820
  const maintenanceRoute = (route: string, extra?: Record<string, unknown>) => {
2801
2821
  logger.debug("agent_end maintenance routing", {
2802
2822
  route,
@@ -2808,7 +2828,7 @@ export class AgentSession {
2808
2828
  hasText: msg.content.some(content => content.type === "text"),
2809
2829
  goalModeEnabled: this.#goalModeState?.enabled === true,
2810
2830
  goalStatus: this.#goalModeState?.goal.status,
2811
- successfulYield: this.#assistantEndedWithSuccessfulYield(msg),
2831
+ successfulYield: successfulYieldMessage !== undefined,
2812
2832
  ...extra,
2813
2833
  });
2814
2834
  };
@@ -2833,21 +2853,24 @@ export class AgentSession {
2833
2853
  }
2834
2854
 
2835
2855
  const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
2836
- const yieldOnThisMessage = this.#assistantEndedWithSuccessfulYield(msg);
2837
2856
  // A successful `yield` in this run is terminal for execution purposes.
2838
2857
  // Suppress empty-stop retry, unexpected-stop retry, queued-message drain,
2839
2858
  // and compaction-driven continuations for the rest of this prompt cycle:
2840
2859
  // the executor consumed the yield as the terminal result, so a trailing
2841
2860
  // empty/aborted assistant stop must NOT revive the agent loop. The
2842
2861
  // `#yieldTerminationPending` sticky flag clears on the next `prompt()`.
2843
- if (yieldOnThisMessage || this.#yieldTerminationPending) {
2862
+ if (successfulYieldMessage || this.#yieldTerminationPending) {
2844
2863
  this.#lastSuccessfulYieldToolCallId = undefined;
2845
- if (yieldOnThisMessage && activeGoal) {
2846
- maintenanceRoute("successful-yield-active-goal-checkCompaction");
2847
- const compactionTask = this.#checkCompaction(msg);
2864
+ if (successfulYieldMessage && activeGoal) {
2865
+ maintenanceRoute(
2866
+ yieldOnThisMessage
2867
+ ? "successful-yield-active-goal-checkCompaction"
2868
+ : "post-yield-trailing-stop-active-goal-checkCompaction",
2869
+ );
2870
+ const compactionTask = this.#checkCompaction(successfulYieldMessage);
2848
2871
  this.#trackPostPromptTask(compactionTask);
2849
2872
  await compactionTask;
2850
- } else if (yieldOnThisMessage) {
2873
+ } else if (successfulYieldMessage) {
2851
2874
  maintenanceRoute("successful-yield-no-active-goal");
2852
2875
  } else {
2853
2876
  maintenanceRoute("post-yield-trailing-stop-suppressed");
@@ -6813,7 +6836,12 @@ export class AgentSession {
6813
6836
  * the transcript can distinguish a deliberate user interrupt from an opaque
6814
6837
  * abort. Omit it for internal/lifecycle aborts.
6815
6838
  */
6816
- async abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string }): Promise<void> {
6839
+ async abort(options?: {
6840
+ goalReason?: "interrupted" | "internal";
6841
+ reason?: string;
6842
+ /** Internal `/compact` startup keeps the manual-compaction marker alive while aborting the active turn. */
6843
+ preserveCompaction?: boolean;
6844
+ }): Promise<void> {
6817
6845
  const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
6818
6846
  if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
6819
6847
  // Pull advisor concerns out of the steer/follow-up queues before any await so
@@ -6828,7 +6856,17 @@ export class AgentSession {
6828
6856
  this.abortRetry();
6829
6857
  this.#promptGeneration++;
6830
6858
  this.#scheduledHiddenNextTurnGeneration = undefined;
6831
- this.abortCompaction();
6859
+ if (options?.preserveCompaction) {
6860
+ // Manual `/compact` installed its own #compactionAbortController before
6861
+ // this internal abort and must keep it alive (that marker is what makes
6862
+ // isCompacting report true during startup). Any in-flight
6863
+ // auto-compaction MUST still be cancelled, though: otherwise a
6864
+ // background maintenance pass races the manual run and both
6865
+ // appendCompaction/replaceMessages, double-rewriting session history.
6866
+ this.#autoCompactionAbortController?.abort();
6867
+ } else {
6868
+ this.abortCompaction();
6869
+ }
6832
6870
  this.abortHandoff();
6833
6871
  this.abortBash();
6834
6872
  this.abortEval();
@@ -7801,12 +7839,12 @@ export class AgentSession {
7801
7839
  if (compactMode?.rejectsFocus && customInstructions) {
7802
7840
  throw new Error(`/compact ${compactMode.name} does not take focus instructions.`);
7803
7841
  }
7804
- this.#disconnectFromAgent();
7805
- await this.abort({ goalReason: "internal" });
7806
7842
  const compactionAbortController = new AbortController();
7807
7843
  this.#compactionAbortController = compactionAbortController;
7808
7844
 
7809
7845
  try {
7846
+ this.#disconnectFromAgent();
7847
+ await this.abort({ goalReason: "internal", preserveCompaction: true });
7810
7848
  if (!this.model) {
7811
7849
  throw new Error("No model selected");
7812
7850
  }
@@ -8591,9 +8629,7 @@ export class AgentSession {
8591
8629
  }
8592
8630
  return COMPACTION_CHECK_NONE;
8593
8631
  }
8594
- #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
8595
- const toolCallId = this.#lastSuccessfulYieldToolCallId;
8596
- if (!toolCallId) return false;
8632
+ #assistantMessageHasSuccessfulYieldToolCall(assistantMessage: AssistantMessage, toolCallId: string): boolean {
8597
8633
  const lastToolCall = assistantMessage.content
8598
8634
  .slice()
8599
8635
  .reverse()
@@ -8601,6 +8637,22 @@ export class AgentSession {
8601
8637
  return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
8602
8638
  }
8603
8639
 
8640
+ #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
8641
+ const toolCallId = this.#lastSuccessfulYieldToolCallId;
8642
+ return toolCallId ? this.#assistantMessageHasSuccessfulYieldToolCall(assistantMessage, toolCallId) : false;
8643
+ }
8644
+
8645
+ #findSuccessfulYieldAssistantMessage(messages: readonly AgentMessage[]): AssistantMessage | undefined {
8646
+ const toolCallId = this.#lastSuccessfulYieldToolCallId;
8647
+ if (!toolCallId) return undefined;
8648
+ for (let i = messages.length - 1; i >= 0; i--) {
8649
+ const message = messages[i];
8650
+ if (message.role !== "assistant") continue;
8651
+ if (this.#assistantMessageHasSuccessfulYieldToolCall(message, toolCallId)) return message;
8652
+ }
8653
+ return undefined;
8654
+ }
8655
+
8604
8656
  async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
8605
8657
  if (!this.#isEmptyAssistantStop(assistantMessage)) {
8606
8658
  this.#emptyStopRetryCount = 0;
@@ -10619,7 +10671,16 @@ export class AgentSession {
10619
10671
  #getRetryFallbackChains(): RetryFallbackChains {
10620
10672
  const configuredChains = this.settings.get("retry.fallbackChains");
10621
10673
  if (!configuredChains || typeof configuredChains !== "object") return {};
10622
- return configuredChains as RetryFallbackChains;
10674
+ const chains: RetryFallbackChains = { ...(configuredChains as RetryFallbackChains) };
10675
+ const defaultChain = chains.default;
10676
+ if (Array.isArray(defaultChain)) {
10677
+ for (const role of Object.keys(this.settings.getModelRoles())) {
10678
+ if (role !== "default" && chains[role] === undefined) {
10679
+ chains[role] = defaultChain;
10680
+ }
10681
+ }
10682
+ }
10683
+ return chains;
10623
10684
  }
10624
10685
 
10625
10686
  #validateRetryFallbackChains(): void {
@@ -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
+ }
@@ -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 [
package/src/tools/todo.ts CHANGED
@@ -534,21 +534,31 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
534
534
  lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
535
535
  }
536
536
  }
537
+ // Closed = completed + abandoned, mirroring the per-phase `done` count.
538
+ const closedAll = tasks.filter(task => task.status === "completed" || task.status === "abandoned").length;
539
+ // The active phase is the EARLIEST one still holding open work, so the
540
+ // in-progress pointer can sit in a phase whose successors already have
541
+ // completed tasks. Detect that "worked ahead" case to explain the
542
+ // otherwise-surprising backward pointer instead of letting it read as a
543
+ // completed task reverting to pending.
544
+ const workedAhead = phases.some(
545
+ (phase, idx) =>
546
+ idx > currentIdx && phase.tasks.some(task => task.status === "completed" || task.status === "abandoned"),
547
+ );
548
+ lines.push(`Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open.`);
537
549
  lines.push(
538
- `Phase ${currentIdx + 1}/${phases.length} "${current.name}" ${done}/${current.tasks.length} tasks complete`,
550
+ `Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${
551
+ workedAhead
552
+ ? " — earliest phase with open tasks; the in-progress pointer auto-advances to the earliest open task on each completion, so it can sit behind out-of-order work (nothing was un-completed)."
553
+ : "."
554
+ }`,
539
555
  );
540
556
  for (const phase of phases) {
541
557
  lines.push(` ${phase.name}:`);
542
558
  for (const task of phase.tasks) {
543
- const sym =
544
- task.status === "completed"
545
- ? "✓"
546
- : task.status === "in_progress"
547
- ? "→"
548
- : task.status === "abandoned"
549
- ? "✗"
550
- : "○";
551
- lines.push(` ${sym} ${task.content}`);
559
+ const checkbox = task.status === "completed" ? "[X]" : "[ ]";
560
+ const tag = task.status === "in_progress" ? " (in progress)" : task.status === "abandoned" ? " (dropped)" : "";
561
+ lines.push(` - ${checkbox} ${task.content}${tag}`);
552
562
  }
553
563
  }
554
564
  return lines.join("\n");