@oh-my-pi/pi-coding-agent 16.0.9 → 16.0.11

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 (110) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/cli.js +3402 -3443
  3. package/dist/types/advisor/index.d.ts +1 -0
  4. package/dist/types/advisor/transcript-recorder.d.ts +52 -0
  5. package/dist/types/collab/host.d.ts +2 -2
  6. package/dist/types/collab/protocol.d.ts +4 -5
  7. package/dist/types/commit/agentic/agent.d.ts +1 -1
  8. package/dist/types/config/model-resolver.d.ts +11 -2
  9. package/dist/types/config/settings-schema.d.ts +12 -6
  10. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  12. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  13. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  14. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  16. package/dist/types/modes/interactive-mode.d.ts +2 -1
  17. package/dist/types/modes/types.d.ts +2 -1
  18. package/dist/types/registry/agent-registry.d.ts +10 -3
  19. package/dist/types/session/agent-session.d.ts +13 -0
  20. package/dist/types/session/compact-modes.d.ts +60 -0
  21. package/dist/types/session/streaming-output.d.ts +0 -2
  22. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  23. package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
  24. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  25. package/dist/types/tools/index.d.ts +9 -1
  26. package/dist/types/utils/image-loading.d.ts +12 -0
  27. package/dist/types/utils/qrcode.d.ts +48 -0
  28. package/package.json +12 -12
  29. package/src/advisor/index.ts +1 -0
  30. package/src/advisor/transcript-recorder.ts +136 -0
  31. package/src/cli/args.ts +7 -1
  32. package/src/cli/stats-cli.ts +2 -11
  33. package/src/collab/host.ts +29 -17
  34. package/src/collab/protocol.ts +48 -15
  35. package/src/commit/agentic/agent.ts +2 -1
  36. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  37. package/src/commit/changelog/index.ts +1 -1
  38. package/src/commit/map-reduce/map-phase.ts +1 -1
  39. package/src/commit/map-reduce/utils.ts +1 -1
  40. package/src/config/config-file.ts +1 -1
  41. package/src/config/keybindings.ts +2 -2
  42. package/src/config/model-registry.ts +16 -4
  43. package/src/config/model-resolver.ts +193 -35
  44. package/src/config/settings-schema.ts +14 -7
  45. package/src/config/settings.ts +3 -9
  46. package/src/edit/file-snapshot-store.ts +1 -1
  47. package/src/edit/renderer.ts +7 -7
  48. package/src/eval/js/tool-bridge.ts +3 -2
  49. package/src/eval/py/prelude.py +3 -2
  50. package/src/export/html/tool-views.generated.js +28 -28
  51. package/src/extensibility/extensions/types.ts +7 -0
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/docs-index.generated.txt +1 -1
  54. package/src/internal-urls/history-protocol.ts +8 -3
  55. package/src/irc/bus.ts +8 -0
  56. package/src/lsp/index.ts +2 -2
  57. package/src/main.ts +6 -3
  58. package/src/modes/acp/acp-agent.ts +63 -0
  59. package/src/modes/components/agent-hub.ts +97 -920
  60. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  61. package/src/modes/components/chat-transcript-builder.ts +462 -0
  62. package/src/modes/components/diff.ts +12 -35
  63. package/src/modes/components/oauth-selector.ts +31 -2
  64. package/src/modes/controllers/command-controller.ts +12 -2
  65. package/src/modes/controllers/event-controller.ts +1 -1
  66. package/src/modes/controllers/input-controller.ts +8 -1
  67. package/src/modes/controllers/selector-controller.ts +4 -1
  68. package/src/modes/interactive-mode.ts +4 -2
  69. package/src/modes/types.ts +2 -1
  70. package/src/prompts/tools/inspect-image.md +1 -1
  71. package/src/prompts/tools/read.md +1 -1
  72. package/src/registry/agent-registry.ts +13 -4
  73. package/src/sdk.ts +27 -8
  74. package/src/session/agent-session.ts +185 -17
  75. package/src/session/compact-modes.ts +105 -0
  76. package/src/session/session-dump-format.ts +1 -1
  77. package/src/session/session-history-format.ts +1 -1
  78. package/src/session/streaming-output.ts +5 -5
  79. package/src/slash-commands/builtin-registry.ts +45 -15
  80. package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
  81. package/src/task/executor.ts +1 -1
  82. package/src/task/output-manager.ts +5 -0
  83. package/src/thinking.ts +25 -5
  84. package/src/tools/__tests__/json-tree.test.ts +35 -0
  85. package/src/tools/approval.ts +1 -1
  86. package/src/tools/bash.ts +0 -1
  87. package/src/tools/browser.ts +0 -1
  88. package/src/tools/eval.ts +1 -1
  89. package/src/tools/gh.ts +1 -1
  90. package/src/tools/index.ts +10 -1
  91. package/src/tools/inspect-image.ts +72 -9
  92. package/src/tools/irc.ts +1 -1
  93. package/src/tools/json-tree.ts +22 -5
  94. package/src/tools/read.ts +5 -6
  95. package/src/utils/file-mentions.ts +5 -2
  96. package/src/utils/image-loading.ts +58 -0
  97. package/src/utils/qrcode.ts +535 -0
  98. package/src/web/scrapers/firefox-addons.ts +1 -1
  99. package/src/web/scrapers/github.ts +1 -1
  100. package/src/web/scrapers/go-pkg.ts +2 -2
  101. package/src/web/scrapers/metacpan.ts +2 -2
  102. package/src/web/scrapers/nvd.ts +2 -2
  103. package/src/web/scrapers/ollama.ts +1 -1
  104. package/src/web/scrapers/opencorporates.ts +1 -1
  105. package/src/web/scrapers/pub-dev.ts +1 -1
  106. package/src/web/scrapers/repology.ts +1 -1
  107. package/src/web/scrapers/sourcegraph.ts +1 -1
  108. package/src/web/scrapers/terraform.ts +6 -6
  109. package/src/web/scrapers/wikidata.ts +2 -2
  110. package/src/workspace-tree.ts +1 -1
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.9",
4
+ "version": "16.0.11",
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.9",
52
- "@oh-my-pi/omp-stats": "16.0.9",
53
- "@oh-my-pi/pi-agent-core": "16.0.9",
54
- "@oh-my-pi/pi-ai": "16.0.9",
55
- "@oh-my-pi/pi-catalog": "16.0.9",
56
- "@oh-my-pi/pi-mnemopi": "16.0.9",
57
- "@oh-my-pi/pi-natives": "16.0.9",
58
- "@oh-my-pi/pi-tui": "16.0.9",
59
- "@oh-my-pi/pi-utils": "16.0.9",
60
- "@oh-my-pi/pi-wire": "16.0.9",
61
- "@oh-my-pi/snapcompact": "16.0.9",
51
+ "@oh-my-pi/hashline": "16.0.11",
52
+ "@oh-my-pi/omp-stats": "16.0.11",
53
+ "@oh-my-pi/pi-agent-core": "16.0.11",
54
+ "@oh-my-pi/pi-ai": "16.0.11",
55
+ "@oh-my-pi/pi-catalog": "16.0.11",
56
+ "@oh-my-pi/pi-mnemopi": "16.0.11",
57
+ "@oh-my-pi/pi-natives": "16.0.11",
58
+ "@oh-my-pi/pi-tui": "16.0.11",
59
+ "@oh-my-pi/pi-utils": "16.0.11",
60
+ "@oh-my-pi/pi-wire": "16.0.11",
61
+ "@oh-my-pi/snapcompact": "16.0.11",
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",
@@ -1,3 +1,4 @@
1
1
  export * from "./advise-tool";
2
2
  export * from "./runtime";
3
+ export * from "./transcript-recorder";
3
4
  export * from "./watchdog";
@@ -0,0 +1,136 @@
1
+ import * as path from "node:path";
2
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import type { Message, UserMessage } from "@oh-my-pi/pi-ai";
4
+ import { logger } from "@oh-my-pi/pi-utils";
5
+ import { SessionManager } from "../session/session-manager";
6
+
7
+ /**
8
+ * Reserved transcript stem for advisor session files. Chosen so it cannot
9
+ * collide with a task subagent's `<id>.jsonl` (task ids are reserved against
10
+ * this exact stem in {@link AgentOutputManager}).
11
+ */
12
+ export const ADVISOR_TRANSCRIPT_STEM = "__advisor";
13
+ export const ADVISOR_TRANSCRIPT_FILENAME = `${ADVISOR_TRANSCRIPT_STEM}.jsonl`;
14
+
15
+ const JSONL_SUFFIX = ".jsonl";
16
+
17
+ /**
18
+ * Append-only persister for an advisor agent's transcript.
19
+ *
20
+ * The advisor is a passive reviewer with its own model usage, so — like a task
21
+ * subagent — its turns are written to a JSONL inside the owning session's
22
+ * artifacts dir (`<session>/__advisor.jsonl`, `<session>/<SubId>/__advisor.jsonl`
23
+ * for subagent advisors). That single file gives the advisor model proper usage
24
+ * attribution in `omp stats` (the stats parser scans the session dir
25
+ * recursively) and a read-only transcript in the Agent Hub, without making the
26
+ * advisor a registered, messageable peer.
27
+ *
28
+ * The target is derived from the *session file* (`getSessionFile()`), never
29
+ * `getArtifactsDir()` — subagents adopt the parent's artifact manager, so the
30
+ * artifacts dir points at the parent root and every subagent advisor would
31
+ * collide. The file path is resolved synchronously when a message finalizes and
32
+ * captured for the queued write, so a `/new`, resume, or session switch in
33
+ * flight can never misattribute an old advisor turn into the new session's file.
34
+ * On such a switch the previous writer is closed and the new file opened on the
35
+ * next recorded turn. The recorder never truncates: the advisor's in-memory
36
+ * context resets/compacts independently, but every billed turn is appended here.
37
+ */
38
+ export class AdvisorTranscriptRecorder {
39
+ #manager: SessionManager | undefined;
40
+ #file: string | undefined;
41
+ /** Serializes the async open/close against synchronous appends so records land in order. */
42
+ #queue: Promise<void>;
43
+
44
+ /**
45
+ * @param after Optional barrier the queue starts behind — used on the advisor
46
+ * on→off→on toggle so a fresh recorder's first `open` waits for the prior
47
+ * recorder's `close` and the two never hold the same `__advisor.jsonl` at once.
48
+ */
49
+ constructor(
50
+ private readonly resolveSessionFile: () => string | undefined,
51
+ private readonly resolveCwd: () => string,
52
+ after?: Promise<unknown>,
53
+ ) {
54
+ this.#queue = after
55
+ ? after.then(
56
+ () => {},
57
+ () => {},
58
+ )
59
+ : Promise.resolve();
60
+ }
61
+
62
+ /**
63
+ * Persist one finalized advisor message. Assistant turns carry the usage the
64
+ * stats parser reads; tool results round out the Hub transcript; user deltas
65
+ * (the advisor's "session update" prompts) are persisted but flagged
66
+ * `synthetic`/agent-attributed so they never inflate user-message metrics.
67
+ * Non-conversational message kinds are skipped.
68
+ */
69
+ record(message: AgentMessage): void {
70
+ let persisted: Message;
71
+ switch (message.role) {
72
+ case "assistant":
73
+ case "toolResult":
74
+ persisted = message;
75
+ break;
76
+ case "user":
77
+ // Clone so the live advisor message stays untouched; mark synthetic so
78
+ // stats' user-message metrics skip these agent-internal review prompts.
79
+ persisted = { ...(message as UserMessage), synthetic: true, attribution: "agent" };
80
+ break;
81
+ default:
82
+ return;
83
+ }
84
+ const sessionFile = this.resolveSessionFile();
85
+ if (!sessionFile?.endsWith(JSONL_SUFFIX)) return;
86
+ const file = path.join(sessionFile.slice(0, -JSONL_SUFFIX.length), ADVISOR_TRANSCRIPT_FILENAME);
87
+ const cwd = this.resolveCwd();
88
+ this.#enqueue(async () => {
89
+ if (file !== this.#file) {
90
+ await this.#closeManager();
91
+ this.#manager = await SessionManager.open(file, undefined, undefined, {
92
+ initialCwd: cwd,
93
+ suppressBreadcrumb: true,
94
+ });
95
+ this.#file = file;
96
+ }
97
+ this.#manager?.appendMessage(persisted);
98
+ });
99
+ }
100
+
101
+ /** Flush pending writes (best-effort). */
102
+ flush(): Promise<void> {
103
+ return this.#enqueueResult(async () => {
104
+ if (this.#manager) await this.#manager.flush();
105
+ });
106
+ }
107
+
108
+ /** Flush and close the writer, releasing the session file. */
109
+ close(): Promise<void> {
110
+ return this.#enqueueResult(() => this.#closeManager());
111
+ }
112
+
113
+ async #closeManager(): Promise<void> {
114
+ const manager = this.#manager;
115
+ this.#manager = undefined;
116
+ this.#file = undefined;
117
+ if (!manager) return;
118
+ try {
119
+ await manager.close();
120
+ } catch (err) {
121
+ logger.debug("advisor transcript close failed", { err: String(err) });
122
+ }
123
+ }
124
+
125
+ #enqueue(work: () => Promise<void>): void {
126
+ this.#queue = this.#queue.then(work, work).catch(err => {
127
+ logger.debug("advisor transcript record failed", { err: String(err) });
128
+ });
129
+ }
130
+
131
+ #enqueueResult(work: () => Promise<void>): Promise<void> {
132
+ const next = this.#queue.then(work, work);
133
+ this.#queue = next.catch(() => {});
134
+ return next;
135
+ }
136
+ }
package/src/cli/args.ts CHANGED
@@ -214,7 +214,13 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
214
214
  } else if (arg === "--auto-approve" || arg === "--yolo") {
215
215
  result.autoApprove = true;
216
216
  } else if (arg.startsWith("@")) {
217
- 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);
218
224
  } else if (!arg.startsWith("-") || arg === "-") {
219
225
  // Plain positional or lone `-` (stdin marker) — pass through as a
220
226
  // message rather than flagging it.
@@ -4,6 +4,7 @@
4
4
  * Handles `omp stats` subcommand for viewing AI usage statistics.
5
5
  */
6
6
 
7
+ import { truncateToWidth } from "@oh-my-pi/pi-tui/utils";
7
8
  import { APP_NAME, formatDuration, formatNumber, formatPercent } from "@oh-my-pi/pi-utils";
8
9
  import chalk from "chalk";
9
10
  import { openPath } from "../utils/open";
@@ -32,7 +33,7 @@ function createSyncProgressReporter(): {
32
33
  const counter = chalk.cyan(`[${event.current}/${event.total}]`);
33
34
  const line = `${counter} ${pct}% ${label}`;
34
35
  const columns = stream.columns ?? 120;
35
- const trimmed = truncateToColumns(line, columns - 1);
36
+ const trimmed = truncateToWidth(line, columns - 1);
36
37
  stream.write(`\r${trimmed.padEnd(lastWidth)}`);
37
38
  lastWidth = trimmed.length;
38
39
  },
@@ -50,16 +51,6 @@ function shortenSessionFile(p: string): string {
50
51
  return idx >= 0 ? p.slice(idx + marker.length) : p;
51
52
  }
52
53
 
53
- function truncateToColumns(s: string, max: number): string {
54
- if (max <= 0) return "";
55
- const width = Bun.stringWidth(s, { countAnsiEscapeCodes: false });
56
- if (width <= max) return s;
57
- // Cheap right-trim with an ellipsis - we don't need ANSI-aware slicing
58
- // because the colored prefix is short and the truncated tail is the
59
- // dim filename, where dropping bytes is fine.
60
- return `${s.slice(0, Math.max(0, max - 1))}\u2026`;
61
- }
62
-
63
54
  // =============================================================================
64
55
  // Types
65
56
  // =============================================================================
@@ -17,7 +17,7 @@ import { logger } from "@oh-my-pi/pi-utils";
17
17
  import type { BusChannel, AgentEvent as WireAgentEvent, SessionEntry as WireSessionEntry } from "@oh-my-pi/pi-wire";
18
18
  import type { InteractiveModeContext } from "../modes/types";
19
19
  import { AgentLifecycleManager } from "../registry/agent-lifecycle";
20
- import { AgentRegistry } from "../registry/agent-registry";
20
+ import { type AgentRef, AgentRegistry } from "../registry/agent-registry";
21
21
  import type { AgentSessionEvent } from "../session/agent-session";
22
22
  import { stripImagesFromMessage, USER_INTERRUPT_LABEL } from "../session/messages";
23
23
  import type { SessionEntry as StoredSessionEntry } from "../session/session-entries";
@@ -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);
@@ -445,18 +445,24 @@ export class CollabHost {
445
445
  }
446
446
 
447
447
  #snapshotAgents(): AgentSnapshot[] {
448
- return AgentRegistry.global()
449
- .list()
450
- .map(ref => ({
451
- id: ref.id,
452
- displayName: ref.displayName,
453
- kind: ref.kind,
454
- parentId: ref.parentId,
455
- status: ref.status,
456
- hasSessionFile: !!ref.sessionFile,
457
- createdAt: ref.createdAt,
458
- lastActivity: ref.lastActivity,
459
- }));
448
+ return (
449
+ AgentRegistry.global()
450
+ .list()
451
+ // Advisor transcripts are local observability only; never mirror them to
452
+ // guests (the wire AgentSnapshot kind has no `advisor`, and guests must not
453
+ // be able to chat/kill/revive them).
454
+ .filter((ref): ref is AgentRef & { kind: "main" | "sub" } => ref.kind !== "advisor")
455
+ .map(ref => ({
456
+ id: ref.id,
457
+ displayName: ref.displayName,
458
+ kind: ref.kind,
459
+ parentId: ref.parentId,
460
+ status: ref.status,
461
+ hasSessionFile: !!ref.sessionFile,
462
+ createdAt: ref.createdAt,
463
+ lastActivity: ref.lastActivity,
464
+ }))
465
+ );
460
466
  }
461
467
 
462
468
  #scheduleAgentsBroadcast(): void {
@@ -472,6 +478,12 @@ export class CollabHost {
472
478
  this.#rejectReadOnly("agent control", fromPeer);
473
479
  return;
474
480
  }
481
+ // Advisor refs are excluded from snapshots, but reject control by id defensively:
482
+ // a stale/malicious client must never chat/kill/revive a read-only advisor transcript.
483
+ if (AgentRegistry.global().get(agentId)?.kind === "advisor") {
484
+ this.#socket?.send({ t: "error", message: `agent ${agentId}: advisor transcripts are read-only` }, fromPeer);
485
+ return;
486
+ }
475
487
  const fail = (err: unknown) => {
476
488
  logger.warn("collab agent-cmd failed", { cmd, agentId, error: String(err) });
477
489
  this.#socket?.send({ t: "error", message: `agent ${agentId}: ${String(err)}` }, fromPeer);
@@ -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]!;
@@ -1,7 +1,8 @@
1
- import { INTENT_FIELD, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
1
+ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Api, Model } from "@oh-my-pi/pi-ai";
3
3
  import { Markdown } from "@oh-my-pi/pi-tui";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
5
6
  import chalk from "chalk";
6
7
  import typesDescriptionPrompt from "../../commit/prompts/types-description.md" with { type: "text" };
7
8
  import type { ModelRegistry } from "../../config/model-registry";
@@ -87,7 +87,7 @@ function truncateDiffContent(diff: string): { content: string; truncated: boolea
87
87
  const truncatedCount = lines.length - KEEP_HEAD_LINES - KEEP_TAIL_LINES;
88
88
 
89
89
  return {
90
- content: [...head, `\n... (truncated ${truncatedCount} lines) ...\n`, ...tail].join("\n"),
90
+ content: [...head, `\n[…${truncatedCount}ln elided…]\n`, ...tail].join("\n"),
91
91
  truncated: true,
92
92
  };
93
93
  }
@@ -117,7 +117,7 @@ function processDiffs(files: string[], diffs: Map<string, string>): { result: st
117
117
  }
118
118
  content = truncated;
119
119
  if (content.length > remaining) {
120
- content = `${content.slice(0, remaining)}\n... (diff truncated due to size) ...`;
120
+ content = `${content.slice(0, remaining)}\n[…${content.length - remaining}ch elided…]`;
121
121
  if (!truncatedFiles.includes(file)) {
122
122
  truncatedFiles.push(file);
123
123
  }
@@ -138,7 +138,7 @@ export async function applyChangelogProposals({
138
138
 
139
139
  function truncateDiff(diff: string, maxChars: number): string {
140
140
  if (diff.length <= maxChars) return diff;
141
- return `${diff.slice(0, maxChars)}\n... (truncated)`;
141
+ return `${diff.slice(0, maxChars)}\n[…${diff.length - maxChars}ch elided…]`;
142
142
  }
143
143
 
144
144
  function formatExistingEntries(entries: Record<string, string[]>): string {
@@ -126,7 +126,7 @@ function generateContextHeader(files: FileDiff[], currentFile: string): string {
126
126
  }
127
127
 
128
128
  if (toShow.length < sorted.length) {
129
- lines.push(`... and ${sorted.length - toShow.length} more files`);
129
+ lines.push(`[…${sorted.length - toShow.length} files elided…]`);
130
130
  }
131
131
 
132
132
  return lines.join("\n");
@@ -5,5 +5,5 @@ export function estimateTokens(text: string): number {
5
5
  export function truncateToTokenLimit(text: string, maxTokens: number): string {
6
6
  const maxChars = maxTokens * 4;
7
7
  if (text.length <= maxChars) return text;
8
- return `${text.slice(0, maxChars)}\n... (truncated)`;
8
+ return `${text.slice(0, maxChars)}\n[…${text.length - maxChars}ch elided…]`;
9
9
  }
@@ -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()) {