@oh-my-pi/pi-coding-agent 16.0.8 → 16.0.10

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 (47) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +3004 -2976
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/collab/host.d.ts +2 -2
  5. package/dist/types/collab/protocol.d.ts +4 -5
  6. package/dist/types/commands/launch.d.ts +3 -0
  7. package/dist/types/config/model-resolver.d.ts +11 -2
  8. package/dist/types/config/settings-schema.d.ts +12 -2
  9. package/dist/types/goals/runtime.d.ts +4 -1
  10. package/dist/types/modes/print-mode.d.ts +2 -0
  11. package/dist/types/session/agent-session.d.ts +13 -0
  12. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  13. package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
  14. package/dist/types/tools/index.d.ts +9 -1
  15. package/dist/types/utils/image-loading.d.ts +12 -0
  16. package/dist/types/utils/qrcode.d.ts +48 -0
  17. package/package.json +12 -12
  18. package/src/cli/args.ts +10 -1
  19. package/src/cli/flag-tables.ts +1 -0
  20. package/src/collab/host.ts +4 -4
  21. package/src/collab/protocol.ts +48 -15
  22. package/src/commands/launch.ts +3 -0
  23. package/src/config/config-file.ts +1 -1
  24. package/src/config/keybindings.ts +2 -2
  25. package/src/config/model-registry.ts +16 -4
  26. package/src/config/model-resolver.ts +193 -35
  27. package/src/config/settings-schema.ts +14 -2
  28. package/src/config/settings.ts +3 -3
  29. package/src/goals/runtime.ts +19 -7
  30. package/src/internal-urls/docs-index.generated.txt +1 -1
  31. package/src/main.ts +10 -2
  32. package/src/modes/components/oauth-selector.ts +31 -2
  33. package/src/modes/interactive-mode.ts +7 -3
  34. package/src/modes/print-mode.ts +5 -1
  35. package/src/prompts/advisor/advise-tool.md +3 -1
  36. package/src/prompts/advisor/system.md +55 -12
  37. package/src/prompts/tools/inspect-image.md +1 -1
  38. package/src/sdk.ts +26 -7
  39. package/src/session/agent-session.ts +103 -16
  40. package/src/slash-commands/builtin-registry.ts +29 -11
  41. package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
  42. package/src/thinking.ts +25 -5
  43. package/src/tools/index.ts +10 -1
  44. package/src/tools/inspect-image.ts +72 -9
  45. package/src/utils/file-mentions.ts +5 -2
  46. package/src/utils/image-loading.ts +58 -0
  47. package/src/utils/qrcode.ts +535 -0
@@ -42,6 +42,7 @@ export interface Args {
42
42
  noExtensions?: boolean;
43
43
  pluginDirs?: string[];
44
44
  print?: boolean;
45
+ printThoughts?: boolean;
45
46
  export?: string;
46
47
  noSkills?: boolean;
47
48
  skills?: string[];
@@ -16,14 +16,14 @@ export declare class CollabHost {
16
16
  #private;
17
17
  constructor(ctx: InteractiveModeContext);
18
18
  get link(): string;
19
- /** Browser deep link (`https://<relay>/#<link>`) the relay serves the web client at `/`. */
19
+ /** Browser deep link for the configured collab web UI. */
20
20
  get webLink(): string;
21
21
  /** Read-only variant of {@link link}: bare room key, no write token. */
22
22
  get viewLink(): string;
23
23
  /** Read-only variant of {@link webLink}. */
24
24
  get webViewLink(): string;
25
25
  get participants(): CollabParticipant[];
26
- start(relayUrl: string): Promise<void>;
26
+ start(relayUrl: string, webUrl?: string): Promise<void>;
27
27
  /** Broadcast a goodbye, detach all taps, and close the socket. */
28
28
  stop(reason: string): Promise<void>;
29
29
  }
@@ -108,12 +108,11 @@ export declare function generateRoomId(): string;
108
108
  */
109
109
  export declare function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array): string;
110
110
  /**
111
- * Render the browser deep link: `http(s)://<relay-host>/#<collab-link>`. The
112
- * relay serves the web client at `/`, and the whole collab link (including the
113
- * room key) rides in the fragment, so it never appears in any HTTP request.
114
- * Terminals auto-link the https form, making it click-to-join.
111
+ * Render the browser deep link. The browser UI may be hosted separately from
112
+ * the relay; the fragment always carries the relay-specific collab link, so
113
+ * room secrets stay out of HTTP path and query bytes.
115
114
  */
116
- export declare function formatCollabWebLink(relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array): string;
115
+ export declare function formatCollabWebLink(relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array, webUrl?: string): string;
117
116
  export declare function parseCollabLink(link: string): ParsedCollabLink | {
118
117
  error: string;
119
118
  };
@@ -127,6 +127,9 @@ export default class Index extends Command {
127
127
  "no-title": import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
128
128
  description: string;
129
129
  };
130
+ "print-thoughts": import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"boolean"> & {
131
+ description: string;
132
+ };
130
133
  "max-time": import("@oh-my-pi/pi-utils/cli").FlagDescriptor<"string"> & {
131
134
  description: string;
132
135
  };
@@ -32,11 +32,17 @@ export interface ScopedModel {
32
32
  thinkingLevel?: ThinkingLevel;
33
33
  explicitThinkingLevel: boolean;
34
34
  }
35
+ interface ThinkingSuffixOptions {
36
+ allowMaxAlias?: boolean;
37
+ }
38
+ interface ModelStringParseOptions extends ThinkingSuffixOptions {
39
+ isLiteralModelId?: (provider: string, id: string) => boolean;
40
+ }
35
41
  /**
36
42
  * Parse a model string in "provider/modelId" format.
37
43
  * Returns undefined if the format is invalid.
38
44
  */
39
- export declare function parseModelString(modelStr: string): {
45
+ export declare function parseModelString(modelStr: string, options?: ModelStringParseOptions): {
40
46
  provider: string;
41
47
  id: string;
42
48
  thinkingLevel?: ThinkingLevel;
@@ -115,7 +121,10 @@ export declare function resolveModelRoleValue(roleValue: string | undefined, ava
115
121
  matchPreferences?: ModelMatchPreferences;
116
122
  modelRegistry?: CanonicalModelRegistry;
117
123
  }): ResolvedModelRoleValue;
118
- export declare function extractExplicitThinkingSelector(value: string | undefined, settings?: Settings): ThinkingLevel | undefined;
124
+ interface ExplicitThinkingSelectorOptions {
125
+ isLiteralModelId?: (provider: string, id: string) => boolean;
126
+ }
127
+ export declare function extractExplicitThinkingSelector(value: string | undefined, settings?: Settings, options?: ExplicitThinkingSelectorOptions): ThinkingLevel | undefined;
119
128
  /**
120
129
  * Resolve a model identifier or pattern to a Model instance.
121
130
  */
@@ -835,7 +835,7 @@ export declare const SETTINGS_SCHEMA: {
835
835
  };
836
836
  readonly defaultThinkingLevel: {
837
837
  readonly type: "enum";
838
- readonly values: readonly [...import("@oh-my-pi/pi-catalog").Effort[], "auto"];
838
+ readonly values: readonly [...import("@oh-my-pi/pi-catalog").Effort[], "auto", "max"];
839
839
  readonly default: "high";
840
840
  readonly ui: {
841
841
  readonly tab: "model";
@@ -1579,6 +1579,16 @@ export declare const SETTINGS_SCHEMA: {
1579
1579
  readonly description: "Relay used by /collab (wss://host[:port])";
1580
1580
  };
1581
1581
  };
1582
+ readonly "collab.webUrl": {
1583
+ readonly type: "string";
1584
+ readonly default: "";
1585
+ readonly ui: {
1586
+ readonly tab: "interaction";
1587
+ readonly group: "Collab";
1588
+ readonly label: "Web UI URL";
1589
+ readonly description: "Browser UI used by /collab links; empty derives from collab.relayUrl; explicit http:// is localhost-only";
1590
+ };
1591
+ };
1582
1592
  readonly "collab.displayName": {
1583
1593
  readonly type: "string";
1584
1594
  readonly default: "";
@@ -2012,7 +2022,7 @@ export declare const SETTINGS_SCHEMA: {
2012
2022
  }, {
2013
2023
  readonly value: "pi";
2014
2024
  readonly label: "Pi";
2015
- readonly description: "Use the Pi owned dialect.";
2025
+ readonly description: "Use the Pi owned dialect (compact sigil-delimited tool calls).";
2016
2026
  }, {
2017
2027
  readonly value: "qwen3";
2018
2028
  readonly label: "Qwen3";
@@ -36,6 +36,7 @@ export declare class GoalRuntime {
36
36
  #private;
37
37
  constructor(host: GoalRuntimeHost);
38
38
  get snapshot(): GoalRuntimeSnapshot;
39
+ clearAccounting(): void;
39
40
  onTurnStart(turnId: string, baselineUsage: GoalTokenUsage): void;
40
41
  onToolCompleted(toolName: string): Promise<void>;
41
42
  onGoalToolCompleted(): Promise<void>;
@@ -46,7 +47,9 @@ export declare class GoalRuntime {
46
47
  onTaskAborted(options?: {
47
48
  reason?: "interrupted" | "internal";
48
49
  }): Promise<void>;
49
- onThreadResumed(): Promise<GoalModeState | undefined>;
50
+ onThreadResumed(options?: {
51
+ preserveActiveGoal?: boolean;
52
+ }): Promise<GoalModeState | undefined>;
50
53
  onBudgetMutated(newBudget: number | undefined): Promise<GoalModeState | undefined>;
51
54
  flushUsage(steering: GoalBudgetSteering, currentUsage?: GoalTokenUsage): Promise<void>;
52
55
  createGoal(input: {
@@ -19,6 +19,8 @@ export interface PrintModeOptions {
19
19
  initialMessage?: string;
20
20
  /** Images to attach to the initial message */
21
21
  initialImages?: ImageContent[];
22
+ /** If true, include thinking blocks in text output */
23
+ printThoughts?: boolean;
22
24
  }
23
25
  /**
24
26
  * Run in print (single-shot) mode.
@@ -237,6 +237,13 @@ export interface AgentSessionConfig {
237
237
  advisorReadOnlyTools?: AgentTool[];
238
238
  /** Preloaded watchdog prompt content for the advisor. */
239
239
  advisorWatchdogPrompt?: string;
240
+ /**
241
+ * Disconnect this session's OWNED MCP manager on dispose. Provided only when
242
+ * the session created the manager (top-level sessions); subagents reuse a
243
+ * parent's manager via `options.mcpManager` and omit this so a child's
244
+ * teardown never tears down the shared servers.
245
+ */
246
+ disconnectOwnedMcpManager?: () => Promise<void>;
240
247
  }
241
248
  /** Options for AgentSession.prompt() */
242
249
  export interface PromptOptions {
@@ -552,6 +559,12 @@ export declare class AgentSession {
552
559
  get hasPostPromptWork(): boolean;
553
560
  /** All messages including custom types like BashExecutionMessage */
554
561
  get messages(): AgentMessage[];
562
+ /** Latest image attachments addressable by tools as `Image #N` or `attachment://N`. */
563
+ getImageAttachments(): {
564
+ label: string;
565
+ uri: string;
566
+ image: ImageContent;
567
+ }[];
555
568
  buildDisplaySessionContext(): SessionContext;
556
569
  /**
557
570
  * Full-history transcript for TUI display: every path entry in
@@ -1,4 +1,4 @@
1
- import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
1
+ import { type AutocompleteItem } from "@oh-my-pi/pi-tui";
2
2
  import type { BuiltinSlashCommand, ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime, SlashCommandSpec, TuiSlashCommandRuntime } from "./types";
3
3
  export type { BuiltinSlashCommand, SubcommandDef } from "./types";
4
4
  /** TUI-specific runtime accepted by `executeBuiltinSlashCommand`. */
@@ -0,0 +1,13 @@
1
+ import { type Component } from "@oh-my-pi/pi-tui";
2
+ /**
3
+ * One-shot transcript block that prints a collab browser-join URL as a
4
+ * scannable QR code. The symbol is encoded once at construction (byte mode,
5
+ * EC level M) and rendered as ANSI half-blocks; on terminals too narrow for
6
+ * the symbol it degrades to a one-line hint pointing at the printed URL.
7
+ */
8
+ export declare class CollabQrCodeComponent implements Component {
9
+ #private;
10
+ readonly url: string;
11
+ constructor(url: string);
12
+ render(width: number): readonly string[];
13
+ }
@@ -1,6 +1,6 @@
1
1
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
- import type { FetchImpl, Model, ToolChoice } from "@oh-my-pi/pi-ai";
3
+ import type { FetchImpl, ImageContent, Model, ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import type { AsyncJobManager } from "../async/job-manager";
5
5
  import type { Rule } from "../capability/rule";
6
6
  import type { PromptTemplate } from "../config/prompt-templates";
@@ -71,6 +71,12 @@ export type ContextFileEntry = {
71
71
  content: string;
72
72
  depth?: number;
73
73
  };
74
+ /** Image attachment handle exposed to tools for user-facing labels such as `Image #1`. */
75
+ export type ImageAttachmentEntry = {
76
+ label: string;
77
+ uri: string;
78
+ image: ImageContent;
79
+ };
74
80
  export type { DiscoverableTool, DiscoverableToolSearchIndex, DiscoverableToolSearchResult, DiscoverableToolSource, } from "../tool-discovery/tool-index";
75
81
  /**
76
82
  * A late LSP diagnostics result that arrived after the edit/write tool already
@@ -309,6 +315,8 @@ export interface ToolSession {
309
315
  /** Get the active OpenTelemetry config so subagent dispatch can forward
310
316
  * the parent's tracer/hooks with the subagent's own identity stamped. */
311
317
  getTelemetry?: () => AgentTelemetryConfig | undefined;
318
+ /** Return image attachments visible to tools for resolving labels such as `Image #1`. */
319
+ getImageAttachments?: () => ImageAttachmentEntry[];
312
320
  }
313
321
  export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
314
322
  export type BuiltinToolLoadMode = "essential" | "discoverable";
@@ -25,6 +25,16 @@ export interface LoadImageInputOptions {
25
25
  /** Force non-WebP output (e.g. for Ollama). Leave unset to honor `OMP_NO_WEBP`. */
26
26
  excludeWebP?: boolean;
27
27
  }
28
+ /** Options for loading an in-memory chat image attachment as a vision-model input. */
29
+ export interface LoadImageAttachmentInputOptions {
30
+ image: ImageContent;
31
+ label: string;
32
+ uri: string;
33
+ autoResize: boolean;
34
+ maxBytes?: number;
35
+ /** Force non-WebP output (e.g. for Ollama). Leave unset to honor `OMP_NO_WEBP`. */
36
+ excludeWebP?: boolean;
37
+ }
28
38
  export interface LoadedImageInput {
29
39
  resolvedPath: string;
30
40
  mimeType: string;
@@ -53,3 +63,5 @@ export interface NormalizeModelContextImagesOptions {
53
63
  */
54
64
  export declare function normalizeModelContextImages(images: ImageContent[] | undefined, options?: NormalizeModelContextImagesOptions): Promise<ImageContent[] | undefined>;
55
65
  export declare function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null>;
66
+ /** Loads a chat attachment image through the same size and encoder policy as file-backed image inputs. */
67
+ export declare function loadImageAttachmentInput(options: LoadImageAttachmentInputOptions): Promise<LoadedImageInput | null>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Self-contained QR Code generator (byte mode, versions 1-40, EC levels
3
+ * L/M/Q/H) with a half-block ANSI terminal renderer.
4
+ *
5
+ * Pure TypeScript, zero dependencies: the collab `/collab qrcode` command uses
6
+ * it to print scannable browser-join codes without pulling a runtime QR
7
+ * package into the bundle. The algorithm follows ISO/IEC 18004; the two
8
+ * error-correction tables below are direct transcriptions of that spec.
9
+ */
10
+ export type QrEcLevel = "L" | "M" | "Q" | "H";
11
+ export interface QrEncodeOptions {
12
+ /** Lowest version to consider (default 1). */
13
+ minVersion?: number;
14
+ /** Highest version to consider (default 40). */
15
+ maxVersion?: number;
16
+ /** Force a mask 0-7; -1 (default) auto-selects the lowest-penalty mask. */
17
+ mask?: number;
18
+ }
19
+ /**
20
+ * A finished QR symbol: a square grid of dark/light modules plus the chosen
21
+ * version, EC level, and mask. `module(x, y)` is the only access path the
22
+ * renderers need.
23
+ */
24
+ export declare class QrCode {
25
+ #private;
26
+ readonly version: number;
27
+ readonly ecLevel: QrEcLevel;
28
+ readonly size: number;
29
+ /** Selected mask pattern (0-7). */
30
+ readonly mask: number;
31
+ private constructor();
32
+ module(x: number, y: number): boolean;
33
+ /** Encode a string in byte mode (UTF-8). Throws if it exceeds version 40. */
34
+ static encodeText(text: string, ecLevel?: QrEcLevel, options?: QrEncodeOptions): QrCode;
35
+ /** Encode raw bytes in byte mode. Throws if they exceed version 40 at this EC level. */
36
+ static encodeBytes(data: Uint8Array, ecLevel?: QrEcLevel, options?: QrEncodeOptions): QrCode;
37
+ }
38
+ export interface QrRenderOptions {
39
+ /** Quiet-zone width in modules on every side (default 4, per spec). */
40
+ margin?: number;
41
+ }
42
+ /**
43
+ * Render a QR symbol as ANSI half-block rows: each text row packs two module
44
+ * rows via `▀`/`▄`/`█`, drawn black-on-white so a phone camera reads dark
45
+ * modules as data and the quiet zone as the light margin. The leading margin
46
+ * makes the symbol scannable regardless of the terminal's own background.
47
+ */
48
+ export declare function renderQrHalfBlocks(qr: QrCode, options?: QrRenderOptions): string[];
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.0.8",
4
+ "version": "16.0.10",
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",
@@ -48,17 +48,17 @@
48
48
  "@agentclientprotocol/sdk": "0.25.0",
49
49
  "@babel/parser": "^7.29.7",
50
50
  "@mozilla/readability": "^0.6.0",
51
- "@oh-my-pi/hashline": "16.0.8",
52
- "@oh-my-pi/omp-stats": "16.0.8",
53
- "@oh-my-pi/pi-agent-core": "16.0.8",
54
- "@oh-my-pi/pi-ai": "16.0.8",
55
- "@oh-my-pi/pi-catalog": "16.0.8",
56
- "@oh-my-pi/pi-mnemopi": "16.0.8",
57
- "@oh-my-pi/pi-natives": "16.0.8",
58
- "@oh-my-pi/pi-tui": "16.0.8",
59
- "@oh-my-pi/pi-utils": "16.0.8",
60
- "@oh-my-pi/pi-wire": "16.0.8",
61
- "@oh-my-pi/snapcompact": "16.0.8",
51
+ "@oh-my-pi/hashline": "16.0.10",
52
+ "@oh-my-pi/omp-stats": "16.0.10",
53
+ "@oh-my-pi/pi-agent-core": "16.0.10",
54
+ "@oh-my-pi/pi-ai": "16.0.10",
55
+ "@oh-my-pi/pi-catalog": "16.0.10",
56
+ "@oh-my-pi/pi-mnemopi": "16.0.10",
57
+ "@oh-my-pi/pi-natives": "16.0.10",
58
+ "@oh-my-pi/pi-tui": "16.0.10",
59
+ "@oh-my-pi/pi-utils": "16.0.10",
60
+ "@oh-my-pi/pi-wire": "16.0.10",
61
+ "@oh-my-pi/snapcompact": "16.0.10",
62
62
  "@opentelemetry/api": "^1.9.1",
63
63
  "@opentelemetry/context-async-hooks": "^2.7.1",
64
64
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
package/src/cli/args.ts CHANGED
@@ -56,6 +56,7 @@ export interface Args {
56
56
  noExtensions?: boolean;
57
57
  pluginDirs?: string[];
58
58
  print?: boolean;
59
+ printThoughts?: boolean;
59
60
  export?: string;
60
61
  noSkills?: boolean;
61
62
  skills?: string[];
@@ -200,6 +201,8 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
200
201
  result.advisor = true;
201
202
  } else if (arg === "--print" || arg === "-p") {
202
203
  result.print = true;
204
+ } else if (arg === "--print-thoughts") {
205
+ result.printThoughts = true;
203
206
  } else if (arg === "--no-extensions") {
204
207
  result.noExtensions = true;
205
208
  } else if (arg === "--no-skills") {
@@ -211,7 +214,13 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
211
214
  } else if (arg === "--auto-approve" || arg === "--yolo") {
212
215
  result.autoApprove = true;
213
216
  } else if (arg.startsWith("@")) {
214
- result.fileArgs.push(arg.slice(1)); // Remove @ prefix
217
+ let filePath = arg.slice(1);
218
+ if (filePath.startsWith('"') && filePath.endsWith('"') && filePath.length > 1) {
219
+ filePath = filePath.slice(1, -1);
220
+ } else if (filePath.startsWith("'") && filePath.endsWith("'") && filePath.length > 1) {
221
+ filePath = filePath.slice(1, -1);
222
+ }
223
+ result.fileArgs.push(filePath);
215
224
  } else if (!arg.startsWith("-") || arg === "-") {
216
225
  // Plain positional or lone `-` (stdin marker) — pass through as a
217
226
  // message rather than flagging it.
@@ -270,6 +270,7 @@ export const VALUELESS_FLAGS: ReadonlySet<string> = new Set([
270
270
  "--hide-thinking",
271
271
  "--advisor",
272
272
  "--print",
273
+ "--print-thoughts",
273
274
  "--no-extensions",
274
275
  "--no-skills",
275
276
  "--no-rules",
@@ -133,7 +133,7 @@ export class CollabHost {
133
133
  return this.#link;
134
134
  }
135
135
 
136
- /** Browser deep link (`https://<relay>/#<link>`) the relay serves the web client at `/`. */
136
+ /** Browser deep link for the configured collab web UI. */
137
137
  get webLink(): string {
138
138
  return this.#webLink;
139
139
  }
@@ -156,15 +156,15 @@ export class CollabHost {
156
156
  return list;
157
157
  }
158
158
 
159
- async start(relayUrl: string): Promise<void> {
159
+ async start(relayUrl: string, webUrl = ""): Promise<void> {
160
160
  const rawKey = generateRoomKey();
161
161
  const writeToken = generateWriteToken();
162
162
  const roomId = generateRoomId();
163
163
  this.#writeToken = writeToken;
164
164
  this.#link = formatCollabLink(relayUrl, roomId, rawKey, writeToken);
165
- this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken);
165
+ this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken, webUrl);
166
166
  this.#viewLink = formatCollabLink(relayUrl, roomId, rawKey);
167
- this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey);
167
+ this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey, undefined, webUrl);
168
168
  const parsed = parseCollabLink(this.#link);
169
169
  if ("error" in parsed) throw new Error(parsed.error);
170
170
  const key = await importRoomKey(rawKey);
@@ -116,6 +116,10 @@ const BARE_LINK_RE = /^([A-Za-z0-9_-]{10,64})[#.]([A-Za-z0-9_-]+)$/;
116
116
  const B64URL_RE = /^[A-Za-z0-9_-]+$/;
117
117
  const LOCAL_HOSTNAMES: Record<string, true> = { localhost: true, "127.0.0.1": true, "::1": true, "[::1]": true };
118
118
 
119
+ function isLocalHostname(hostname: string): boolean {
120
+ return LOCAL_HOSTNAMES[hostname] === true;
121
+ }
122
+
119
123
  export function generateRoomId(): string {
120
124
  const bytes = new Uint8Array(ROOM_ID_BYTES);
121
125
  crypto.getRandomValues(bytes);
@@ -143,7 +147,7 @@ function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: s
143
147
  default:
144
148
  return { error: `Unsupported relay URL scheme: ${url.protocol}` };
145
149
  }
146
- if (scheme === "ws:" && !LOCAL_HOSTNAMES[url.hostname]) {
150
+ if (scheme === "ws:" && !isLocalHostname(url.hostname)) {
147
151
  return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" };
148
152
  }
149
153
  const port = url.port ? `:${url.port}` : "";
@@ -178,24 +182,48 @@ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Arr
178
182
  return `${compact}/r/${roomId}.${keyText}`;
179
183
  }
180
184
 
185
+ function normalizeCollabWebBaseUrl(relayUrl: string, webUrl?: string): string {
186
+ const explicitWebUrl = webUrl?.trim();
187
+ if (!explicitWebUrl) {
188
+ const normalized = normalizeRelayOrigin(relayUrl);
189
+ if ("error" in normalized) throw new Error(normalized.error);
190
+ return normalized.origin.startsWith("wss://")
191
+ ? `https://${normalized.origin.slice("wss://".length)}`
192
+ : `http://${normalized.origin.slice("ws://".length)}`;
193
+ }
194
+
195
+ let url: URL;
196
+ try {
197
+ url = new URL(explicitWebUrl);
198
+ } catch {
199
+ throw new Error("collab.webUrl must start with http:// or https://");
200
+ }
201
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
202
+ throw new Error("collab.webUrl must start with http:// or https://");
203
+ }
204
+ if (url.protocol === "http:" && !isLocalHostname(url.hostname)) {
205
+ throw new Error("collab.webUrl must use https:// unless it targets localhost");
206
+ }
207
+ if (url.search || url.hash) {
208
+ throw new Error("collab.webUrl must not include a query string or fragment");
209
+ }
210
+ const path = url.pathname.replace(/\/+$/, "");
211
+ return `${url.origin}${path}`;
212
+ }
213
+
181
214
  /**
182
- * Render the browser deep link: `http(s)://<relay-host>/#<collab-link>`. The
183
- * relay serves the web client at `/`, and the whole collab link (including the
184
- * room key) rides in the fragment, so it never appears in any HTTP request.
185
- * Terminals auto-link the https form, making it click-to-join.
215
+ * Render the browser deep link. The browser UI may be hosted separately from
216
+ * the relay; the fragment always carries the relay-specific collab link, so
217
+ * room secrets stay out of HTTP path and query bytes.
186
218
  */
187
219
  export function formatCollabWebLink(
188
220
  relayUrl: string,
189
221
  roomId: string,
190
222
  key: Uint8Array,
191
223
  writeToken?: Uint8Array,
224
+ webUrl?: string,
192
225
  ): string {
193
- const normalized = normalizeRelayOrigin(relayUrl);
194
- if ("error" in normalized) throw new Error(normalized.error);
195
- const httpOrigin = normalized.origin.startsWith("wss://")
196
- ? `https://${normalized.origin.slice("wss://".length)}`
197
- : `http://${normalized.origin.slice("ws://".length)}`;
198
- return `${httpOrigin}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`;
226
+ return `${normalizeCollabWebBaseUrl(relayUrl, webUrl)}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`;
199
227
  }
200
228
 
201
229
  export function parseCollabLink(link: string): ParsedCollabLink | { error: string } {
@@ -213,15 +241,20 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin
213
241
  } catch {
214
242
  return { error: `Invalid collab link: ${link}` };
215
243
  }
244
+ if ((url.protocol === "http:" || url.protocol === "https:") && url.hash) {
245
+ const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
246
+ const parsed = parseCollabLink(inner);
247
+ if (!("error" in parsed)) return parsed;
248
+ }
216
249
  const normalized = normalizeRelayOrigin(url.origin);
217
250
  if ("error" in normalized) return normalized;
218
251
  const match = ROOM_PATH_RE.exec(url.pathname);
219
252
  if (!match) {
220
- // Web deep link: `http(s)://<relay>/#<collab-link>` the fragment holds
221
- // the whole link, so recurse on it. The recursion terminates because
222
- // the inner text is a strict suffix of the input.
253
+ // Non-http(s) deep links may also carry a complete collab link in the
254
+ // fragment. http(s) links are handled once above so invalid fragments
255
+ // fall through to direct relay validation instead of double-recursing.
223
256
  const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
224
- if (inner) return parseCollabLink(inner);
257
+ if (inner && url.protocol !== "http:" && url.protocol !== "https:") return parseCollabLink(inner);
225
258
  return { error: "Collab link must contain a /r/<roomId> path" };
226
259
  }
227
260
  const roomId = match[1]!;
@@ -136,6 +136,9 @@ export default class Index extends Command {
136
136
  "no-title": Flags.boolean({
137
137
  description: "Disable title auto-generation",
138
138
  }),
139
+ "print-thoughts": Flags.boolean({
140
+ description: "Include thinking blocks in print mode text output",
141
+ }),
139
142
  "max-time": Flags.string({
140
143
  description: "Stop the session after this many seconds",
141
144
  }),
@@ -40,7 +40,7 @@ function migrateJsonToYml(jsonPath: string, ymlPath: string) {
40
40
  }
41
41
 
42
42
  const content = fs.readFileSync(jsonPath, "utf-8");
43
- const parsed = JSON.parse(content);
43
+ const parsed = JSONC.parse(content);
44
44
  if (!parsed) {
45
45
  logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath });
46
46
  migratedPaths.add(key);
@@ -10,7 +10,7 @@ import {
10
10
  KeybindingsManager as TuiKeybindingsManager,
11
11
  } from "@oh-my-pi/pi-tui";
12
12
  import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
13
- import { YAML } from "bun";
13
+ import { JSONC, YAML } from "bun";
14
14
 
15
15
  /**
16
16
  * Application-level keybindings (coding agent specific).
@@ -381,7 +381,7 @@ function loadRawConfig(filePath: string): unknown {
381
381
  try {
382
382
  const content = fs.readFileSync(filePath, "utf-8");
383
383
  if (filePath.endsWith(".json")) {
384
- return JSON.parse(content);
384
+ return JSONC.parse(content);
385
385
  }
386
386
  if (filePath.endsWith(".yml") || filePath.endsWith(".yaml")) {
387
387
  return YAML.parse(content);
@@ -563,10 +563,16 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
563
563
  } as ModelSpec<Api>);
564
564
  }
565
565
 
566
- function normalizeSuppressedSelector(selector: string): string {
566
+ function normalizeSuppressedSelector(
567
+ selector: string,
568
+ hasLiveModel?: (provider: string, id: string) => boolean,
569
+ ): string {
567
570
  const trimmed = selector.trim();
568
571
  if (!trimmed) return trimmed;
569
- const parsed = parseModelString(trimmed);
572
+ const parsed = parseModelString(trimmed, {
573
+ allowMaxAlias: true,
574
+ isLiteralModelId: (provider, id) => hasLiveModel?.(provider, id) === true,
575
+ });
570
576
  if (!parsed) return trimmed;
571
577
  // Retired effort-tier variant ids normalize to their collapsed logical id
572
578
  // so persisted suppressions keyed by raw member ids still bind.
@@ -2155,14 +2161,20 @@ export class ModelRegistry {
2155
2161
  * Suppress a specific model selector (e.g., "provider/id") until a specific timestamp.
2156
2162
  */
2157
2163
  suppressSelector(selector: string, untilMs: number): void {
2158
- this.#suppressedSelectors.set(normalizeSuppressedSelector(selector), untilMs);
2164
+ this.#suppressedSelectors.set(
2165
+ normalizeSuppressedSelector(selector, (provider, id) => this.find(provider, id) !== undefined),
2166
+ untilMs,
2167
+ );
2159
2168
  }
2160
2169
 
2161
2170
  /**
2162
2171
  * Check if a model selector is currently suppressed due to rate limits.
2163
2172
  */
2164
2173
  isSelectorSuppressed(selector: string): boolean {
2165
- const normalizedSelector = normalizeSuppressedSelector(selector);
2174
+ const normalizedSelector = normalizeSuppressedSelector(
2175
+ selector,
2176
+ (provider, id) => this.find(provider, id) !== undefined,
2177
+ );
2166
2178
  const suppressedUntil = this.#suppressedSelectors.get(normalizedSelector);
2167
2179
  if (!suppressedUntil) return false;
2168
2180
  if (suppressedUntil <= Date.now()) {