@oh-my-pi/pi-coding-agent 15.11.8 → 15.12.0

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 (80) hide show
  1. package/CHANGELOG.md +36 -2
  2. package/dist/cli.js +8083 -7692
  3. package/dist/types/collab/crypto.d.ts +1 -6
  4. package/dist/types/collab/guest.d.ts +2 -0
  5. package/dist/types/collab/host.d.ts +16 -0
  6. package/dist/types/collab/protocol.d.ts +14 -1
  7. package/dist/types/config/settings-schema.d.ts +40 -5
  8. package/dist/types/export/custom-share.d.ts +1 -2
  9. package/dist/types/export/html/index.d.ts +39 -1
  10. package/dist/types/export/share.d.ts +43 -0
  11. package/dist/types/main.d.ts +2 -0
  12. package/dist/types/modes/components/agent-hub.d.ts +19 -1
  13. package/dist/types/modes/components/status-line/component.d.ts +6 -1
  14. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  15. package/dist/types/modes/controllers/event-controller.d.ts +7 -0
  16. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  17. package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
  18. package/dist/types/modes/interactive-mode.d.ts +9 -0
  19. package/dist/types/modes/session-observer-registry.d.ts +7 -0
  20. package/dist/types/modes/theme/theme.d.ts +2 -1
  21. package/dist/types/modes/types.d.ts +12 -0
  22. package/dist/types/session/agent-session.d.ts +2 -0
  23. package/dist/types/session/codex-auto-reset.d.ts +8 -4
  24. package/dist/types/task/executor.d.ts +7 -0
  25. package/dist/types/task/types.d.ts +9 -0
  26. package/package.json +13 -14
  27. package/scripts/build-binary.ts +4 -0
  28. package/scripts/bundle-dist.ts +4 -0
  29. package/scripts/generate-share-viewer.ts +34 -0
  30. package/src/collab/crypto.ts +10 -4
  31. package/src/collab/guest.ts +31 -2
  32. package/src/collab/host.ts +73 -11
  33. package/src/collab/protocol.ts +48 -7
  34. package/src/commands/join.ts +1 -1
  35. package/src/config/settings-schema.ts +40 -4
  36. package/src/config/settings.ts +12 -0
  37. package/src/export/custom-share.ts +1 -1
  38. package/src/export/html/index.ts +122 -17
  39. package/src/export/html/share-loader.js +102 -0
  40. package/src/export/html/template.css +745 -459
  41. package/src/export/html/template.html +6 -3
  42. package/src/export/html/template.js +240 -915
  43. package/src/export/html/tool-views.generated.js +38 -0
  44. package/src/export/share.ts +268 -0
  45. package/src/internal-urls/docs-index.generated.ts +73 -73
  46. package/src/main.ts +22 -9
  47. package/src/modes/components/agent-hub.ts +541 -410
  48. package/src/modes/components/status-line/component.ts +38 -5
  49. package/src/modes/components/status-line/segments.ts +5 -1
  50. package/src/modes/components/status-line/types.ts +2 -0
  51. package/src/modes/components/tips.txt +3 -1
  52. package/src/modes/controllers/command-controller.ts +55 -96
  53. package/src/modes/controllers/event-controller.ts +45 -16
  54. package/src/modes/controllers/input-controller.ts +104 -4
  55. package/src/modes/controllers/selector-controller.ts +11 -15
  56. package/src/modes/controllers/session-focus-controller.ts +112 -0
  57. package/src/modes/interactive-mode.ts +44 -2
  58. package/src/modes/session-observer-registry.ts +11 -0
  59. package/src/modes/theme/theme.ts +6 -0
  60. package/src/modes/types.ts +12 -0
  61. package/src/modes/utils/ui-helpers.ts +16 -13
  62. package/src/prompts/tools/job.md +1 -1
  63. package/src/session/agent-session.ts +65 -7
  64. package/src/session/codex-auto-reset.ts +23 -11
  65. package/src/slash-commands/builtin-registry.ts +62 -35
  66. package/src/task/executor.ts +14 -0
  67. package/src/task/index.ts +5 -1
  68. package/src/task/render.ts +76 -5
  69. package/src/task/types.ts +9 -0
  70. package/src/tiny/worker.ts +17 -95
  71. package/src/tools/job.ts +6 -9
  72. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  73. package/dist/types/export/html/template.generated.d.ts +0 -1
  74. package/dist/types/export/html/template.macro.d.ts +0 -5
  75. package/dist/types/tiny/compiled-runtime.d.ts +0 -35
  76. package/scripts/generate-template.ts +0 -33
  77. package/src/bun-imports.d.ts +0 -28
  78. package/src/export/html/template.generated.ts +0 -2
  79. package/src/export/html/template.macro.ts +0 -25
  80. package/src/tiny/compiled-runtime.ts +0 -179
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": "15.11.8",
4
+ "version": "15.12.0",
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",
@@ -40,25 +40,24 @@
40
40
  "fmt": "biome format --write . && bun run format-prompts",
41
41
  "format-prompts": "bun scripts/format-prompts.ts",
42
42
  "generate-docs-index": "bun scripts/generate-docs-index.ts",
43
- "prepack": "bun scripts/generate-docs-index.ts && bun scripts/bundle-dist.ts",
44
- "generate-template": "bun scripts/generate-template.ts",
43
+ "prepack": "bun scripts/generate-docs-index.ts && bun --cwd=../collab-web run build:tool-views && bun scripts/bundle-dist.ts",
45
44
  "bench:guard": "bun scripts/bench-guard.ts"
46
45
  },
47
46
  "dependencies": {
48
47
  "@agentclientprotocol/sdk": "0.22.1",
49
48
  "@babel/parser": "^7.29.7",
50
49
  "@mozilla/readability": "^0.6.0",
51
- "@oh-my-pi/hashline": "15.11.8",
52
- "@oh-my-pi/omp-stats": "15.11.8",
53
- "@oh-my-pi/pi-agent-core": "15.11.8",
54
- "@oh-my-pi/pi-ai": "15.11.8",
55
- "@oh-my-pi/pi-catalog": "15.11.8",
56
- "@oh-my-pi/pi-mnemopi": "15.11.8",
57
- "@oh-my-pi/pi-natives": "15.11.8",
58
- "@oh-my-pi/pi-tui": "15.11.8",
59
- "@oh-my-pi/pi-utils": "15.11.8",
60
- "@oh-my-pi/pi-wire": "15.11.8",
61
- "@oh-my-pi/snapcompact": "15.11.8",
50
+ "@oh-my-pi/hashline": "15.12.0",
51
+ "@oh-my-pi/omp-stats": "15.12.0",
52
+ "@oh-my-pi/pi-agent-core": "15.12.0",
53
+ "@oh-my-pi/pi-ai": "15.12.0",
54
+ "@oh-my-pi/pi-catalog": "15.12.0",
55
+ "@oh-my-pi/pi-mnemopi": "15.12.0",
56
+ "@oh-my-pi/pi-natives": "15.12.0",
57
+ "@oh-my-pi/pi-tui": "15.12.0",
58
+ "@oh-my-pi/pi-utils": "15.12.0",
59
+ "@oh-my-pi/pi-wire": "15.12.0",
60
+ "@oh-my-pi/snapcompact": "15.12.0",
62
61
  "@opentelemetry/api": "^1.9.1",
63
62
  "@opentelemetry/context-async-hooks": "^2.7.1",
64
63
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -59,6 +59,10 @@ async function main(): Promise<void> {
59
59
  `process.env.PI_TINY_TRANSFORMERS_VERSION=${JSON.stringify(transformersVersion)}`,
60
60
  "--external",
61
61
  "mupdf",
62
+ "--external",
63
+ "fastembed",
64
+ "--external",
65
+ "onnxruntime-node",
62
66
  "--root",
63
67
  ".",
64
68
  "./packages/coding-agent/src/cli.ts",
@@ -72,6 +72,10 @@ async function main(): Promise<void> {
72
72
  "@oh-my-pi/pi-natives",
73
73
  "--external",
74
74
  "@huggingface/transformers",
75
+ "--external",
76
+ "fastembed",
77
+ "--external",
78
+ "onnxruntime-node",
75
79
  "--define",
76
80
  'process.env.PI_BUNDLED="true"',
77
81
  "./src/cli.ts",
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Build the standalone share-viewer page the omp relay serves at `GET /s/<id>`.
4
+ *
5
+ * Same template as HTML exports, but with no embedded session: share-loader.js
6
+ * (injected right after the empty #session-data tag) fetches the sealed blob
7
+ * (gist or relay store), decrypts it with the `#<key>` fragment in-browser, and
8
+ * hands the JSON to template.js via `window.__OMP_SESSION_DATA__`.
9
+ *
10
+ * The relay repo's build script runs this and embeds the output via go:embed.
11
+ */
12
+ import * as path from "node:path";
13
+ import { generateThemeVars, getTemplate } from "../src/export/html";
14
+
15
+ const outPath = process.argv[2];
16
+ if (!outPath) {
17
+ console.error("usage: bun scripts/generate-share-viewer.ts <output.html>");
18
+ process.exit(2);
19
+ }
20
+
21
+ const loaderJs = await Bun.file(new URL("../src/export/html/share-loader.js", import.meta.url).pathname).text();
22
+ // Pin a built-in theme: the viewer is a public artifact, not a per-user export.
23
+ const themeVars = await generateThemeVars("dark");
24
+
25
+ const html = getTemplate()
26
+ .replace("<theme-vars/>", () => `<style>:root { ${themeVars} }</style>`)
27
+ .replace("<title>Session Export</title>", () => "<title>omp session</title>")
28
+ .replace("{{SESSION_DATA}}</script>", () => `</script>\n <script>${loaderJs}</script>`);
29
+
30
+ if (html.includes("{{SESSION_DATA}}")) throw new Error("session-data placeholder survived substitution");
31
+ if (!html.includes("__OMP_SESSION_DATA__")) throw new Error("share loader not injected");
32
+
33
+ await Bun.write(outPath, html);
34
+ console.log(`Generated ${path.resolve(outPath)} (${(html.length / 1024).toFixed(0)} KB)`);
@@ -4,23 +4,29 @@
4
4
  * The room key lives only in the link fragment; the relay sees opaque bytes.
5
5
  * Sealed layout: `[12B IV][ciphertext+tag]`.
6
6
  */
7
+ import { ROOM_KEY_BYTES, WRITE_TOKEN_BYTES } from "@oh-my-pi/pi-wire";
7
8
  import type { CollabFrame } from "./protocol";
8
9
 
9
10
  const AES_ALGORITHM = "AES-GCM";
10
11
  const IV_LENGTH = 12;
11
- const KEY_LENGTH = 32;
12
12
  const TEXT_ENCODER = new TextEncoder();
13
13
  const TEXT_DECODER = new TextDecoder();
14
14
 
15
15
  export function generateRoomKey(): Uint8Array {
16
- const key = new Uint8Array(KEY_LENGTH);
16
+ const key = new Uint8Array(ROOM_KEY_BYTES);
17
17
  crypto.getRandomValues(key);
18
18
  return key;
19
19
  }
20
20
 
21
+ export function generateWriteToken(): Uint8Array {
22
+ const token = new Uint8Array(WRITE_TOKEN_BYTES);
23
+ crypto.getRandomValues(token);
24
+ return token;
25
+ }
26
+
21
27
  export function importRoomKey(raw: Uint8Array): Promise<CryptoKey> {
22
- if (raw.byteLength !== KEY_LENGTH) {
23
- throw new Error(`Room key must be ${KEY_LENGTH} bytes, got ${raw.byteLength}`);
28
+ if (raw.byteLength !== ROOM_KEY_BYTES) {
29
+ throw new Error(`Room key must be ${ROOM_KEY_BYTES} bytes, got ${raw.byteLength}`);
24
30
  }
25
31
  return crypto.subtle.importKey("raw", asStrict(raw), AES_ALGORITHM, false, ["encrypt", "decrypt"]);
26
32
  }
@@ -62,6 +62,10 @@ export class CollabGuestLink {
62
62
  #applyChain: Promise<void> = Promise.resolve();
63
63
  #welcomed = false;
64
64
  #left = false;
65
+ /** base64url write token from a full link; absent when joined via a view link. */
66
+ #writeToken: string | undefined;
67
+ /** True when the host marked this peer read-only (view link). */
68
+ #readOnly = false;
65
69
  /** False until the first assistant message_start (real or synthesized) since (re)sync. */
66
70
  #assistantStreamSynced = false;
67
71
  state: CollabSessionState | null = null;
@@ -73,12 +77,15 @@ export class CollabGuestLink {
73
77
  #nextReqId = 1;
74
78
  readonly #hubRemote: AgentHubRemote = {
75
79
  chat: (id, text) => {
80
+ if (this.#rejectReadOnly()) return;
76
81
  this.#socket?.send({ t: "agent-cmd", cmd: "chat", agentId: id, text });
77
82
  },
78
83
  kill: id => {
84
+ if (this.#rejectReadOnly()) return;
79
85
  this.#socket?.send({ t: "agent-cmd", cmd: "kill", agentId: id });
80
86
  },
81
87
  revive: id => {
88
+ if (this.#rejectReadOnly()) return;
82
89
  this.#socket?.send({ t: "agent-cmd", cmd: "revive", agentId: id });
83
90
  },
84
91
  readTranscript: (id, fromByte) => {
@@ -106,6 +113,18 @@ export class CollabGuestLink {
106
113
  return this.#hubRemote;
107
114
  }
108
115
 
116
+ /** True when this guest joined through a read-only (view) link. */
117
+ get readOnly(): boolean {
118
+ return this.#readOnly;
119
+ }
120
+
121
+ /** Shows the read-only status hint when applicable; true when the action must be dropped. */
122
+ #rejectReadOnly(): boolean {
123
+ if (!this.#readOnly) return false;
124
+ this.#ctx.showStatus("This collab link is read-only");
125
+ return true;
126
+ }
127
+
109
128
  constructor(ctx: InteractiveModeContext) {
110
129
  this.#ctx = ctx;
111
130
  }
@@ -114,6 +133,7 @@ export class CollabGuestLink {
114
133
  const parsed = parseCollabLink(link);
115
134
  if ("error" in parsed) throw new Error(parsed.error);
116
135
  this.#roomId = parsed.roomId;
136
+ this.#writeToken = parsed.writeToken ? Buffer.from(parsed.writeToken).toString("base64url") : undefined;
117
137
  const key = await importRoomKey(parsed.key);
118
138
 
119
139
  this.#returnSessionFile = this.#ctx.sessionManager.getSessionFile() ?? null;
@@ -128,7 +148,12 @@ export class CollabGuestLink {
128
148
  // (Re)connect: re-introduce ourselves; the host answers with a fresh
129
149
  // welcome which (re)syncs the replica.
130
150
  this.#welcomed = false;
131
- socket.send({ t: "hello", proto: COLLAB_PROTO, name: collabDisplayName(this.#ctx) });
151
+ socket.send({
152
+ t: "hello",
153
+ proto: COLLAB_PROTO,
154
+ name: collabDisplayName(this.#ctx),
155
+ writeToken: this.#writeToken,
156
+ });
132
157
  };
133
158
  socket.onFrame = frame => {
134
159
  this.#applyChain = this.#applyChain
@@ -188,10 +213,12 @@ export class CollabGuestLink {
188
213
  }
189
214
 
190
215
  sendPrompt(text: string, images?: ImageContent[]): void {
216
+ if (this.#rejectReadOnly()) return;
191
217
  this.#socket?.send({ t: "prompt", text, images: images && images.length > 0 ? images : undefined });
192
218
  }
193
219
 
194
220
  sendAbort(): void {
221
+ if (this.#rejectReadOnly()) return;
195
222
  this.#socket?.send({ t: "abort" });
196
223
  }
197
224
 
@@ -218,8 +245,10 @@ export class CollabGuestLink {
218
245
  this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
219
246
  await this.#ctx.reloadTodos();
220
247
  this.#updateStatusSegment();
248
+ this.#readOnly = frame.readOnly === true;
221
249
  this.#welcomed = true;
222
- this.#ctx.showStatus(isResync ? "Reconnected to collab session" : "Joined collab session");
250
+ const suffix = this.#readOnly ? " (read-only)" : "";
251
+ this.#ctx.showStatus(isResync ? `Reconnected to collab session${suffix}` : `Joined collab session${suffix}`);
223
252
  }
224
253
 
225
254
  #applyFrame(frame: CollabFrame): void {
@@ -8,6 +8,8 @@
8
8
  * agent-registry snapshots (Agent Hub table), hub chat/kill/revive commands,
9
9
  * and incremental subagent-transcript reads.
10
10
  */
11
+
12
+ import { timingSafeEqual } from "node:crypto";
11
13
  import * as fs from "node:fs/promises";
12
14
  import * as os from "node:os";
13
15
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
@@ -20,7 +22,7 @@ import type { AgentSessionEvent } from "../session/agent-session";
20
22
  import { stripImagesFromMessage, USER_INTERRUPT_LABEL } from "../session/messages";
21
23
  import type { SessionEntry as StoredSessionEntry } from "../session/session-manager";
22
24
  import { TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL } from "../task";
23
- import { generateRoomKey, importRoomKey } from "./crypto";
25
+ import { generateRoomKey, generateWriteToken, importRoomKey } from "./crypto";
24
26
  import {
25
27
  type AgentSnapshot,
26
28
  COLLAB_PROMPT_MESSAGE_TYPE,
@@ -29,6 +31,7 @@ import {
29
31
  type CollabParticipant,
30
32
  type CollabSessionState,
31
33
  formatCollabLink,
34
+ formatCollabWebLink,
32
35
  generateRoomId,
33
36
  parseCollabLink,
34
37
  } from "./protocol";
@@ -106,9 +109,13 @@ export class CollabHost {
106
109
  #ctx: InteractiveModeContext;
107
110
  #socket: CollabSocket | null = null;
108
111
  #link = "";
112
+ #webLink = "";
113
+ #viewLink = "";
114
+ #webViewLink = "";
115
+ #writeToken: Uint8Array | null = null;
109
116
  #sessionId = "";
110
117
  #unsubscribe?: () => void;
111
- #peers = new Map<number, string>();
118
+ #peers = new Map<number, { name: string; canWrite: boolean }>();
112
119
  #lastStateJson = "";
113
120
  #stateDebounce: Timer | null = null;
114
121
  #streamingInterval: Timer | null = null;
@@ -125,16 +132,38 @@ export class CollabHost {
125
132
  return this.#link;
126
133
  }
127
134
 
135
+ /** Browser deep link (`https://<relay>/#<link>`) — the relay serves the web client at `/`. */
136
+ get webLink(): string {
137
+ return this.#webLink;
138
+ }
139
+
140
+ /** Read-only variant of {@link link}: bare room key, no write token. */
141
+ get viewLink(): string {
142
+ return this.#viewLink;
143
+ }
144
+
145
+ /** Read-only variant of {@link webLink}. */
146
+ get webViewLink(): string {
147
+ return this.#webViewLink;
148
+ }
149
+
128
150
  get participants(): CollabParticipant[] {
129
151
  const list: CollabParticipant[] = [{ name: collabDisplayName(this.#ctx), role: "host" }];
130
- for (const name of this.#peers.values()) list.push({ name, role: "guest" });
152
+ for (const peer of this.#peers.values()) {
153
+ list.push({ name: peer.name, role: "guest", readOnly: peer.canWrite ? undefined : true });
154
+ }
131
155
  return list;
132
156
  }
133
157
 
134
158
  async start(relayUrl: string): Promise<void> {
135
159
  const rawKey = generateRoomKey();
160
+ const writeToken = generateWriteToken();
136
161
  const roomId = generateRoomId();
137
- this.#link = formatCollabLink(relayUrl, roomId, rawKey);
162
+ this.#writeToken = writeToken;
163
+ this.#link = formatCollabLink(relayUrl, roomId, rawKey, writeToken);
164
+ this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken);
165
+ this.#viewLink = formatCollabLink(relayUrl, roomId, rawKey);
166
+ this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey);
138
167
  const parsed = parseCollabLink(this.#link);
139
168
  if ("error" in parsed) throw new Error(parsed.error);
140
169
  const key = await importRoomKey(rawKey);
@@ -249,7 +278,7 @@ export class CollabHost {
249
278
  #handleFrame(frame: CollabFrame, fromPeer: number): void {
250
279
  switch (frame.t) {
251
280
  case "hello":
252
- this.#handleHello(frame.name, frame.proto, fromPeer);
281
+ this.#handleHello(frame.name, frame.proto, frame.writeToken, fromPeer);
253
282
  break;
254
283
  case "prompt":
255
284
  this.#handlePrompt(frame.text, frame.images, fromPeer);
@@ -268,7 +297,20 @@ export class CollabHost {
268
297
  }
269
298
  }
270
299
 
271
- #handleHello(name: string, proto: number, fromPeer: number): void {
300
+ /** Timing-safe write-token check; peers without a valid token are read-only. */
301
+ #verifyWriteToken(token: string | undefined): boolean {
302
+ const expected = this.#writeToken;
303
+ if (!expected || !token) return false;
304
+ const bytes = Buffer.from(token, "base64url");
305
+ return bytes.byteLength === expected.byteLength && timingSafeEqual(bytes, expected);
306
+ }
307
+
308
+ /** Reject a mutating frame from a read-only peer with a targeted error. */
309
+ #rejectReadOnly(action: string, fromPeer: number): void {
310
+ this.#socket?.send({ t: "error", message: `${action} is disabled on a read-only link` }, fromPeer);
311
+ }
312
+
313
+ #handleHello(name: string, proto: number, writeToken: string | undefined, fromPeer: number): void {
272
314
  if (proto !== COLLAB_PROTO) {
273
315
  this.#socket?.send(
274
316
  { t: "error", message: `protocol mismatch: host speaks v${COLLAB_PROTO}, guest sent v${proto}` },
@@ -277,7 +319,8 @@ export class CollabHost {
277
319
  return;
278
320
  }
279
321
  const cleanName = name.trim().slice(0, 64) || `guest-${fromPeer}`;
280
- this.#peers.set(fromPeer, cleanName);
322
+ const canWrite = this.#verifyWriteToken(writeToken);
323
+ this.#peers.set(fromPeer, { name: cleanName, canWrite });
281
324
 
282
325
  // Snapshot and send synchronously: no awaits between snapshot and send, so
283
326
  // later entries/events queue behind the welcome on the same socket and the
@@ -299,16 +342,26 @@ export class CollabHost {
299
342
  entries,
300
343
  state: this.#buildState(),
301
344
  agents: this.#snapshotAgents(),
345
+ readOnly: canWrite ? undefined : true,
302
346
  },
303
347
  fromPeer,
304
348
  );
305
- this.#ctx.session.emitNotice("info", `${cleanName} joined the collab session`, "collab");
349
+ this.#ctx.session.emitNotice(
350
+ "info",
351
+ `${cleanName} joined the collab session${canWrite ? "" : " (read-only)"}`,
352
+ "collab",
353
+ );
306
354
  this.#updateStatusSegment();
307
355
  this.#scheduleStateBroadcast();
308
356
  }
309
357
 
310
358
  #handlePrompt(text: string, images: ImageContent[] | undefined, fromPeer: number): void {
311
- const name = this.#peers.get(fromPeer) ?? `guest-${fromPeer}`;
359
+ const peer = this.#peers.get(fromPeer);
360
+ if (!peer?.canWrite) {
361
+ this.#rejectReadOnly("prompting", fromPeer);
362
+ return;
363
+ }
364
+ const name = peer.name;
312
365
  const content: string | (TextContent | ImageContent)[] =
313
366
  images && images.length > 0 ? [{ type: "text", text }, ...images] : text;
314
367
  this.#ctx.session
@@ -329,7 +382,12 @@ export class CollabHost {
329
382
  }
330
383
 
331
384
  #handleAbort(fromPeer: number): void {
332
- const name = this.#peers.get(fromPeer) ?? `guest-${fromPeer}`;
385
+ const peer = this.#peers.get(fromPeer);
386
+ if (!peer?.canWrite) {
387
+ this.#rejectReadOnly("interrupting", fromPeer);
388
+ return;
389
+ }
390
+ const name = peer.name;
333
391
  void this.#ctx.session
334
392
  .abort()
335
393
  .then(() => this.#ctx.session.emitNotice("info", `${name} interrupted`, "collab"))
@@ -337,7 +395,7 @@ export class CollabHost {
337
395
  }
338
396
 
339
397
  #handlePeerLeft(peer: number): void {
340
- const name = this.#peers.get(peer);
398
+ const name = this.#peers.get(peer)?.name;
341
399
  this.#peers.delete(peer);
342
400
  if (name) this.#ctx.session.emitNotice("info", `${name} left the collab session`, "collab");
343
401
  this.#updateStatusSegment();
@@ -401,6 +459,10 @@ export class CollabHost {
401
459
  }
402
460
 
403
461
  #handleAgentCmd(cmd: "chat" | "kill" | "revive", agentId: string, text: string | undefined, fromPeer: number): void {
462
+ if (!this.#peers.get(fromPeer)?.canWrite) {
463
+ this.#rejectReadOnly("agent control", fromPeer);
464
+ return;
465
+ }
404
466
  const fail = (err: unknown) => {
405
467
  logger.warn("collab agent-cmd failed", { cmd, agentId, error: String(err) });
406
468
  this.#socket?.send({ t: "error", message: `agent ${agentId}: ${String(err)}` }, fromPeer);
@@ -16,7 +16,13 @@ import type {
16
16
  SessionState,
17
17
  AgentSnapshot as WireAgentSnapshot,
18
18
  } from "@oh-my-pi/pi-wire";
19
- import { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES } from "@oh-my-pi/pi-wire";
19
+ import {
20
+ DEFAULT_RELAY_URL,
21
+ ENVELOPE_HEADER_LENGTH,
22
+ ROOM_ID_BYTES,
23
+ ROOM_KEY_BYTES,
24
+ WRITE_TOKEN_BYTES,
25
+ } from "@oh-my-pi/pi-wire";
20
26
  import type { ContextUsage } from "../extensibility/extensions/types";
21
27
  import type { AgentSessionEvent } from "../session/agent-session";
22
28
  import type { SessionEntry, SessionHeader } from "../session/session-manager";
@@ -62,6 +68,8 @@ export type CollabFrame =
62
68
  entries: SessionEntry[];
63
69
  state: CollabSessionState;
64
70
  agents: AgentSnapshot[];
71
+ /** True when this peer joined through a read-only (view) link. */
72
+ readOnly?: boolean;
65
73
  }
66
74
  | { t: "entry"; entry: SessionEntry }
67
75
  | { t: "event"; event: AgentSessionEvent }
@@ -147,11 +155,16 @@ function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: s
147
155
  * `<roomId>#<key>`, other wss relays drop the scheme (`host[:port]/r/…`);
148
156
  * only localhost ws:// links keep their full URL so parsing cannot
149
157
  * mis-infer wss.
158
+ *
159
+ * Full links append the write token to the key in the fragment
160
+ * (`base64url(key ∥ writeToken)`); read-only (view) links carry the bare
161
+ * 32-byte key, which is also the pre-token link format.
150
162
  */
151
- export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Array): string {
163
+ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array): string {
152
164
  const normalized = normalizeRelayOrigin(relayUrl);
153
165
  if ("error" in normalized) throw new Error(normalized.error);
154
- const keyText = Buffer.from(key).toString("base64url");
166
+ const secret = writeToken ? Buffer.concat([key, writeToken]) : Buffer.from(key);
167
+ const keyText = secret.toString("base64url");
155
168
  if (normalized.origin === DEFAULT_RELAY_URL) return `${roomId}#${keyText}`;
156
169
  const compact = normalized.origin.startsWith("wss://")
157
170
  ? normalized.origin.slice("wss://".length)
@@ -159,6 +172,26 @@ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Arr
159
172
  return `${compact}/r/${roomId}#${keyText}`;
160
173
  }
161
174
 
175
+ /**
176
+ * Render the browser deep link: `http(s)://<relay-host>/#<collab-link>`. The
177
+ * relay serves the web client at `/`, and the whole collab link (including the
178
+ * room key) rides in the fragment, so it never appears in any HTTP request.
179
+ * Terminals auto-link the https form, making it click-to-join.
180
+ */
181
+ export function formatCollabWebLink(
182
+ relayUrl: string,
183
+ roomId: string,
184
+ key: Uint8Array,
185
+ writeToken?: Uint8Array,
186
+ ): string {
187
+ const normalized = normalizeRelayOrigin(relayUrl);
188
+ if ("error" in normalized) throw new Error(normalized.error);
189
+ const httpOrigin = normalized.origin.startsWith("wss://")
190
+ ? `https://${normalized.origin.slice("wss://".length)}`
191
+ : `http://${normalized.origin.slice("ws://".length)}`;
192
+ return `${httpOrigin}/#${formatCollabLink(relayUrl, roomId, key, writeToken)}`;
193
+ }
194
+
162
195
  export function parseCollabLink(link: string): ParsedCollabLink | { error: string } {
163
196
  let text = link.trim();
164
197
  // Bare `<roomId>#<key>` → default relay.
@@ -176,6 +209,12 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin
176
209
  if ("error" in normalized) return normalized;
177
210
  const match = ROOM_PATH_RE.exec(url.pathname);
178
211
  if (!match) {
212
+ // Web deep link: `http(s)://<relay>/#<collab-link>` — the fragment holds
213
+ // the whole link (which itself contains another `#`). URL.hash spans to
214
+ // the end of the string, so recurse on it; the inner fragment is just
215
+ // the key (no `#`), which bounds the recursion.
216
+ const inner = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
217
+ if (inner.includes("#")) return parseCollabLink(inner);
179
218
  return { error: "Collab link must contain a /r/<roomId> path" };
180
219
  }
181
220
  const roomId = match[1]!;
@@ -183,9 +222,11 @@ export function parseCollabLink(link: string): ParsedCollabLink | { error: strin
183
222
  if (!fragment) {
184
223
  return { error: "Collab link is missing the #<key> fragment" };
185
224
  }
186
- const key = B64URL_RE.test(fragment) ? new Uint8Array(Buffer.from(fragment, "base64url")) : null;
187
- if (key?.byteLength !== 32) {
188
- return { error: "Collab link key must be 32 base64url bytes" };
225
+ const secret = B64URL_RE.test(fragment) ? new Uint8Array(Buffer.from(fragment, "base64url")) : null;
226
+ if (!secret || (secret.byteLength !== ROOM_KEY_BYTES && secret.byteLength !== ROOM_KEY_BYTES + WRITE_TOKEN_BYTES)) {
227
+ return { error: "Collab link key must be 32 (view) or 48 (full) base64url bytes" };
189
228
  }
190
- return { wsUrl: `${normalized.origin}/r/${roomId}`, roomId, key };
229
+ const key = secret.subarray(0, ROOM_KEY_BYTES);
230
+ const writeToken = secret.byteLength > ROOM_KEY_BYTES ? secret.subarray(ROOM_KEY_BYTES) : undefined;
231
+ return { wsUrl: `${normalized.origin}/r/${roomId}`, roomId, key, writeToken };
191
232
  }
@@ -17,7 +17,7 @@ export default class Join extends Command {
17
17
  }),
18
18
  };
19
19
 
20
- static examples = [`${APP_NAME} join wss://relay.omp.sh/s/abc123#key`];
20
+ static examples = [`${APP_NAME} join "relay.example.sh/abc123#key"`];
21
21
 
22
22
  async run(): Promise<void> {
23
23
  const { args } = await this.parse(Join);
@@ -1,4 +1,5 @@
1
1
  import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
2
+ import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire";
2
3
  import { SHAPE_VARIANT_NAMES } from "@oh-my-pi/snapcompact";
3
4
  import { DEFAULT_RELAY_URL } from "../collab/protocol";
4
5
  import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking";
@@ -1353,6 +1354,29 @@ export const SETTINGS_SCHEMA = {
1353
1354
  },
1354
1355
  },
1355
1356
 
1357
+ "share.serverUrl": {
1358
+ type: "string",
1359
+ default: DEFAULT_SHARE_URL,
1360
+ ui: {
1361
+ tab: "interaction",
1362
+ group: "Collab",
1363
+ label: "Share Server",
1364
+ description:
1365
+ "Share viewer/upload base used by /share (encrypted blob upload + viewer; links are <base>/<id>#<key>)",
1366
+ },
1367
+ },
1368
+
1369
+ "share.redactSecrets": {
1370
+ type: "boolean",
1371
+ default: true,
1372
+ ui: {
1373
+ tab: "interaction",
1374
+ group: "Collab",
1375
+ label: "Share Secret Redaction",
1376
+ description: "Run the secret obfuscator over /share snapshots before upload (uses the secrets.* config)",
1377
+ },
1378
+ },
1379
+
1356
1380
  // Speech-to-text
1357
1381
  "stt.enabled": {
1358
1382
  type: "boolean",
@@ -3751,14 +3775,24 @@ export const SETTINGS_SCHEMA = {
3751
3775
  },
3752
3776
  // Codex saved rate-limit resets (auto-redeem)
3753
3777
  "codexResets.autoRedeem": {
3754
- type: "boolean",
3755
- default: false,
3778
+ type: "enum",
3779
+ values: ["unset", "yes", "no"] as const,
3780
+ default: "unset" as const,
3756
3781
  ui: {
3757
3782
  tab: "providers",
3758
3783
  group: "Services",
3759
3784
  label: "Codex Auto-Redeem Saved Resets",
3760
3785
  description:
3761
- "When a turn is blocked by the Codex weekly limit on the active account and no other account is available, automatically spend one saved rate-limit reset (ChatGPT 'save rate limit resets'). Conservative: never fires for 5-hour-only or Spark limits, near a natural reset, or twice for the same block. Requires retries enabled.",
3786
+ "When a turn is blocked by the Codex weekly limit on the active account and no other account is available, run the conservative saved-reset check. unset asks before spending the first eligible reset, yes spends eligible resets without prompting, and no disables the check entirely. Requires retries enabled.",
3787
+ options: [
3788
+ {
3789
+ value: "unset",
3790
+ label: "Unset",
3791
+ description: "Check eligibility, then ask before spending the first saved reset.",
3792
+ },
3793
+ { value: "yes", label: "Yes", description: "Spend eligible saved resets without prompting." },
3794
+ { value: "no", label: "No", description: "Do not run the saved-reset auto-redeem check." },
3795
+ ],
3762
3796
  },
3763
3797
  },
3764
3798
  "codexResets.minBlockedMinutes": {
@@ -4170,8 +4204,10 @@ export interface ShellMinimizerSettings {
4170
4204
  sourceOutlineLevel: "default" | "aggressive";
4171
4205
  legacyFilters: boolean | undefined;
4172
4206
  }
4207
+ export type CodexAutoRedeemMode = "unset" | "yes" | "no";
4208
+
4173
4209
  export interface CodexResetsSettings {
4174
- autoRedeem: boolean;
4210
+ autoRedeem: CodexAutoRedeemMode;
4175
4211
  minBlockedMinutes: number;
4176
4212
  keepCredits: number;
4177
4213
  }
@@ -834,6 +834,18 @@ export class Settings {
834
834
  }
835
835
  delete raw["providers.parallelFetch"];
836
836
 
837
+ // codexResets.autoRedeem: boolean -> tri-state enum.
838
+ // Existing explicit false keeps the old "do not run" behavior; missing
839
+ // config now falls through to the new "unset" default, which asks before
840
+ // the first eligible spend.
841
+ const codexResetsObj = raw.codexResets as Record<string, unknown> | undefined;
842
+ if (codexResetsObj && typeof codexResetsObj.autoRedeem === "boolean") {
843
+ codexResetsObj.autoRedeem = codexResetsObj.autoRedeem ? "yes" : "no";
844
+ }
845
+ if (typeof raw["codexResets.autoRedeem"] === "boolean") {
846
+ raw["codexResets.autoRedeem"] = raw["codexResets.autoRedeem"] ? "yes" : "no";
847
+ }
848
+
837
849
  // Map legacy `memories.enabled` boolean to the explicit `memory.backend`
838
850
  // enum if the latter hasn't been set yet. Idempotent: subsequent
839
851
  // migrations are no-ops once memory.backend is materialised.
@@ -17,7 +17,7 @@ export interface CustomShareResult {
17
17
 
18
18
  export type CustomShareFn = (htmlPath: string) => Promise<CustomShareResult | string | undefined>;
19
19
 
20
- interface LoadedCustomShare {
20
+ export interface LoadedCustomShare {
21
21
  path: string;
22
22
  fn: CustomShareFn;
23
23
  }