@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.
- package/CHANGELOG.md +36 -2
- package/dist/cli.js +8083 -7692
- 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 +40 -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/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/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 +40 -4
- 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/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 +65 -7
- 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/job.ts +6 -9
- 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,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session sharing.
|
|
3
|
+
*
|
|
4
|
+
* The session JSON is gzipped and sealed with a fresh AES-256-GCM key
|
|
5
|
+
* (`[12B IV][ciphertext+tag]`, same layout as collab frames), then pushed to
|
|
6
|
+
* one of two stores:
|
|
7
|
+
*
|
|
8
|
+
* 1. A secret GitHub gist (preferred — free, durable, no relay storage)
|
|
9
|
+
* holding base64 of the sealed blob, when an authenticated `gh` exists.
|
|
10
|
+
* 2. The share server (`POST <serverUrl>` → `{"id":"…"}`), capped at 1 MB;
|
|
11
|
+
* oversized sessions are truncated (images first, then long strings,
|
|
12
|
+
* then oldest entries) until the sealed blob fits.
|
|
13
|
+
*
|
|
14
|
+
* Either way the link is `<serverUrl>/<id>#<base64url key>`. The viewer page
|
|
15
|
+
* served there fetches the blob (gist ids are hex; server ids never are),
|
|
16
|
+
* decrypts with the fragment key — which never leaves the browser — and
|
|
17
|
+
* renders the same template as `/export`.
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from "node:fs/promises";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import type { AgentState } from "@oh-my-pi/pi-agent-core";
|
|
23
|
+
import { $which, logger } from "@oh-my-pi/pi-utils";
|
|
24
|
+
import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire";
|
|
25
|
+
import { $ } from "bun";
|
|
26
|
+
import type { SecretObfuscator } from "../secrets/obfuscator";
|
|
27
|
+
import type { SessionManager } from "../session/session-manager";
|
|
28
|
+
import { buildSessionData, type SessionData } from "./html";
|
|
29
|
+
|
|
30
|
+
export { DEFAULT_SHARE_URL };
|
|
31
|
+
|
|
32
|
+
/** Hard cap for blobs accepted by the share server (mirrors relay shareMaxBytes). */
|
|
33
|
+
export const SERVER_MAX_SEALED_BYTES = 1_000_000;
|
|
34
|
+
/** Gist raw fetches cap at 10 MB; keep base64 (×4/3) comfortably under it. */
|
|
35
|
+
const GIST_MAX_SEALED_BYTES = 5_000_000;
|
|
36
|
+
|
|
37
|
+
const IV_LENGTH = 12;
|
|
38
|
+
const SHARE_KEY_BYTES = 32;
|
|
39
|
+
/** The viewer picks the gist file by this suffix. */
|
|
40
|
+
const GIST_FILENAME = "session.ompshare.txt";
|
|
41
|
+
/** Gist ids are hex; the relay never issues pure-hex ids, so the viewer can route on shape. */
|
|
42
|
+
const GIST_ID_RE = /^[0-9a-f]{20,64}$/;
|
|
43
|
+
|
|
44
|
+
/** Progressively harsher per-string caps applied when the sealed blob is over budget. */
|
|
45
|
+
const TEXT_CAPS = [32_768, 8_192, 2_048, 512];
|
|
46
|
+
/** 1×1 transparent GIF; stands in for stripped data-URL images so <img> tags stay valid. */
|
|
47
|
+
const BLANK_IMAGE_DATA_URL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
|
|
48
|
+
const IMAGE_OMITTED_TEXT = "[image omitted from share]";
|
|
49
|
+
|
|
50
|
+
export interface ShareSessionOptions {
|
|
51
|
+
/** Share server/viewer base URL; defaults to {@link DEFAULT_SHARE_URL}. */
|
|
52
|
+
serverUrl?: string;
|
|
53
|
+
/** Agent state for system prompt + tool descriptions in the snapshot. */
|
|
54
|
+
state?: AgentState;
|
|
55
|
+
/**
|
|
56
|
+
* Redacts the snapshot before sealing: deep-walks every string (entries,
|
|
57
|
+
* header, system prompt, tool descriptions) through the obfuscator, so
|
|
58
|
+
* secrets that landed in persisted entries (tool outputs reading .env,
|
|
59
|
+
* etc.) never leave the machine. Pass undefined to skip.
|
|
60
|
+
*/
|
|
61
|
+
obfuscator?: SecretObfuscator;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ShareSessionResult {
|
|
65
|
+
/** Viewer link: `<serverUrl>/<id>#<key>`. */
|
|
66
|
+
url: string;
|
|
67
|
+
method: "gist" | "server";
|
|
68
|
+
/** Underlying gist URL (gist method only). */
|
|
69
|
+
gistUrl?: string;
|
|
70
|
+
/** True when content was trimmed to fit the upload budget. */
|
|
71
|
+
truncated: boolean;
|
|
72
|
+
sealedBytes: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build the snapshot that gets sealed and uploaded, redacted when an obfuscator is provided. */
|
|
76
|
+
export function buildShareSnapshot(sm: SessionManager, options?: ShareSessionOptions): SessionData {
|
|
77
|
+
const data = buildSessionData(sm, options?.state);
|
|
78
|
+
return options?.obfuscator?.hasSecrets() ? options.obfuscator.obfuscateObject(data) : data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Share the session; tries a secret gist first, then the share server. */
|
|
82
|
+
export async function shareSession(sm: SessionManager, options?: ShareSessionOptions): Promise<ShareSessionResult> {
|
|
83
|
+
const data = buildShareSnapshot(sm, options);
|
|
84
|
+
const keyBytes = new Uint8Array(SHARE_KEY_BYTES);
|
|
85
|
+
crypto.getRandomValues(keyBytes);
|
|
86
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]);
|
|
87
|
+
const keyText = Buffer.from(keyBytes).toString("base64url");
|
|
88
|
+
const base = normalizeShareServerUrl(options?.serverUrl);
|
|
89
|
+
|
|
90
|
+
const forGist = await sealToFit(key, data, GIST_MAX_SEALED_BYTES);
|
|
91
|
+
const gist = await tryCreateGist(forGist.sealed);
|
|
92
|
+
if (gist) {
|
|
93
|
+
return {
|
|
94
|
+
url: `${base}/${gist.id}#${keyText}`,
|
|
95
|
+
method: "gist",
|
|
96
|
+
gistUrl: gist.url,
|
|
97
|
+
truncated: forGist.truncated,
|
|
98
|
+
sealedBytes: forGist.sealed.byteLength,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const forServer =
|
|
103
|
+
forGist.sealed.byteLength <= SERVER_MAX_SEALED_BYTES
|
|
104
|
+
? forGist
|
|
105
|
+
: await sealToFit(key, data, SERVER_MAX_SEALED_BYTES);
|
|
106
|
+
const id = await uploadToServer(forServer.sealed, base);
|
|
107
|
+
return {
|
|
108
|
+
url: `${base}/${id}#${keyText}`,
|
|
109
|
+
method: "server",
|
|
110
|
+
truncated: forServer.truncated,
|
|
111
|
+
sealedBytes: forServer.sealed.byteLength,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Strip trailing slashes so `<base>/<id>` composes cleanly. */
|
|
116
|
+
export function normalizeShareServerUrl(serverUrl?: string): string {
|
|
117
|
+
const base = (serverUrl ?? DEFAULT_SHARE_URL).trim().replace(/\/+$/, "");
|
|
118
|
+
return base || DEFAULT_SHARE_URL;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface SealedSession {
|
|
122
|
+
sealed: Uint8Array<ArrayBuffer>;
|
|
123
|
+
truncated: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Seal `data`, trimming content until the sealed blob fits `maxBytes`. Exported for tests. */
|
|
127
|
+
export async function sealToFit(key: CryptoKey, data: SessionData, maxBytes: number): Promise<SealedSession> {
|
|
128
|
+
let sealed = await sealSessionData(key, data);
|
|
129
|
+
if (sealed.byteLength <= maxBytes) return { sealed, truncated: false };
|
|
130
|
+
|
|
131
|
+
// Work on a deep copy; the caller may re-fit the original at another budget.
|
|
132
|
+
const working = structuredClone(data);
|
|
133
|
+
stripImagePayloads(working);
|
|
134
|
+
sealed = await sealSessionData(key, working);
|
|
135
|
+
if (sealed.byteLength <= maxBytes) return { sealed, truncated: true };
|
|
136
|
+
|
|
137
|
+
for (const cap of TEXT_CAPS) {
|
|
138
|
+
capLongStrings(working, cap);
|
|
139
|
+
sealed = await sealSessionData(key, working);
|
|
140
|
+
if (sealed.byteLength <= maxBytes) return { sealed, truncated: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Last resort: drop oldest entries (orphaned children render as roots).
|
|
144
|
+
while (working.entries.length > 4) {
|
|
145
|
+
working.entries = working.entries.slice(Math.ceil(working.entries.length / 2));
|
|
146
|
+
sealed = await sealSessionData(key, working);
|
|
147
|
+
if (sealed.byteLength <= maxBytes) return { sealed, truncated: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error(`Session too large to share: ${sealed.byteLength} bytes sealed exceeds the ${maxBytes} byte limit`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `[12B IV][AES-256-GCM(gzip(JSON))]` — decrypted and gunzipped by share-loader.js. */
|
|
154
|
+
async function sealSessionData(key: CryptoKey, data: SessionData): Promise<Uint8Array<ArrayBuffer>> {
|
|
155
|
+
const compressed = Bun.gzipSync(new TextEncoder().encode(JSON.stringify(data)));
|
|
156
|
+
const iv = new Uint8Array(IV_LENGTH);
|
|
157
|
+
crypto.getRandomValues(iv);
|
|
158
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, compressed));
|
|
159
|
+
const out = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
|
|
160
|
+
out.set(iv, 0);
|
|
161
|
+
out.set(ciphertext, IV_LENGTH);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
166
|
+
return typeof value === "object" && value !== null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Replace inline image payloads (image blocks + data: URLs) with tiny placeholders, in place. */
|
|
170
|
+
function stripImagePayloads(value: unknown): void {
|
|
171
|
+
if (Array.isArray(value)) {
|
|
172
|
+
for (let i = 0; i < value.length; i++) {
|
|
173
|
+
const item: unknown = value[i];
|
|
174
|
+
if (isRecord(item) && item.type === "image" && typeof item.data === "string" && item.data.length > 1024) {
|
|
175
|
+
value[i] = { type: "text", text: IMAGE_OMITTED_TEXT };
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
stripImagePayloads(item);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!isRecord(value)) return;
|
|
183
|
+
for (const k in value) {
|
|
184
|
+
const v = value[k];
|
|
185
|
+
if (typeof v === "string") {
|
|
186
|
+
if (v.length > 1024 && v.startsWith("data:")) value[k] = BLANK_IMAGE_DATA_URL;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
stripImagePayloads(v);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Truncate every string longer than `cap`, in place. */
|
|
194
|
+
function capLongStrings(value: unknown, cap: number): void {
|
|
195
|
+
if (Array.isArray(value)) {
|
|
196
|
+
for (let i = 0; i < value.length; i++) {
|
|
197
|
+
const item: unknown = value[i];
|
|
198
|
+
if (typeof item === "string" && item.length > cap) value[i] = `${item.slice(0, cap)}\n…[truncated for share]`;
|
|
199
|
+
else capLongStrings(item, cap);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!isRecord(value)) return;
|
|
204
|
+
for (const k in value) {
|
|
205
|
+
const v = value[k];
|
|
206
|
+
if (typeof v === "string") {
|
|
207
|
+
if (v.length > cap) value[k] = `${v.slice(0, cap)}\n…[truncated for share]`;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
capLongStrings(v, cap);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Create a secret gist holding base64 of the sealed blob; null when `gh` is unusable. */
|
|
215
|
+
async function tryCreateGist(sealed: Uint8Array): Promise<{ id: string; url: string } | null> {
|
|
216
|
+
if (!$which("gh")) return null;
|
|
217
|
+
const auth = await $`gh auth status`.quiet().nothrow();
|
|
218
|
+
if (auth.exitCode !== 0) {
|
|
219
|
+
logger.debug("share: gh present but not authenticated; falling back to share server");
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-share-"));
|
|
224
|
+
try {
|
|
225
|
+
const file = path.join(dir, GIST_FILENAME);
|
|
226
|
+
await Bun.write(file, Buffer.from(sealed).toString("base64"));
|
|
227
|
+
const result = await $`gh gist create --public=false ${file}`.quiet().nothrow();
|
|
228
|
+
if (result.exitCode !== 0) {
|
|
229
|
+
logger.warn("share: gist creation failed; falling back to share server", {
|
|
230
|
+
stderr: result.stderr.toString("utf-8").trim().slice(0, 500),
|
|
231
|
+
});
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const url = result.text().trim().split("\n").pop()?.trim() ?? "";
|
|
235
|
+
const id = url.split("/").pop() ?? "";
|
|
236
|
+
if (!GIST_ID_RE.test(id)) {
|
|
237
|
+
logger.warn("share: could not parse gist id from gh output", { url });
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
return { id, url };
|
|
241
|
+
} finally {
|
|
242
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** POST the sealed blob to the share server; returns the assigned id. */
|
|
247
|
+
async function uploadToServer(sealed: Uint8Array, base: string): Promise<string> {
|
|
248
|
+
let res: Response;
|
|
249
|
+
try {
|
|
250
|
+
res = await fetch(base, {
|
|
251
|
+
method: "POST",
|
|
252
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
253
|
+
body: sealed,
|
|
254
|
+
});
|
|
255
|
+
} catch (err) {
|
|
256
|
+
throw new Error(`Share upload to ${base} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
257
|
+
}
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
const detail = (await res.text().catch(() => "")).trim().slice(0, 200);
|
|
260
|
+
throw new Error(`Share upload to ${base} failed: HTTP ${res.status}${detail ? ` (${detail})` : ""}`);
|
|
261
|
+
}
|
|
262
|
+
const body = (await res.json().catch(() => null)) as { id?: unknown } | null;
|
|
263
|
+
const id = body && typeof body.id === "string" ? body.id : "";
|
|
264
|
+
if (!/^[A-Za-z0-9_-]{10,64}$/.test(id)) {
|
|
265
|
+
throw new Error(`Share upload to ${base} failed: server returned no usable id`);
|
|
266
|
+
}
|
|
267
|
+
return id;
|
|
268
|
+
}
|