@oh-my-pi/pi-coding-agent 16.2.6 → 16.2.7

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 +23 -0
  2. package/dist/cli.js +2656 -2651
  3. package/dist/types/cli/bench-cli.d.ts +3 -3
  4. package/dist/types/commands/bench.d.ts +1 -1
  5. package/dist/types/config/service-tier.d.ts +39 -24
  6. package/dist/types/config/settings-schema.d.ts +36 -36
  7. package/dist/types/mcp/config-writer.d.ts +48 -0
  8. package/dist/types/mcp/types.d.ts +3 -0
  9. package/dist/types/session/agent-session.d.ts +33 -13
  10. package/dist/types/session/messages.d.ts +1 -1
  11. package/dist/types/session/session-context.d.ts +2 -2
  12. package/dist/types/session/session-entries.d.ts +2 -2
  13. package/dist/types/session/session-manager.d.ts +2 -2
  14. package/dist/types/system-prompt.test.d.ts +1 -0
  15. package/dist/types/task/executor.d.ts +6 -6
  16. package/dist/types/task/types.d.ts +6 -0
  17. package/dist/types/task/worktree.d.ts +32 -6
  18. package/dist/types/tiny/title-client.d.ts +5 -1
  19. package/dist/types/tools/index.d.ts +3 -3
  20. package/dist/types/utils/git.d.ts +17 -0
  21. package/package.json +12 -12
  22. package/src/cli/bench-cli.ts +19 -12
  23. package/src/cli/tiny-models-cli.ts +18 -4
  24. package/src/commands/bench.ts +3 -3
  25. package/src/config/mcp-schema.json +10 -1
  26. package/src/config/service-tier.ts +85 -56
  27. package/src/config/settings-schema.ts +42 -36
  28. package/src/config/settings.ts +47 -0
  29. package/src/eval/agent-bridge.ts +4 -2
  30. package/src/internal-urls/docs-index.generated.txt +1 -1
  31. package/src/main.ts +1 -1
  32. package/src/mcp/config-writer.ts +121 -0
  33. package/src/mcp/config.ts +10 -6
  34. package/src/mcp/types.ts +3 -0
  35. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  36. package/src/modes/components/extensions/state-manager.ts +24 -3
  37. package/src/modes/controllers/event-controller.ts +7 -0
  38. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  39. package/src/sdk.ts +12 -11
  40. package/src/session/agent-session.ts +186 -76
  41. package/src/session/messages.ts +1 -1
  42. package/src/session/session-context.ts +4 -4
  43. package/src/session/session-entries.ts +2 -2
  44. package/src/session/session-manager.ts +9 -2
  45. package/src/slash-commands/builtin-registry.ts +2 -10
  46. package/src/system-prompt.test.ts +158 -0
  47. package/src/system-prompt.ts +69 -26
  48. package/src/task/executor.ts +23 -16
  49. package/src/task/index.ts +7 -5
  50. package/src/task/isolation-runner.ts +15 -1
  51. package/src/task/types.ts +6 -0
  52. package/src/task/worktree.ts +219 -38
  53. package/src/tiny/title-client.ts +19 -13
  54. package/src/tools/index.ts +3 -3
  55. package/src/tools/irc.ts +9 -3
  56. package/src/tools/read.ts +28 -28
  57. package/src/utils/file-mentions.ts +10 -1
  58. package/src/utils/git.ts +38 -0
  59. package/src/web/search/providers/duckduckgo.ts +17 -3
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ interface ProbeRunResult {
7
+ elapsedMs: number;
8
+ childElapsedMs: number;
9
+ cached: unknown;
10
+ count: number;
11
+ }
12
+
13
+ async function runProbeScenario(options: {
14
+ runs: number;
15
+ sleepSeconds?: number;
16
+ holdStdoutOpen?: boolean;
17
+ descendantHoldsStdout?: boolean;
18
+ validOutput?: string;
19
+ }): Promise<ProbeRunResult> {
20
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-gpu-probe-"));
21
+ try {
22
+ const binDir = path.join(tempRoot, "bin");
23
+ const cacheRoot = path.join(tempRoot, "cache");
24
+ const probeCountPath = path.join(tempRoot, "probe-count");
25
+ await fs.mkdir(binDir, { recursive: true });
26
+ await fs.mkdir(path.join(cacheRoot, "omp"), { recursive: true });
27
+ const lspciPath = path.join(binDir, "lspci");
28
+ await Bun.write(
29
+ lspciPath,
30
+ '#!/usr/bin/env sh\nprintf x >> "$OMP_GPU_PROBE_COUNT"\nif [ -n "$OMP_GPU_PROBE_VALID_OUTPUT" ]; then printf "%s\\n" "$OMP_GPU_PROBE_VALID_OUTPUT"; fi\nif [ "$OMP_GPU_PROBE_DESCENDANT_HOLDS_STDOUT" = "true" ]; then sleep "$OMP_GPU_PROBE_SLEEP" & exit 0; fi\nif [ "$OMP_GPU_PROBE_HOLD_STDOUT_OPEN" = "true" ]; then sleep "$OMP_GPU_PROBE_SLEEP" & wait "$!"; fi\nif [ -n "$OMP_GPU_PROBE_SLEEP" ]; then exec sleep "$OMP_GPU_PROBE_SLEEP"; fi\nexit 0\n',
31
+ );
32
+ await fs.chmod(lspciPath, 0o755);
33
+
34
+ const scenarioPath = path.join(tempRoot, "scenario.ts");
35
+ await Bun.write(
36
+ scenarioPath,
37
+ `import { getGpuCachePath, refreshDirsFromEnv } from ${JSON.stringify(path.resolve(import.meta.dir, "../../utils/src/index.ts"))};
38
+ import { buildSystemPrompt } from ${JSON.stringify(path.join(import.meta.dir, "system-prompt.ts"))};
39
+
40
+ refreshDirsFromEnv();
41
+ const buildOptions = {
42
+ contextFiles: [],
43
+ skills: [],
44
+ toolNames: [],
45
+ workspaceTree: {
46
+ rootPath: process.cwd(),
47
+ rendered: "",
48
+ truncated: false,
49
+ totalLines: 0,
50
+ agentsMdFiles: [],
51
+ },
52
+ activeRepoContext: null,
53
+ };
54
+ const startedAt = performance.now();
55
+ for (let index = 0; index < Number(process.env.OMP_GPU_PROBE_RUNS ?? "1"); index += 1) {
56
+ await buildSystemPrompt(buildOptions);
57
+ }
58
+ const cacheFile = Bun.file(getGpuCachePath());
59
+ const cached = await cacheFile.exists() ? await cacheFile.json() : null;
60
+ const countFile = Bun.file(process.env.OMP_GPU_PROBE_COUNT ?? "");
61
+ const count = await countFile.exists() ? (await countFile.text()).length : 0;
62
+ console.log(JSON.stringify({ elapsedMs: Math.round(performance.now() - startedAt), cached, count }));
63
+ `,
64
+ );
65
+
66
+ const env: Record<string, string | undefined> = {
67
+ ...process.env,
68
+ PATH: `${binDir}:${process.env.PATH ?? ""}`,
69
+ XDG_CACHE_HOME: cacheRoot,
70
+ OMP_GPU_PROBE_COUNT: probeCountPath,
71
+ OMP_GPU_PROBE_RUNS: String(options.runs),
72
+ };
73
+ // Strip inherited dirs-resolver overrides so XDG_CACHE_HOME above wins and
74
+ // the test cannot touch the developer/CI profile's real gpu_cache.json.
75
+ for (const key of ["PI_CODING_AGENT_DIR", "OMP_PROFILE", "PI_PROFILE", "PI_CONFIG_DIR"]) {
76
+ delete env[key];
77
+ }
78
+ if (options.sleepSeconds === undefined) {
79
+ delete env.OMP_GPU_PROBE_SLEEP;
80
+ } else {
81
+ env.OMP_GPU_PROBE_SLEEP = String(options.sleepSeconds);
82
+ }
83
+ if (options.holdStdoutOpen) {
84
+ env.OMP_GPU_PROBE_HOLD_STDOUT_OPEN = "true";
85
+ } else {
86
+ delete env.OMP_GPU_PROBE_HOLD_STDOUT_OPEN;
87
+ }
88
+ if (options.descendantHoldsStdout) {
89
+ env.OMP_GPU_PROBE_DESCENDANT_HOLDS_STDOUT = "true";
90
+ } else {
91
+ delete env.OMP_GPU_PROBE_DESCENDANT_HOLDS_STDOUT;
92
+ }
93
+ if (options.validOutput !== undefined) {
94
+ env.OMP_GPU_PROBE_VALID_OUTPUT = options.validOutput;
95
+ } else {
96
+ delete env.OMP_GPU_PROBE_VALID_OUTPUT;
97
+ }
98
+
99
+ const childStartedAt = performance.now();
100
+ const child = Bun.spawn([process.execPath, scenarioPath], { stdout: "pipe", stderr: "pipe", env });
101
+ const [stdout, stderr, exitCode] = await Promise.all([
102
+ new Response(child.stdout).text(),
103
+ new Response(child.stderr).text(),
104
+ child.exited,
105
+ ]);
106
+ const childElapsedMs = Math.round(performance.now() - childStartedAt);
107
+ if (exitCode !== 0) {
108
+ throw new Error(`GPU probe scenario failed with exit ${exitCode}: ${stderr}`);
109
+ }
110
+ return { ...JSON.parse(stdout.trim()), childElapsedMs };
111
+ } finally {
112
+ await fs.rm(tempRoot, { recursive: true, force: true });
113
+ }
114
+ }
115
+
116
+ describe.skipIf(process.platform !== "linux")("system prompt GPU probe", () => {
117
+ it("caches empty GPU probe results", async () => {
118
+ const result = await runProbeScenario({ runs: 2 });
119
+
120
+ expect(result.cached).toEqual({ gpu: null });
121
+ expect(result.count).toBe(1);
122
+ }, 15_000);
123
+
124
+ it("kills the GPU probe at the prep deadline", async () => {
125
+ const result = await runProbeScenario({ runs: 1, sleepSeconds: 7, holdStdoutOpen: true });
126
+
127
+ expect(result.cached).toEqual({ gpu: null });
128
+ expect(result.elapsedMs).toBeLessThan(6500);
129
+ // Codex#3838: the child process MUST exit shortly after the deadline,
130
+ // not linger until a descendant holding stdout (sleep 7) exits on its own.
131
+ expect(result.childElapsedMs).toBeLessThan(6500);
132
+ }, 15_000);
133
+
134
+ it("does not wait on stdout held by a descendant after a successful probe", async () => {
135
+ const result = await runProbeScenario({ runs: 1, sleepSeconds: 3, descendantHoldsStdout: true });
136
+
137
+ expect(result.cached).toEqual({ gpu: null });
138
+ // Probe exits 0 immediately but leaves a backgrounded sleep holding the stdout
139
+ // pipe. The success path MUST bound the drain wait, not block until sleep exits.
140
+ expect(result.elapsedMs).toBeLessThan(2000);
141
+ expect(result.childElapsedMs).toBeLessThan(2000);
142
+ }, 15_000);
143
+
144
+ it("keeps probe output captured before a descendant delays EOF", async () => {
145
+ const result = await runProbeScenario({
146
+ runs: 1,
147
+ sleepSeconds: 3,
148
+ descendantHoldsStdout: true,
149
+ validOutput: "00:02.0 VGA compatible controller: NVIDIA TestGPU",
150
+ });
151
+
152
+ // Probe exited 0 with valid output before bg sleep held stdout open.
153
+ // Captured stdout MUST be cached, not discarded as if the probe failed.
154
+ expect(result.cached).toEqual({ gpu: "02.0 VGA compatible controller: NVIDIA TestGPU" });
155
+ expect(result.elapsedMs).toBeLessThan(2000);
156
+ expect(result.childElapsedMs).toBeLessThan(2000);
157
+ }, 15_000);
158
+ });
@@ -7,7 +7,6 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
7
7
  import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai";
8
8
  import { renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
9
9
  import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
10
- import { $ } from "bun";
11
10
  import { contextFileCapability } from "./capability/context-file";
12
11
  import { systemPromptCapability } from "./capability/system-prompt";
13
12
  import { findConfigFile } from "./config";
@@ -112,21 +111,60 @@ function parseWmicTable(output: string, header: string): string | null {
112
111
  }
113
112
 
114
113
  const SYSTEM_PROMPT_PREP_TIMEOUT_MS = 5000;
114
+ /** Kept below prep timeout so timed-out probes can still write the null cache before fallback. */
115
+ const GPU_PROBE_TIMEOUT_MS = SYSTEM_PROMPT_PREP_TIMEOUT_MS - 500;
116
+ /** Drop stdout from a probe descendant that inherited the pipe after the probe exited. */
117
+ const GPU_PROBE_STDOUT_DRAIN_MS = 250;
118
+
119
+ async function runGpuProbe(cmd: string[]): Promise<string | null> {
120
+ try {
121
+ const proc = Bun.spawn({
122
+ cmd,
123
+ stdout: "pipe",
124
+ stderr: "ignore",
125
+ stdin: "ignore",
126
+ timeout: GPU_PROBE_TIMEOUT_MS,
127
+ // SIGKILL so a probe ignoring SIGTERM (PATH wrapper, wedged WMI) still
128
+ // dies at the deadline and lets getCachedGpu reach the null-cache write.
129
+ killSignal: "SIGKILL",
130
+ });
131
+ const stdoutReader = proc.stdout.getReader();
132
+ let stdout = "";
133
+ const decoder = new TextDecoder();
134
+ const stdoutDone = (async () => {
135
+ while (true) {
136
+ const chunk = await stdoutReader.read();
137
+ if (chunk.done) break;
138
+ stdout += decoder.decode(chunk.value, { stream: true });
139
+ }
140
+ stdout += decoder.decode();
141
+ })();
142
+ const exitCode = await proc.exited;
143
+ // Even on exit 0, a probe wrapper can leave a descendant holding stdout open.
144
+ // Bound the EOF wait so getCachedGpu cannot outlive the probe in either path;
145
+ // keep whatever bytes the reader already captured before cancelling.
146
+ const drained = await Promise.race([
147
+ stdoutDone.then(() => "ok" as const).catch(() => "err" as const),
148
+ Bun.sleep(GPU_PROBE_STDOUT_DRAIN_MS).then(() => "timeout" as const),
149
+ ]);
150
+ if (drained !== "ok") {
151
+ await stdoutReader.cancel().catch(() => undefined);
152
+ await stdoutDone.catch(() => undefined);
153
+ }
154
+ return exitCode === 0 ? stdout : null;
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
115
159
 
116
160
  async function getGpuModel(): Promise<string | null> {
117
161
  switch (process.platform) {
118
162
  case "win32": {
119
- const output = await $`wmic path win32_VideoController get name`
120
- .quiet()
121
- .text()
122
- .catch(() => null);
163
+ const output = await runGpuProbe(["wmic", "path", "win32_VideoController", "get", "name"]);
123
164
  return output ? parseWmicTable(output, "Name") : null;
124
165
  }
125
166
  case "linux": {
126
- const output = await $`lspci`
127
- .quiet()
128
- .text()
129
- .catch(() => null);
167
+ const output = await runGpuProbe(["lspci"]);
130
168
  if (!output) return null;
131
169
  const gpus: Array<{ name: string; priority: number }> = [];
132
170
  for (const line of output.split("\n")) {
@@ -176,20 +214,20 @@ function getTerminalName(): string | undefined {
176
214
  return term ?? undefined;
177
215
  }
178
216
 
179
- /** Cached system info structure */
217
+ /** Cached GPU probe result. */
180
218
  interface GpuCache {
181
- gpu: string;
182
- }
183
-
184
- function getSystemInfoCachePath(): string {
185
- return getGpuCachePath();
219
+ gpu: string | null;
186
220
  }
187
221
 
188
222
  async function loadGpuCache(): Promise<GpuCache | null> {
189
223
  try {
190
- const cachePath = getSystemInfoCachePath();
224
+ const cachePath = getGpuCachePath();
191
225
  const content = await Bun.file(cachePath).json();
192
- return content as GpuCache;
226
+ if (content && typeof content === "object" && "gpu" in content) {
227
+ const gpu = content.gpu;
228
+ return { gpu: typeof gpu === "string" ? gpu : null };
229
+ }
230
+ return null;
193
231
  } catch {
194
232
  return null;
195
233
  }
@@ -197,7 +235,7 @@ async function loadGpuCache(): Promise<GpuCache | null> {
197
235
 
198
236
  async function saveGpuCache(info: GpuCache): Promise<void> {
199
237
  try {
200
- const cachePath = getSystemInfoCachePath();
238
+ const cachePath = getGpuCachePath();
201
239
  await Bun.write(cachePath, JSON.stringify(info, null, "\t"));
202
240
  } catch {
203
241
  // Silently ignore cache write failures
@@ -206,15 +244,12 @@ async function saveGpuCache(info: GpuCache): Promise<void> {
206
244
 
207
245
  async function getCachedGpu(): Promise<string | undefined> {
208
246
  const cached = await logger.time("getCachedGpu:loadGpuCache", loadGpuCache);
209
- if (cached) return cached.gpu;
247
+ if (cached) return cached.gpu ?? undefined;
210
248
  const gpu = await logger.time("getCachedGpu:getGpuModel", getGpuModel);
211
- if (gpu) {
212
- await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
213
- }
249
+ await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
214
250
  return gpu ?? undefined;
215
251
  }
216
- async function getEnvironmentInfo(): Promise<Array<{ label: string; value: string }>> {
217
- const gpu = await getCachedGpu();
252
+ function getEnvironmentInfo(gpu: string | undefined): Array<{ label: string; value: string }> {
218
253
  let cpuModel: string | undefined;
219
254
  try {
220
255
  cpuModel = os.cpus()[0]?.model;
@@ -500,9 +535,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
500
535
  agentsMdFiles: [],
501
536
  } satisfies WorkspaceTree,
502
537
  activeRepoContext: null as ActiveRepoContext | null,
538
+ gpu: undefined as string | undefined,
503
539
  };
504
540
 
505
- const deadline = Bun.sleep(SYSTEM_PROMPT_PREP_TIMEOUT_MS).then(() => "__timeout__" as const);
541
+ const { promise: deadline, resolve: fireDeadline } = Promise.withResolvers<"__timeout__">();
542
+ const deadlineTimer = setTimeout(() => fireDeadline("__timeout__"), SYSTEM_PROMPT_PREP_TIMEOUT_MS);
543
+ // Unref so a fast prep does not hold a one-shot CLI alive waiting for this timer.
544
+ deadlineTimer.unref();
506
545
  const timedOut: string[] = [];
507
546
  const failed: Array<{ name: string; error: unknown }> = [];
508
547
 
@@ -566,6 +605,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
566
605
  providedActiveRepoContext !== undefined
567
606
  ? Promise.resolve(providedActiveRepoContext)
568
607
  : logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd));
608
+ const gpuPromise = logger.time("getCachedGpu", getCachedGpu);
569
609
 
570
610
  const [
571
611
  resolvedCustomPrompt,
@@ -575,6 +615,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
575
615
  skills,
576
616
  workspaceTree,
577
617
  activeRepoContext,
618
+ gpu,
578
619
  ] = await Promise.all([
579
620
  withDeadline(
580
621
  "customPrompt",
@@ -597,7 +638,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
597
638
  withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
598
639
  withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
599
640
  withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext),
641
+ withDeadline("getCachedGpu", gpuPromise, prepDefaults.gpu),
600
642
  ]);
643
+ clearTimeout(deadlineTimer);
601
644
  const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT);
602
645
 
603
646
  if (timedOut.length > 0) {
@@ -675,7 +718,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
675
718
  ];
676
719
  const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
677
720
 
678
- const environment = await logger.time("getEnvironmentInfo", getEnvironmentInfo);
721
+ const environment = getEnvironmentInfo(gpu);
679
722
  const data = {
680
723
  systemPromptCustomization: effectiveSystemPromptCustomization,
681
724
  customPrompt: resolvedCustomPrompt,
@@ -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, ServiceTier, Usage } from "@oh-my-pi/pi-ai";
10
+ import type { Api, Model, ServiceTierByFamily, 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,7 +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
+ import { buildServiceTierByFamily, resolveSubagentServiceTier } from "../config/service-tier";
22
22
  import { Settings } from "../config/settings";
23
23
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
24
24
  import type { ToolPathWithSource } from "../extensibility/custom-tools";
@@ -344,12 +344,12 @@ export interface ExecutorOptions {
344
344
  modelRegistry?: ModelRegistry;
345
345
  settings?: Settings;
346
346
  /**
347
- * Parent session's live effective service tier, the source of truth for a
348
- * subagent whose `serviceTierSubagent` is `"inherit"`. `null` = the parent
347
+ * Parent session's live per-family service tiers, the source of truth for a
348
+ * subagent whose `tier.subagent` is `"inherit"`. `null` = the parent
349
349
  * explicitly has no tier (e.g. `/fast off`); omitted = no live session, so
350
- * inherit falls back to the configured `serviceTier` setting.
350
+ * inherit falls back to the subagent's configured `tier.*` settings.
351
351
  */
352
- parentServiceTier?: ServiceTier | null;
352
+ parentServiceTier?: ServiceTierByFamily | null;
353
353
  /** Override local:// protocol options so subagent shares parent's local:// root */
354
354
  localProtocolOptions?: LocalProtocolOptions;
355
355
  /**
@@ -739,21 +739,28 @@ export function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
739
739
  export function createSubagentSettings(
740
740
  baseSettings: Settings,
741
741
  overrides?: Partial<Record<SettingPath, unknown>>,
742
- inheritedServiceTier?: ServiceTier | null,
742
+ inheritedServiceTier?: ServiceTierByFamily | null,
743
743
  ): Settings {
744
744
  const snapshot: Partial<Record<SettingPath, unknown>> = {};
745
745
  for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
746
746
  snapshot[key] = baseSettings.get(key);
747
747
  }
748
- // Resolve the subagent's service tier from `serviceTierSubagent` ("inherit" =
749
- // match the parent's live tier when a live session supplied one, else the
750
- // configured `serviceTier`). The result is stamped back onto the snapshot so
751
- // createAgentSession's `settings.get("serviceTier")` read picks it up.
752
- snapshot.serviceTier = resolveSubagentServiceTier(
753
- baseSettings.get("serviceTierSubagent"),
754
- baseSettings.get("serviceTier"),
755
- inheritedServiceTier,
756
- );
748
+ // Resolve the subagent's per-family tiers from `tier.subagent` ("inherit" =
749
+ // match the parent's live tiers when a live session supplied them, else the
750
+ // subagent's own configured tier.* settings). The result is stamped back onto
751
+ // the snapshot so createAgentSession's tier.* reads pick it up.
752
+ const inheritedTiers =
753
+ inheritedServiceTier === undefined
754
+ ? buildServiceTierByFamily(
755
+ baseSettings.get("tier.openai"),
756
+ baseSettings.get("tier.anthropic"),
757
+ baseSettings.get("tier.google"),
758
+ )
759
+ : (inheritedServiceTier ?? {});
760
+ const subagentTiers = resolveSubagentServiceTier(baseSettings.get("tier.subagent"), inheritedTiers);
761
+ snapshot["tier.openai"] = subagentTiers.openai ?? "none";
762
+ snapshot["tier.anthropic"] = subagentTiers.anthropic ?? "none";
763
+ snapshot["tier.google"] = subagentTiers.google ?? "none";
757
764
  return Settings.isolated({
758
765
  ...snapshot,
759
766
  "async.enabled": false,
package/src/task/index.ts CHANGED
@@ -1296,11 +1296,13 @@ 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
+ // Live source of truth for `tier.subagent: inherit`. When the session
1300
+ // exposes a tier accessor, pass the per-family map or null (null =
1301
+ // explicit none, e.g. /fast off); otherwise leave undefined so inherit
1302
+ // falls back to the subagent's configured tier.* settings.
1303
+ parentServiceTier: this.session.getServiceTierByFamily
1304
+ ? (this.session.getServiceTierByFamily() ?? null)
1305
+ : undefined,
1304
1306
  };
1305
1307
 
1306
1308
  const runTask = async (): Promise<SingleResult> => {
@@ -154,6 +154,7 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<S
154
154
  return {
155
155
  ...result,
156
156
  branchName: commitResult?.branchName,
157
+ branchBaseSha: commitResult?.baseSha,
157
158
  nestedPatches: commitResult?.nestedPatches,
158
159
  };
159
160
  } catch (mergeErr) {
@@ -222,6 +223,14 @@ export async function mergeIsolatedChanges(opts: IsolationMergeOptions): Promise
222
223
  const { result, repoRoot, mergeMode } = opts;
223
224
  try {
224
225
  if (mergeMode === "branch") {
226
+ if (!result.branchName && result.exitCode === 0 && !result.aborted && result.error) {
227
+ return {
228
+ summary: `\n\n<system-notification>Branch merge failed before a task branch could be created: ${result.error}\nTask outputs are preserved but changes were not applied.</system-notification>`,
229
+ changesApplied: false,
230
+ hadAnyChanges: false,
231
+ mergedBranchForNestedPatches: false,
232
+ };
233
+ }
225
234
  const canApplyNestedOnly =
226
235
  !result.branchName && result.exitCode === 0 && !result.aborted && (result.nestedPatches?.length ?? 0) > 0;
227
236
  if (!result.branchName || result.exitCode !== 0 || result.aborted) {
@@ -235,7 +244,12 @@ export async function mergeIsolatedChanges(opts: IsolationMergeOptions): Promise
235
244
  };
236
245
  }
237
246
  const mergeResult = await mergeTaskBranches(repoRoot, [
238
- { branchName: result.branchName, taskId: result.id, description: result.description },
247
+ {
248
+ branchName: result.branchName,
249
+ taskId: result.id,
250
+ description: result.description,
251
+ baseSha: result.branchBaseSha,
252
+ },
239
253
  ]);
240
254
  const mergedBranchForNestedPatches = mergeResult.merged.includes(result.branchName);
241
255
  const changesApplied = mergeResult.failed.length === 0;
package/src/task/types.ts CHANGED
@@ -383,6 +383,12 @@ export interface SingleResult {
383
383
  patchPath?: string;
384
384
  /** Branch name for isolated branch-mode output */
385
385
  branchName?: string;
386
+ /**
387
+ * Baseline commit SHA the task branch was created from. Passed to
388
+ * `mergeTaskBranches` so cherry-pick uses the inclusive range
389
+ * `branchBaseSha..branchName` and preserves every agent commit's message.
390
+ */
391
+ branchBaseSha?: string;
386
392
  /** Nested repo patches to apply after parent merge */
387
393
  nestedPatches?: NestedRepoPatch[];
388
394
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */