@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.
@@ -103,6 +103,16 @@ export interface SegmentContext {
103
103
  url: string;
104
104
  } | null;
105
105
  };
106
+ /**
107
+ * Set when the path cwd is a *linked* git worktree, naming the shared
108
+ * primary checkout (the project). Lets the path segment collapse the
109
+ * base-prefixed `<base>/<project>/<worktree>` path to the project name —
110
+ * the worktree/branch is already shown by the git segment.
111
+ */
112
+ worktree: {
113
+ projectName: string;
114
+ worktreeName: string;
115
+ } | null;
106
116
  usage: {
107
117
  tier?: string;
108
118
  fiveHour?: {
@@ -6,7 +6,7 @@ export type SymbolPreset = "unicode" | "nerd" | "ascii";
6
6
  /**
7
7
  * All available symbol keys organized by category.
8
8
  */
9
- export type SymbolKey = "status.success" | "status.error" | "status.warning" | "status.info" | "status.pending" | "status.disabled" | "status.enabled" | "status.running" | "status.shadowed" | "status.aborted" | "status.done" | "nav.cursor" | "nav.selected" | "nav.expand" | "nav.collapse" | "nav.back" | "tree.branch" | "tree.last" | "tree.vertical" | "tree.horizontal" | "tree.hook" | "boxRound.topLeft" | "boxRound.topRight" | "boxRound.bottomLeft" | "boxRound.bottomRight" | "boxRound.horizontal" | "boxRound.vertical" | "boxSharp.topLeft" | "boxSharp.topRight" | "boxSharp.bottomLeft" | "boxSharp.bottomRight" | "boxSharp.horizontal" | "boxSharp.vertical" | "boxSharp.cross" | "boxSharp.teeDown" | "boxSharp.teeUp" | "boxSharp.teeRight" | "boxSharp.teeLeft" | "sep.powerline" | "sep.powerlineThin" | "sep.powerlineLeft" | "sep.powerlineRight" | "sep.powerlineThinLeft" | "sep.powerlineThinRight" | "sep.block" | "sep.space" | "sep.asciiLeft" | "sep.asciiRight" | "sep.dot" | "sep.slash" | "sep.pipe" | "icon.model" | "icon.plan" | "icon.goal" | "icon.pause" | "icon.loop" | "icon.folder" | "icon.search" | "icon.scratchFolder" | "icon.file" | "icon.git" | "icon.branch" | "icon.pr" | "icon.tokens" | "icon.context" | "icon.cost" | "icon.time" | "icon.pi" | "icon.ghost" | "icon.agents" | "icon.job" | "icon.cache" | "icon.cacheMiss" | "icon.input" | "icon.output" | "icon.host" | "icon.session" | "icon.package" | "icon.warning" | "icon.rewind" | "icon.auto" | "icon.fast" | "icon.extensionSkill" | "icon.extensionTool" | "icon.extensionSlashCommand" | "icon.extensionMcp" | "icon.extensionRule" | "icon.extensionHook" | "icon.extensionPrompt" | "icon.extensionContextFile" | "icon.extensionInstruction" | "icon.mic" | "icon.camera" | "thinking.minimal" | "thinking.low" | "thinking.medium" | "thinking.high" | "thinking.xhigh" | "thinking.autoPending" | "checkbox.checked" | "checkbox.unchecked" | "radio.selected" | "radio.unselected" | "format.bullet" | "format.dash" | "format.bracketLeft" | "format.bracketRight" | "md.quoteBorder" | "md.hrChar" | "md.bullet" | "md.colorSwatch" | "lang.default" | "lang.typescript" | "lang.javascript" | "lang.python" | "lang.rust" | "lang.go" | "lang.java" | "lang.c" | "lang.cpp" | "lang.csharp" | "lang.ruby" | "lang.julia" | "lang.php" | "lang.swift" | "lang.kotlin" | "lang.shell" | "lang.html" | "lang.css" | "lang.json" | "lang.yaml" | "lang.markdown" | "lang.sql" | "lang.docker" | "lang.lua" | "lang.text" | "lang.env" | "lang.toml" | "lang.xml" | "lang.ini" | "lang.conf" | "lang.log" | "lang.csv" | "lang.tsv" | "lang.image" | "lang.pdf" | "lang.archive" | "lang.binary" | "tab.appearance" | "tab.model" | "tab.interaction" | "tab.context" | "tab.files" | "tab.shell" | "tab.tools" | "tab.memory" | "tab.tasks" | "tab.providers" | "tool.write" | "tool.edit" | "tool.bash" | "tool.ssh" | "tool.lsp" | "tool.gh" | "tool.webSearch" | "tool.exa" | "tool.browser" | "tool.eval" | "tool.debug" | "tool.mcp" | "tool.job" | "tool.task" | "tool.todo" | "tool.memory" | "tool.ask" | "tool.resolve" | "tool.review" | "tool.inspectImage" | "tool.goal" | "tool.irc" | "tool.delete" | "tool.move";
9
+ export type SymbolKey = "status.success" | "status.error" | "status.warning" | "status.info" | "status.pending" | "status.disabled" | "status.enabled" | "status.running" | "status.shadowed" | "status.aborted" | "status.done" | "nav.cursor" | "nav.selected" | "nav.expand" | "nav.collapse" | "nav.back" | "tree.branch" | "tree.last" | "tree.vertical" | "tree.horizontal" | "tree.hook" | "boxRound.topLeft" | "boxRound.topRight" | "boxRound.bottomLeft" | "boxRound.bottomRight" | "boxRound.horizontal" | "boxRound.vertical" | "boxSharp.topLeft" | "boxSharp.topRight" | "boxSharp.bottomLeft" | "boxSharp.bottomRight" | "boxSharp.horizontal" | "boxSharp.vertical" | "boxSharp.cross" | "boxSharp.teeDown" | "boxSharp.teeUp" | "boxSharp.teeRight" | "boxSharp.teeLeft" | "sep.powerline" | "sep.powerlineThin" | "sep.powerlineLeft" | "sep.powerlineRight" | "sep.powerlineThinLeft" | "sep.powerlineThinRight" | "sep.block" | "sep.space" | "sep.asciiLeft" | "sep.asciiRight" | "sep.dot" | "sep.slash" | "sep.pipe" | "icon.model" | "icon.plan" | "icon.goal" | "icon.pause" | "icon.loop" | "icon.folder" | "icon.worktree" | "icon.search" | "icon.scratchFolder" | "icon.file" | "icon.git" | "icon.branch" | "icon.pr" | "icon.tokens" | "icon.context" | "icon.cost" | "icon.time" | "icon.pi" | "icon.ghost" | "icon.agents" | "icon.job" | "icon.cache" | "icon.cacheMiss" | "icon.input" | "icon.output" | "icon.host" | "icon.session" | "icon.package" | "icon.warning" | "icon.rewind" | "icon.auto" | "icon.fast" | "icon.extensionSkill" | "icon.extensionTool" | "icon.extensionSlashCommand" | "icon.extensionMcp" | "icon.extensionRule" | "icon.extensionHook" | "icon.extensionPrompt" | "icon.extensionContextFile" | "icon.extensionInstruction" | "icon.mic" | "icon.camera" | "thinking.minimal" | "thinking.low" | "thinking.medium" | "thinking.high" | "thinking.xhigh" | "thinking.autoPending" | "checkbox.checked" | "checkbox.unchecked" | "radio.selected" | "radio.unselected" | "format.bullet" | "format.dash" | "format.bracketLeft" | "format.bracketRight" | "md.quoteBorder" | "md.hrChar" | "md.bullet" | "md.colorSwatch" | "lang.default" | "lang.typescript" | "lang.javascript" | "lang.python" | "lang.rust" | "lang.go" | "lang.java" | "lang.c" | "lang.cpp" | "lang.csharp" | "lang.ruby" | "lang.julia" | "lang.php" | "lang.swift" | "lang.kotlin" | "lang.shell" | "lang.html" | "lang.css" | "lang.json" | "lang.yaml" | "lang.markdown" | "lang.sql" | "lang.docker" | "lang.lua" | "lang.text" | "lang.env" | "lang.toml" | "lang.xml" | "lang.ini" | "lang.conf" | "lang.log" | "lang.csv" | "lang.tsv" | "lang.image" | "lang.pdf" | "lang.archive" | "lang.binary" | "tab.appearance" | "tab.model" | "tab.interaction" | "tab.context" | "tab.files" | "tab.shell" | "tab.tools" | "tab.memory" | "tab.tasks" | "tab.providers" | "tool.write" | "tool.edit" | "tool.bash" | "tool.ssh" | "tool.lsp" | "tool.gh" | "tool.webSearch" | "tool.exa" | "tool.browser" | "tool.eval" | "tool.debug" | "tool.mcp" | "tool.job" | "tool.task" | "tool.todo" | "tool.memory" | "tool.ask" | "tool.resolve" | "tool.review" | "tool.inspectImage" | "tool.goal" | "tool.irc" | "tool.delete" | "tool.move";
10
10
  export type SpinnerType = "status" | "activity";
11
11
  export type ThemeColor = "accent" | "border" | "borderAccent" | "borderMuted" | "success" | "error" | "warning" | "muted" | "dim" | "text" | "thinkingText" | "userMessageText" | "customMessageText" | "customMessageLabel" | "toolTitle" | "toolOutput" | "mdHeading" | "mdLink" | "mdLinkUrl" | "mdCode" | "mdCodeBlock" | "mdCodeBlockBorder" | "mdQuote" | "mdQuoteBorder" | "mdHr" | "mdListBullet" | "toolDiffAdded" | "toolDiffRemoved" | "toolDiffContext" | "syntaxComment" | "syntaxKeyword" | "syntaxFunction" | "syntaxVariable" | "syntaxString" | "syntaxNumber" | "syntaxType" | "syntaxOperator" | "syntaxPunctuation" | "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "bashMode" | "pythonMode" | "statusLineSep" | "statusLineModel" | "statusLinePath" | "statusLineGitClean" | "statusLineGitDirty" | "statusLineContext" | "statusLineSpend" | "statusLineStaged" | "statusLineDirty" | "statusLineUntracked" | "statusLineOutput" | "statusLineCost" | "statusLineSubagents";
12
12
  /** Check if a string is a valid ThemeColor value */
@@ -164,6 +164,7 @@ export declare class Theme {
164
164
  pause: string;
165
165
  loop: string;
166
166
  folder: string;
167
+ worktree: string;
167
168
  scratchFolder: string;
168
169
  file: string;
169
170
  git: string;
@@ -169,6 +169,13 @@ export interface AgentSessionConfig {
169
169
  * inherits this so its requests undergo the same shaping as the main turn.
170
170
  */
171
171
  transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
172
+ /**
173
+ * Stream wrapper passed to side-channel requests (`/btw`, `/omfg`, IRC
174
+ * auto-replies, and handoff generation) so they apply the same provider
175
+ * shaping and host-level request wrappers as normal agent turns. Defaults
176
+ * to plain `streamSimple` when omitted.
177
+ */
178
+ sideStreamFn?: StreamFn;
172
179
  /**
173
180
  * Stream wrapper passed to the advisor agent so its requests apply the
174
181
  * session's `providers.openrouterVariant`, `providers.antigravityEndpoint`,
@@ -0,0 +1,40 @@
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
+ import type { StreamFn } from "@oh-my-pi/pi-agent-core";
14
+ import type { Settings } from "../config/settings";
15
+ import { Semaphore } from "./parallel";
16
+ /**
17
+ * Resolve the configured concurrency ceiling for a provider, or `undefined`
18
+ * when the provider has no cap concept at all. A configured value `<= 0` means
19
+ * "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
20
+ * holds a slot and a later finite resize counts work started while unlimited.
21
+ */
22
+ export declare function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined;
23
+ /**
24
+ * Hand out the single shared limiter for `provider` (creating one lazily) and
25
+ * resize it in place when the configured limit changes. Replacing the
26
+ * semaphore would orphan in-flight slots on the old instance and let a
27
+ * runtime or mixed limit value exceed the cap (issue #3464 review feedback).
28
+ */
29
+ export declare function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined;
30
+ /**
31
+ * Wrap a {@link StreamFn} so every LLM HTTP request acquires the provider's
32
+ * concurrency slot before the request goes out and releases it when the
33
+ * stream finishes producing (success, error, or abort). Providers without a
34
+ * configured cap pass straight through.
35
+ *
36
+ * The acquire bracket is intentionally narrow (one slot per LLM call), so
37
+ * spawn trees deeper than `maxConcurrency` no longer deadlock on themselves —
38
+ * see the module-level comment for the failure mode this fixes.
39
+ */
40
+ export declare function wrapStreamFnWithProviderConcurrency(settings: Settings, base: StreamFn): StreamFn;
@@ -349,6 +349,17 @@ export declare const repo: {
349
349
  * the shared common dir (`foo.git`) because they have no primary checkout.
350
350
  */
351
351
  primaryRootSync(cwd: string): string | null;
352
+ /**
353
+ * Linked-worktree metadata for `cwd`, or `null` when `cwd` is the primary
354
+ * checkout (or outside a repository). `root` is the worktree's own checkout
355
+ * root; `primaryRoot` is the shared main checkout that names the project.
356
+ * Resolves purely via on-disk `.git`/`commondir` walking — no subprocess —
357
+ * so the status line may call it on every render.
358
+ */
359
+ linkedWorktreeSync(cwd: string): {
360
+ root: string;
361
+ primaryRoot: string;
362
+ } | null;
352
363
  /** Full GitRepository metadata (sync). */
353
364
  resolveSync(cwd: string): GitRepository | null;
354
365
  /** Full GitRepository metadata. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.2.4",
4
+ "version": "16.2.5",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -55,17 +55,17 @@
55
55
  "@agentclientprotocol/sdk": "0.25.0",
56
56
  "@babel/parser": "^7.29.7",
57
57
  "@mozilla/readability": "^0.6.0",
58
- "@oh-my-pi/hashline": "16.2.4",
59
- "@oh-my-pi/omp-stats": "16.2.4",
60
- "@oh-my-pi/pi-agent-core": "16.2.4",
61
- "@oh-my-pi/pi-ai": "16.2.4",
62
- "@oh-my-pi/pi-catalog": "16.2.4",
63
- "@oh-my-pi/pi-mnemopi": "16.2.4",
64
- "@oh-my-pi/pi-natives": "16.2.4",
65
- "@oh-my-pi/pi-tui": "16.2.4",
66
- "@oh-my-pi/pi-utils": "16.2.4",
67
- "@oh-my-pi/pi-wire": "16.2.4",
68
- "@oh-my-pi/snapcompact": "16.2.4",
58
+ "@oh-my-pi/hashline": "16.2.5",
59
+ "@oh-my-pi/omp-stats": "16.2.5",
60
+ "@oh-my-pi/pi-agent-core": "16.2.5",
61
+ "@oh-my-pi/pi-ai": "16.2.5",
62
+ "@oh-my-pi/pi-catalog": "16.2.5",
63
+ "@oh-my-pi/pi-mnemopi": "16.2.5",
64
+ "@oh-my-pi/pi-natives": "16.2.5",
65
+ "@oh-my-pi/pi-tui": "16.2.5",
66
+ "@oh-my-pi/pi-utils": "16.2.5",
67
+ "@oh-my-pi/pi-wire": "16.2.5",
68
+ "@oh-my-pi/snapcompact": "16.2.5",
69
69
  "@opentelemetry/api": "^1.9.1",
70
70
  "@opentelemetry/context-async-hooks": "^2.7.1",
71
71
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -7,9 +7,9 @@
7
7
  * containing a `.git` *file* that points back at
8
8
  * `<parent-repo>/.git/worktrees/<name>/`.
9
9
  * - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a
10
- * `merged` subdir mounted/cloned by `natives.isoStart`. These are ephemeral
11
- * `ensureIsolation` always `rm -rf`s the base before re-creating it, so
12
- * any leftover on disk is a leak from a crashed run.
10
+ * compact `m` subdir mounted/cloned by `natives.isoStart`. Legacy `merged`
11
+ * subdirs are still recognized. These are ephemeral; `ensureIsolation`
12
+ * removes the base before re-creating it, so leftovers are crashed runs.
13
13
  *
14
14
  * Legacy entries from before the encoding change keep working because git still
15
15
  * tracks them by branch name. This command exists to GC them on demand.
@@ -22,6 +22,8 @@ import * as git from "../utils/git";
22
22
 
23
23
  type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
24
24
 
25
+ const TASK_ISOLATION_MOUNT_DIRS = ["m", "merged"] as const;
26
+
25
27
  export interface WorktreeEntry {
26
28
  /** Absolute path to the worktree dir (or stray container) under `~/.omp/wt/`. */
27
29
  path: string;
@@ -209,8 +211,9 @@ async function classifyDir(dir: string): Promise<WorktreeEntry | null> {
209
211
  if (gitStat?.isFile()) {
210
212
  return classifyPrCheckout(dir, gitEntry);
211
213
  }
212
- const mergedStat = await fs.stat(path.join(dir, "merged")).catch(() => null);
213
- if (mergedStat?.isDirectory()) {
214
+ for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) {
215
+ const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null);
216
+ if (!mountStat?.isDirectory()) continue;
214
217
  return {
215
218
  path: dir,
216
219
  kind: "task-isolation",
@@ -126,7 +126,8 @@ type LlamaCppDiscoveredServerMetadata = {
126
126
 
127
127
  type LlamaCppModelListEntry = {
128
128
  id: string;
129
- contextWindow?: number;
129
+ runtimeContextWindow?: number;
130
+ trainingContextWindow?: number;
130
131
  };
131
132
 
132
133
  function toPositiveNumberOrUndefined(value: unknown): number | undefined {
@@ -142,7 +143,21 @@ function toPositiveNumberOrUndefined(value: unknown): number | undefined {
142
143
  return undefined;
143
144
  }
144
145
 
146
+ function extractOllamaRuntimeContextWindow(payload: Record<string, unknown>): number | undefined {
147
+ const parameters = payload.parameters;
148
+ if (typeof parameters !== "string") {
149
+ return undefined;
150
+ }
151
+ const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
152
+ return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
153
+ }
154
+
145
155
  function extractOllamaContextWindow(payload: Record<string, unknown>): number | undefined {
156
+ const runtimeContextWindow = extractOllamaRuntimeContextWindow(payload);
157
+ if (runtimeContextWindow !== undefined) {
158
+ return runtimeContextWindow;
159
+ }
160
+
146
161
  const modelInfo = payload.model_info;
147
162
  if (isRecord(modelInfo)) {
148
163
  for (const [key, value] of Object.entries(modelInfo)) {
@@ -155,12 +170,7 @@ function extractOllamaContextWindow(payload: Record<string, unknown>): number |
155
170
  }
156
171
  }
157
172
 
158
- const parameters = payload.parameters;
159
- if (typeof parameters !== "string") {
160
- return undefined;
161
- }
162
- const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
163
- return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
173
+ return undefined;
164
174
  }
165
175
 
166
176
  function extractLlamaCppContextWindow(payload: Record<string, unknown>): number | undefined {
@@ -174,12 +184,17 @@ function extractLlamaCppContextWindow(payload: Record<string, unknown>): number
174
184
  return toPositiveNumberOrUndefined(payload.n_ctx);
175
185
  }
176
186
 
177
- function extractLlamaCppModelContextWindow(item: Record<string, unknown>): number | undefined {
187
+ function extractLlamaCppModelContextWindows(
188
+ item: Record<string, unknown>,
189
+ ): Pick<LlamaCppModelListEntry, "runtimeContextWindow" | "trainingContextWindow"> {
178
190
  const meta = item.meta;
179
191
  if (!isRecord(meta)) {
180
- return undefined;
192
+ return {};
181
193
  }
182
- return toPositiveNumberOrUndefined(meta.n_ctx) ?? toPositiveNumberOrUndefined(meta.n_ctx_train);
194
+ return {
195
+ runtimeContextWindow: toPositiveNumberOrUndefined(meta.n_ctx),
196
+ trainingContextWindow: toPositiveNumberOrUndefined(meta.n_ctx_train),
197
+ };
183
198
  }
184
199
 
185
200
  function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
@@ -190,7 +205,7 @@ function parseLlamaCppModelList(payload: unknown): LlamaCppModelListEntry[] {
190
205
  if (!isRecord(item) || typeof item.id !== "string" || !item.id) {
191
206
  return [];
192
207
  }
193
- return [{ id: item.id, contextWindow: extractLlamaCppModelContextWindow(item) }];
208
+ return [{ id: item.id, ...extractLlamaCppModelContextWindows(item) }];
194
209
  });
195
210
  }
196
211
 
@@ -378,7 +393,11 @@ export async function discoverLlamaCppModels(
378
393
  for (const item of models) {
379
394
  const { id } = item;
380
395
  if (!id) continue;
381
- const contextWindow = item.contextWindow ?? serverMetadata?.contextWindow ?? DISCOVERY_DEFAULT_CONTEXT_WINDOW;
396
+ const contextWindow =
397
+ item.runtimeContextWindow ??
398
+ serverMetadata?.contextWindow ??
399
+ item.trainingContextWindow ??
400
+ DISCOVERY_DEFAULT_CONTEXT_WINDOW;
382
401
  discovered.push(
383
402
  buildModel({
384
403
  id,
@@ -420,7 +439,8 @@ export async function discoverLlamaCppModelContextWindow(
420
439
  return undefined;
421
440
  }
422
441
  const entries = parseLlamaCppModelList(await response.json());
423
- return entries.find(entry => entry.id === model.id)?.contextWindow;
442
+ const entry = entries.find(entry => entry.id === model.id);
443
+ return entry?.runtimeContextWindow ?? entry?.trainingContextWindow;
424
444
  };
425
445
  try {
426
446
  const apiKey = await ctx.getBearerApiKeyResolver(model.provider);
@@ -317,8 +317,11 @@ export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
317
317
  // `>` must sit outside quoted regions (so `echo "a -> b"` passes) and be
318
318
  // followed by a plausible filename — including `$VAR` targets; `>|`
319
319
  // (clobber) counts as a redirect; `>&2`/`2>&1` style fd duplication is
320
- // not matched.
321
- pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+(?:[^\"'>]|\"[^\"]*\"|'[^']*')*(?<!\\|)>{1,2}\\|?\\s*[$\\w./~\"'-]",
320
+ // not matched. Allowed device sinks are consumed while looking for later
321
+ // real file redirects because the write tool cannot replace shell
322
+ // output/discard targets.
323
+ pattern:
324
+ "^\\s*(echo|printf|cat\\s*<<)\\s+(?:(?:[^\"'>]|\"[^\"]*\"|'[^']*')|(?<!\\|)>{1,2}\\|?\\s*(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))*(?<!\\|)>{1,2}\\|?\\s*(?!(?:\"/dev/(?:null|tty|stdout|stderr)\"|'/dev/(?:null|tty|stdout|stderr)'|/dev/(?:null|tty|stdout|stderr))(?:[\\s;&|]|$))[$\\w./~\"'-]",
322
325
  tool: "write",
323
326
  message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.",
324
327
  },
@@ -160,6 +160,29 @@ interface ActiveRepoCache {
160
160
  projectDir: string;
161
161
  activeRepo: ActiveRepoContext | null;
162
162
  effectiveGitCwd: string;
163
+ /** Project + worktree dir name when `projectDir` is a linked worktree, else null. */
164
+ worktree: WorktreeContext | null;
165
+ }
166
+
167
+ interface WorktreeContext {
168
+ /** Primary-checkout (project) name shown by the path segment. */
169
+ projectName: string;
170
+ /** Worktree directory name — suppressed from the path when it equals the branch. */
171
+ worktreeName: string;
172
+ }
173
+
174
+ /**
175
+ * Project + worktree-dir names when `cwd` is a linked git worktree, else null.
176
+ * The project name comes from the shared primary checkout; bare-repo worktrees
177
+ * resolve to the shared `foo.git` dir, so a trailing `.git` is stripped.
178
+ */
179
+ function resolveWorktreeContext(cwd: string): WorktreeContext | null {
180
+ const worktree = git.repo.linkedWorktreeSync(cwd);
181
+ if (!worktree) return null;
182
+ const base = path.basename(worktree.primaryRoot);
183
+ const projectName = base.endsWith(".git") ? base.slice(0, -4) : base;
184
+ if (!projectName) return null;
185
+ return { projectName, worktreeName: path.basename(worktree.root) };
163
186
  }
164
187
 
165
188
  /**
@@ -312,7 +335,10 @@ export class StatusLineComponent implements Component {
312
335
 
313
336
  const activeRepo = resolveActiveRepoContextSync(projectDir);
314
337
  const effectiveGitCwd = activeRepo?.repoRoot ?? projectDir;
315
- this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd };
338
+ // Only collapse the bare-cwd case: a single-direct-child-repo context
339
+ // (activeRepo set) renders `<parent> ↳ <child>`, which we leave intact.
340
+ const worktree = activeRepo ? null : resolveWorktreeContext(effectiveGitCwd);
341
+ this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd, worktree };
316
342
  return this.#activeRepoCache;
317
343
  }
318
344
 
@@ -988,7 +1014,7 @@ export class StatusLineComponent implements Component {
988
1014
  const projectDir = getProjectDir();
989
1015
  const activeRepoCache = shouldResolveActiveRepo
990
1016
  ? this.#resolveActiveRepoCache()
991
- : { projectDir, activeRepo: null, effectiveGitCwd: projectDir };
1017
+ : { projectDir, activeRepo: null, effectiveGitCwd: projectDir, worktree: null };
992
1018
  const gitBranch = includeGit || includePr ? this.#getCurrentBranch(activeRepoCache.effectiveGitCwd) : null;
993
1019
  const gitStatus = includeGit ? this.#getGitStatus(activeRepoCache.effectiveGitCwd) : null;
994
1020
  const gitPr = includePr ? this.#lookupPr(activeRepoCache.effectiveGitCwd) : null;
@@ -1015,6 +1041,7 @@ export class StatusLineComponent implements Component {
1015
1041
  status: gitStatus,
1016
1042
  pr: gitPr,
1017
1043
  },
1044
+ worktree: activeRepoCache.worktree,
1018
1045
  usage: this.#cachedUsage,
1019
1046
  };
1020
1047
  }
@@ -20,6 +20,13 @@ function withIcon(icon: string, text: string): string {
20
20
  return icon ? `${icon} ${text}` : text;
21
21
  }
22
22
 
23
+ /** Left-truncate a path/label to `maxLen`, prefixing an ellipsis when clipped. */
24
+ function clampPathLength(pwd: string, maxLen: number): string {
25
+ if (pwd.length <= maxLen) return pwd;
26
+ const ellipsis = "…";
27
+ return `${ellipsis}${pwd.slice(-Math.max(0, maxLen - ellipsis.length))}`;
28
+ }
29
+
23
30
  /**
24
31
  * Leading glyph of a thinking-level display string (e.g. "◉ xhigh" → "◉").
25
32
  * Compact mode promotes this glyph to the model-segment icon so the level
@@ -220,12 +227,24 @@ const pathSegment: StatusLineSegment = {
220
227
  id: "path",
221
228
  render(ctx) {
222
229
  const opts = ctx.options.path ?? {};
230
+ const stripPrefix = opts.stripWorkPrefix !== false;
231
+
232
+ // Linked git worktree: the on-disk path nests the worktree base, the
233
+ // project, and a worktree dir that usually duplicates the branch (already
234
+ // shown by the git segment). Collapse to the project name, appending the
235
+ // worktree dir only when it diverges from the branch.
236
+ if (stripPrefix && ctx.worktree) {
237
+ const { projectName, worktreeName } = ctx.worktree;
238
+ const label = ctx.git.branch === worktreeName ? projectName : `${projectName}/${worktreeName}`;
239
+ const content = withIcon(theme.icon.worktree, clampPathLength(label, opts.maxLength ?? 40));
240
+ return { content: theme.fg("statusLinePath", content), visible: true };
241
+ }
223
242
 
224
243
  const projectDir = ctx.activeRepo?.cwd ?? getProjectDir();
225
244
  const { scratch, relative } = classifyProjectDir(projectDir);
226
245
  let pwd = projectDir;
227
246
 
228
- if (opts.stripWorkPrefix !== false) {
247
+ if (stripPrefix) {
229
248
  if (scratch) {
230
249
  if (relative) pwd = relative;
231
250
  } else {
@@ -237,17 +256,12 @@ const pathSegment: StatusLineSegment = {
237
256
  pwd = shortenPath(pwd);
238
257
  }
239
258
 
240
- const maxLen = opts.maxLength ?? 40;
241
- if (pwd.length > maxLen) {
242
- const ellipsis = "…";
243
- const sliceLen = Math.max(0, maxLen - ellipsis.length);
244
- pwd = `${ellipsis}${pwd.slice(-sliceLen)}`;
245
- }
259
+ pwd = clampPathLength(pwd, opts.maxLength ?? 40);
246
260
  if (repoSuffix) {
247
261
  pwd = `${pwd}${repoSuffix}`;
248
262
  }
249
263
 
250
- const showScratchIcon = scratch && opts.stripWorkPrefix !== false;
264
+ const showScratchIcon = scratch && stripPrefix;
251
265
  const icon = showScratchIcon ? theme.icon.scratchFolder : theme.icon.folder;
252
266
  const content = withIcon(icon, pwd);
253
267
  return { content: theme.fg("statusLinePath", content), visible: true };
@@ -97,6 +97,13 @@ export interface SegmentContext {
97
97
  status: { staged: number; unstaged: number; untracked: number } | null;
98
98
  pr: { number: number; url: string } | null;
99
99
  };
100
+ /**
101
+ * Set when the path cwd is a *linked* git worktree, naming the shared
102
+ * primary checkout (the project). Lets the path segment collapse the
103
+ * base-prefixed `<base>/<project>/<worktree>` path to the project name —
104
+ * the worktree/branch is already shown by the git segment.
105
+ */
106
+ worktree: { projectName: string; worktreeName: string } | null;
100
107
  usage: {
101
108
  tier?: string;
102
109
  fiveHour?: { percent: number; resetMinutes?: number };
@@ -1145,7 +1145,7 @@ export class InputController {
1145
1145
 
1146
1146
  /** Send editor text as a follow-up message (queued behind current stream). */
1147
1147
  async handleFollowUp(): Promise<void> {
1148
- let text = this.ctx.editor.getText().trim();
1148
+ let text = this.ctx.editor.getExpandedText().trim();
1149
1149
  const images = this.ctx.editor.pendingImages.length > 0 ? [...this.ctx.editor.pendingImages] : undefined;
1150
1150
  const imageLinks =
1151
1151
  images && this.ctx.editor.pendingImageLinks.length > 0 ? [...this.ctx.editor.pendingImageLinks] : undefined;
@@ -96,6 +96,7 @@ export type SymbolKey =
96
96
  | "icon.pause"
97
97
  | "icon.loop"
98
98
  | "icon.folder"
99
+ | "icon.worktree"
99
100
  | "icon.search"
100
101
  | "icon.scratchFolder"
101
102
  | "icon.file"
@@ -299,6 +300,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
299
300
  "icon.pause": "⏸",
300
301
  "icon.loop": "↻",
301
302
  "icon.folder": "📁",
303
+ "icon.worktree": "🌳",
302
304
  "icon.search": "🔍",
303
305
  "icon.scratchFolder": "🗑",
304
306
  "icon.file": "📄",
@@ -561,6 +563,8 @@ const NERD_SYMBOLS: SymbolMap = {
561
563
  "icon.search": "\uf002",
562
564
  // pick: | alt:
563
565
  "icon.scratchFolder": "\uf014",
566
+ // pick: nf-fa-sitemap | alt: nf-cod-list_tree
567
+ "icon.worktree": "\uf0e8",
564
568
  // pick:  | alt:  
565
569
  "icon.file": "\uf15b",
566
570
  // pick:  | alt:  ⎇
@@ -808,6 +812,7 @@ const ASCII_SYMBOLS: SymbolMap = {
808
812
  "icon.pause": "||",
809
813
  "icon.loop": "loop",
810
814
  "icon.folder": "[D]",
815
+ "icon.worktree": "[wt]",
811
816
  "icon.search": "[/]",
812
817
  "icon.scratchFolder": "[T]",
813
818
  "icon.file": "[F]",
@@ -1797,6 +1802,7 @@ export class Theme {
1797
1802
  pause: this.#symbols["icon.pause"],
1798
1803
  loop: this.#symbols["icon.loop"],
1799
1804
  folder: this.#symbols["icon.folder"],
1805
+ worktree: this.#symbols["icon.worktree"],
1800
1806
  scratchFolder: this.#symbols["icon.scratchFolder"],
1801
1807
  file: this.#symbols["icon.file"],
1802
1808
  git: this.#symbols["icon.git"],
package/src/sdk.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  loadProjectContextFiles as loadContextFilesInternal,
131
131
  } from "./system-prompt";
132
132
  import { AgentOutputManager } from "./task/output-manager";
133
+ import { wrapStreamFnWithProviderConcurrency } from "./task/provider-concurrency";
133
134
  import {
134
135
  AUTO_THINKING,
135
136
  type ConfiguredThinkingLevel,
@@ -2535,11 +2536,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2535
2536
  // One-shot launch-latency marker: fired the first time the loop dispatches
2536
2537
  // a chat request to the provider transport. See onFirstChatDispatch.
2537
2538
  let notifyFirstChatDispatch = options.onFirstChatDispatch;
2538
- // Shared, settings-aware stream wrapper used by both the main agent and
2539
- // the advisor (via AgentSessionConfig.streamFn). Keeps OpenRouter
2540
- // sticky-routing variants, antigravity endpoint routing, in-flight caps,
2541
- // and the loop guard consistent across every agent the session drives.
2542
- const settingsAwareStreamFn = createSettingsAwareStreamFn(settings);
2539
+ // Shared, settings-aware stream wrapper used by the main agent, advisor,
2540
+ // and side-channel requests (`/btw`, `/omfg`, IRC auto-replies, handoff).
2541
+ // Keeps OpenRouter sticky-routing variants, antigravity endpoint routing,
2542
+ // in-flight caps, and the loop guard consistent across every provider call
2543
+ // the session drives. Wrapped in a per-provider concurrency limiter so
2544
+ // each LLM HTTP request — not the whole subagent lifecycle — holds the
2545
+ // slot, preventing the nested-spawn deadlock from issue #3749.
2546
+ const settingsAwareStreamFn = wrapStreamFnWithProviderConcurrency(
2547
+ settings,
2548
+ createSettingsAwareStreamFn(settings),
2549
+ );
2543
2550
  agent = new Agent({
2544
2551
  initialState: {
2545
2552
  systemPrompt,
@@ -2709,6 +2716,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2709
2716
  transformProviderContext,
2710
2717
  onPayload,
2711
2718
  onResponse,
2719
+ sideStreamFn: settingsAwareStreamFn,
2712
2720
  advisorStreamFn: settingsAwareStreamFn,
2713
2721
  convertToLlm: convertToLlmFinal,
2714
2722
  rebuildSystemPrompt,