@oh-my-pi/pi-coding-agent 16.1.6 → 16.1.8
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 +52 -0
- package/dist/cli.js +3160 -3139
- package/dist/types/cli/session-picker.d.ts +0 -1
- package/dist/types/cli/tiny-models-cli.d.ts +2 -0
- package/dist/types/collab/protocol.d.ts +20 -1
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/models-config.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +57 -6
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +5 -1
- package/dist/types/internal-urls/filesystem-resource.d.ts +9 -0
- package/dist/types/mcp/loader.d.ts +3 -2
- package/dist/types/mcp/manager.d.ts +4 -3
- package/dist/types/mcp/startup-events.d.ts +21 -4
- package/dist/types/mnemopi/config.d.ts +1 -0
- package/dist/types/modes/components/agent-hub.d.ts +1 -0
- package/dist/types/modes/components/agent-transcript-viewer.d.ts +1 -0
- package/dist/types/modes/components/assistant-message.d.ts +5 -1
- package/dist/types/modes/components/btw-panel.d.ts +2 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -0
- package/dist/types/modes/components/session-selector.d.ts +0 -2
- package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/controllers/streaming-reveal.d.ts +3 -2
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +5 -3
- package/dist/types/session/session-history-format.d.ts +25 -0
- package/dist/types/session/snapcompact-inline.d.ts +1 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +7 -4
- package/dist/types/slash-commands/types.d.ts +2 -0
- package/dist/types/system-prompt.d.ts +1 -0
- package/dist/types/tiny/models.d.ts +11 -7
- package/dist/types/tools/todo.d.ts +1 -0
- package/dist/types/utils/thinking-display.d.ts +2 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +104 -0
- package/src/advisor/runtime.ts +38 -2
- package/src/cli/session-picker.ts +1 -2
- package/src/cli/tiny-models-cli.ts +7 -2
- package/src/collab/guest.ts +172 -20
- package/src/collab/host.ts +47 -5
- package/src/collab/protocol.ts +16 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +59 -5
- package/src/edit/renderer.ts +8 -12
- package/src/extensibility/legacy-pi-ai-shim.ts +16 -4
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/filesystem-resource.ts +34 -0
- package/src/internal-urls/local-protocol.ts +7 -1
- package/src/internal-urls/memory-protocol.ts +5 -1
- package/src/internal-urls/skill-protocol.ts +20 -4
- package/src/internal-urls/vault-protocol.ts +5 -2
- package/src/main.ts +8 -8
- package/src/mcp/loader.ts +4 -3
- package/src/mcp/manager.ts +35 -15
- package/src/mcp/startup-events.ts +106 -11
- package/src/mnemopi/config.ts +2 -0
- package/src/mnemopi/state.ts +3 -1
- package/src/modes/components/agent-hub.ts +4 -0
- package/src/modes/components/agent-transcript-viewer.ts +2 -0
- package/src/modes/components/assistant-message.ts +217 -18
- package/src/modes/components/btw-panel.ts +15 -3
- package/src/modes/components/chat-transcript-builder.ts +8 -2
- package/src/modes/components/model-selector.ts +72 -9
- package/src/modes/components/omfg-panel.ts +1 -1
- package/src/modes/components/session-selector.ts +4 -9
- package/src/modes/components/snapcompact-shape-preview.ts +1 -1
- package/src/modes/components/tool-execution.ts +10 -2
- package/src/modes/controllers/btw-controller.ts +32 -7
- package/src/modes/controllers/event-controller.ts +24 -4
- package/src/modes/controllers/input-controller.ts +43 -21
- package/src/modes/controllers/selector-controller.ts +23 -13
- package/src/modes/controllers/streaming-reveal.ts +78 -20
- package/src/modes/controllers/todo-command-controller.ts +9 -10
- package/src/modes/interactive-mode.ts +83 -8
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/advisor/system.md +1 -1
- package/src/prompts/system/side-channel-no-tools.md +3 -0
- package/src/prompts/system/system-prompt.md +164 -156
- package/src/sdk.ts +12 -8
- package/src/session/agent-session.ts +349 -84
- package/src/session/history-storage.ts +15 -16
- package/src/session/session-history-format.ts +41 -1
- package/src/session/snapcompact-inline.ts +9 -6
- package/src/slash-commands/builtin-registry.ts +147 -21
- package/src/slash-commands/helpers/todo.ts +12 -6
- package/src/slash-commands/types.ts +2 -0
- package/src/system-prompt.ts +6 -11
- package/src/tiny/models.ts +7 -3
- package/src/tiny/title-client.ts +8 -2
- package/src/tiny/worker.ts +1 -0
- package/src/tools/search.ts +10 -1
- package/src/tools/sqlite-reader.ts +59 -3
- package/src/tools/todo.ts +6 -0
- package/src/tools/write.ts +4 -6
- package/src/utils/thinking-display.ts +78 -0
package/src/collab/guest.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { AgentHubRemote } from "../modes/components/agent-hub";
|
|
|
20
20
|
import type { InteractiveModeContext } from "../modes/types";
|
|
21
21
|
import { AgentRegistry } from "../registry/agent-registry";
|
|
22
22
|
import type { AgentSessionEvent } from "../session/agent-session";
|
|
23
|
+
import type { SessionEntry } from "../session/session-entries";
|
|
23
24
|
import { shouldDisableReasoning, toReasoningEffort } from "../thinking";
|
|
24
25
|
import { setSessionTerminalTitle } from "../utils/title-generator";
|
|
25
26
|
import { importRoomKey } from "./crypto";
|
|
@@ -47,10 +48,35 @@ export const COLLAB_GUEST_ALLOWED_COMMANDS: Record<string, true> = {
|
|
|
47
48
|
exit: true,
|
|
48
49
|
quit: true,
|
|
49
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* How long the guest waits for the host's small `welcome` frame before giving
|
|
53
|
+
* up on the join. The welcome carries metadata only (`entryCount`, header,
|
|
54
|
+
* state, agents), so it lands well under one second on any working relay.
|
|
55
|
+
*/
|
|
50
56
|
const WELCOME_TIMEOUT_MS = 30_000;
|
|
57
|
+
/**
|
|
58
|
+
* How long the guest waits between `snapshot-chunk` frames during the initial
|
|
59
|
+
* sync. Resets on each chunk arrival, so a multi-MB snapshot only fails when
|
|
60
|
+
* the relay genuinely stalls — not because the total wall-clock crossed the
|
|
61
|
+
* welcome budget. The default relay sustains ~350 KB/s; a 512 KB chunk lands
|
|
62
|
+
* in under two seconds with comfortable headroom.
|
|
63
|
+
*/
|
|
64
|
+
const SNAPSHOT_PROGRESS_TIMEOUT_MS = 30_000;
|
|
51
65
|
const TRANSCRIPT_TIMEOUT_MS = 20_000;
|
|
52
66
|
|
|
53
67
|
type WelcomeFrame = Extract<CollabFrame, { t: "welcome" }>;
|
|
68
|
+
type SnapshotChunkFrame = Extract<CollabFrame, { t: "snapshot-chunk" }>;
|
|
69
|
+
|
|
70
|
+
/** Accumulator for an in-flight chunked welcome — see {@link CollabGuestLink}. */
|
|
71
|
+
interface PendingSnapshot {
|
|
72
|
+
header: WelcomeFrame["header"];
|
|
73
|
+
state: WelcomeFrame["state"];
|
|
74
|
+
agents: AgentSnapshot[];
|
|
75
|
+
readOnly: boolean;
|
|
76
|
+
entryCount: number;
|
|
77
|
+
entries: SessionEntry[];
|
|
78
|
+
isResync: boolean;
|
|
79
|
+
}
|
|
54
80
|
|
|
55
81
|
export class CollabGuestLink {
|
|
56
82
|
#ctx: InteractiveModeContext;
|
|
@@ -60,8 +86,24 @@ export class CollabGuestLink {
|
|
|
60
86
|
#returnSessionFile: string | null = null;
|
|
61
87
|
/** Frames apply strictly in arrival order through this chain. */
|
|
62
88
|
#applyChain: Promise<void> = Promise.resolve();
|
|
89
|
+
/** True after the initial snapshot has been written to disk and resumed. */
|
|
63
90
|
#welcomed = false;
|
|
64
91
|
#left = false;
|
|
92
|
+
/**
|
|
93
|
+
* Buffer for the in-flight chunked welcome. Set by the small `welcome`
|
|
94
|
+
* frame, accumulated by every `snapshot-chunk`, drained when the final
|
|
95
|
+
* chunk lands (or the snapshot-progress timer fires).
|
|
96
|
+
*/
|
|
97
|
+
#pendingSnapshot: PendingSnapshot | null = null;
|
|
98
|
+
/**
|
|
99
|
+
* Fires `firstWelcome.reject` from a stalled welcome/snapshot during the
|
|
100
|
+
* initial join. Set in {@link join}, cleared on resolve/reject; arming a
|
|
101
|
+
* timer after that point is a no-op so reconnect-time stalls fall through
|
|
102
|
+
* to the normal socket close handling instead of aborting the live session.
|
|
103
|
+
*/
|
|
104
|
+
#joinReject: ((err: Error) => void) | null = null;
|
|
105
|
+
#welcomeTimer: Timer | null = null;
|
|
106
|
+
#snapshotProgressTimer: Timer | null = null;
|
|
65
107
|
/** base64url write token from a full link; absent when joined via a view link. */
|
|
66
108
|
#writeToken: string | undefined;
|
|
67
109
|
/** True when the host marked this peer read-only (view link). */
|
|
@@ -143,11 +185,23 @@ export class CollabGuestLink {
|
|
|
143
185
|
|
|
144
186
|
const firstWelcome = Promise.withResolvers<void>();
|
|
145
187
|
let joined = false;
|
|
188
|
+
this.#joinReject = err => firstWelcome.reject(err);
|
|
189
|
+
|
|
190
|
+
const finishJoin = (): void => {
|
|
191
|
+
if (joined) return;
|
|
192
|
+
joined = true;
|
|
193
|
+
firstWelcome.resolve();
|
|
194
|
+
};
|
|
146
195
|
|
|
147
196
|
socket.onOpen = () => {
|
|
148
197
|
// (Re)connect: re-introduce ourselves; the host answers with a fresh
|
|
149
|
-
// welcome which (re)syncs the replica.
|
|
198
|
+
// welcome which (re)syncs the replica. Discard any partially-streamed
|
|
199
|
+
// snapshot from a prior connection: the host will resend the full
|
|
200
|
+
// chunk train.
|
|
150
201
|
this.#welcomed = false;
|
|
202
|
+
this.#pendingSnapshot = null;
|
|
203
|
+
this.#clearSnapshotProgressTimer();
|
|
204
|
+
this.#armWelcomeTimer();
|
|
151
205
|
socket.send({
|
|
152
206
|
t: "hello",
|
|
153
207
|
proto: COLLAB_PROTO,
|
|
@@ -159,19 +213,35 @@ export class CollabGuestLink {
|
|
|
159
213
|
this.#applyChain = this.#applyChain
|
|
160
214
|
.then(async () => {
|
|
161
215
|
if (frame.t === "welcome") {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
216
|
+
this.#clearWelcomeTimer();
|
|
217
|
+
this.#beginWelcome(frame, joined);
|
|
218
|
+
if (frame.entryCount === 0) {
|
|
219
|
+
await this.#finalizeSnapshot();
|
|
220
|
+
finishJoin();
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (frame.t === "snapshot-chunk") {
|
|
225
|
+
const ready = this.#accumulateSnapshotChunk(frame);
|
|
226
|
+
if (ready) {
|
|
227
|
+
await this.#finalizeSnapshot();
|
|
228
|
+
finishJoin();
|
|
166
229
|
}
|
|
167
230
|
return;
|
|
168
231
|
}
|
|
169
232
|
if (!this.#welcomed || this.#left) return;
|
|
170
233
|
this.#applyFrame(frame);
|
|
171
234
|
})
|
|
172
|
-
.catch(err =>
|
|
235
|
+
.catch(err => {
|
|
236
|
+
logger.warn("collab guest frame apply failed", { type: frame.t, error: String(err) });
|
|
237
|
+
if (!joined && (frame.t === "welcome" || frame.t === "snapshot-chunk")) {
|
|
238
|
+
firstWelcome.reject(err instanceof Error ? err : new Error(String(err)));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
173
241
|
};
|
|
174
242
|
socket.onClose = (reason, willReconnect) => {
|
|
243
|
+
this.#clearWelcomeTimer();
|
|
244
|
+
this.#clearSnapshotProgressTimer();
|
|
175
245
|
this.#flushPendingTranscripts();
|
|
176
246
|
if (this.#left) return;
|
|
177
247
|
if (!joined) {
|
|
@@ -186,11 +256,12 @@ export class CollabGuestLink {
|
|
|
186
256
|
void this.#restoreLocalSession();
|
|
187
257
|
};
|
|
188
258
|
socket.connect();
|
|
259
|
+
// Cover the connect phase too: if the relay blackholes the WebSocket
|
|
260
|
+
// handshake (no onOpen, no onClose), onOpen never arms the welcome timer,
|
|
261
|
+
// so without this the join would hang forever. onOpen re-arms (resetting
|
|
262
|
+
// the budget) once the socket actually opens.
|
|
263
|
+
this.#armWelcomeTimer();
|
|
189
264
|
|
|
190
|
-
const timeout = setTimeout(
|
|
191
|
-
() => firstWelcome.reject(new Error("timed out waiting for the host's welcome")),
|
|
192
|
-
WELCOME_TIMEOUT_MS,
|
|
193
|
-
);
|
|
194
265
|
try {
|
|
195
266
|
await firstWelcome.promise;
|
|
196
267
|
} catch (err) {
|
|
@@ -199,7 +270,9 @@ export class CollabGuestLink {
|
|
|
199
270
|
this.#socket = null;
|
|
200
271
|
throw err;
|
|
201
272
|
} finally {
|
|
202
|
-
|
|
273
|
+
this.#joinReject = null;
|
|
274
|
+
this.#clearWelcomeTimer();
|
|
275
|
+
this.#clearSnapshotProgressTimer();
|
|
203
276
|
}
|
|
204
277
|
|
|
205
278
|
this.#ctx.collabGuest = this;
|
|
@@ -222,11 +295,56 @@ export class CollabGuestLink {
|
|
|
222
295
|
this.#socket?.send({ t: "abort" });
|
|
223
296
|
}
|
|
224
297
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Latch the welcome metadata and prime the snapshot accumulator. The
|
|
300
|
+
* heavy resume work (file write, `switchSession`, render) only happens in
|
|
301
|
+
* {@link #finalizeSnapshot}, so the small welcome frame clears the join
|
|
302
|
+
* timeout immediately even when the transcript still has to stream in.
|
|
303
|
+
*/
|
|
304
|
+
#beginWelcome(frame: WelcomeFrame, isResync: boolean): void {
|
|
227
305
|
if (this.#left) return;
|
|
306
|
+
this.#pendingSnapshot = {
|
|
307
|
+
header: frame.header,
|
|
308
|
+
state: frame.state,
|
|
309
|
+
agents: frame.agents,
|
|
310
|
+
readOnly: frame.readOnly === true,
|
|
311
|
+
entryCount: frame.entryCount,
|
|
312
|
+
entries: [],
|
|
313
|
+
isResync,
|
|
314
|
+
};
|
|
315
|
+
this.#armSnapshotProgressTimer();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Append a chunk to the pending snapshot. Returns `true` when the
|
|
320
|
+
* accumulator has gathered every entry the welcome promised, or the host
|
|
321
|
+
* tagged this chunk as `final`. The caller is responsible for invoking
|
|
322
|
+
* {@link #finalizeSnapshot} on the same applyChain microtask.
|
|
323
|
+
*/
|
|
324
|
+
#accumulateSnapshotChunk(frame: SnapshotChunkFrame): boolean {
|
|
325
|
+
const pending = this.#pendingSnapshot;
|
|
326
|
+
if (!pending) {
|
|
327
|
+
logger.debug("collab guest dropping orphan snapshot-chunk");
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
pending.entries.push(...frame.entries);
|
|
331
|
+
const complete = frame.final || pending.entries.length >= pending.entryCount;
|
|
332
|
+
if (complete) {
|
|
333
|
+
this.#clearSnapshotProgressTimer();
|
|
334
|
+
} else {
|
|
335
|
+
this.#armSnapshotProgressTimer();
|
|
336
|
+
}
|
|
337
|
+
return complete;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Write the accumulated welcome snapshot to the replica file and (re)load it through the resume machinery. */
|
|
341
|
+
async #finalizeSnapshot(): Promise<void> {
|
|
342
|
+
const pending = this.#pendingSnapshot;
|
|
343
|
+
this.#pendingSnapshot = null;
|
|
344
|
+
this.#clearSnapshotProgressTimer();
|
|
345
|
+
if (!pending || this.#left) return;
|
|
228
346
|
const replicaPath = path.join(getConfigRootDir(), "collab", `${this.#roomId}.jsonl`);
|
|
229
|
-
const lines = [
|
|
347
|
+
const lines = [pending.header, ...pending.entries].map(entry => JSON.stringify(entry)).join("\n");
|
|
230
348
|
await Bun.write(replicaPath, `${lines}\n`);
|
|
231
349
|
|
|
232
350
|
// Resume sequence (selector-controller.handleResumeSession) minus
|
|
@@ -235,20 +353,54 @@ export class CollabGuestLink {
|
|
|
235
353
|
this.#clearTransientUi();
|
|
236
354
|
this.#clearAgentMirror();
|
|
237
355
|
await this.#ctx.session.switchSession(replicaPath);
|
|
238
|
-
this.state =
|
|
239
|
-
this.#applyHostState(
|
|
356
|
+
this.state = pending.state;
|
|
357
|
+
this.#applyHostState(pending.state);
|
|
240
358
|
this.#ctx.resetObserverRegistry();
|
|
241
|
-
this.#applyAgentSnapshots(
|
|
359
|
+
this.#applyAgentSnapshots(pending.agents);
|
|
242
360
|
this.#assistantStreamSynced = false;
|
|
243
|
-
setSessionTerminalTitle(
|
|
361
|
+
setSessionTerminalTitle(pending.state.sessionName ?? pending.header.title, pending.state.cwd);
|
|
244
362
|
this.#ctx.chatContainer.clear();
|
|
245
363
|
this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
|
|
246
364
|
await this.#ctx.reloadTodos();
|
|
247
365
|
this.#updateStatusSegment();
|
|
248
|
-
this.#readOnly =
|
|
366
|
+
this.#readOnly = pending.readOnly;
|
|
249
367
|
this.#welcomed = true;
|
|
250
368
|
const suffix = this.#readOnly ? " (read-only)" : "";
|
|
251
|
-
this.#ctx.showStatus(
|
|
369
|
+
this.#ctx.showStatus(
|
|
370
|
+
pending.isResync ? `Reconnected to collab session${suffix}` : `Joined collab session${suffix}`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#armWelcomeTimer(): void {
|
|
375
|
+
if (this.#joinReject === null) return;
|
|
376
|
+
this.#clearWelcomeTimer();
|
|
377
|
+
this.#welcomeTimer = setTimeout(() => {
|
|
378
|
+
this.#welcomeTimer = null;
|
|
379
|
+
this.#joinReject?.(new Error("timed out waiting for the host's welcome"));
|
|
380
|
+
}, WELCOME_TIMEOUT_MS);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#clearWelcomeTimer(): void {
|
|
384
|
+
if (this.#welcomeTimer !== null) {
|
|
385
|
+
clearTimeout(this.#welcomeTimer);
|
|
386
|
+
this.#welcomeTimer = null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#armSnapshotProgressTimer(): void {
|
|
391
|
+
if (this.#joinReject === null) return;
|
|
392
|
+
this.#clearSnapshotProgressTimer();
|
|
393
|
+
this.#snapshotProgressTimer = setTimeout(() => {
|
|
394
|
+
this.#snapshotProgressTimer = null;
|
|
395
|
+
this.#joinReject?.(new Error("timed out waiting for the host's session snapshot"));
|
|
396
|
+
}, SNAPSHOT_PROGRESS_TIMEOUT_MS);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#clearSnapshotProgressTimer(): void {
|
|
400
|
+
if (this.#snapshotProgressTimer !== null) {
|
|
401
|
+
clearTimeout(this.#snapshotProgressTimer);
|
|
402
|
+
this.#snapshotProgressTimer = null;
|
|
403
|
+
}
|
|
252
404
|
}
|
|
253
405
|
|
|
254
406
|
#applyFrame(frame: CollabFrame): void {
|
package/src/collab/host.ts
CHANGED
|
@@ -94,6 +94,13 @@ function isWireSessionEntry(entry: StoredSessionEntry): entry is StoredSessionEn
|
|
|
94
94
|
const CONNECT_TIMEOUT_MS = 15_000;
|
|
95
95
|
/** Max bytes served per fetch-transcript reply (guest re-requests from `newSize`). */
|
|
96
96
|
const TRANSCRIPT_READ_CAP = 4 * 1024 * 1024;
|
|
97
|
+
/**
|
|
98
|
+
* Soft byte cap per `snapshot-chunk` frame. The first MB of a snapshot takes
|
|
99
|
+
* ~3s through the default relay, so a 512 KB chunk lands well under the
|
|
100
|
+
* guest's 30 s per-chunk progress timeout; oversized single entries still
|
|
101
|
+
* ship in a chunk of their own.
|
|
102
|
+
*/
|
|
103
|
+
const SNAPSHOT_CHUNK_BYTES = 512 * 1024;
|
|
97
104
|
|
|
98
105
|
/** Display name for this process's user in collab sessions. */
|
|
99
106
|
export function collabDisplayName(ctx: InteractiveModeContext): string {
|
|
@@ -323,9 +330,10 @@ export class CollabHost {
|
|
|
323
330
|
const canWrite = this.#verifyWriteToken(writeToken);
|
|
324
331
|
this.#peers.set(fromPeer, { name: cleanName, canWrite });
|
|
325
332
|
|
|
326
|
-
// Snapshot and send synchronously: no awaits between snapshot
|
|
327
|
-
//
|
|
328
|
-
//
|
|
333
|
+
// Snapshot and send synchronously: no awaits between snapshot, welcome,
|
|
334
|
+
// and chunk sends, so subsequent broadcast frames (entry/event/state/bus)
|
|
335
|
+
// queue behind the snapshot on the same socket and the guest can't
|
|
336
|
+
// observe a gap between the snapshot fragment and live traffic.
|
|
329
337
|
const snapshot = this.#ctx.sessionManager.snapshotForReplication();
|
|
330
338
|
if (JSON.stringify(snapshot).length > WELCOME_IMAGE_STRIP_THRESHOLD) {
|
|
331
339
|
let stripped = 0;
|
|
@@ -335,18 +343,21 @@ export class CollabHost {
|
|
|
335
343
|
logger.info("collab welcome exceeded size threshold; stripped images", { stripped });
|
|
336
344
|
}
|
|
337
345
|
const entries = snapshot.entries.filter(isWireSessionEntry);
|
|
338
|
-
this.#socket
|
|
346
|
+
const socket = this.#socket;
|
|
347
|
+
if (!socket) return;
|
|
348
|
+
socket.send(
|
|
339
349
|
{
|
|
340
350
|
t: "welcome",
|
|
341
351
|
proto: COLLAB_PROTO,
|
|
342
352
|
header: snapshot.header,
|
|
343
|
-
entries,
|
|
344
353
|
state: this.#buildState(),
|
|
345
354
|
agents: this.#snapshotAgents(),
|
|
355
|
+
entryCount: entries.length,
|
|
346
356
|
readOnly: canWrite ? undefined : true,
|
|
347
357
|
},
|
|
348
358
|
fromPeer,
|
|
349
359
|
);
|
|
360
|
+
this.#sendSnapshotChunks(entries, fromPeer);
|
|
350
361
|
this.#ctx.session.emitNotice(
|
|
351
362
|
"info",
|
|
352
363
|
`${cleanName} joined the collab session${canWrite ? "" : " (read-only)"}`,
|
|
@@ -356,6 +367,37 @@ export class CollabHost {
|
|
|
356
367
|
this.#scheduleStateBroadcast();
|
|
357
368
|
}
|
|
358
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Slice {@link entries} into byte-bounded `snapshot-chunk` frames targeted
|
|
372
|
+
* at {@link fromPeer}. Every batch carries at least one entry (a single
|
|
373
|
+
* oversize entry ships alone), and the last batch is tagged `final: true`
|
|
374
|
+
* so the guest can finalize the replica. An empty snapshot still emits one
|
|
375
|
+
* `final` chunk so the guest never blocks on a missing terminator.
|
|
376
|
+
*/
|
|
377
|
+
#sendSnapshotChunks(entries: (StoredSessionEntry & WireSessionEntry)[], fromPeer: number): void {
|
|
378
|
+
const socket = this.#socket;
|
|
379
|
+
if (!socket) return;
|
|
380
|
+
if (entries.length === 0) {
|
|
381
|
+
socket.send({ t: "snapshot-chunk", entries: [], final: true }, fromPeer);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
let i = 0;
|
|
385
|
+
while (i < entries.length) {
|
|
386
|
+
const batch: (StoredSessionEntry & WireSessionEntry)[] = [];
|
|
387
|
+
let batchBytes = 0;
|
|
388
|
+
while (i < entries.length) {
|
|
389
|
+
const entry = entries[i];
|
|
390
|
+
if (!entry) break;
|
|
391
|
+
const entryBytes = JSON.stringify(entry).length;
|
|
392
|
+
if (batch.length > 0 && batchBytes + entryBytes > SNAPSHOT_CHUNK_BYTES) break;
|
|
393
|
+
batch.push(entry);
|
|
394
|
+
batchBytes += entryBytes;
|
|
395
|
+
i++;
|
|
396
|
+
}
|
|
397
|
+
socket.send({ t: "snapshot-chunk", entries: batch, final: i >= entries.length }, fromPeer);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
359
401
|
#handlePrompt(text: string, images: ImageContent[] | undefined, fromPeer: number): void {
|
|
360
402
|
const peer = this.#peers.get(fromPeer);
|
|
361
403
|
if (!peer?.canWrite) {
|
package/src/collab/protocol.ts
CHANGED
|
@@ -65,12 +65,27 @@ export type CollabFrame =
|
|
|
65
65
|
t: "welcome";
|
|
66
66
|
proto: number;
|
|
67
67
|
header: SessionHeader;
|
|
68
|
-
entries: SessionEntry[];
|
|
69
68
|
state: CollabSessionState;
|
|
70
69
|
agents: AgentSnapshot[];
|
|
70
|
+
/**
|
|
71
|
+
* Total number of `SessionEntry` items the host will deliver in the
|
|
72
|
+
* `snapshot-chunk` frames that follow. The guest stays in the
|
|
73
|
+
* snapshot-loading phase until it has accumulated that many entries
|
|
74
|
+
* (or a chunk arrives with `final: true`).
|
|
75
|
+
*/
|
|
76
|
+
entryCount: number;
|
|
71
77
|
/** True when this peer joined through a read-only (view) link. */
|
|
72
78
|
readOnly?: boolean;
|
|
73
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Targeted snapshot fragment delivered after `welcome`. Splits a large
|
|
82
|
+
* transcript across many small frames so the guest's per-chunk progress
|
|
83
|
+
* timeout resets each time the relay delivers another batch; without
|
|
84
|
+
* chunking, a multi-MB session has to fit one giant frame inside the
|
|
85
|
+
* 30 s first-welcome budget. The last chunk carries `final: true` so the
|
|
86
|
+
* guest can finalize the replica session.
|
|
87
|
+
*/
|
|
88
|
+
| { t: "snapshot-chunk"; entries: SessionEntry[]; final: boolean }
|
|
74
89
|
| { t: "entry"; entry: SessionEntry }
|
|
75
90
|
| { t: "event"; event: AgentSessionEvent }
|
|
76
91
|
| { t: "state"; state: CollabSessionState }
|
|
@@ -57,6 +57,7 @@ const OpenAICompatFields = {
|
|
|
57
57
|
"supportsReasoningParams?": "boolean",
|
|
58
58
|
"alwaysSendMaxTokens?": "boolean",
|
|
59
59
|
"strictResponsesPairing?": "boolean",
|
|
60
|
+
"supportsImageDetailOriginal?": "boolean",
|
|
60
61
|
// anthropic-messages compat flags (same `compat` slot, per-api interpretation)
|
|
61
62
|
"requiresToolResultId?": "boolean",
|
|
62
63
|
"replayUnsignedThinking?": "boolean",
|
|
@@ -134,7 +134,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
|
|
|
134
134
|
"Developer",
|
|
135
135
|
],
|
|
136
136
|
tasks: ["Modes", "Subagents", "Isolation", "Commands & Skills"],
|
|
137
|
-
providers: ["Services", "Tiny Model", "Protocol", "Privacy"],
|
|
137
|
+
providers: ["Services", "Fireworks", "Tiny Model", "Protocol", "Privacy"],
|
|
138
138
|
};
|
|
139
139
|
|
|
140
140
|
/** Status line segment identifiers */
|
|
@@ -413,7 +413,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
413
413
|
},
|
|
414
414
|
"advisor.immuneTurns": {
|
|
415
415
|
type: "number",
|
|
416
|
-
default:
|
|
416
|
+
default: 3,
|
|
417
417
|
ui: {
|
|
418
418
|
tab: "model",
|
|
419
419
|
group: "Advisor",
|
|
@@ -422,9 +422,9 @@ export const SETTINGS_SCHEMA = {
|
|
|
422
422
|
"After an advisor concern or blocker interrupts, route further concerns/blockers non-interruptingly for this many primary turns.",
|
|
423
423
|
options: [
|
|
424
424
|
{ value: "0", label: "0 turns", description: "Allow every concern/blocker to interrupt." },
|
|
425
|
-
{ value: "1", label: "1 turn"
|
|
425
|
+
{ value: "1", label: "1 turn" },
|
|
426
426
|
{ value: "2", label: "2 turns" },
|
|
427
|
-
{ value: "3", label: "3 turns" },
|
|
427
|
+
{ value: "3", label: "3 turns", description: "Default." },
|
|
428
428
|
{ value: "4", label: "4 turns" },
|
|
429
429
|
{ value: "5", label: "5 turns" },
|
|
430
430
|
],
|
|
@@ -899,6 +899,28 @@ export const SETTINGS_SCHEMA = {
|
|
|
899
899
|
description: "Hide thinking blocks in assistant responses",
|
|
900
900
|
},
|
|
901
901
|
},
|
|
902
|
+
proseOnlyThinking: {
|
|
903
|
+
type: "boolean",
|
|
904
|
+
default: true,
|
|
905
|
+
ui: {
|
|
906
|
+
tab: "model",
|
|
907
|
+
group: "Thinking",
|
|
908
|
+
label: "Prose Only Thinking",
|
|
909
|
+
description: "Omit code blocks from thinking summaries and replace them with an ellipsis",
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
omitThinking: {
|
|
914
|
+
type: "boolean",
|
|
915
|
+
default: false,
|
|
916
|
+
ui: {
|
|
917
|
+
tab: "model",
|
|
918
|
+
group: "Thinking",
|
|
919
|
+
label: "Omit Thinking summaries",
|
|
920
|
+
description:
|
|
921
|
+
"Instruct upstream providers to completely omit thinking summaries from responses (where supported)",
|
|
922
|
+
},
|
|
923
|
+
},
|
|
902
924
|
|
|
903
925
|
"model.loopGuard.enabled": {
|
|
904
926
|
type: "boolean",
|
|
@@ -1631,7 +1653,7 @@ export const SETTINGS_SCHEMA = {
|
|
|
1631
1653
|
"compaction.strategy": {
|
|
1632
1654
|
type: "enum",
|
|
1633
1655
|
values: ["context-full", "handoff", "shake", "snapcompact", "off"] as const,
|
|
1634
|
-
default: "
|
|
1656
|
+
default: "snapcompact",
|
|
1635
1657
|
ui: {
|
|
1636
1658
|
tab: "context",
|
|
1637
1659
|
group: "Compaction",
|
|
@@ -2235,6 +2257,18 @@ export const SETTINGS_SCHEMA = {
|
|
|
2235
2257
|
condition: "mnemopiActive",
|
|
2236
2258
|
},
|
|
2237
2259
|
},
|
|
2260
|
+
"mnemopi.proactiveLinking": {
|
|
2261
|
+
type: "boolean",
|
|
2262
|
+
default: false,
|
|
2263
|
+
ui: {
|
|
2264
|
+
tab: "memory",
|
|
2265
|
+
group: "Mnemopi",
|
|
2266
|
+
label: "Mnemopi Proactive Linking",
|
|
2267
|
+
description:
|
|
2268
|
+
"Ingest new memories into the episodic graph as they are stored, linking them to related entities and memories",
|
|
2269
|
+
condition: "mnemopiActive",
|
|
2270
|
+
},
|
|
2271
|
+
},
|
|
2238
2272
|
"mnemopi.noEmbeddings": {
|
|
2239
2273
|
type: "boolean",
|
|
2240
2274
|
default: false,
|
|
@@ -4020,6 +4054,26 @@ export const SETTINGS_SCHEMA = {
|
|
|
4020
4054
|
],
|
|
4021
4055
|
},
|
|
4022
4056
|
},
|
|
4057
|
+
"providers.fireworksTier": {
|
|
4058
|
+
type: "enum",
|
|
4059
|
+
values: ["standard", "priority"] as const,
|
|
4060
|
+
default: "standard",
|
|
4061
|
+
ui: {
|
|
4062
|
+
tab: "providers",
|
|
4063
|
+
group: "Fireworks",
|
|
4064
|
+
label: "Fireworks Tier",
|
|
4065
|
+
description:
|
|
4066
|
+
'Serving path for Fireworks requests. Priority sends `service_tier: "priority"` for higher reliability during peak traffic at a higher price; Standard omits it. Fast (`-fast`) models ignore this — Fast is its own serving path.',
|
|
4067
|
+
options: [
|
|
4068
|
+
{ value: "standard", label: "Standard", description: "Default serving path (no service_tier)" },
|
|
4069
|
+
{
|
|
4070
|
+
value: "priority",
|
|
4071
|
+
label: "Priority",
|
|
4072
|
+
description: "Priority serving path: higher reliability, premium per-token pricing",
|
|
4073
|
+
},
|
|
4074
|
+
],
|
|
4075
|
+
},
|
|
4076
|
+
},
|
|
4023
4077
|
"providers.tts": {
|
|
4024
4078
|
type: "enum",
|
|
4025
4079
|
values: ["auto", "local", "xai"] as const,
|
package/src/edit/renderer.ts
CHANGED
|
@@ -283,7 +283,7 @@ function renderEditHeader(
|
|
|
283
283
|
width: number,
|
|
284
284
|
uiTheme: Theme,
|
|
285
285
|
options: {
|
|
286
|
-
icon
|
|
286
|
+
icon?: "pending" | "success" | "error";
|
|
287
287
|
iconOverride?: string;
|
|
288
288
|
op?: Operation;
|
|
289
289
|
rawPath: string;
|
|
@@ -614,14 +614,12 @@ export const editToolRenderer = {
|
|
|
614
614
|
}
|
|
615
615
|
const callPreviewCaches: RenderedStringCache[] = [];
|
|
616
616
|
return framedBlock(uiTheme, width => {
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
// mid-stream. The liveness cue rides the trailing "(preview)" /
|
|
617
|
+
// No status icon on the head row: it's the head of the framed block,
|
|
618
|
+
// and native-scrollback commits are prefix-only — an animated glyph
|
|
619
|
+
// would pin the commit boundary at the top, and the pending hourglass
|
|
620
|
+
// just adds noise. The liveness cue rides the trailing "(preview)" /
|
|
622
621
|
// "(streaming)" line instead.
|
|
623
622
|
const header = renderEditHeader(width, uiTheme, {
|
|
624
|
-
icon: "pending",
|
|
625
623
|
op,
|
|
626
624
|
rawPath,
|
|
627
625
|
rename,
|
|
@@ -796,20 +794,18 @@ function renderMultiFileResult(
|
|
|
796
794
|
if (allLines.length > 0) allLines.push("");
|
|
797
795
|
const spinnerFrame = options.spinnerFrame;
|
|
798
796
|
const spinner = spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, spinnerFrame) : "";
|
|
797
|
+
// Spinner while actively rendering, otherwise no icon — never the
|
|
798
|
+
// pending hourglass on the head row.
|
|
799
799
|
allLines.push(
|
|
800
800
|
renderStatusLine(
|
|
801
801
|
{
|
|
802
|
-
|
|
802
|
+
iconOverride: spinner,
|
|
803
803
|
title: "Edit",
|
|
804
804
|
description: uiTheme.fg("dim", `${remaining} more file${remaining > 1 ? "s" : ""} pending…`),
|
|
805
805
|
},
|
|
806
806
|
uiTheme,
|
|
807
807
|
),
|
|
808
808
|
);
|
|
809
|
-
if (spinner) {
|
|
810
|
-
// Replace the pending icon with spinner on the last line
|
|
811
|
-
allLines[allLines.length - 1] = allLines[allLines.length - 1].replace(/^(?:\x1b\[[^m]*m)*./u, spinner);
|
|
812
|
-
}
|
|
813
809
|
}
|
|
814
810
|
|
|
815
811
|
cached = { key, lines: allLines };
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* `types.ts` via the `export *` below — pi-ai still exports both as types,
|
|
20
20
|
* only the runtime `Type` builder and `StringEnum()` helper were removed.
|
|
21
21
|
*/
|
|
22
|
+
import { getBundledModel, getBundledModels } from "@oh-my-pi/pi-catalog/models";
|
|
22
23
|
import { type TSchema, Type } from "./typebox";
|
|
23
24
|
|
|
24
25
|
export interface StringEnumOptions<T extends string> {
|
|
@@ -28,10 +29,14 @@ export interface StringEnumOptions<T extends string> {
|
|
|
28
29
|
[key: string]: unknown;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function stringEnumWireSchema<T extends string
|
|
32
|
+
function stringEnumWireSchema<T extends string | number>(
|
|
33
|
+
values: readonly T[] | Record<string, T>,
|
|
34
|
+
options: StringEnumOptions<any> | undefined,
|
|
35
|
+
) {
|
|
36
|
+
const enumValues = Array.isArray(values) ? [...values] : Object.values(values);
|
|
32
37
|
const schema: Record<string, unknown> = {
|
|
33
38
|
type: "string",
|
|
34
|
-
enum:
|
|
39
|
+
enum: enumValues,
|
|
35
40
|
};
|
|
36
41
|
if (!options) return schema;
|
|
37
42
|
for (const key in options) {
|
|
@@ -42,12 +47,15 @@ function stringEnumWireSchema<T extends string>(values: readonly T[], options: S
|
|
|
42
47
|
return schema;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
export function StringEnum<T extends string
|
|
50
|
+
export function StringEnum<T extends string | number>(
|
|
51
|
+
values: readonly T[] | Record<string, T>,
|
|
52
|
+
options?: StringEnumOptions<any>,
|
|
53
|
+
): TSchema {
|
|
46
54
|
const opts = {
|
|
47
55
|
description: options?.description ?? "Legacy string enum compatibility schema",
|
|
48
56
|
...options,
|
|
49
57
|
};
|
|
50
|
-
const schema: TSchema = values.length === 0 ? Type.Never(opts) : Type.Enum(values, opts);
|
|
58
|
+
const schema: TSchema = Array.isArray(values) && values.length === 0 ? Type.Never(opts) : Type.Enum(values, opts);
|
|
51
59
|
Object.defineProperty(schema, "toJSON", {
|
|
52
60
|
value: () => stringEnumWireSchema(values, options),
|
|
53
61
|
enumerable: false,
|
|
@@ -59,3 +67,7 @@ export function StringEnum<T extends string>(values: readonly T[], options?: Str
|
|
|
59
67
|
|
|
60
68
|
export * from "@oh-my-pi/pi-ai";
|
|
61
69
|
export { Type };
|
|
70
|
+
|
|
71
|
+
/** Compatibility aliases for renamed catalog functions */
|
|
72
|
+
export const getModel = getBundledModel;
|
|
73
|
+
export const getModels = getBundledModels;
|