@oh-my-pi/pi-coding-agent 16.2.4 → 16.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -534,6 +534,13 @@ export interface AgentSessionConfig {
534
534
  * inherits this so its requests undergo the same shaping as the main turn.
535
535
  */
536
536
  transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
537
+ /**
538
+ * Stream wrapper passed to side-channel requests (`/btw`, `/omfg`, IRC
539
+ * auto-replies, and handoff generation) so they apply the same provider
540
+ * shaping and host-level request wrappers as normal agent turns. Defaults
541
+ * to plain `streamSimple` when omitted.
542
+ */
543
+ sideStreamFn?: StreamFn;
537
544
  /**
538
545
  * Stream wrapper passed to the advisor agent so its requests apply the
539
546
  * session's `providers.openrouterVariant`, `providers.antigravityEndpoint`,
@@ -1459,6 +1466,7 @@ export class AgentSession {
1459
1466
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
1460
1467
  #onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
1461
1468
  #transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
1469
+ #sideStreamFn: StreamFn;
1462
1470
  #advisorStreamFn: StreamFn | undefined;
1463
1471
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
1464
1472
  #rebuildSystemPrompt:
@@ -1788,6 +1796,7 @@ export class AgentSession {
1788
1796
  this.#requestedToolNames = config.requestedToolNames;
1789
1797
  this.#transformContext = config.transformContext ?? (messages => messages);
1790
1798
  this.#transformProviderContext = config.transformProviderContext;
1799
+ this.#sideStreamFn = config.sideStreamFn ?? streamSimple;
1791
1800
  this.#advisorStreamFn = config.advisorStreamFn;
1792
1801
  this.#onPayload = config.onPayload;
1793
1802
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
@@ -9195,6 +9204,10 @@ export class AgentSession {
9195
9204
  model,
9196
9205
  {
9197
9206
  streamOptions: handoffStreamOptions,
9207
+ completeImpl: async (requestModel, requestContext, requestOptions) => {
9208
+ const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions);
9209
+ return stream.result();
9210
+ },
9198
9211
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
9199
9212
  // Honor the user's /model thinking selection on the handoff path.
9200
9213
  // Clamped per-model inside generateHandoffFromContext via
@@ -9482,16 +9495,18 @@ export class AgentSession {
9482
9495
  const errorIsFromBeforeCompaction =
9483
9496
  compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
9484
9497
  if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
9485
- // Remove the error message from agent state (it IS saved to session for history,
9486
- // but we don't want it in context for the retry)
9487
- const messages = this.agent.state.messages;
9488
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
9489
- this.agent.replaceMessages(messages.slice(0, -1));
9490
- }
9498
+ // Clear the failed turn from active context so the retry (or the next
9499
+ // user prompt) does not replay it. The persisted branch entry stays
9500
+ // for now: when no recovery path runs, the user-facing transcript
9501
+ // MUST keep the only assistant message explaining why the turn
9502
+ // stopped. The branch entry is dropped further down, but only on the
9503
+ // paths that actually schedule a retry/compaction.
9504
+ this.#removeAssistantMessageFromActiveContext(assistantMessage);
9491
9505
 
9492
9506
  // Try context promotion first - switch to a larger model and retry without compacting
9493
9507
  const promoted = await this.#tryContextPromotion(assistantMessage);
9494
9508
  if (promoted) {
9509
+ await this.#dropPersistedAssistantTurn(assistantMessage);
9495
9510
  // Retry on the promoted (larger) model without compacting
9496
9511
  this.#scheduleAgentContinue({ delayMs: 100, generation });
9497
9512
  return COMPACTION_CHECK_CONTINUATION;
@@ -9500,7 +9515,9 @@ export class AgentSession {
9500
9515
  // No promotion target available fall through to compaction
9501
9516
  const compactionSettings = this.settings.getGroup("compaction");
9502
9517
  if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
9503
- return await this.#runAutoCompaction("overflow", true, false, allowDefer, { autoContinue });
9518
+ return await this.#runRecoveryCompactionWithRollback("overflow", assistantMessage, allowDefer, {
9519
+ autoContinue,
9520
+ });
9504
9521
  }
9505
9522
  return COMPACTION_CHECK_NONE;
9506
9523
  }
@@ -9512,13 +9529,14 @@ export class AgentSession {
9512
9529
  // otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
9513
9530
  // allow the handoff strategy to actually run.
9514
9531
  if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
9515
- const messages = this.agent.state.messages;
9516
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
9517
- this.agent.replaceMessages(messages.slice(0, -1));
9518
- }
9532
+ // Same active-context vs persisted-history split as the overflow path
9533
+ // above: clear the dead turn from agent state so it cannot be replayed,
9534
+ // but keep it on the branch unless promotion or compaction actually runs.
9535
+ this.#removeAssistantMessageFromActiveContext(assistantMessage);
9519
9536
 
9520
9537
  const promoted = await this.#tryContextPromotion(assistantMessage);
9521
9538
  if (promoted) {
9539
+ await this.#dropPersistedAssistantTurn(assistantMessage);
9522
9540
  logger.debug("Context promotion triggered by response.incomplete (length stop)", {
9523
9541
  from: `${assistantMessage.provider}/${assistantMessage.model}`,
9524
9542
  });
@@ -9532,7 +9550,7 @@ export class AgentSession {
9532
9550
  model: `${assistantMessage.provider}/${assistantMessage.model}`,
9533
9551
  strategy: incompleteCompactionSettings.strategy,
9534
9552
  });
9535
- return await this.#runAutoCompaction("incomplete", true, false, allowDefer, {
9553
+ return await this.#runRecoveryCompactionWithRollback("incomplete", assistantMessage, allowDefer, {
9536
9554
  autoContinue,
9537
9555
  triggerContextTokens: calculateContextTokens(assistantMessage.usage),
9538
9556
  });
@@ -9797,6 +9815,54 @@ export class AgentSession {
9797
9815
  }
9798
9816
  }
9799
9817
 
9818
+ /**
9819
+ * Drop a recoverable assistant turn from the persisted session branch once a
9820
+ * recovery path (context promotion or compaction) is committed. Waits for the
9821
+ * in-flight `message_end` persistence slot first so the branch entry exists
9822
+ * before we reparent past it. Active context removal is the caller's
9823
+ * responsibility — recovery paths clear it eagerly so the retry never
9824
+ * replays the failed turn, while no-recovery paths leave the persisted entry
9825
+ * (and the user-visible transcript line) in place.
9826
+ */
9827
+ async #dropPersistedAssistantTurn(assistantMessage: AssistantMessage): Promise<void> {
9828
+ await this.#waitForSessionMessagePersistence(assistantMessage);
9829
+ this.#discardAssistantTurn(assistantMessage);
9830
+ }
9831
+
9832
+ /**
9833
+ * Drop the failed assistant turn from persisted history, run
9834
+ * {@link #runAutoCompaction} for an `overflow` / `incomplete` recovery, and
9835
+ * restore the assistant entry if compaction did not actually commit
9836
+ * anything (no usable model/preparation, hook cancel, or compaction error).
9837
+ *
9838
+ * Compaction has to see a clean branch — otherwise its `prepareCompaction`
9839
+ * pass would keep the failed turn in the kept region and the retry would
9840
+ * replay it. But a NONE return that was not paired with a fresh compaction
9841
+ * summary means no recovery is in progress, and leaving the branch
9842
+ * reparented would erase the only user-visible explanation for why the turn
9843
+ * stopped. Reverting the drop in that case preserves the transcript while
9844
+ * still letting a real recovery path own the rewrite.
9845
+ */
9846
+ async #runRecoveryCompactionWithRollback(
9847
+ reason: "overflow" | "incomplete",
9848
+ assistantMessage: AssistantMessage,
9849
+ allowDefer: boolean,
9850
+ options: { autoContinue: boolean; triggerContextTokens?: number },
9851
+ ): Promise<CompactionCheckResult> {
9852
+ const compactionEntryBefore = getLatestCompactionEntry(this.sessionManager.getBranch());
9853
+ await this.#dropPersistedAssistantTurn(assistantMessage);
9854
+ const result = await this.#runAutoCompaction(reason, true, false, allowDefer, options);
9855
+ const recoveryCommitted =
9856
+ result.continuationScheduled || result.deferredHandoff || result.automaticContinuationBlocked === true;
9857
+ if (!recoveryCommitted) {
9858
+ const compactionEntryAfter = getLatestCompactionEntry(this.sessionManager.getBranch());
9859
+ if (compactionEntryAfter === compactionEntryBefore) {
9860
+ this.sessionManager.appendMessage(assistantMessage);
9861
+ }
9862
+ }
9863
+ return result;
9864
+ }
9865
+
9800
9866
  /**
9801
9867
  * Drop an assistant turn from BOTH the live agent context and the persisted
9802
9868
  * session branch (reparenting the leaf to the turn's parent), so a discarded
@@ -10694,6 +10760,18 @@ export class AgentSession {
10694
10760
  tools: this.agent.state.tools,
10695
10761
  sessionId: this.sessionId,
10696
10762
  promptCacheKey: this.sessionId,
10763
+ // Route every summarization HTTP request through the
10764
+ // session's side-stream transport so the provider
10765
+ // concurrency cap (e.g. providers.ollama-cloud.maxConcurrency)
10766
+ // brackets compaction the same way it brackets the live
10767
+ // agent turn — without this, multiple ollama-cloud
10768
+ // subagents auto/manually compacting issued uncapped
10769
+ // summary requests in parallel (chatgpt-codex review on
10770
+ // #3751).
10771
+ completeImpl: async (requestModel, requestContext, requestOptions) => {
10772
+ const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions);
10773
+ return stream.result();
10774
+ },
10697
10775
  },
10698
10776
  );
10699
10777
  } catch (error) {
@@ -12904,7 +12982,7 @@ export class AgentSession {
12904
12982
  let providerReplyText = "";
12905
12983
  let emittedReplyText = "";
12906
12984
  let assistantMessage: AssistantMessage | undefined;
12907
- const stream = streamSimple(model, obfuscateProviderContext(this.#obfuscator, context), options);
12985
+ const stream = await this.#sideStreamFn(model, obfuscateProviderContext(this.#obfuscator, context), options);
12908
12986
  for await (const event of stream) {
12909
12987
  if (event.type === "text_delta") {
12910
12988
  providerReplyText += event.delta;
@@ -13522,6 +13600,12 @@ export class AgentSession {
13522
13600
  metadata: this.agent.metadataForProvider(model.provider),
13523
13601
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
13524
13602
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
13603
+ // Same per-provider concurrency cap rationale as the compaction
13604
+ // path above (chatgpt-codex review on #3751).
13605
+ completeImpl: async (requestModel, requestContext, requestOptions) => {
13606
+ const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions);
13607
+ return stream.result();
13608
+ },
13525
13609
  });
13526
13610
  this.#branchSummaryAbortController = undefined;
13527
13611
  if (result.aborted) {
@@ -56,7 +56,6 @@ 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";
60
59
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
61
60
  import {
62
61
  type AgentDefinition,
@@ -199,51 +198,6 @@ function installSubagentRetryFallbackChain(args: {
199
198
  return role;
200
199
  }
201
200
 
202
- const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
203
- "ollama-cloud": "providers.ollama-cloud.maxConcurrency",
204
- };
205
-
206
- interface ProviderSemaphoreEntry {
207
- limit: number;
208
- semaphore: Semaphore;
209
- }
210
-
211
- const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
212
-
213
- /**
214
- * Resolve the configured concurrency ceiling for a provider, or `undefined`
215
- * when the provider has no cap concept at all. A configured value `<= 0` means
216
- * "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
217
- * holds a slot and a later finite resize counts work started while unlimited.
218
- */
219
- function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
220
- const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
221
- if (!settingPath) return undefined;
222
- const raw = settings.get(settingPath);
223
- const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
224
- return limit > 0 ? limit : Number.POSITIVE_INFINITY;
225
- }
226
-
227
- function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
228
- const limit = getProviderConcurrencyLimit(settings, provider);
229
- if (limit === undefined) return undefined;
230
- // Always hand out (and acquire on) the single shared limiter, even when
231
- // unlimited (Infinity). Resizing it in place — rather than replacing it —
232
- // keeps every in-flight slot counted, so a runtime or mixed limit change can
233
- // never push concurrency past the cap (issue #3464 review feedback).
234
- const existing = providerSemaphores.get(provider);
235
- if (existing) {
236
- if (existing.limit !== limit) {
237
- existing.limit = limit;
238
- existing.semaphore.resize(limit);
239
- }
240
- return existing.semaphore;
241
- }
242
- const semaphore = new Semaphore(limit);
243
- providerSemaphores.set(provider, { limit, semaphore });
244
- return semaphore;
245
- }
246
-
247
201
  function renderIrcPeerRoster(selfId: string): string {
248
202
  const peers = AgentRegistry.global()
249
203
  .list()
@@ -2028,8 +1982,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2028
1982
  let sessionOpenedAt: number | undefined;
2029
1983
  let sessionCreatedAt: number | undefined;
2030
1984
  let readyAt: number | undefined;
2031
- let providerSemaphore: Semaphore | undefined;
2032
- let providerSemaphoreAcquired = false;
2033
1985
 
2034
1986
  try {
2035
1987
  checkAbort();
@@ -2098,13 +2050,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2098
2050
  ? resolvedThinkingLevel
2099
2051
  : (thinkingLevel ?? resolvedThinkingLevel);
2100
2052
  resolvedAt = performance.now();
2101
- if (model) {
2102
- providerSemaphore = getProviderSemaphore(settings, model.provider);
2103
- if (providerSemaphore) {
2104
- await providerSemaphore.acquire(abortSignal);
2105
- providerSemaphoreAcquired = true;
2106
- }
2107
- }
2108
2053
 
2109
2054
  const effectiveCwd = worktree ?? cwd;
2110
2055
  const sessionManager = sessionFile
@@ -2395,10 +2340,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2395
2340
  }
2396
2341
  if (exitCode === 0) exitCode = 1;
2397
2342
  }
2398
- if (providerSemaphoreAcquired) {
2399
- providerSemaphore?.release();
2400
- providerSemaphoreAcquired = false;
2401
- }
2402
2343
  sessionAbortController.abort();
2403
2344
  try {
2404
2345
  await untilAborted(AbortSignal.timeout(5000), () => monitor.waitForActiveSessionAbort());
@@ -2429,9 +2370,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2429
2370
  }
2430
2371
 
2431
2372
  // Launch-latency breakdown (subagent invocation → first chat dispatch).
2432
- // Phase deltas are performance.now() spans; the semaphore brackets use the
2433
- // Date.now epochs captured by the spawn site (invokedAt before acquire,
2434
- // acquiredAt after) so queue wait and pre-run setup are reported apart.
2373
+ // Phase deltas are performance.now() spans; the task-tool concurrency
2374
+ // brackets use the Date.now epochs captured by the spawn site
2375
+ // (invokedAt before acquire, acquiredAt after) so queue wait and
2376
+ // pre-run setup are reported apart.
2435
2377
  const span = (from: number | undefined, to: number | undefined): number | undefined =>
2436
2378
  from !== undefined && to !== undefined ? Math.round(to - from) : undefined;
2437
2379
  const queueMs =
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Per-provider LLM concurrency cap, applied around each provider HTTP request.
3
+ *
4
+ * The semaphore brackets only the streaming request itself, not the whole
5
+ * agent lifetime: a parent subagent releases its slot the moment its LLM
6
+ * stream finishes producing, so children spawned during tool execution can
7
+ * acquire slots for their own turns. Holding the slot across the parent's
8
+ * full conversation deadlocks any spawn tree whose width exceeds
9
+ * `maxConcurrency` because the parents wait for children that wait for
10
+ * slots the parents are holding (issue
11
+ * [#3749](https://github.com/can1357/oh-my-pi/issues/3749)).
12
+ */
13
+
14
+ import type { StreamFn } from "@oh-my-pi/pi-agent-core";
15
+ import type { Settings } from "../config/settings";
16
+ import type { SettingPath } from "../config/settings-schema";
17
+ import { Semaphore } from "./parallel";
18
+
19
+ const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
20
+ "ollama-cloud": "providers.ollama-cloud.maxConcurrency",
21
+ };
22
+
23
+ interface ProviderSemaphoreEntry {
24
+ limit: number;
25
+ semaphore: Semaphore;
26
+ }
27
+
28
+ const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
29
+
30
+ /**
31
+ * Resolve the configured concurrency ceiling for a provider, or `undefined`
32
+ * when the provider has no cap concept at all. A configured value `<= 0` means
33
+ * "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
34
+ * holds a slot and a later finite resize counts work started while unlimited.
35
+ */
36
+ export function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
37
+ const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
38
+ if (!settingPath) return undefined;
39
+ const raw = settings.get(settingPath);
40
+ const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
41
+ return limit > 0 ? limit : Number.POSITIVE_INFINITY;
42
+ }
43
+
44
+ /**
45
+ * Hand out the single shared limiter for `provider` (creating one lazily) and
46
+ * resize it in place when the configured limit changes. Replacing the
47
+ * semaphore would orphan in-flight slots on the old instance and let a
48
+ * runtime or mixed limit value exceed the cap (issue #3464 review feedback).
49
+ */
50
+ export function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
51
+ const limit = getProviderConcurrencyLimit(settings, provider);
52
+ if (limit === undefined) return undefined;
53
+ const existing = providerSemaphores.get(provider);
54
+ if (existing) {
55
+ if (existing.limit !== limit) {
56
+ existing.limit = limit;
57
+ existing.semaphore.resize(limit);
58
+ }
59
+ return existing.semaphore;
60
+ }
61
+ const semaphore = new Semaphore(limit);
62
+ providerSemaphores.set(provider, { limit, semaphore });
63
+ return semaphore;
64
+ }
65
+
66
+ /**
67
+ * Wrap a {@link StreamFn} so every LLM HTTP request acquires the provider's
68
+ * concurrency slot before the request goes out and releases it when the
69
+ * stream finishes producing (success, error, or abort). Providers without a
70
+ * configured cap pass straight through.
71
+ *
72
+ * The acquire bracket is intentionally narrow (one slot per LLM call), so
73
+ * spawn trees deeper than `maxConcurrency` no longer deadlock on themselves —
74
+ * see the module-level comment for the failure mode this fixes.
75
+ */
76
+ export function wrapStreamFnWithProviderConcurrency(settings: Settings, base: StreamFn): StreamFn {
77
+ return async (model, context, options) => {
78
+ const semaphore = getProviderSemaphore(settings, model.provider);
79
+ if (!semaphore) return base(model, context, options);
80
+ await semaphore.acquire(options?.signal);
81
+ let released = false;
82
+ const release = () => {
83
+ if (released) return;
84
+ released = true;
85
+ semaphore.release();
86
+ };
87
+ try {
88
+ const stream = await base(model, context, options);
89
+ // EventStream.result() settles when the producer pushes 'done'/'error'
90
+ // or calls fail() — i.e. once the provider has finished producing.
91
+ // Releasing here keeps the slot held for the network request and
92
+ // nothing else.
93
+ stream.result().then(release, release);
94
+ return stream;
95
+ } catch (err) {
96
+ release();
97
+ throw err;
98
+ }
99
+ };
100
+ }
@@ -3,12 +3,16 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import * as natives from "@oh-my-pi/pi-natives";
6
- import { getWorktreeDir, hashPath, logger, Snowflake } from "@oh-my-pi/pi-utils";
6
+ import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
7
7
  import * as git from "../utils/git";
8
8
  import * as jj from "../utils/jj";
9
9
  import { mapWithConcurrencyLimit } from "./parallel";
10
10
 
11
11
  const { IsoBackendKind } = natives;
12
+
13
+ const TASK_ISOLATION_DIR_PREFIX = "t";
14
+ const TASK_ISOLATION_DIR_DIGEST_CHARS = 9;
15
+ const TASK_ISOLATION_MOUNT_DIR = "m";
12
16
  type IsoBackendKind = natives.IsoBackendKind;
13
17
 
14
18
  /** Baseline state for a single git repository. */
@@ -389,15 +393,20 @@ function errorMessage(err: unknown): string {
389
393
  return err instanceof Error ? err.message : String(err);
390
394
  }
391
395
 
396
+ function getTaskIsolationSegment(repoRoot: string, id: string): string {
397
+ const key = `${path.resolve(repoRoot)}\0${id}`;
398
+ const digest = Bun.hash(key).toString(16).padStart(16, "0").slice(-TASK_ISOLATION_DIR_DIGEST_CHARS);
399
+ return `${TASK_ISOLATION_DIR_PREFIX}${digest}`;
400
+ }
401
+
392
402
  export async function ensureIsolation(
393
403
  baseCwd: string,
394
404
  id: string,
395
405
  preferred?: IsoBackendKind,
396
406
  ): Promise<IsolationHandle> {
397
407
  const repoRoot = await getRepoRoot(baseCwd);
398
- const baseDir = getWorktreeDir(`${id}-${hashPath(repoRoot)}`);
399
- const mergedDir = path.join(baseDir, "merged");
400
-
408
+ const baseDir = getWorktreeDir(getTaskIsolationSegment(repoRoot, id));
409
+ const mergedDir = path.join(baseDir, TASK_ISOLATION_MOUNT_DIR);
401
410
  const resolution = natives.isoResolve(preferred ?? null);
402
411
  const candidates = resolution.candidates.length > 0 ? resolution.candidates : [resolution.kind];
403
412
  let fallbackReason = resolution.reason ?? null;
@@ -130,74 +130,62 @@ export function assembleYieldResult(
130
130
  arrayLabels?: ReadonlySet<string>,
131
131
  ): AssembledYieldResult | undefined {
132
132
  if (yieldItems.length === 0) return undefined;
133
+
134
+ // Terminal = the last non-incremental yield (untyped, or string-typed like
135
+ // `type: "result"`). Array-typed yields are incremental sections and never
136
+ // terminate on their own.
133
137
  let terminalItem: YieldItem | undefined;
134
138
  for (let index = yieldItems.length - 1; index >= 0; index--) {
135
139
  const item = yieldItems[index];
136
- if (!item) continue;
137
- if (!isIncrementalYieldType(item.type)) {
140
+ if (item && !isIncrementalYieldType(item.type)) {
138
141
  terminalItem = item;
139
142
  break;
140
143
  }
141
144
  }
142
- let hasTypedSections = false;
143
- for (const item of yieldItems) {
144
- if (getYieldLabels(item.type).length > 0) {
145
- hasTypedSections = true;
146
- break;
147
- }
148
- }
149
- if (terminalItem && typeof terminalItem.type === "string" && terminalItem.data === undefined) {
150
- const resolved = resolveYieldPayload(terminalItem, lastAssistantText, getYieldLabels(terminalItem.type));
151
- return {
152
- data: resolved.value,
153
- schemaOverridden: terminalItem.schemaOverridden === true,
154
- rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
155
- missingData: resolved.missingData,
156
- };
157
- }
158
- if (!hasTypedSections && terminalItem) {
159
- const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []);
160
- return {
161
- data: resolved.value,
162
- schemaOverridden: terminalItem.schemaOverridden === true,
163
- rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
164
- missingData: resolved.missingData,
165
- };
166
- }
167
145
 
146
+ // Sections come ONLY from incremental (array-typed) yields. A string `type`
147
+ // is a terminal marker, never a section label: folding its data under the
148
+ // label is what nested a finalize payload (`type: "result"`, `data: {…}`) one
149
+ // level deep and made output-schema validation report every field missing.
168
150
  const sections: Record<string, unknown> = {};
169
151
  const sectionCounts = new Map<string, number>();
170
152
  let schemaOverridden = false;
171
153
  let missingData = false;
172
154
  let hasSections = false;
173
-
174
155
  for (const item of yieldItems) {
175
156
  if (item.status === "aborted") continue;
157
+ if (!isIncrementalYieldType(item.type)) continue;
176
158
  schemaOverridden ||= item.schemaOverridden === true;
177
159
  const labels = getYieldLabels(item.type);
178
- if (labels.length === 0) continue;
179
160
  const resolved = resolveYieldPayload(item, lastAssistantText, labels);
180
161
  missingData ||= resolved.missingData;
181
- const incremental = isIncrementalYieldType(item.type);
182
162
  for (const label of labels) {
183
- appendYieldSection(
184
- sections,
185
- sectionCounts,
186
- label,
187
- resolved.value,
188
- incremental && (arrayLabels?.has(label) ?? false),
189
- );
163
+ appendYieldSection(sections, sectionCounts, label, resolved.value, arrayLabels?.has(label) ?? false);
190
164
  hasSections = true;
191
165
  }
192
- if (!isIncrementalYieldType(item.type)) break;
193
166
  }
194
167
 
168
+ // An explicit terminal payload wins: an untyped final result or a
169
+ // `type: "result"` finalize that carries `data` is the complete result, used
170
+ // verbatim — never wrapped in a section.
171
+ if (terminalItem && terminalItem.data !== undefined) {
172
+ const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []);
173
+ return {
174
+ data: resolved.value,
175
+ schemaOverridden: terminalItem.schemaOverridden === true,
176
+ rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
177
+ missingData: resolved.missingData,
178
+ };
179
+ }
180
+
181
+ // A data-less terminal finalize keeps accumulated sections; only when none
182
+ // exist does the last assistant turn become the raw result.
195
183
  if (hasSections) {
196
184
  return { data: sections, schemaOverridden, rawText: false, missingData };
197
185
  }
198
186
 
199
187
  if (!terminalItem) return undefined;
200
- const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []);
188
+ const resolved = resolveYieldPayload(terminalItem, lastAssistantText, getYieldLabels(terminalItem.type));
201
189
  return {
202
190
  data: resolved.value,
203
191
  schemaOverridden: terminalItem.schemaOverridden === true,
package/src/tools/read.ts CHANGED
@@ -178,7 +178,17 @@ interface HashlineHeaderContext {
178
178
  }
179
179
 
180
180
  function formatReadHashlineHeader(displayPath: string, tag: string): string {
181
- return formatHashlineHeader(path.basename(displayPath), tag);
181
+ // In-workspace reads collapse to the bare filename for brevity: the edit
182
+ // tool's snapshot-tag recovery rebinds a bare `[name#tag]` onto the in-tree
183
+ // file it uniquely names. Out-of-workspace reads can't lean on that —
184
+ // recovery refuses to redirect a write outside the cwd/sandbox
185
+ // (HashlineFilesystem.allowTagPathRecovery) — so an absolute displayPath
186
+ // must stay directly resolvable, otherwise the basename resolves against
187
+ // cwd, misses, and the edit fails with "File not found" (e.g. ~/.claude/*).
188
+ // `shortenPath` keeps `~/.claude/...` (round-trips through resolveToCwd's ~
189
+ // expansion) instead of leaking the full home path into the read output.
190
+ const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : path.basename(displayPath);
191
+ return formatHashlineHeader(anchor, tag);
182
192
  }
183
193
 
184
194
  function recordFullHashlineContext(
package/src/utils/git.ts CHANGED
@@ -1712,6 +1712,19 @@ export const repo = {
1712
1712
  return primaryRootFromRepositorySync(repository);
1713
1713
  },
1714
1714
 
1715
+ /**
1716
+ * Linked-worktree metadata for `cwd`, or `null` when `cwd` is the primary
1717
+ * checkout (or outside a repository). `root` is the worktree's own checkout
1718
+ * root; `primaryRoot` is the shared main checkout that names the project.
1719
+ * Resolves purely via on-disk `.git`/`commondir` walking — no subprocess —
1720
+ * so the status line may call it on every render.
1721
+ */
1722
+ linkedWorktreeSync(cwd: string): { root: string; primaryRoot: string } | null {
1723
+ const repository = resolveRepositorySync(cwd);
1724
+ if (!repository || !isLinkedWorktree(repository)) return null;
1725
+ return { root: repository.repoRoot, primaryRoot: primaryRootFromRepositorySync(repository) };
1726
+ },
1727
+
1715
1728
  /** Full GitRepository metadata (sync). */
1716
1729
  resolveSync(cwd: string): GitRepository | null {
1717
1730
  return resolveRepositorySync(cwd);