@tshu-w/pi-agents 0.1.1 → 0.2.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.
@@ -0,0 +1,618 @@
1
+ /**
2
+ * `pd --mode rpc`, `-p`, and `--mode json` clients of a root Session: Pi's RPC commands on the
3
+ * Session's own runtime, which an attached terminal uses too. Relies on private members of
4
+ * `AgentSession` and `ExtensionRunner`; check them when Pi is updated.
5
+ */
6
+ import { ExtensionEditorComponent, getPackageDir, SessionManager, type AgentSession, type ExtensionRunner, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { AsyncLocalStorage, AsyncResource } from "node:async_hooks";
8
+ import { randomUUID } from "node:crypto";
9
+ import { writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ type Patchable = Record<PropertyKey, any>;
14
+
15
+ export interface RpcChannel {
16
+ signal: AbortSignal;
17
+ send(value: object): void;
18
+ onMessage(listener: (value: any) => void): void;
19
+ }
20
+
21
+ /** The host Pi runs in, as dialogs see it. */
22
+ export interface DialogHost {
23
+ attached(): boolean;
24
+ onChange(listener: () => void): () => void;
25
+ }
26
+
27
+ /** A `-p` or `--mode json` client's prompts, which run back to back once the Session is idle. */
28
+ interface Job {
29
+ channel: RpcChannel;
30
+ prompts: { message: string; images?: unknown[] }[];
31
+ stopped: boolean;
32
+ /** The job's prompts being admitted. */
33
+ admitting: number;
34
+ /** Resolves once the job's turns end. */
35
+ ended: Promise<void>;
36
+ end(): void;
37
+ }
38
+
39
+ /** How one Session admits prompts. */
40
+ interface Lane {
41
+ /** Prompts being admitted, until each starts, queues, or fails. */
42
+ admitting: number;
43
+ changed: Set<() => void>;
44
+ /** The job whose turns are running; until they end, no other prompt starts a turn. */
45
+ holder?: Job;
46
+ /** Runs that wait for the holder to end. */
47
+ deferred: number;
48
+ waiting: Job[];
49
+ draining: boolean;
50
+ }
51
+
52
+ // Shared across extension reloads: the prototypes are patched once.
53
+ const shared: {
54
+ patched: boolean;
55
+ sessions: WeakMap<object, AgentSession>;
56
+ forwards: WeakMap<object, (record: object) => void>;
57
+ lanes: WeakMap<object, Lane>;
58
+ host?: DialogHost;
59
+ /** Admits a prompt of `job`'s; prompts made while it runs, as by extensions, are part of the job. */
60
+ admit?: (session: AgentSession, job: Job, text: string, options: Patchable) => Promise<void>;
61
+ } = (globalThis as Patchable)[Symbol.for("pi-agents.rpc")] ??= { patched: false, sessions: new WeakMap(), forwards: new WeakMap(), lanes: new WeakMap() };
62
+
63
+ function lane(session: object): Lane {
64
+ let current = shared.lanes.get(session);
65
+ if (!current) shared.lanes.set(session, current = { admitting: 0, changed: new Set(), deferred: 0, waiting: [], draining: false });
66
+ return current;
67
+ }
68
+
69
+ /**
70
+ * Dialogs wait for an attached terminal: without one they are cancelled at once, and an open one is
71
+ * cancelled when its terminal detaches. Status, widgets, titles, notifications, and editor text also
72
+ * reach the RPC clients.
73
+ */
74
+ function sharedUI(ui: Patchable, manager: object): Patchable {
75
+ const forward = (record: object) => shared.forwards.get(manager)?.(record);
76
+ const dialog = <T>(cancelled: T, show: (signal: AbortSignal) => Promise<T>): Promise<T> => {
77
+ const host = shared.host;
78
+ if (!host) return show(new AbortController().signal);
79
+ if (!host.attached()) return Promise.resolve(cancelled);
80
+ const controller = new AbortController();
81
+ const stop = host.onChange(() => { if (!host.attached()) controller.abort(); });
82
+ return show(controller.signal).finally(stop);
83
+ };
84
+ const either = (signal: AbortSignal, other?: AbortSignal) => other ? AbortSignal.any([signal, other]) : signal;
85
+ return {
86
+ ...ui,
87
+ select: (title: string, options: string[], opts?: Patchable) =>
88
+ dialog(undefined, (signal) => ui.select(title, options, { ...opts, signal: either(signal, opts?.signal) })),
89
+ confirm: (title: string, message: string, opts?: Patchable) =>
90
+ dialog(false, (signal) => ui.confirm(title, message, { ...opts, signal: either(signal, opts?.signal) })),
91
+ input: (title: string, placeholder?: string, opts?: Patchable) =>
92
+ dialog(undefined, (signal) => ui.input(title, placeholder, { ...opts, signal: either(signal, opts?.signal) })),
93
+ // Pi's editor dialog, shown as a custom one so that it can be closed.
94
+ editor: (title: string, prefill?: string) => dialog(undefined, (signal) =>
95
+ ui.custom((tui: any, _theme: unknown, keybindings: any, done: (result: string | undefined) => void) => {
96
+ signal.addEventListener("abort", () => done(undefined), { once: true });
97
+ const command = shared.sessions.get(manager)?.settingsManager.getExternalEditorCommand();
98
+ return new ExtensionEditorComponent(tui, keybindings, title, prefill, done, () => done(undefined), undefined, command);
99
+ })),
100
+ custom: (factory: (...args: any[]) => unknown, options?: unknown) => dialog(undefined, (signal) =>
101
+ ui.custom((tui: unknown, theme: unknown, keybindings: unknown, done: (result: unknown) => void) => {
102
+ let cancelled = false;
103
+ signal.addEventListener("abort", () => { cancelled = true; done(undefined); }, { once: true });
104
+ // Pi drops a component that arrives after its dialog closed; it is disposed here instead.
105
+ return Promise.resolve(factory(tui, theme, keybindings, done)).then((component: any) => {
106
+ if (cancelled) component?.dispose?.();
107
+ return component;
108
+ });
109
+ }, options)),
110
+ notify(message: string, type?: string) {
111
+ ui.notify(message, type);
112
+ forward({ method: "notify", message, notifyType: type });
113
+ },
114
+ setStatus(key: string, text: string | undefined) {
115
+ ui.setStatus(key, text);
116
+ forward({ method: "setStatus", statusKey: key, statusText: text });
117
+ },
118
+ setWidget(key: string, content: unknown, options?: Patchable) {
119
+ ui.setWidget(key, content, options);
120
+ if (content === undefined || Array.isArray(content)) forward({ method: "setWidget", widgetKey: key, widgetLines: content, widgetPlacement: options?.placement });
121
+ },
122
+ setTitle(title: string) {
123
+ ui.setTitle(title);
124
+ forward({ method: "setTitle", title });
125
+ },
126
+ pasteToEditor(text: string) {
127
+ ui.pasteToEditor(text);
128
+ forward({ method: "set_editor_text", text });
129
+ },
130
+ setEditorText(text: string) {
131
+ ui.setEditorText(text);
132
+ forward({ method: "set_editor_text", text });
133
+ },
134
+ };
135
+ }
136
+
137
+ /** The `AgentSession` of the Session `manager` holds. */
138
+ export function agentSession(manager: object): AgentSession | undefined {
139
+ return shared.sessions.get(manager);
140
+ }
141
+
142
+ /** Keeps each Session's `AgentSession`, and shares the terminal's UI with RPC clients. */
143
+ export function installRpcPatches(Session: typeof AgentSession, Runner: typeof ExtensionRunner, host: DialogHost | undefined): void {
144
+ shared.host = host;
145
+ if (shared.patched) return;
146
+ shared.patched = true;
147
+ const session = Session.prototype as Patchable;
148
+ const bind = session.bindExtensions;
149
+ session.bindExtensions = function (this: Patchable, ...args: unknown[]) {
150
+ shared.sessions.set(this.sessionManager, this as AgentSession);
151
+ // Pi runs what it defers until a run settles one at a time, in that run's context; each keeps the context it
152
+ // was deferred in. One that is not part of the job holding the Session waits for it apart, so that the job's
153
+ // own run next does not wait for the job to end.
154
+ const deferred: Patchable = this._deferredSettledActions;
155
+ deferred.push = (...actions: (() => Promise<unknown>)[]) => Array.prototype.push.apply(deferred, actions.map((action) => {
156
+ const store = admitting.getStore();
157
+ const bound = AsyncResource.bind(action);
158
+ return () => {
159
+ const holder = lane(this).holder;
160
+ if (!holder || (store?.session === this && store.job === holder)) return bound();
161
+ bound().catch((e: unknown) => this.extensionRunner.emitError({ extensionPath: "<runtime>", event: "agent_settled", error: e instanceof Error ? e.message : String(e) }));
162
+ };
163
+ }));
164
+ return bind.apply(this, args);
165
+ };
166
+ // Prompts from the terminal and RPC clients are admitted one at a time, until each starts, queues, or
167
+ // fails: two that both find the Session idle would both start. A prompt made while one is being
168
+ // admitted, as by its extension command, is part of it.
169
+ const prompt = session.prompt;
170
+ const admissions = new WeakMap<object, Promise<void>>();
171
+ const admitting = new AsyncLocalStorage<{ session: object; active: boolean; job?: Job }>();
172
+ const admit = (self: AgentSession, store: { session: object; active: boolean; job?: Job }, text: string, options?: Patchable) => {
173
+ const current = lane(self);
174
+ current.admitting++;
175
+ if (store.job) store.job.admitting++;
176
+ const admitted = () => {
177
+ if (!store.active) return;
178
+ store.active = false;
179
+ current.admitting--;
180
+ if (store.job) store.job.admitting--;
181
+ for (const listener of current.changed) listener();
182
+ };
183
+ return admitting.run(store, () => prompt.call(self, text, {
184
+ ...options,
185
+ preflightResult: (disposition: string) => {
186
+ admitted();
187
+ options?.preflightResult?.(disposition);
188
+ },
189
+ })).finally(admitted);
190
+ };
191
+ shared.admit = (self, job, text, options) => admit(self, { session: self, active: true, job }, text, options);
192
+ session.prompt = async function (this: AgentSession, text: string, options?: Patchable) {
193
+ const store = admitting.getStore();
194
+ if (store?.session === this && store.active) return admit(this, { session: this, active: true, job: store.job }, text, options);
195
+ const current = lane(this);
196
+ if (store?.session === this && store.job && store.job === current.holder) return shared.admit!(this, store.job, text, options ?? {});
197
+ // Between a job's turns, a prompt that would start a turn waits until they end, letting later prompts in.
198
+ const blocked = () => current.holder && !this.isStreaming;
199
+ for (;;) {
200
+ while (blocked()) await current.holder!.ended;
201
+ let admitted!: () => void;
202
+ const turn = new Promise<void>((resolve) => { admitted = resolve; });
203
+ const previous = admissions.get(this) ?? Promise.resolve();
204
+ admissions.set(this, previous.then(() => turn));
205
+ await previous;
206
+ if (blocked()) {
207
+ admitted();
208
+ continue;
209
+ }
210
+ try {
211
+ return await admit(this, { session: this, active: true }, text, {
212
+ ...options,
213
+ preflightResult: (disposition: string) => {
214
+ admitted();
215
+ options?.preflightResult?.(disposition);
216
+ },
217
+ });
218
+ } finally {
219
+ admitted();
220
+ }
221
+ }
222
+ };
223
+ // While a job holds the Session, a run that is not part of it, as one an input hook delayed past the job's turn
224
+ // or an Agent's message started, waits until the job ends; a run started by then takes its messages as follow-ups.
225
+ // Pi 1.2 starts runs with `_startRun(trigger, onStarted)`, Pi 1.1 with `_runAgentPrompt(messages)`. A run that
226
+ // need not wait starts at once: Pi marks the Session busy only when the run starts.
227
+ const heldByOtherJob = (self: Patchable): boolean => {
228
+ const current = lane(self);
229
+ const store = admitting.getStore();
230
+ return current.holder !== undefined && (store?.session !== self || store.job !== current.holder);
231
+ };
232
+ const waitForJob = async (self: Patchable): Promise<void> => {
233
+ const current = lane(self);
234
+ current.deferred++;
235
+ try {
236
+ while (current.holder) await current.holder.ended;
237
+ } finally {
238
+ current.deferred--;
239
+ }
240
+ };
241
+ if (typeof session._startRun === "function") {
242
+ const startRun = session._startRun;
243
+ session._startRun = async function (this: Patchable, trigger: Patchable, onStarted?: () => void) {
244
+ if (!heldByOtherJob(this)) return startRun.call(this, trigger, onStarted);
245
+ // Another job may take the Session before this continues.
246
+ do await waitForJob(this); while (heldByOtherJob(this));
247
+ if (this._isEmittingAgentSettled) return void this._deferredSettledActions.push(() => this._startRun(trigger, onStarted));
248
+ if (this.isStreaming) {
249
+ // Queued as Pi queues input while it streams, without preparing another run.
250
+ if (trigger.messageType === "custom") this.agent.followUp(trigger.message);
251
+ else void this._queueFollowUp(trigger.prompt, trigger.images);
252
+ return void onStarted?.();
253
+ }
254
+ return startRun.call(this, trigger, onStarted);
255
+ };
256
+ } else {
257
+ const runAgentPrompt = session._runAgentPrompt;
258
+ session._runAgentPrompt = async function (this: Patchable, messages: unknown) {
259
+ if (!heldByOtherJob(this)) return runAgentPrompt.call(this, messages);
260
+ // Another job may take the Session before this continues.
261
+ do await waitForJob(this); while (heldByOtherJob(this));
262
+ if (this._isEmittingAgentSettled) return void this._deferredSettledActions.push(() => this._runAgentPrompt(messages));
263
+ if (this.isStreaming) return void [messages].flat().forEach((message) => this.agent.followUp(message));
264
+ return runAgentPrompt.call(this, messages);
265
+ };
266
+ }
267
+ const runner = Runner.prototype as Patchable;
268
+ const setUIContext = runner.setUIContext;
269
+ runner.setUIContext = function (this: Patchable, ui: Patchable | undefined, mode: string) {
270
+ return setUIContext.call(this, ui && mode === "tui" ? sharedUI(ui, this.sessionManager) : ui, mode);
271
+ };
272
+ }
273
+
274
+ function textOf(content: unknown): string {
275
+ if (typeof content === "string") return content;
276
+ return (content as Patchable[]).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("");
277
+ }
278
+
279
+ /**
280
+ * Writes a new Session beside `manager`'s, as Pi's new Session, fork, and clone do, without moving this
281
+ * Session's runtime to it: the branch up to `leafId`, or an empty Session without one.
282
+ */
283
+ function branch(manager: SessionManager, leafId: string | null, parentSession: string | undefined): string {
284
+ const copy = SessionManager.inMemory(manager.getCwd(), undefined, leafId ? [manager.getHeader()!, ...manager.getEntries()] : undefined);
285
+ if (leafId) copy.createBranchedSession(leafId);
286
+ const header = { ...copy.getHeader()!, parentSession };
287
+ const file = join(manager.getSessionDir(), `${header.timestamp.replace(/[:.]/g, "-")}_${header.id}.jsonl`);
288
+ writeFileSync(file, [header, ...copy.getEntries()].map((entry) => `${JSON.stringify(entry)}\n`).join(""), { flag: "wx" });
289
+ return file;
290
+ }
291
+
292
+ let toJsonEvent: ((event: unknown) => object) | undefined;
293
+
294
+ /**
295
+ * Resolves once `session` has no turn or compaction, and no prompt of `job`'s being admitted. Without `job`, no
296
+ * prompt may be admitted, nor a run wait for the previous job to end; `claim` runs at that moment.
297
+ */
298
+ function whenSettled(session: Patchable, job: Job | undefined, claim?: () => void): Promise<void> {
299
+ const current = lane(session);
300
+ const settled = () => session.isIdle && !session._isEmittingAgentSettled && (job ? job.admitting === 0 : current.admitting === 0 && current.deferred === 0);
301
+ return new Promise((resolve) => {
302
+ let done = false;
303
+ // The Session's state changes after its events and admissions, so it is checked once they finish.
304
+ const check = () => setImmediate(() => {
305
+ if (done || !settled()) return;
306
+ done = true;
307
+ unsubscribe();
308
+ current.changed.delete(check);
309
+ clearInterval(timer);
310
+ claim?.();
311
+ resolve();
312
+ });
313
+ const unsubscribe = session.subscribe(check);
314
+ current.changed.add(check);
315
+ const timer = setInterval(check, 100);
316
+ check();
317
+ });
318
+ }
319
+
320
+ /** Runs the waiting jobs of `session` one at a time, each once the Session is settled. */
321
+ async function drain(session: Patchable, current: Lane): Promise<void> {
322
+ if (current.draining) return;
323
+ current.draining = true;
324
+ try {
325
+ while (current.waiting.length > 0) {
326
+ let job: Job | undefined;
327
+ await whenSettled(session, undefined, () => { job = current.holder = current.waiting.shift(); });
328
+ if (job) await run(session, job);
329
+ }
330
+ } finally {
331
+ current.draining = false;
332
+ }
333
+ }
334
+
335
+ /** Stops the run or compaction in progress; each run of a stopped job is aborted once it starts. */
336
+ function stopTurn(session: Patchable): void {
337
+ if (session.isStreaming ? !session._agentRunAbortRequested : session.isCompacting) void session.abort().catch(() => undefined);
338
+ }
339
+
340
+ /** Runs `job`'s prompts back to back, sending it the Session's events until they end. */
341
+ async function run(session: Patchable, job: Job): Promise<void> {
342
+ let error: string | undefined;
343
+ const unsubscribe = session.subscribe((event: unknown) => {
344
+ job.channel.send({ event: toJsonEvent!(event) });
345
+ if (job.stopped) stopTurn(session);
346
+ });
347
+ try {
348
+ job.channel.send({ start: true });
349
+ for (const { message, images } of job.prompts) {
350
+ if (job.stopped || job.channel.signal.aborted) break;
351
+ for (;;) {
352
+ try {
353
+ // A turn started meanwhile, as by an Agent's message, takes the prompt as a follow-up.
354
+ await new Promise<void>((resolve, reject) => {
355
+ shared.admit!(session as AgentSession, job, message, { images, source: "rpc", streamingBehavior: "followUp", preflightResult: () => resolve() }).then(resolve, reject);
356
+ });
357
+ break;
358
+ } catch (e) {
359
+ // Another client may have started a compaction before the prompt reached Pi.
360
+ if (!session.isCompacting || job.stopped) throw e;
361
+ await whenSettled(session, job);
362
+ }
363
+ }
364
+ await whenSettled(session, job);
365
+ }
366
+ } catch (e) {
367
+ error = e instanceof Error ? e.message : String(e);
368
+ } finally {
369
+ unsubscribe();
370
+ lane(session).holder = undefined;
371
+ job.end();
372
+ job.channel.send(error === undefined ? { end: true } : { end: true, error });
373
+ }
374
+ }
375
+
376
+ /** The RPC clients of one root Session. */
377
+ export function createRpc() {
378
+ const clients = new Set<RpcChannel>();
379
+ const printers = new Set<RpcChannel>();
380
+ let session: Patchable | undefined;
381
+ let unsubscribe: (() => void) | undefined;
382
+
383
+ const success = (id: unknown, command: string, data?: unknown) =>
384
+ data === undefined ? { id, type: "response", command, success: true } : { id, type: "response", command, success: true, data };
385
+ const error = (id: unknown, command: string, message: string) => ({ id, type: "response", command, success: false, error: message });
386
+ const broadcast = (value: object) => { for (const client of clients) client.send(value); };
387
+
388
+ // Pi's RPC mode, with `switch_session` left to the client, which moves to the other Session, and
389
+ // `new_session`, `fork`, and `clone` creating the Session for the client to move to.
390
+ async function run(command: Patchable, reply: (value: object) => void): Promise<object | undefined> {
391
+ const current = session!;
392
+ const id = command.id;
393
+ switch (command.type) {
394
+ case "prompt": {
395
+ let preflightSucceeded = false;
396
+ void current.prompt(command.message, {
397
+ images: command.images,
398
+ streamingBehavior: command.streamingBehavior,
399
+ source: "rpc",
400
+ preflightResult: (disposition: string) => {
401
+ preflightSucceeded = true;
402
+ reply(success(id, "prompt", { disposition }));
403
+ },
404
+ }).catch((e: Error) => { if (!preflightSucceeded) reply(error(id, "prompt", e.message)); });
405
+ return undefined;
406
+ }
407
+ case "steer":
408
+ return success(id, "steer", { disposition: await current.steer(command.message, command.images, { source: "rpc" }) });
409
+ case "follow_up":
410
+ return success(id, "follow_up", { disposition: await current.followUp(command.message, command.images, { source: "rpc" }) });
411
+ case "abort":
412
+ await current.abort();
413
+ return success(id, "abort");
414
+ case "clear_queue":
415
+ return success(id, "clear_queue", current.clearQueue());
416
+ case "get_state":
417
+ return success(id, "get_state", {
418
+ model: current.model,
419
+ thinkingLevel: current.thinkingLevel,
420
+ isStreaming: current.isStreaming,
421
+ isCompacting: current.isCompacting,
422
+ steeringMode: current.steeringMode,
423
+ followUpMode: current.followUpMode,
424
+ sessionFile: current.sessionFile,
425
+ sessionId: current.sessionId,
426
+ sessionName: current.sessionName,
427
+ autoCompactionEnabled: current.autoCompactionEnabled,
428
+ messageCount: current.messages.length,
429
+ pendingMessageCount: current.pendingMessageCount,
430
+ });
431
+ case "set_model": {
432
+ const model = current.modelRuntime.getAvailableSnapshot().find((m: Patchable) => m.provider === command.provider && m.id === command.modelId);
433
+ if (!model) return error(id, "set_model", `Model not found: ${command.provider}/${command.modelId}`);
434
+ await current.setModel(model);
435
+ return success(id, "set_model", model);
436
+ }
437
+ case "cycle_model":
438
+ return success(id, "cycle_model", (await current.cycleModel()) ?? null);
439
+ case "get_available_models":
440
+ return success(id, "get_available_models", { models: current.modelRuntime.getAvailableSnapshot() });
441
+ case "set_thinking_level":
442
+ current.setThinkingLevel(command.level);
443
+ return success(id, "set_thinking_level");
444
+ case "cycle_thinking_level": {
445
+ const level = current.cycleThinkingLevel();
446
+ return success(id, "cycle_thinking_level", level ? { level } : null);
447
+ }
448
+ case "get_available_thinking_levels":
449
+ return success(id, "get_available_thinking_levels", { levels: current.getAvailableThinkingLevels() });
450
+ case "set_steering_mode":
451
+ current.setSteeringMode(command.mode);
452
+ return success(id, "set_steering_mode");
453
+ case "set_follow_up_mode":
454
+ current.setFollowUpMode(command.mode);
455
+ return success(id, "set_follow_up_mode");
456
+ case "compact":
457
+ return success(id, "compact", await current.compact(command.customInstructions));
458
+ case "set_auto_compaction":
459
+ current.setAutoCompactionEnabled(command.enabled);
460
+ return success(id, "set_auto_compaction");
461
+ case "set_auto_retry":
462
+ current.setAutoRetryEnabled(command.enabled);
463
+ return success(id, "set_auto_retry");
464
+ case "abort_retry":
465
+ current.abortRetry();
466
+ return success(id, "abort_retry");
467
+ case "bash": {
468
+ const eventResult = await current.extensionRunner.emitUserBash({
469
+ type: "user_bash",
470
+ command: command.command,
471
+ excludeFromContext: command.excludeFromContext ?? false,
472
+ cwd: current.sessionManager.getCwd(),
473
+ });
474
+ if (eventResult?.result) {
475
+ current.recordBashResult(command.command, eventResult.result, { excludeFromContext: command.excludeFromContext });
476
+ return success(id, "bash", eventResult.result);
477
+ }
478
+ return success(id, "bash", await current.executeBash(command.command, undefined, {
479
+ excludeFromContext: command.excludeFromContext, id, operations: eventResult?.operations,
480
+ }));
481
+ }
482
+ case "abort_bash":
483
+ current.abortBash();
484
+ return success(id, "abort_bash");
485
+ case "get_session_stats":
486
+ return success(id, "get_session_stats", current.getSessionStats());
487
+ case "export_html":
488
+ return success(id, "export_html", { path: await current.exportToHtml(command.outputPath) });
489
+ case "new_session":
490
+ return success(id, "new_session", { cancelled: false, sessionFile: branch(current.sessionManager, null, command.parentSession) });
491
+ case "fork": {
492
+ const entry = current.sessionManager.getEntry(command.entryId);
493
+ if (entry?.type !== "message" || entry.message.role !== "user") throw new Error("Invalid entry ID for forking");
494
+ return success(id, "fork", { text: textOf(entry.message.content), cancelled: false, sessionFile: branch(current.sessionManager, entry.parentId, current.sessionFile) });
495
+ }
496
+ case "clone": {
497
+ const leafId = current.sessionManager.getLeafId();
498
+ if (!leafId) return error(id, "clone", "Cannot clone session: no current entry selected");
499
+ return success(id, "clone", { cancelled: false, sessionFile: branch(current.sessionManager, leafId, current.sessionFile) });
500
+ }
501
+ case "get_fork_messages":
502
+ return success(id, "get_fork_messages", { messages: current.getUserMessagesForForking() });
503
+ case "get_entries": {
504
+ let entries = current.sessionManager.getEntries();
505
+ if (command.since !== undefined) {
506
+ const sinceIndex = entries.findIndex((e: Patchable) => e.id === command.since);
507
+ if (sinceIndex === -1) return error(id, "get_entries", `Entry not found: ${command.since}`);
508
+ entries = entries.slice(sinceIndex + 1);
509
+ }
510
+ return success(id, "get_entries", { entries, leafId: current.sessionManager.getLeafId() });
511
+ }
512
+ case "get_tree":
513
+ return success(id, "get_tree", { tree: current.sessionManager.getTree(), leafId: current.sessionManager.getLeafId() });
514
+ case "get_last_assistant_text":
515
+ return success(id, "get_last_assistant_text", { text: current.getLastAssistantText() });
516
+ case "set_session_name": {
517
+ const name = command.name.trim();
518
+ if (!name) return error(id, "set_session_name", "Session name cannot be empty");
519
+ current.setSessionName(name);
520
+ return success(id, "set_session_name");
521
+ }
522
+ case "get_messages":
523
+ return success(id, "get_messages", { messages: current.messages });
524
+ case "get_commands": {
525
+ const commands = [
526
+ ...current.extensionRunner.getRegisteredCommands().map((c: Patchable) => ({ name: c.invocationName, description: c.description, source: "extension", sourceInfo: c.sourceInfo })),
527
+ ...current.promptTemplates.map((t: Patchable) => ({ name: t.name, description: t.description, source: "prompt", sourceInfo: t.sourceInfo })),
528
+ ...current.resourceLoader.getSkills().skills.map((s: Patchable) => ({ name: `skill:${s.name}`, description: s.description, source: "skill", sourceInfo: s.sourceInfo })),
529
+ ];
530
+ return success(id, "get_commands", { commands });
531
+ }
532
+ default:
533
+ return error(id, command.type, `Unknown command: ${command.type}`);
534
+ }
535
+ }
536
+
537
+ async function handle(channel: RpcChannel, command: Patchable): Promise<void> {
538
+ // Dialogs are not sent to RPC clients, so there is nothing to answer.
539
+ if (command?.type === "extension_ui_response") return;
540
+ const reply = (value: object) => channel.send(value);
541
+ try {
542
+ const response = await run(command, reply);
543
+ if (response) reply(response);
544
+ } catch (e) {
545
+ reply(error(command?.id, command?.type, e instanceof Error ? e.message : String(e)));
546
+ }
547
+ }
548
+
549
+ return {
550
+ /** Loads the Pi module that turns events into RPC records. */
551
+ async prepare(): Promise<void> {
552
+ toJsonEvent ??= (await import(pathToFileURL(join(getPackageDir(), "dist/modes/json-event.js")).href)).toJsonEvent;
553
+ },
554
+
555
+ /** Connects a client to the Session `manager` holds; resolves to the Session's header. */
556
+ open(manager: ExtensionContext["sessionManager"], channel: RpcChannel): { header: unknown } {
557
+ const current = shared.sessions.get(manager);
558
+ if (!current || !toJsonEvent) throw new Error("The Session is not ready.");
559
+ if (session !== current) {
560
+ unsubscribe?.();
561
+ session = current;
562
+ unsubscribe = current.subscribe((event) => broadcast(toJsonEvent!(event)));
563
+ shared.forwards.set(manager, (record) => broadcast({ type: "extension_ui_request", id: randomUUID(), ...record }));
564
+ }
565
+ clients.add(channel);
566
+ channel.signal.addEventListener("abort", () => clients.delete(channel), { once: true });
567
+ channel.onMessage((command) => void handle(channel, command));
568
+ return { header: manager.getHeader() };
569
+ },
570
+
571
+ /**
572
+ * Connects a `-p` or `--mode json` client to the Session `manager` holds; resolves to the Session's header.
573
+ * Its `run` command queues its prompts, and `stop` withdraws them or stops their turn.
574
+ */
575
+ print(manager: ExtensionContext["sessionManager"], channel: RpcChannel): { header: unknown } {
576
+ const current = shared.sessions.get(manager) as Patchable | undefined;
577
+ if (!current || !toJsonEvent) throw new Error("The Session is not ready.");
578
+ const queue = lane(current);
579
+ let job: Job | undefined;
580
+ const withdraw = () => {
581
+ const at = job ? queue.waiting.indexOf(job) : -1;
582
+ if (at < 0) return false;
583
+ queue.waiting.splice(at, 1);
584
+ return true;
585
+ };
586
+ printers.add(channel);
587
+ // A client that is gone withdraws its prompts that have not started; its running turn goes on.
588
+ channel.signal.addEventListener("abort", () => {
589
+ printers.delete(channel);
590
+ withdraw();
591
+ }, { once: true });
592
+ channel.onMessage((command) => {
593
+ if (command?.type === "run" && !job) {
594
+ let end!: () => void;
595
+ const ended = new Promise<void>((resolve) => { end = resolve; });
596
+ job = { channel, prompts: command.prompts, stopped: false, admitting: 0, ended, end };
597
+ queue.waiting.push(job);
598
+ void drain(current, queue);
599
+ } else if (command?.type === "stop" && job && !job.stopped) {
600
+ job.stopped = true;
601
+ if (withdraw()) channel.send({ end: true });
602
+ else if (queue.holder === job) stopTurn(current);
603
+ }
604
+ });
605
+ return { header: manager.getHeader() };
606
+ },
607
+
608
+ clients: () => clients.size + printers.size,
609
+
610
+ close(): void {
611
+ unsubscribe?.();
612
+ unsubscribe = undefined;
613
+ if (session) shared.forwards.delete(session.sessionManager);
614
+ session = undefined;
615
+ clients.clear();
616
+ },
617
+ };
618
+ }
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { trackTreeWork, treeMetadata } from "../agents/registry.ts";
6
6
  import { sendViaDaemon } from "../daemon/client.mjs";
7
7
  import { prepareDirectory, statePaths, sessionDir as resolveSessionDir } from "./paths.mjs";
8
+ import { createRpc } from "./rpc.ts";
8
9
  import { rootSnapshot } from "./snapshot.mjs";
9
10
  import { recoverSocket } from "./socket-recovery.mjs";
10
11
  import { listen, request } from "./transport.mjs";
@@ -46,7 +47,7 @@ function piCli(): string {
46
47
 
47
48
  /**
48
49
  * Makes this root Agent reachable from other processes: it answers status requests,
49
- * and accepts messages from other roots.
50
+ * accepts messages from other roots, and serves RPC clients.
50
51
  */
51
52
  export function createRootRuntime(pi: ExtensionAPI, hooks: RootHooks) {
52
53
  const paths = statePaths();
@@ -55,6 +56,7 @@ export function createRootRuntime(pi: ExtensionAPI, hooks: RootHooks) {
55
56
  let server: Awaited<ReturnType<typeof listen>> | undefined;
56
57
  let ready = false;
57
58
  let seen = new Set<string>();
59
+ const rpc = createRpc();
58
60
 
59
61
  function availabilityError(): string | undefined {
60
62
  const ctx = active!;
@@ -159,11 +161,24 @@ export function createRootRuntime(pi: ExtensionAPI, hooks: RootHooks) {
159
161
  await prepareDirectory(paths.runtime);
160
162
  const socket = paths.session(ctx.sessionManager.getSessionId());
161
163
  await recoverSocket(socket);
162
- server = await listen(socket, { status: current, accept });
164
+ await rpc.prepare();
165
+ server = await listen(socket, {
166
+ status: current,
167
+ accept,
168
+ stream: (input: { role: string }, channel: Parameters<typeof rpc.open>[1]) => {
169
+ if (input.role !== "rpc" && input.role !== "print") throw new Error("Unsupported stream role.");
170
+ if (!ready || !active) throw Object.assign(new Error("The Session is not ready for RPC clients."), { code: "NOT_READY" });
171
+ return input.role === "rpc" ? rpc.open(active.sessionManager, channel) : rpc.print(active.sessionManager, channel);
172
+ },
173
+ });
163
174
  },
164
175
 
176
+ /** How many RPC clients are connected. */
177
+ rpcClients: () => rpc.clients(),
178
+
165
179
  async stop(): Promise<void> {
166
180
  ready = false;
181
+ rpc.close();
167
182
  await server?.close();
168
183
  server = undefined;
169
184
  active = undefined;