@oh-my-pi/pi-coding-agent 15.11.8 → 15.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -2
- package/dist/cli.js +8095 -7704
- package/dist/types/collab/crypto.d.ts +1 -6
- package/dist/types/collab/guest.d.ts +2 -0
- package/dist/types/collab/host.d.ts +16 -0
- package/dist/types/collab/protocol.d.ts +14 -1
- package/dist/types/config/settings-schema.d.ts +52 -6
- package/dist/types/export/custom-share.d.ts +1 -2
- package/dist/types/export/html/index.d.ts +39 -1
- package/dist/types/export/share.d.ts +43 -0
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/agent-hub.d.ts +19 -1
- package/dist/types/modes/components/status-line/component.d.ts +6 -1
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/controllers/event-controller.d.ts +7 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -1
- package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
- package/dist/types/modes/interactive-mode.d.ts +9 -0
- package/dist/types/modes/session-observer-registry.d.ts +7 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +12 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/codex-auto-reset.d.ts +8 -4
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/task/types.d.ts +9 -0
- package/dist/types/tools/tool-result.d.ts +2 -0
- package/package.json +13 -14
- package/scripts/build-binary.ts +4 -0
- package/scripts/bundle-dist.ts +4 -0
- package/scripts/generate-share-viewer.ts +34 -0
- package/src/collab/crypto.ts +10 -4
- package/src/collab/guest.ts +31 -2
- package/src/collab/host.ts +73 -11
- package/src/collab/protocol.ts +48 -7
- package/src/commands/join.ts +1 -1
- package/src/config/settings-schema.ts +54 -5
- package/src/config/settings.ts +12 -0
- package/src/export/custom-share.ts +1 -1
- package/src/export/html/index.ts +122 -17
- package/src/export/html/share-loader.js +102 -0
- package/src/export/html/template.css +745 -459
- package/src/export/html/template.html +6 -3
- package/src/export/html/template.js +240 -915
- package/src/export/html/tool-views.generated.js +38 -0
- package/src/export/share.ts +268 -0
- package/src/internal-urls/docs-index.generated.ts +73 -73
- package/src/lsp/index.ts +11 -0
- package/src/main.ts +22 -9
- package/src/modes/components/agent-hub.ts +541 -410
- package/src/modes/components/status-line/component.ts +38 -5
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/tips.txt +3 -1
- package/src/modes/controllers/command-controller.ts +55 -96
- package/src/modes/controllers/event-controller.ts +45 -16
- package/src/modes/controllers/input-controller.ts +104 -4
- package/src/modes/controllers/selector-controller.ts +11 -15
- package/src/modes/controllers/session-focus-controller.ts +112 -0
- package/src/modes/interactive-mode.ts +44 -2
- package/src/modes/session-observer-registry.ts +11 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/types.ts +12 -0
- package/src/modes/utils/ui-helpers.ts +16 -13
- package/src/prompts/tools/job.md +1 -1
- package/src/session/agent-session.ts +87 -19
- package/src/session/codex-auto-reset.ts +23 -11
- package/src/slash-commands/builtin-registry.ts +62 -35
- package/src/task/executor.ts +14 -0
- package/src/task/index.ts +5 -1
- package/src/task/render.ts +76 -5
- package/src/task/types.ts +9 -0
- package/src/tiny/worker.ts +17 -95
- package/src/tools/ast-grep.ts +3 -1
- package/src/tools/find.ts +3 -1
- package/src/tools/gh.ts +20 -6
- package/src/tools/irc.ts +4 -0
- package/src/tools/job.ts +18 -13
- package/src/tools/memory-recall.ts +2 -0
- package/src/tools/search.ts +3 -1
- package/src/tools/tool-result.ts +8 -0
- package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
- package/dist/types/export/html/template.generated.d.ts +0 -1
- package/dist/types/export/html/template.macro.d.ts +0 -5
- package/dist/types/tiny/compiled-runtime.d.ts +0 -35
- package/scripts/generate-template.ts +0 -33
- package/src/bun-imports.d.ts +0 -28
- package/src/export/html/template.generated.ts +0 -2
- package/src/export/html/template.macro.ts +0 -25
- 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.
|
|
4
|
+
"version": "15.12.1",
|
|
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.
|
|
52
|
-
"@oh-my-pi/omp-stats": "15.
|
|
53
|
-
"@oh-my-pi/pi-agent-core": "15.
|
|
54
|
-
"@oh-my-pi/pi-ai": "15.
|
|
55
|
-
"@oh-my-pi/pi-catalog": "15.
|
|
56
|
-
"@oh-my-pi/pi-mnemopi": "15.
|
|
57
|
-
"@oh-my-pi/pi-natives": "15.
|
|
58
|
-
"@oh-my-pi/pi-tui": "15.
|
|
59
|
-
"@oh-my-pi/pi-utils": "15.
|
|
60
|
-
"@oh-my-pi/pi-wire": "15.
|
|
61
|
-
"@oh-my-pi/snapcompact": "15.
|
|
50
|
+
"@oh-my-pi/hashline": "15.12.1",
|
|
51
|
+
"@oh-my-pi/omp-stats": "15.12.1",
|
|
52
|
+
"@oh-my-pi/pi-agent-core": "15.12.1",
|
|
53
|
+
"@oh-my-pi/pi-ai": "15.12.1",
|
|
54
|
+
"@oh-my-pi/pi-catalog": "15.12.1",
|
|
55
|
+
"@oh-my-pi/pi-mnemopi": "15.12.1",
|
|
56
|
+
"@oh-my-pi/pi-natives": "15.12.1",
|
|
57
|
+
"@oh-my-pi/pi-tui": "15.12.1",
|
|
58
|
+
"@oh-my-pi/pi-utils": "15.12.1",
|
|
59
|
+
"@oh-my-pi/pi-wire": "15.12.1",
|
|
60
|
+
"@oh-my-pi/snapcompact": "15.12.1",
|
|
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",
|
package/scripts/build-binary.ts
CHANGED
|
@@ -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",
|
package/scripts/bundle-dist.ts
CHANGED
|
@@ -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)`);
|
package/src/collab/crypto.ts
CHANGED
|
@@ -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(
|
|
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 !==
|
|
23
|
-
throw new Error(`Room key must be ${
|
|
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
|
}
|
package/src/collab/guest.ts
CHANGED
|
@@ -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({
|
|
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.#
|
|
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 {
|
package/src/collab/host.ts
CHANGED
|
@@ -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
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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(
|
|
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
|
|
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
|
|
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);
|
package/src/collab/protocol.ts
CHANGED
|
@@ -16,7 +16,13 @@ import type {
|
|
|
16
16
|
SessionState,
|
|
17
17
|
AgentSnapshot as WireAgentSnapshot,
|
|
18
18
|
} from "@oh-my-pi/pi-wire";
|
|
19
|
-
import {
|
|
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
|
|
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
|
|
187
|
-
if (
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/join.ts
CHANGED
|
@@ -17,7 +17,7 @@ export default class Join extends Command {
|
|
|
17
17
|
}),
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
static examples = [`${APP_NAME} join
|
|
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";
|
|
@@ -1131,7 +1132,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
1131
1132
|
doubleEscapeAction: {
|
|
1132
1133
|
type: "enum",
|
|
1133
1134
|
values: ["branch", "tree", "none"] as const,
|
|
1134
|
-
default: "
|
|
1135
|
+
default: "branch",
|
|
1135
1136
|
ui: {
|
|
1136
1137
|
tab: "interaction",
|
|
1137
1138
|
group: "Input",
|
|
@@ -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",
|
|
@@ -1597,6 +1621,18 @@ export const SETTINGS_SCHEMA = {
|
|
|
1597
1621
|
},
|
|
1598
1622
|
},
|
|
1599
1623
|
|
|
1624
|
+
"compaction.dropUseless": {
|
|
1625
|
+
type: "boolean",
|
|
1626
|
+
default: true,
|
|
1627
|
+
ui: {
|
|
1628
|
+
tab: "context",
|
|
1629
|
+
group: "Compaction",
|
|
1630
|
+
label: "Elide Uneventful Results",
|
|
1631
|
+
description:
|
|
1632
|
+
"Prune tool results flagged contextually useless (no matches, timed-out waits) once consumed (cache-aware)",
|
|
1633
|
+
},
|
|
1634
|
+
},
|
|
1635
|
+
|
|
1600
1636
|
// Experimental: snapcompact inline imaging (transient, per-request; never persisted)
|
|
1601
1637
|
"snapcompact.systemPrompt": {
|
|
1602
1638
|
type: "enum",
|
|
@@ -3751,14 +3787,24 @@ export const SETTINGS_SCHEMA = {
|
|
|
3751
3787
|
},
|
|
3752
3788
|
// Codex saved rate-limit resets (auto-redeem)
|
|
3753
3789
|
"codexResets.autoRedeem": {
|
|
3754
|
-
type: "
|
|
3755
|
-
|
|
3790
|
+
type: "enum",
|
|
3791
|
+
values: ["unset", "yes", "no"] as const,
|
|
3792
|
+
default: "unset" as const,
|
|
3756
3793
|
ui: {
|
|
3757
3794
|
tab: "providers",
|
|
3758
3795
|
group: "Services",
|
|
3759
3796
|
label: "Codex Auto-Redeem Saved Resets",
|
|
3760
3797
|
description:
|
|
3761
|
-
"When a turn is blocked by the Codex weekly limit on the active account and no other account is available,
|
|
3798
|
+
"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.",
|
|
3799
|
+
options: [
|
|
3800
|
+
{
|
|
3801
|
+
value: "unset",
|
|
3802
|
+
label: "Unset",
|
|
3803
|
+
description: "Check eligibility, then ask before spending the first saved reset.",
|
|
3804
|
+
},
|
|
3805
|
+
{ value: "yes", label: "Yes", description: "Spend eligible saved resets without prompting." },
|
|
3806
|
+
{ value: "no", label: "No", description: "Do not run the saved-reset auto-redeem check." },
|
|
3807
|
+
],
|
|
3762
3808
|
},
|
|
3763
3809
|
},
|
|
3764
3810
|
"codexResets.minBlockedMinutes": {
|
|
@@ -4045,6 +4091,7 @@ export interface CompactionSettings {
|
|
|
4045
4091
|
idleThresholdTokens: number;
|
|
4046
4092
|
idleTimeoutSeconds: number;
|
|
4047
4093
|
supersedeReads: boolean;
|
|
4094
|
+
dropUseless: boolean;
|
|
4048
4095
|
}
|
|
4049
4096
|
|
|
4050
4097
|
export interface ContextPromotionSettings {
|
|
@@ -4170,8 +4217,10 @@ export interface ShellMinimizerSettings {
|
|
|
4170
4217
|
sourceOutlineLevel: "default" | "aggressive";
|
|
4171
4218
|
legacyFilters: boolean | undefined;
|
|
4172
4219
|
}
|
|
4220
|
+
export type CodexAutoRedeemMode = "unset" | "yes" | "no";
|
|
4221
|
+
|
|
4173
4222
|
export interface CodexResetsSettings {
|
|
4174
|
-
autoRedeem:
|
|
4223
|
+
autoRedeem: CodexAutoRedeemMode;
|
|
4175
4224
|
minBlockedMinutes: number;
|
|
4176
4225
|
keepCredits: number;
|
|
4177
4226
|
}
|
package/src/config/settings.ts
CHANGED
|
@@ -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
|
}
|