@oh-my-pi/pi-coding-agent 15.11.7 → 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.
- package/CHANGELOG.md +63 -1
- package/dist/cli.js +8106 -7708
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/collab/crypto.d.ts +7 -0
- package/dist/types/collab/guest.d.ts +23 -0
- package/dist/types/collab/host.d.ts +29 -0
- package/dist/types/collab/protocol.d.ts +113 -0
- package/dist/types/collab/relay-client.d.ts +22 -0
- package/dist/types/commands/join.d.ts +12 -0
- package/dist/types/config/settings-schema.d.ts +60 -5
- 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/extensibility/slash-commands.d.ts +1 -11
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/agent-hub.d.ts +32 -1
- package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
- package/dist/types/modes/components/hook-selector.d.ts +4 -6
- package/dist/types/modes/components/segment-track.d.ts +11 -6
- package/dist/types/modes/components/status-line/component.d.ts +10 -2
- package/dist/types/modes/components/status-line/types.d.ts +11 -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 +16 -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 +20 -0
- package/dist/types/session/agent-session.d.ts +13 -0
- package/dist/types/session/codex-auto-reset.d.ts +8 -4
- package/dist/types/session/session-manager.d.ts +21 -0
- package/dist/types/session/snapcompact-inline.d.ts +6 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/task/types.d.ts +9 -0
- package/package.json +14 -13
- package/scripts/bench-guard.ts +71 -0
- package/scripts/build-binary.ts +4 -0
- package/scripts/bundle-dist.ts +4 -0
- package/scripts/generate-share-viewer.ts +34 -0
- package/src/cli/args.ts +2 -0
- package/src/cli-commands.ts +1 -0
- package/src/collab/crypto.ts +63 -0
- package/src/collab/guest.ts +450 -0
- package/src/collab/host.ts +556 -0
- package/src/collab/protocol.ts +232 -0
- package/src/collab/relay-client.ts +216 -0
- package/src/commands/join.ts +39 -0
- package/src/config/model-registry.ts +22 -14
- package/src/config/settings-schema.ts +67 -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/extensibility/slash-commands.ts +1 -97
- package/src/internal-urls/docs-index.generated.ts +74 -73
- package/src/main.ts +33 -11
- package/src/modes/components/agent-hub.ts +659 -431
- package/src/modes/components/assistant-message.ts +126 -6
- package/src/modes/components/collab-prompt-message.ts +30 -0
- package/src/modes/components/hook-selector.ts +4 -5
- package/src/modes/components/segment-track.ts +44 -7
- package/src/modes/components/status-line/component.ts +59 -6
- package/src/modes/components/status-line/presets.ts +1 -1
- package/src/modes/components/status-line/segments.ts +18 -1
- package/src/modes/components/status-line/types.ts +12 -0
- package/src/modes/components/tips.txt +4 -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 +175 -9
- package/src/modes/controllers/selector-controller.ts +13 -15
- package/src/modes/controllers/session-focus-controller.ts +112 -0
- package/src/modes/controllers/streaming-reveal.ts +7 -0
- package/src/modes/interactive-mode.ts +56 -6
- package/src/modes/session-observer-registry.ts +11 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/types.ts +20 -0
- package/src/modes/utils/ui-helpers.ts +23 -13
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk.ts +239 -36
- package/src/session/agent-session.ts +82 -7
- package/src/session/codex-auto-reset.ts +23 -11
- package/src/session/session-manager.ts +44 -0
- package/src/session/snapcompact-inline.ts +9 -3
- package/src/slash-commands/builtin-registry.ts +261 -24
- 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/job.ts +6 -9
- package/src/tools/read.ts +38 -5
- package/src/tools/write.ts +13 -42
- 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
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collab live-session wire protocol.
|
|
3
|
+
*
|
|
4
|
+
* Hub topology: the host is authoritative, guests never peer. All session
|
|
5
|
+
* payloads (`CollabFrame`) travel AES-256-GCM sealed; the relay only sees the
|
|
6
|
+
* plaintext envelope (`[4B uint32 BE peerId][sealed payload]`) plus TEXT JSON
|
|
7
|
+
* control messages that carry no session data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
|
|
11
|
+
import type {
|
|
12
|
+
BusChannel,
|
|
13
|
+
GuestFrame,
|
|
14
|
+
ParsedCollabLink,
|
|
15
|
+
Participant,
|
|
16
|
+
SessionState,
|
|
17
|
+
AgentSnapshot as WireAgentSnapshot,
|
|
18
|
+
} 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";
|
|
26
|
+
import type { ContextUsage } from "../extensibility/extensions/types";
|
|
27
|
+
import type { AgentSessionEvent } from "../session/agent-session";
|
|
28
|
+
import type { SessionEntry, SessionHeader } from "../session/session-manager";
|
|
29
|
+
|
|
30
|
+
export type {
|
|
31
|
+
CollabPromptDetails,
|
|
32
|
+
ParsedCollabLink,
|
|
33
|
+
RelayControlMessage,
|
|
34
|
+
RelayControlToGuest,
|
|
35
|
+
RelayControlToHost,
|
|
36
|
+
} from "@oh-my-pi/pi-wire";
|
|
37
|
+
export { COLLAB_PROMPT_MESSAGE_TYPE, COLLAB_PROTO } from "@oh-my-pi/pi-wire";
|
|
38
|
+
export { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES };
|
|
39
|
+
|
|
40
|
+
export type CollabParticipant = Participant;
|
|
41
|
+
export type AgentSnapshot = WireAgentSnapshot;
|
|
42
|
+
|
|
43
|
+
/** Debounced footer snapshot broadcast by the host. */
|
|
44
|
+
export type CollabSessionState = SessionState & {
|
|
45
|
+
/**
|
|
46
|
+
* Host model (full catalog object). Guests apply it to their replica
|
|
47
|
+
* agent state so model display and context-window math are native.
|
|
48
|
+
*/
|
|
49
|
+
model?: Model;
|
|
50
|
+
/** Host status-line context numbers (guest system prompt/tools differ, so local estimates drift). */
|
|
51
|
+
contextUsage?: ContextUsage;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Encrypted payload frames (inside AES-GCM, JSON). The wire package pins the
|
|
56
|
+
* JSON skeleton (`WireFrame`); host-side frames carry the rich session types
|
|
57
|
+
* that serialize into those shapes.
|
|
58
|
+
*/
|
|
59
|
+
export type CollabFrame =
|
|
60
|
+
// guest -> host (hello/abort/agent-cmd/fetch-transcript are taken verbatim from the wire grammar)
|
|
61
|
+
| Exclude<GuestFrame, { t: "prompt" }>
|
|
62
|
+
| { t: "prompt"; text: string; images?: ImageContent[] }
|
|
63
|
+
// host -> guest
|
|
64
|
+
| {
|
|
65
|
+
t: "welcome";
|
|
66
|
+
proto: number;
|
|
67
|
+
header: SessionHeader;
|
|
68
|
+
entries: SessionEntry[];
|
|
69
|
+
state: CollabSessionState;
|
|
70
|
+
agents: AgentSnapshot[];
|
|
71
|
+
/** True when this peer joined through a read-only (view) link. */
|
|
72
|
+
readOnly?: boolean;
|
|
73
|
+
}
|
|
74
|
+
| { t: "entry"; entry: SessionEntry }
|
|
75
|
+
| { t: "event"; event: AgentSessionEvent }
|
|
76
|
+
| { t: "state"; state: CollabSessionState }
|
|
77
|
+
/** Mirrored EventBus traffic (task subagent lifecycle/progress channels only). */
|
|
78
|
+
| { t: "bus"; channel: BusChannel; data: unknown }
|
|
79
|
+
/** Full agent-registry snapshot (debounced on registry change). */
|
|
80
|
+
| { t: "agents"; agents: AgentSnapshot[] }
|
|
81
|
+
/** Targeted reply to fetch-transcript; `text` is decoded JSONL from `fromByte`, `newSize` the next offset base. */
|
|
82
|
+
| { t: "transcript"; reqId: number; text: string; newSize: number; error?: string }
|
|
83
|
+
| { t: "bye"; reason: string }
|
|
84
|
+
| { t: "error"; message: string };
|
|
85
|
+
|
|
86
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
87
|
+
// Wire envelope: [4B uint32 BE peerId][sealed payload]
|
|
88
|
+
// Host→relay: peerId 0 broadcasts to all guests; peerId N targets guest N.
|
|
89
|
+
// Guest→relay: always 0; the relay rewrites it to the sender's id.
|
|
90
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
91
|
+
|
|
92
|
+
export function packEnvelope(peerId: number, sealed: Uint8Array): Uint8Array {
|
|
93
|
+
const out = new Uint8Array(ENVELOPE_HEADER_LENGTH + sealed.byteLength);
|
|
94
|
+
new DataView(out.buffer).setUint32(0, peerId, false);
|
|
95
|
+
out.set(sealed, ENVELOPE_HEADER_LENGTH);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function unpackEnvelope(data: Uint8Array): { peerId: number; payload: Uint8Array } | null {
|
|
100
|
+
if (data.byteLength < ENVELOPE_HEADER_LENGTH) return null;
|
|
101
|
+
const peerId = new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).getUint32(0, false);
|
|
102
|
+
return { peerId, payload: data.subarray(ENVELOPE_HEADER_LENGTH) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Rewrite the peerId in place without copying the payload. */
|
|
106
|
+
export function rewriteEnvelopePeer(data: Uint8Array, peerId: number): void {
|
|
107
|
+
new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).setUint32(0, peerId, false);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
|
+
// Link format: wss://<host[:port]>/r/<roomId>#<base64url-32-byte-key>
|
|
112
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
|
|
114
|
+
const ROOM_PATH_RE = /^\/r\/([A-Za-z0-9_-]{10,64})$/;
|
|
115
|
+
const BARE_LINK_RE = /^([A-Za-z0-9_-]{10,64})#([A-Za-z0-9_-]+)$/;
|
|
116
|
+
const B64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
117
|
+
const LOCAL_HOSTNAMES: Record<string, true> = { localhost: true, "127.0.0.1": true, "::1": true, "[::1]": true };
|
|
118
|
+
|
|
119
|
+
export function generateRoomId(): string {
|
|
120
|
+
const bytes = new Uint8Array(ROOM_ID_BYTES);
|
|
121
|
+
crypto.getRandomValues(bytes);
|
|
122
|
+
return Buffer.from(bytes).toString("base64url");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Normalize a relay base URL (ws/wss/http/https) into a ws/wss origin, or an error. */
|
|
126
|
+
function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: string } {
|
|
127
|
+
let url: URL;
|
|
128
|
+
try {
|
|
129
|
+
url = new URL(relayUrl);
|
|
130
|
+
} catch {
|
|
131
|
+
return { error: `Invalid relay URL: ${relayUrl}` };
|
|
132
|
+
}
|
|
133
|
+
let scheme: string;
|
|
134
|
+
switch (url.protocol) {
|
|
135
|
+
case "wss:":
|
|
136
|
+
case "https:":
|
|
137
|
+
scheme = "wss:";
|
|
138
|
+
break;
|
|
139
|
+
case "ws:":
|
|
140
|
+
case "http:":
|
|
141
|
+
scheme = "ws:";
|
|
142
|
+
break;
|
|
143
|
+
default:
|
|
144
|
+
return { error: `Unsupported relay URL scheme: ${url.protocol}` };
|
|
145
|
+
}
|
|
146
|
+
if (scheme === "ws:" && !LOCAL_HOSTNAMES[url.hostname]) {
|
|
147
|
+
return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" };
|
|
148
|
+
}
|
|
149
|
+
const port = url.port ? `:${url.port}` : "";
|
|
150
|
+
return { origin: `${scheme}//${url.hostname}${port}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Render the shareable link. Compact forms: the default relay collapses to
|
|
155
|
+
* `<roomId>#<key>`, other wss relays drop the scheme (`host[:port]/r/…`);
|
|
156
|
+
* only localhost ws:// links keep their full URL so parsing cannot
|
|
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.
|
|
162
|
+
*/
|
|
163
|
+
export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Array, writeToken?: Uint8Array): string {
|
|
164
|
+
const normalized = normalizeRelayOrigin(relayUrl);
|
|
165
|
+
if ("error" in normalized) throw new Error(normalized.error);
|
|
166
|
+
const secret = writeToken ? Buffer.concat([key, writeToken]) : Buffer.from(key);
|
|
167
|
+
const keyText = secret.toString("base64url");
|
|
168
|
+
if (normalized.origin === DEFAULT_RELAY_URL) return `${roomId}#${keyText}`;
|
|
169
|
+
const compact = normalized.origin.startsWith("wss://")
|
|
170
|
+
? normalized.origin.slice("wss://".length)
|
|
171
|
+
: normalized.origin;
|
|
172
|
+
return `${compact}/r/${roomId}#${keyText}`;
|
|
173
|
+
}
|
|
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
|
+
|
|
195
|
+
export function parseCollabLink(link: string): ParsedCollabLink | { error: string } {
|
|
196
|
+
let text = link.trim();
|
|
197
|
+
// Bare `<roomId>#<key>` → default relay.
|
|
198
|
+
const bare = BARE_LINK_RE.exec(text);
|
|
199
|
+
if (bare) text = `${DEFAULT_RELAY_URL}/r/${bare[1]}#${bare[2]}`;
|
|
200
|
+
// Scheme-less `host[:port]/r/…` → wss.
|
|
201
|
+
else if (!text.includes("://")) text = `wss://${text}`;
|
|
202
|
+
let url: URL;
|
|
203
|
+
try {
|
|
204
|
+
url = new URL(text);
|
|
205
|
+
} catch {
|
|
206
|
+
return { error: `Invalid collab link: ${link}` };
|
|
207
|
+
}
|
|
208
|
+
const normalized = normalizeRelayOrigin(url.origin);
|
|
209
|
+
if ("error" in normalized) return normalized;
|
|
210
|
+
const match = ROOM_PATH_RE.exec(url.pathname);
|
|
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);
|
|
218
|
+
return { error: "Collab link must contain a /r/<roomId> path" };
|
|
219
|
+
}
|
|
220
|
+
const roomId = match[1]!;
|
|
221
|
+
const fragment = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
|
|
222
|
+
if (!fragment) {
|
|
223
|
+
return { error: "Collab link is missing the #<key> fragment" };
|
|
224
|
+
}
|
|
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" };
|
|
228
|
+
}
|
|
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 };
|
|
232
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side WebSocket wrapper for collab live-session sharing.
|
|
3
|
+
*
|
|
4
|
+
* Connects to a relay room, seals/opens AES-GCM frames, and reconnects with
|
|
5
|
+
* exponential backoff on transient drops. Fatal relay close codes (room gone,
|
|
6
|
+
* host conflict, room full) and decryption failures never reconnect.
|
|
7
|
+
*/
|
|
8
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
9
|
+
import { open, seal } from "./crypto";
|
|
10
|
+
import type { CollabFrame, RelayControlMessage } from "./protocol";
|
|
11
|
+
import { packEnvelope, unpackEnvelope } from "./protocol";
|
|
12
|
+
|
|
13
|
+
const FATAL_CLOSE_REASONS: Record<number, string> = {
|
|
14
|
+
4001: "room closed",
|
|
15
|
+
4004: "no such room",
|
|
16
|
+
4009: "a host is already connected for this room",
|
|
17
|
+
4029: "room is full",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const BACKOFF_BASE_MS = 1_000;
|
|
21
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
22
|
+
/** Max enveloped frames buffered while a reconnect is pending; overflow is dropped. */
|
|
23
|
+
const MAX_PENDING_SENDS = 256;
|
|
24
|
+
|
|
25
|
+
export interface CollabSocketOptions {
|
|
26
|
+
/** wss://host[:port]/r/<roomId> — no query string. */
|
|
27
|
+
wsUrl: string;
|
|
28
|
+
role: "host" | "guest";
|
|
29
|
+
key: CryptoKey;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class CollabSocket {
|
|
33
|
+
/** Fires after every successful (re)connect. */
|
|
34
|
+
onOpen?: () => void;
|
|
35
|
+
onFrame?: (frame: CollabFrame, fromPeer: number) => void;
|
|
36
|
+
onControl?: (msg: RelayControlMessage) => void;
|
|
37
|
+
/** Fires once per terminal close (intentional, fatal code, or bad key). willReconnect=true for transient drops that will retry. */
|
|
38
|
+
onClose?: (reason: string, willReconnect: boolean) => void;
|
|
39
|
+
|
|
40
|
+
readonly #opts: CollabSocketOptions;
|
|
41
|
+
#ws: WebSocket | null = null;
|
|
42
|
+
#retryTimer: NodeJS.Timeout | undefined;
|
|
43
|
+
#attempt = 0;
|
|
44
|
+
/** Terminal state: intentional close or fatal failure. Cleared by connect(). */
|
|
45
|
+
#closed = false;
|
|
46
|
+
/** Serializes seal() so frames hit the wire in send() order. */
|
|
47
|
+
#sendChain: Promise<void> = Promise.resolve();
|
|
48
|
+
/** Serializes open() so frames are delivered in arrival order. */
|
|
49
|
+
#recvChain: Promise<void> = Promise.resolve();
|
|
50
|
+
/** Envelopes sealed while disconnected, flushed on the next open. */
|
|
51
|
+
#pendingSends: Uint8Array[] = [];
|
|
52
|
+
|
|
53
|
+
constructor(opts: CollabSocketOptions) {
|
|
54
|
+
this.#opts = opts;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get isOpen(): boolean {
|
|
58
|
+
return this.#ws?.readyState === WebSocket.OPEN;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
connect(): void {
|
|
62
|
+
if (this.#ws || this.#retryTimer) return;
|
|
63
|
+
this.#closed = false;
|
|
64
|
+
this.#attempt = 0;
|
|
65
|
+
this.#openSocket();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
send(frame: CollabFrame, targetPeer = 0): void {
|
|
69
|
+
this.#sendChain = this.#sendChain
|
|
70
|
+
.then(async () => {
|
|
71
|
+
if (this.#closed) {
|
|
72
|
+
logger.debug("collab: dropping frame, socket closed", { t: frame.t });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const sealed = await seal(this.#opts.key, frame);
|
|
76
|
+
const envelope = packEnvelope(targetPeer, sealed);
|
|
77
|
+
const ws = this.#ws;
|
|
78
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
79
|
+
ws.send(envelope);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.#pendingSends.length >= MAX_PENDING_SENDS) {
|
|
83
|
+
logger.debug("collab: dropping frame, reconnect buffer full", { t: frame.t });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.#pendingSends.push(envelope);
|
|
87
|
+
})
|
|
88
|
+
.catch((err: unknown) => {
|
|
89
|
+
logger.debug("collab: send failed", { error: String(err) });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Intentional close: clears any retry timer, suppresses reconnect. A later connect() starts fresh. */
|
|
94
|
+
close(): void {
|
|
95
|
+
const hadActivity = this.#ws !== null || this.#retryTimer !== undefined;
|
|
96
|
+
this.#clearRetry();
|
|
97
|
+
const wasClosed = this.#closed;
|
|
98
|
+
this.#closed = true;
|
|
99
|
+
this.#pendingSends.length = 0;
|
|
100
|
+
const ws = this.#ws;
|
|
101
|
+
this.#ws = null;
|
|
102
|
+
if (ws) {
|
|
103
|
+
try {
|
|
104
|
+
ws.close(1000);
|
|
105
|
+
} catch {
|
|
106
|
+
// already closing/closed
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (hadActivity && !wasClosed) this.onClose?.("closed", false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#openSocket(): void {
|
|
113
|
+
const ws = new WebSocket(`${this.#opts.wsUrl}?role=${this.#opts.role}`);
|
|
114
|
+
ws.binaryType = "arraybuffer";
|
|
115
|
+
this.#ws = ws;
|
|
116
|
+
ws.onopen = () => {
|
|
117
|
+
if (this.#ws !== ws) return;
|
|
118
|
+
this.#attempt = 0;
|
|
119
|
+
for (const envelope of this.#pendingSends) ws.send(envelope);
|
|
120
|
+
this.#pendingSends.length = 0;
|
|
121
|
+
this.onOpen?.();
|
|
122
|
+
};
|
|
123
|
+
ws.onmessage = (event: MessageEvent) => {
|
|
124
|
+
if (this.#ws !== ws) return;
|
|
125
|
+
this.#handleMessage(ws, event.data);
|
|
126
|
+
};
|
|
127
|
+
ws.onerror = () => {
|
|
128
|
+
// The paired close event carries the actionable state; nothing to do here.
|
|
129
|
+
};
|
|
130
|
+
ws.onclose = (event: CloseEvent) => {
|
|
131
|
+
if (this.#ws !== ws) return;
|
|
132
|
+
this.#ws = null;
|
|
133
|
+
this.#handleClose(event.code, event.reason);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#handleMessage(ws: WebSocket, data: unknown): void {
|
|
138
|
+
if (typeof data === "string") {
|
|
139
|
+
try {
|
|
140
|
+
this.onControl?.(JSON.parse(data) as RelayControlMessage);
|
|
141
|
+
} catch {
|
|
142
|
+
logger.debug("collab: ignoring malformed control message");
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null;
|
|
147
|
+
if (!bytes) return;
|
|
148
|
+
const envelope = unpackEnvelope(bytes);
|
|
149
|
+
if (!envelope) return;
|
|
150
|
+
this.#recvChain = this.#recvChain
|
|
151
|
+
.then(async () => {
|
|
152
|
+
if (this.#ws !== ws) return;
|
|
153
|
+
let frame: CollabFrame;
|
|
154
|
+
try {
|
|
155
|
+
frame = await open(this.#opts.key, envelope.payload);
|
|
156
|
+
} catch {
|
|
157
|
+
this.#failFatal("bad key or corrupted frame");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (this.#ws !== ws) return;
|
|
161
|
+
this.onFrame?.(frame, envelope.peerId);
|
|
162
|
+
})
|
|
163
|
+
.catch((err: unknown) => {
|
|
164
|
+
logger.debug("collab: frame handler failed", { error: String(err) });
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#handleClose(code: number, reason: string): void {
|
|
169
|
+
if (this.#closed) return;
|
|
170
|
+
const fatalReason = FATAL_CLOSE_REASONS[code];
|
|
171
|
+
if (fatalReason !== undefined) {
|
|
172
|
+
this.#closed = true;
|
|
173
|
+
this.#pendingSends.length = 0;
|
|
174
|
+
this.onClose?.(fatalReason, false);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.onClose?.(reason || `connection lost (code ${code})`, true);
|
|
178
|
+
this.#scheduleRetry();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Decryption failure: wrong key or corrupted frame. Never reconnect. */
|
|
182
|
+
#failFatal(reason: string): void {
|
|
183
|
+
if (this.#closed) return;
|
|
184
|
+
this.#closed = true;
|
|
185
|
+
this.#clearRetry();
|
|
186
|
+
this.#pendingSends.length = 0;
|
|
187
|
+
const ws = this.#ws;
|
|
188
|
+
this.#ws = null;
|
|
189
|
+
if (ws) {
|
|
190
|
+
try {
|
|
191
|
+
ws.close(1000);
|
|
192
|
+
} catch {
|
|
193
|
+
// already closing/closed
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.onClose?.(reason, false);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#scheduleRetry(): void {
|
|
200
|
+
const base = Math.min(BACKOFF_BASE_MS * 2 ** this.#attempt, BACKOFF_MAX_MS);
|
|
201
|
+
this.#attempt++;
|
|
202
|
+
const delay = base * (0.75 + Math.random() * 0.5);
|
|
203
|
+
this.#retryTimer = setTimeout(() => {
|
|
204
|
+
this.#retryTimer = undefined;
|
|
205
|
+
if (this.#closed) return;
|
|
206
|
+
this.#openSocket();
|
|
207
|
+
}, delay);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#clearRetry(): void {
|
|
211
|
+
if (this.#retryTimer !== undefined) {
|
|
212
|
+
clearTimeout(this.#retryTimer);
|
|
213
|
+
this.#retryTimer = undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Join a shared collab session from the CLI: launches the interactive TUI and
|
|
3
|
+
* immediately runs `/join <link>`.
|
|
4
|
+
*/
|
|
5
|
+
import { APP_NAME } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { Args, Command } from "@oh-my-pi/pi-utils/cli";
|
|
7
|
+
import { parseArgs } from "../cli/args";
|
|
8
|
+
import { runRootCommand } from "../main";
|
|
9
|
+
|
|
10
|
+
export default class Join extends Command {
|
|
11
|
+
static description = "Join a shared collab session (same as /join)";
|
|
12
|
+
|
|
13
|
+
static args = {
|
|
14
|
+
link: Args.string({
|
|
15
|
+
description: "Collab link shared by the host (/collab)",
|
|
16
|
+
required: true,
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
static examples = [`${APP_NAME} join "relay.example.sh/abc123#key"`];
|
|
21
|
+
|
|
22
|
+
async run(): Promise<void> {
|
|
23
|
+
const { args } = await this.parse(Join);
|
|
24
|
+
const link = args.link?.trim();
|
|
25
|
+
if (!link) {
|
|
26
|
+
process.stderr.write(`Usage: ${APP_NAME} join <link>\n`);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
31
|
+
process.stderr.write(`${APP_NAME} join requires an interactive terminal\n`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const parsed = parseArgs([]);
|
|
36
|
+
parsed.join = link;
|
|
37
|
+
await runRootCommand(parsed, []);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -602,6 +602,7 @@ function getConfiguredProviderOrderFromSettings(): string[] {
|
|
|
602
602
|
export class ModelRegistry {
|
|
603
603
|
#models: Model<Api>[] = [];
|
|
604
604
|
#canonicalIndex: CanonicalModelIndex = { records: [], byId: new Map(), bySelector: new Map() };
|
|
605
|
+
#canonicalIndexDirty: boolean = true;
|
|
605
606
|
#customProviderApiKeys: Map<string, string> = new Map();
|
|
606
607
|
#keylessProviders: Set<string> = new Set();
|
|
607
608
|
#discoverableProviders: DiscoveryProviderConfig[] = [];
|
|
@@ -1519,14 +1520,25 @@ export class ModelRegistry {
|
|
|
1519
1520
|
this.#rebuildPending = true;
|
|
1520
1521
|
return;
|
|
1521
1522
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1523
|
+
// Defer the catalog-wide index build to first read. Boot model
|
|
1524
|
+
// resolution reads it only when enabledModels or a default-role pattern
|
|
1525
|
+
// is configured; the empty interactive launch never reads it pre-paint,
|
|
1526
|
+
// so the ~200ms build over the full catalog moves off the first-paint
|
|
1527
|
+
// critical path.
|
|
1528
|
+
this.#canonicalIndexDirty = true;
|
|
1527
1529
|
this.#rebuildPending = false;
|
|
1528
1530
|
}
|
|
1529
1531
|
|
|
1532
|
+
#ensureCanonicalIndex(): CanonicalModelIndex {
|
|
1533
|
+
if (this.#canonicalIndexDirty) {
|
|
1534
|
+
this.#canonicalIndex = logger.time("buildCanonicalModelIndex", () =>
|
|
1535
|
+
buildCanonicalModelIndex(this.#models, getBundledCanonicalReferenceData(), this.#equivalenceConfig),
|
|
1536
|
+
);
|
|
1537
|
+
this.#canonicalIndexDirty = false;
|
|
1538
|
+
}
|
|
1539
|
+
return this.#canonicalIndex;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1530
1542
|
#suspendRebuild(): void {
|
|
1531
1543
|
this.#rebuildSuspended += 1;
|
|
1532
1544
|
}
|
|
@@ -1537,11 +1549,7 @@ export class ModelRegistry {
|
|
|
1537
1549
|
}
|
|
1538
1550
|
if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
|
|
1539
1551
|
this.#rebuildPending = false;
|
|
1540
|
-
this.#
|
|
1541
|
-
this.#models,
|
|
1542
|
-
getBundledCanonicalReferenceData(),
|
|
1543
|
-
this.#equivalenceConfig,
|
|
1544
|
-
);
|
|
1552
|
+
this.#canonicalIndexDirty = true;
|
|
1545
1553
|
}
|
|
1546
1554
|
}
|
|
1547
1555
|
|
|
@@ -1650,7 +1658,7 @@ export class ModelRegistry {
|
|
|
1650
1658
|
getCanonicalModels(options?: CanonicalModelQueryOptions): CanonicalModelRecord[] {
|
|
1651
1659
|
const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
|
|
1652
1660
|
const records: CanonicalModelRecord[] = [];
|
|
1653
|
-
for (const record of this.#
|
|
1661
|
+
for (const record of this.#ensureCanonicalIndex().records) {
|
|
1654
1662
|
const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
|
|
1655
1663
|
if (variants.length === 0) {
|
|
1656
1664
|
continue;
|
|
@@ -1676,7 +1684,7 @@ export class ModelRegistry {
|
|
|
1676
1684
|
const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
|
|
1677
1685
|
const preferences = this.#variantPreferences(candidates);
|
|
1678
1686
|
const selections: CanonicalModelSelection[] = [];
|
|
1679
|
-
for (const record of this.#
|
|
1687
|
+
for (const record of this.#ensureCanonicalIndex().records) {
|
|
1680
1688
|
const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
|
|
1681
1689
|
if (variants.length === 0) {
|
|
1682
1690
|
continue;
|
|
@@ -1694,7 +1702,7 @@ export class ModelRegistry {
|
|
|
1694
1702
|
}
|
|
1695
1703
|
|
|
1696
1704
|
getCanonicalVariants(canonicalId: string, options?: CanonicalModelQueryOptions): CanonicalModelVariant[] {
|
|
1697
|
-
const record = this.#
|
|
1705
|
+
const record = this.#ensureCanonicalIndex().byId.get(canonicalId.trim().toLowerCase());
|
|
1698
1706
|
if (!record) {
|
|
1699
1707
|
return [];
|
|
1700
1708
|
}
|
|
@@ -1712,7 +1720,7 @@ export class ModelRegistry {
|
|
|
1712
1720
|
}
|
|
1713
1721
|
|
|
1714
1722
|
getCanonicalId(model: Model<Api>): string | undefined {
|
|
1715
|
-
return this.#
|
|
1723
|
+
return this.#ensureCanonicalIndex().bySelector.get(formatCanonicalVariantSelector(model).toLowerCase());
|
|
1716
1724
|
}
|
|
1717
1725
|
|
|
1718
1726
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
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";
|
|
4
|
+
import { DEFAULT_RELAY_URL } from "../collab/protocol";
|
|
3
5
|
import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking";
|
|
4
6
|
import {
|
|
5
7
|
TINY_MODEL_DEVICE_DEFAULT,
|
|
@@ -101,6 +103,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
|
|
|
101
103
|
"Approvals",
|
|
102
104
|
"Notifications",
|
|
103
105
|
"Speech",
|
|
106
|
+
"Collab",
|
|
104
107
|
"Magic Keywords",
|
|
105
108
|
"Startup & Updates",
|
|
106
109
|
"Power (macOS)",
|
|
@@ -147,7 +150,8 @@ export type StatusLineSegmentId =
|
|
|
147
150
|
| "cache_write"
|
|
148
151
|
| "cache_hit"
|
|
149
152
|
| "session_name"
|
|
150
|
-
| "usage"
|
|
153
|
+
| "usage"
|
|
154
|
+
| "collab";
|
|
151
155
|
|
|
152
156
|
/** Submenu choice metadata. */
|
|
153
157
|
export type SubmenuOption<V extends string = string> = {
|
|
@@ -1327,6 +1331,52 @@ export const SETTINGS_SCHEMA = {
|
|
|
1327
1331
|
},
|
|
1328
1332
|
},
|
|
1329
1333
|
|
|
1334
|
+
// Collab
|
|
1335
|
+
"collab.relayUrl": {
|
|
1336
|
+
type: "string",
|
|
1337
|
+
default: DEFAULT_RELAY_URL,
|
|
1338
|
+
ui: {
|
|
1339
|
+
tab: "interaction",
|
|
1340
|
+
group: "Collab",
|
|
1341
|
+
label: "Relay URL",
|
|
1342
|
+
description: "Relay used by /collab (wss://host[:port]; self-host with the omp-collab-relay service)",
|
|
1343
|
+
},
|
|
1344
|
+
},
|
|
1345
|
+
|
|
1346
|
+
"collab.displayName": {
|
|
1347
|
+
type: "string",
|
|
1348
|
+
default: "",
|
|
1349
|
+
ui: {
|
|
1350
|
+
tab: "interaction",
|
|
1351
|
+
group: "Collab",
|
|
1352
|
+
label: "Display Name",
|
|
1353
|
+
description: "Name shown to other collab participants (default: OS username)",
|
|
1354
|
+
},
|
|
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
|
+
|
|
1330
1380
|
// Speech-to-text
|
|
1331
1381
|
"stt.enabled": {
|
|
1332
1382
|
type: "boolean",
|
|
@@ -3725,14 +3775,24 @@ export const SETTINGS_SCHEMA = {
|
|
|
3725
3775
|
},
|
|
3726
3776
|
// Codex saved rate-limit resets (auto-redeem)
|
|
3727
3777
|
"codexResets.autoRedeem": {
|
|
3728
|
-
type: "
|
|
3729
|
-
|
|
3778
|
+
type: "enum",
|
|
3779
|
+
values: ["unset", "yes", "no"] as const,
|
|
3780
|
+
default: "unset" as const,
|
|
3730
3781
|
ui: {
|
|
3731
3782
|
tab: "providers",
|
|
3732
3783
|
group: "Services",
|
|
3733
3784
|
label: "Codex Auto-Redeem Saved Resets",
|
|
3734
3785
|
description:
|
|
3735
|
-
"When a turn is blocked by the Codex weekly limit on the active account and no other account is available,
|
|
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
|
+
],
|
|
3736
3796
|
},
|
|
3737
3797
|
},
|
|
3738
3798
|
"codexResets.minBlockedMinutes": {
|
|
@@ -4144,8 +4204,10 @@ export interface ShellMinimizerSettings {
|
|
|
4144
4204
|
sourceOutlineLevel: "default" | "aggressive";
|
|
4145
4205
|
legacyFilters: boolean | undefined;
|
|
4146
4206
|
}
|
|
4207
|
+
export type CodexAutoRedeemMode = "unset" | "yes" | "no";
|
|
4208
|
+
|
|
4147
4209
|
export interface CodexResetsSettings {
|
|
4148
|
-
autoRedeem:
|
|
4210
|
+
autoRedeem: CodexAutoRedeemMode;
|
|
4149
4211
|
minBlockedMinutes: number;
|
|
4150
4212
|
keepCredits: number;
|
|
4151
4213
|
}
|