@oh-my-pi/pi-coding-agent 15.10.8 → 15.10.9

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.
package/src/sdk.ts CHANGED
@@ -62,10 +62,11 @@ import {
62
62
  type LoadedCustomCommand,
63
63
  loadCustomCommands as loadCustomCommandsInternal,
64
64
  } from "./extensibility/custom-commands";
65
- import { discoverAndLoadCustomTools } from "./extensibility/custom-tools";
65
+ import { discoverCustomToolPaths, loadCustomTools, type ToolPathWithSource } from "./extensibility/custom-tools";
66
66
  import type { CustomTool, CustomToolContext, CustomToolSessionEvent } from "./extensibility/custom-tools/types";
67
67
  import {
68
68
  discoverAndLoadExtensions,
69
+ discoverExtensionPaths,
69
70
  type ExtensionContext,
70
71
  type ExtensionFactory,
71
72
  ExtensionRunner,
@@ -337,10 +338,41 @@ export interface CreateAgentSessionOptions {
337
338
  /** Disable extension discovery (explicit paths still load). */
338
339
  disableExtensionDiscovery?: boolean;
339
340
  /**
340
- * Pre-loaded extensions (skips file discovery).
341
- * @internal Used by CLI when extensions are loaded early to parse custom flags.
341
+ * Pre-loaded extensions (skips file discovery and the per-session factory
342
+ * call). Used by the CLI when extensions are loaded early to parse custom
343
+ * flags — the same process owns the returned instances, so reusing them is
344
+ * safe.
345
+ *
346
+ * NEVER pass this across session boundaries (e.g. parent → subagent).
347
+ * `Extension` instances close over a parent-bound `ExtensionAPI` (cwd,
348
+ * eventBus, runtime), and reusing them would route tools/handlers/commands
349
+ * back through the parent. For subagents, forward
350
+ * {@link preloadedExtensionPaths} instead.
351
+ *
352
+ * @internal
342
353
  */
343
354
  preloadedExtensions?: LoadExtensionsResult;
355
+ /**
356
+ * Pre-discovered extension source paths. When provided, the filesystem-scan
357
+ * inside `discoverExtensionPaths()` is skipped — the session still calls
358
+ * `loadExtensions()` itself so each `Extension` is bound to THIS session's
359
+ * `ExtensionAPI` (cwd, eventBus, runtime).
360
+ *
361
+ * This is the safe pass-through for parent → subagent forwarding.
362
+ */
363
+ preloadedExtensionPaths?: string[];
364
+ /**
365
+ * Pre-discovered custom-tool source paths from `.omp/tools/`, `.claude/tools/`,
366
+ * plugins, etc. When provided, the filesystem-scan inside
367
+ * `discoverCustomToolPaths()` is skipped — subagents inherit the parent's
368
+ * scan result and call `loadCustomTools()` themselves so each session binds
369
+ * tools to its OWN `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
370
+ *
371
+ * Forwarding the loaded `LoadedCustomTool[]` instances directly would reuse
372
+ * the parent's session-bound API and route tool execution back through the
373
+ * parent — wrong for isolated tasks and for pending-action routing.
374
+ */
375
+ preloadedCustomToolPaths?: ToolPathWithSource[];
344
376
 
345
377
  /** Shared event bus for tool/extension communication. Default: creates new bus. */
346
378
  eventBus?: EventBus;
@@ -565,6 +597,26 @@ export async function discoverExtensions(cwd?: string): Promise<LoadExtensionsRe
565
597
  return discoverAndLoadExtensions([], resolvedCwd);
566
598
  }
567
599
 
600
+ /**
601
+ * Path-only counterpart of {@link loadSessionExtensions}: the FS-heavy scan
602
+ * without the per-session module load. Subagents reuse the parent's path list
603
+ * (cached on {@link ToolSession.extensionPaths}) and rebuild Extension
604
+ * instances themselves so each session's `ExtensionAPI` (cwd, eventBus,
605
+ * runtime) is its own.
606
+ */
607
+ export async function discoverSessionExtensionPaths(
608
+ options: Pick<CreateAgentSessionOptions, "disableExtensionDiscovery" | "additionalExtensionPaths">,
609
+ cwd: string,
610
+ settings: Settings,
611
+ ): Promise<string[]> {
612
+ if (options.disableExtensionDiscovery) {
613
+ return options.additionalExtensionPaths ?? [];
614
+ }
615
+ const configuredPaths = [...(options.additionalExtensionPaths ?? []), ...(settings.get("extensions") ?? [])];
616
+ const disabledExtensionIds = settings.get("disabledExtensions") ?? [];
617
+ return discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds);
618
+ }
619
+
568
620
  /**
569
621
  * Load the discovered/configured extensions for a session — everything {@link
570
622
  * createAgentSession} would load except the inline factory extensions it appends
@@ -580,23 +632,8 @@ export async function loadSessionExtensions(
580
632
  settings: Settings,
581
633
  eventBus: EventBus,
582
634
  ): Promise<LoadExtensionsResult> {
583
- let result: LoadExtensionsResult;
584
- if (options.disableExtensionDiscovery) {
585
- const configuredPaths = options.additionalExtensionPaths ?? [];
586
- result = await logger.time("loadExtensions", loadExtensions, configuredPaths, cwd, eventBus);
587
- } else {
588
- // Merge CLI extension paths with settings extension paths.
589
- const configuredPaths = [...(options.additionalExtensionPaths ?? []), ...(settings.get("extensions") ?? [])];
590
- const disabledExtensionIds = settings.get("disabledExtensions") ?? [];
591
- result = await logger.time(
592
- "discoverAndLoadExtensions",
593
- discoverAndLoadExtensions,
594
- configuredPaths,
595
- cwd,
596
- eventBus,
597
- disabledExtensionIds,
598
- );
599
- }
635
+ const paths = await discoverSessionExtensionPaths(options, cwd, settings);
636
+ const result = await logger.time("loadExtensions", loadExtensions, paths, cwd, eventBus);
600
637
  for (const { path, error } of result.errors) {
601
638
  logger.error("Failed to load extension", { path, error });
602
639
  }
@@ -1193,23 +1230,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1193
1230
  }
1194
1231
 
1195
1232
  // Discover rules and bucket them in one pass to avoid repeated scans over large rule sets.
1196
- const { ttsrManager, rulebookRules, alwaysApplyRules } = await logger.time("discoverTtsrRules", async () => {
1197
- const { TtsrManager } = await import("./export/ttsr");
1198
- const ttsrSettings = settings.getGroup("ttsr");
1199
- const ttsrManager = new TtsrManager(ttsrSettings);
1200
- const rulesResult =
1201
- options.rules !== undefined
1202
- ? { items: options.rules, warnings: undefined }
1203
- : await loadCapability<Rule>(ruleCapability.id, { cwd });
1204
- const { rulebookRules, alwaysApplyRules } = bucketRules(rulesResult.items, ttsrManager, {
1205
- builtinRules: ttsrSettings.builtinRules,
1206
- disabledRules: ttsrSettings.disabledRules,
1207
- });
1208
- if (existingSession.injectedTtsrRules.length > 0) {
1209
- ttsrManager.restoreInjected(existingSession.injectedTtsrRules);
1210
- }
1211
- return { ttsrManager, rulebookRules, alwaysApplyRules };
1212
- });
1233
+ const { ttsrManager, rulebookRules, alwaysApplyRules, allRules } = await logger.time(
1234
+ "discoverTtsrRules",
1235
+ async () => {
1236
+ const { TtsrManager } = await import("./export/ttsr");
1237
+ const ttsrSettings = settings.getGroup("ttsr");
1238
+ const ttsrManager = new TtsrManager(ttsrSettings);
1239
+ const rulesResult =
1240
+ options.rules !== undefined
1241
+ ? { items: options.rules, warnings: undefined }
1242
+ : await loadCapability<Rule>(ruleCapability.id, { cwd });
1243
+ const { rulebookRules, alwaysApplyRules } = bucketRules(rulesResult.items, ttsrManager, {
1244
+ builtinRules: ttsrSettings.builtinRules,
1245
+ disabledRules: ttsrSettings.disabledRules,
1246
+ });
1247
+ if (existingSession.injectedTtsrRules.length > 0) {
1248
+ ttsrManager.restoreInjected(existingSession.injectedTtsrRules);
1249
+ }
1250
+ return { ttsrManager, rulebookRules, alwaysApplyRules, allRules: rulesResult.items };
1251
+ },
1252
+ );
1213
1253
 
1214
1254
  // Resolve contextFiles up-front (it's needed before tool creation). The
1215
1255
  // workspace tree scan is slow on large repos and we MUST NOT block startup on
@@ -1331,6 +1371,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1331
1371
  contextFiles,
1332
1372
  workspaceTree: resolvedWorkspaceTree,
1333
1373
  skills,
1374
+ rules: allRules,
1334
1375
  eventBus,
1335
1376
  outputSchema: options.outputSchema,
1336
1377
  requireYieldTool: options.requireYieldTool,
@@ -1514,22 +1555,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1514
1555
  customTools.push(...getSearchTools());
1515
1556
  }
1516
1557
 
1517
- // Discover and load custom tools from .omp/tools/, .claude/tools/, etc.
1558
+ // Discover custom tools from `.omp/tools/`, `.claude/tools/`, plugins, etc.
1559
+ // Subagents reuse the parent's scan via `preloadedCustomToolPaths` to skip
1560
+ // the FS walk, but ALWAYS re-call `loadCustomTools` here so factories bind
1561
+ // to THIS session's `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
1562
+ // Forwarding the parent's `LoadedCustomTool[]` directly would route tool
1563
+ // execution back through the parent — wrong for isolated tasks and for
1564
+ // pending-action queueing.
1518
1565
  const builtInToolNames = builtinTools.map(t => t.name);
1519
- const discoveredCustomTools = await logger.time(
1520
- "discoverAndLoadCustomTools",
1521
- discoverAndLoadCustomTools,
1522
- [],
1523
- cwd,
1524
- builtInToolNames,
1525
- action => queueResolveHandler(toolSession, action),
1566
+ const customToolPaths: ToolPathWithSource[] =
1567
+ options.preloadedCustomToolPaths ??
1568
+ (await logger.time("discoverCustomToolPaths", () => discoverCustomToolPaths([], cwd)));
1569
+ const customToolsLoadResult = await logger.time("loadCustomTools", () =>
1570
+ loadCustomTools(customToolPaths, cwd, builtInToolNames, action => queueResolveHandler(toolSession, action)),
1526
1571
  );
1527
- for (const { path, error } of discoveredCustomTools.errors) {
1572
+ for (const { path, error } of customToolsLoadResult.errors) {
1528
1573
  logger.error("Custom tool load failed", { path, error });
1529
1574
  }
1530
- if (discoveredCustomTools.tools.length > 0) {
1531
- customTools.push(...discoveredCustomTools.tools.map(loaded => loaded.tool));
1575
+ if (customToolsLoadResult.tools.length > 0) {
1576
+ customTools.push(...customToolsLoadResult.tools.map(loaded => loaded.tool));
1532
1577
  }
1578
+ // Forward the path list (NOT the loaded tools) to subagents so they
1579
+ // re-bind under their own `CustomToolAPI` while skipping the FS scan.
1580
+ toolSession.customToolPaths = customToolPaths;
1533
1581
 
1534
1582
  const inlineExtensions: ExtensionFactory[] = options.extensions ? [...options.extensions] : [];
1535
1583
  inlineExtensions.push((await import("./autoresearch")).createAutoresearchExtension);
@@ -1537,14 +1585,48 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1537
1585
  inlineExtensions.push(createCustomToolsExtension(customTools));
1538
1586
  }
1539
1587
 
1540
- // Load extensions. A preloaded result (e.g. resolved by the CLI before
1541
- // session creation so it can classify `@file` args extension-aware without
1542
- // a session/breadcrumb existing yet) is reused as-is; otherwise discover now
1543
- // through the shared helper. Preloaded wins over `disableExtensionDiscovery`
1544
- // because the preloaded result already reflects that choice — re-running the
1545
- // loader here would double-load.
1546
- const extensionsResult: LoadExtensionsResult =
1547
- options.preloadedExtensions ?? (await loadSessionExtensions(options, cwd, settings, eventBus));
1588
+ // Load extensions. Three paths:
1589
+ // 1. `preloadedExtensions` (CLI): caller already loaded reuse the
1590
+ // Extension instances. Shallow-clone `extensions` so the inline
1591
+ // push below cannot mutate the caller's array. `runtime` is shared
1592
+ // so flag values set pre-creation flow into the live session.
1593
+ // 2. `preloadedExtensionPaths` (subagent): caller resolved paths;
1594
+ // skip the FS scan but always re-call `loadExtensions` here so
1595
+ // each `Extension` binds to THIS session's `ExtensionAPI`
1596
+ // (cwd, eventBus, runtime).
1597
+ // 3. No preload: run the full session discovery.
1598
+ // `disableExtensionDiscovery` is honored implicitly: a caller that set
1599
+ // the flag and pre-resolved the result already reflects that choice.
1600
+ let extensionPaths: string[];
1601
+ let extensionsResult: LoadExtensionsResult;
1602
+ if (options.preloadedExtensions) {
1603
+ extensionsResult = {
1604
+ ...options.preloadedExtensions,
1605
+ extensions: [...options.preloadedExtensions.extensions],
1606
+ };
1607
+ // Capture paths for downstream forwarding; filter inline-factory
1608
+ // entries (`<inline-N>`) — those are per-session, not source paths.
1609
+ extensionPaths = extensionsResult.extensions
1610
+ .map(ext => ext.resolvedPath)
1611
+ .filter(p => !p.startsWith("<inline"));
1612
+ } else if (options.preloadedExtensionPaths) {
1613
+ extensionPaths = options.preloadedExtensionPaths;
1614
+ extensionsResult = await logger.time("loadExtensions", loadExtensions, extensionPaths, cwd, eventBus);
1615
+ for (const { path, error } of extensionsResult.errors) {
1616
+ logger.error("Failed to load extension", { path, error });
1617
+ }
1618
+ } else {
1619
+ extensionPaths = await logger.time("discoverSessionExtensionPaths", () =>
1620
+ discoverSessionExtensionPaths(options, cwd, settings),
1621
+ );
1622
+ extensionsResult = await logger.time("loadExtensions", loadExtensions, extensionPaths, cwd, eventBus);
1623
+ for (const { path, error } of extensionsResult.errors) {
1624
+ logger.error("Failed to load extension", { path, error });
1625
+ }
1626
+ }
1627
+ // Forward the source-path list (NOT the loaded instances) so subagents
1628
+ // rebuild their own session-scoped extensions.
1629
+ toolSession.extensionPaths = extensionPaths;
1548
1630
 
1549
1631
  // Load inline extensions from factories
1550
1632
  if (inlineExtensions.length > 0) {
@@ -42,6 +42,42 @@ export interface SSHResult {
42
42
  artifactId?: string;
43
43
  }
44
44
 
45
+ type SSHExitEvent = { kind: "exit"; exitCode: number } | { kind: "error"; error: unknown };
46
+
47
+ function sshExitEvent(exitCode: number): SSHExitEvent {
48
+ return { kind: "exit", exitCode };
49
+ }
50
+
51
+ function sshErrorEvent(error: unknown): SSHExitEvent {
52
+ return { kind: "error", error };
53
+ }
54
+
55
+ function createAbortWaiter(
56
+ signal: AbortSignal | undefined,
57
+ streamAbort: AbortController,
58
+ ): { promise: Promise<ptree.AbortError> | undefined; cleanup: () => void } {
59
+ if (!signal) {
60
+ return { promise: undefined, cleanup: () => {} };
61
+ }
62
+
63
+ const { promise, resolve } = Promise.withResolvers<ptree.AbortError>();
64
+ const onAbort = () => {
65
+ const error = new ptree.AbortError(signal.reason, "<cancelled>");
66
+ if (!streamAbort.signal.aborted) {
67
+ streamAbort.abort(error);
68
+ }
69
+ resolve(error);
70
+ };
71
+
72
+ if (signal.aborted) {
73
+ onAbort();
74
+ return { promise, cleanup: () => {} };
75
+ }
76
+
77
+ signal.addEventListener("abort", onAbort, { once: true });
78
+ return { promise, cleanup: () => signal.removeEventListener("abort", onAbort) };
79
+ }
80
+
45
81
  function quoteForCompatShell(command: string): string {
46
82
  if (command.length === 0) {
47
83
  return "''";
@@ -94,19 +130,37 @@ export async function executeSSH(
94
130
  maxColumns: resolveOutputMaxColumns(settings),
95
131
  });
96
132
 
97
- const streams = [child.stdout.pipeTo(sink.createInput())];
133
+ const streamAbort = new AbortController();
134
+ const abortWaiter = createAbortWaiter(options?.signal, streamAbort);
135
+ const streamOptions = { signal: streamAbort.signal };
136
+ const streams = [child.stdout.pipeTo(sink.createInput(), streamOptions)];
98
137
  if (child.stderr) {
99
- streams.push(child.stderr.pipeTo(sink.createInput()));
138
+ streams.push(child.stderr.pipeTo(sink.createInput(), streamOptions));
100
139
  }
101
- await Promise.allSettled(streams).catch(() => {});
140
+ const streamsSettled = Promise.allSettled(streams).then(() => {});
102
141
 
103
142
  try {
143
+ const exitEvent = child.exited.then(sshExitEvent, sshErrorEvent);
144
+ const abortEvent = abortWaiter.promise?.then(sshErrorEvent);
145
+ const event = await (abortEvent ? Promise.race([exitEvent, abortEvent]) : exitEvent);
146
+ if (event.kind === "error") {
147
+ throw event.error;
148
+ }
149
+
150
+ const streamEvent = await (abortEvent ? Promise.race([streamsSettled, abortEvent]) : streamsSettled);
151
+ if (streamEvent?.kind === "error") {
152
+ throw streamEvent.error;
153
+ }
104
154
  return {
105
- exitCode: await child.exited,
155
+ exitCode: event.exitCode,
106
156
  cancelled: false,
107
157
  ...(await sink.dump()),
108
158
  };
109
159
  } catch (err) {
160
+ if (!streamAbort.signal.aborted) {
161
+ streamAbort.abort(err);
162
+ }
163
+ void streamsSettled;
110
164
  if (err instanceof ptree.Exception) {
111
165
  if (err instanceof ptree.TimeoutError) {
112
166
  return {
@@ -129,5 +183,7 @@ export async function executeSSH(
129
183
  };
130
184
  }
131
185
  throw err;
186
+ } finally {
187
+ abortWaiter.cleanup();
132
188
  }
133
189
  }
@@ -8,11 +8,13 @@ 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
10
  import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
11
+ import type { Rule } from "../capability/rule";
11
12
  import { ModelRegistry } from "../config/model-registry";
12
13
  import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
13
14
  import type { PromptTemplate } from "../config/prompt-templates";
14
15
  import { Settings } from "../config/settings";
15
16
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
17
+ import type { ToolPathWithSource } from "../extensibility/custom-tools";
16
18
  import type { CustomTool } from "../extensibility/custom-tools/types";
17
19
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
18
20
  import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
@@ -190,6 +192,20 @@ export interface ExecutorOptions {
190
192
  skills?: Skill[];
191
193
  promptTemplates?: PromptTemplate[];
192
194
  workspaceTree?: WorkspaceTree;
195
+ /** Parent-discovered rules, forwarded to skip rule discovery in the subagent. */
196
+ rules?: Rule[];
197
+ /**
198
+ * Parent's discovered extension source paths. Forwarded to skip the
199
+ * extension FS scan in the subagent; the subagent then re-binds each
200
+ * extension against its own `ExtensionAPI` (cwd, eventBus, runtime).
201
+ */
202
+ preloadedExtensionPaths?: string[];
203
+ /**
204
+ * Parent's discovered custom-tool source paths. Forwarded to skip the
205
+ * `.omp/tools/` FS scan in the subagent; the subagent then re-binds each
206
+ * tool against its own `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
207
+ */
208
+ preloadedCustomToolPaths?: ToolPathWithSource[];
193
209
  mcpManager?: MCPManager;
194
210
  authStorage?: AuthStorage;
195
211
  modelRegistry?: ModelRegistry;
@@ -1284,6 +1300,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1284
1300
  skills: options.skills,
1285
1301
  promptTemplates: options.promptTemplates,
1286
1302
  workspaceTree: options.workspaceTree,
1303
+ rules: options.rules,
1304
+ preloadedExtensionPaths: options.preloadedExtensionPaths,
1305
+ preloadedCustomToolPaths: options.preloadedCustomToolPaths,
1287
1306
  systemPrompt: defaultPrompt => {
1288
1307
  const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
1289
1308
  agent: agent.systemPrompt,
package/src/task/index.ts CHANGED
@@ -990,6 +990,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
990
990
  autoloadSkills: resolvedAutoloadSkills,
991
991
  workspaceTree: this.session.workspaceTree,
992
992
  promptTemplates,
993
+ rules: this.session.rules,
994
+ preloadedExtensionPaths: this.session.extensionPaths,
995
+ preloadedCustomToolPaths: this.session.customToolPaths,
993
996
  localProtocolOptions,
994
997
  parentArtifactManager,
995
998
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
@@ -1048,6 +1051,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1048
1051
  autoloadSkills: resolvedAutoloadSkills,
1049
1052
  workspaceTree: this.session.workspaceTree,
1050
1053
  promptTemplates,
1054
+ rules: this.session.rules,
1051
1055
  localProtocolOptions,
1052
1056
  parentArtifactManager,
1053
1057
  parentHindsightSessionState: this.session.getHindsightSessionState?.(),
@@ -3,10 +3,12 @@ import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { FetchImpl, 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
+ import type { Rule } from "../capability/rule";
6
7
  import type { PromptTemplate } from "../config/prompt-templates";
7
8
  import type { Settings } from "../config/settings";
8
9
  import { EditTool } from "../edit";
9
10
  import { checkPythonKernelAvailability } from "../eval/py/kernel";
11
+ import type { ToolPathWithSource } from "../extensibility/custom-tools";
10
12
  import type { Skill } from "../extensibility/skills";
11
13
  import type { GoalModeState, GoalRuntime } from "../goals";
12
14
  import { GoalTool } from "../goals/tools/goal-tool";
@@ -154,6 +156,21 @@ export interface ToolSession {
154
156
  skills?: Skill[];
155
157
  /** Pre-loaded prompt templates */
156
158
  promptTemplates?: PromptTemplate[];
159
+ /** Pre-loaded rules (forwarded to subagents to skip re-discovery). */
160
+ rules?: Rule[];
161
+ /**
162
+ * Pre-discovered extension source paths. Forwarded to subagents so they
163
+ * skip the FS scan but still re-bind extensions to their own session-scoped
164
+ * `ExtensionAPI` (cwd, eventBus, runtime). Inline extension factories
165
+ * (`<inline-N>`) are NOT included — those are session-local.
166
+ */
167
+ extensionPaths?: string[];
168
+ /**
169
+ * Pre-discovered custom-tool source paths from `.omp/tools/`, `.claude/tools/`,
170
+ * plugins, etc. Forwarded to subagents so they skip the FS scan but still
171
+ * re-bind tools to their own session-scoped `CustomToolAPI`.
172
+ */
173
+ customToolPaths?: ToolPathWithSource[];
157
174
  /** Whether LSP integrations are enabled */
158
175
  enableLsp?: boolean;
159
176
  /** Whether an edit-capable tool is available in this session (controls hashline output) */
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  const OSC = "\x1b]";
20
20
  const ST = "\x1b\\";
21
+ const BEL = "\x07";
21
22
 
22
23
  /** Stable 8-char hex ID derived from a URI — hints terminals to coalesce identical adjacent links. */
23
24
  function buildLinkId(uri: string): string {
@@ -60,14 +61,18 @@ function safeHyperlinkUri(uri: string): string | undefined {
60
61
  return uri;
61
62
  }
62
63
 
63
- function wrapHyperlink(uri: string, displayText: string): string {
64
- if (!isHyperlinkEnabled()) return displayText;
64
+ function wrapHyperlinkCore(uri: string, displayText: string, terminator: typeof ST | typeof BEL): string {
65
65
  // Do not double-wrap if the text already embeds an OSC 8 sequence.
66
66
  if (displayText.includes("\x1b]8;")) return displayText;
67
67
  const safeUri = safeHyperlinkUri(uri);
68
68
  if (!safeUri) return displayText;
69
69
  const id = buildLinkId(safeUri);
70
- return `${OSC}8;id=${id};${safeUri}${ST}${displayText}${OSC}8;;${ST}`;
70
+ return `${OSC}8;id=${id};${safeUri}${terminator}${displayText}${OSC}8;;${terminator}`;
71
+ }
72
+
73
+ function wrapHyperlink(uri: string, displayText: string): string {
74
+ if (!isHyperlinkEnabled()) return displayText;
75
+ return wrapHyperlinkCore(uri, displayText, ST);
71
76
  }
72
77
 
73
78
  /**
@@ -95,6 +100,25 @@ export function urlHyperlink(url: string, displayText: string): string {
95
100
  }
96
101
  }
97
102
 
103
+ /**
104
+ * Wrap `displayText` in an OSC 8 hyperlink pointing at an HTTP(S) URL,
105
+ * bypassing terminal capability auto-detection. Used for auth prompts where
106
+ * an inert "click" label blocks login on terminals whose capabilities are
107
+ * not advertised. Still returns plain text when the user has explicitly
108
+ * opted out via `tui.hyperlinks=off`.
109
+ */
110
+ export function urlHyperlinkAlways(url: string, displayText: string): string {
111
+ if (settings.get("tui.hyperlinks") === "off") return displayText;
112
+ const normalized = url.match(/^www\./i) ? `https://${url}` : url;
113
+ try {
114
+ const parsed = new URL(normalized);
115
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return displayText;
116
+ return wrapHyperlinkCore(parsed.href, displayText, BEL);
117
+ } catch {
118
+ return displayText;
119
+ }
120
+ }
121
+
98
122
  /**
99
123
  * Wrap `displayText` in an OSC 8 hyperlink pointing at a filesystem path.
100
124
  *