@fitchmultz/pi-subagents 0.45.0 → 0.46.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,317 @@
1
+ import { createHash } from "node:crypto";
2
+ import { statSync } from "node:fs";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { loadNativeSession } from "../shared/native-session-loader.ts";
5
+ import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
6
+ import type { SessionInfo } from "./types.ts";
7
+ import type { Identity } from "./bridge-config.ts";
8
+ import type { LaunchPolicy } from "./bridge-launch-policy.ts";
9
+ import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
10
+ import { RpcProcess } from "./bridge-rpc-process.ts";
11
+
12
+ interface Launch {
13
+ readonly identity: Identity;
14
+ readonly name: string;
15
+ readonly controller: Readonly<AbortController>;
16
+ readonly expires: number;
17
+ admitted: boolean;
18
+ sessionId?: string;
19
+ peerId?: string;
20
+ sessionFile?: string;
21
+ process?: RpcProcess;
22
+ timer?: NodeJS.Timeout;
23
+ }
24
+ interface StartContext {
25
+ readonly identity: Identity;
26
+ readonly expires: number;
27
+ readonly signal: AbortSignal;
28
+ readonly peers: (
29
+ signal: AbortSignal,
30
+ ) => Promise<readonly Readonly<Pick<SessionInfo, "id" | "name" | "cwd">>[]>;
31
+ }
32
+
33
+ export function sameIdentity(left: Identity, right: Identity | undefined): boolean {
34
+ return (
35
+ left.name === right?.name &&
36
+ left.cwd === right.cwd &&
37
+ JSON.stringify(left.launch) === JSON.stringify(right.launch)
38
+ );
39
+ }
40
+ function view(entry: Readonly<Launch>): UnknownRecord {
41
+ return {
42
+ sessionId: entry.sessionId,
43
+ peerId: entry.peerId,
44
+ name: entry.name,
45
+ sessionFile: entry.sessionFile,
46
+ pid: entry.process?.pid,
47
+ status: entry.process?.status ?? "starting",
48
+ error: entry.process?.failure?.code,
49
+ };
50
+ }
51
+
52
+ export class Launcher {
53
+ private readonly launches = new Set<Launch>();
54
+ private stopping = false;
55
+
56
+ list(identity: Identity): UnknownRecord {
57
+ return {
58
+ sessions: [...this.launches]
59
+ .filter((entry) => entry.identity.fingerprint256 === identity.fingerprint256)
60
+ .map(view),
61
+ };
62
+ }
63
+ async stop(identity: Identity, sessionId: string): Promise<UnknownRecord> {
64
+ const entry = [...this.launches].find(
65
+ (candidate) =>
66
+ candidate.identity.fingerprint256 === identity.fingerprint256 &&
67
+ candidate.sessionId === sessionId,
68
+ );
69
+ if (!entry) {
70
+ throw new Fault(404, "unknown_session", "Session is not owned by this identity and helper.");
71
+ }
72
+ await this.retire(entry, new Fault(410, "session_stopped", "Session stopped by its owner."));
73
+ return { session: view(entry) };
74
+ }
75
+ async start(name: string, context: StartContext): Promise<UnknownRecord> {
76
+ cancelled(context.signal);
77
+ const policy = context.identity.launch;
78
+ if (!policy || this.stopping) {
79
+ throw new Fault(403, "launch_disabled", "Session launching is not enabled by local policy.");
80
+ }
81
+ const fullName = `remote:${context.identity.name}:${name}`;
82
+ const existing = [...this.launches].find(
83
+ (entry) =>
84
+ entry.identity.fingerprint256 === context.identity.fingerprint256 &&
85
+ entry.name === fullName &&
86
+ entry.process?.status !== "exited",
87
+ );
88
+ if (existing) {
89
+ return this.reuse(existing);
90
+ }
91
+ this.capacity(context.identity, policy);
92
+ const entry: Launch = {
93
+ identity: context.identity,
94
+ name: fullName,
95
+ controller: new AbortController(),
96
+ expires: context.expires,
97
+ admitted: false,
98
+ };
99
+ this.launches.add(entry);
100
+ try {
101
+ return await this.establish(entry, policy, context);
102
+ } catch (error) {
103
+ await this.retire(entry, new Fault(503, "launch_failed", "Pi startup failed.")).catch(() =>
104
+ log("launch_cleanup", { pid: entry.process?.pid, result: "failed" }),
105
+ );
106
+ if (!entry.process) {
107
+ this.launches.delete(entry);
108
+ }
109
+ throw error instanceof Fault
110
+ ? error
111
+ : new Fault(
112
+ 503,
113
+ "launch_failed",
114
+ "Pi startup failed; inspect the pinned local policy and resources.",
115
+ );
116
+ }
117
+ }
118
+ private reuse(entry: Readonly<Launch>): UnknownRecord {
119
+ if (
120
+ entry.admitted &&
121
+ (entry.process?.status === "idle" || entry.process?.status === "running")
122
+ ) {
123
+ return { session: view(entry), reused: true };
124
+ }
125
+ throw new Fault(
126
+ 409,
127
+ "launch_in_progress",
128
+ "This named session is starting or stopping; inspect sessions before retrying.",
129
+ );
130
+ }
131
+ private capacity(identity: Identity, policy: LaunchPolicy): void {
132
+ const active = [...this.launches].filter((entry) => entry.process?.status !== "exited");
133
+ if (
134
+ active.length >= 32 ||
135
+ active.filter((entry) => entry.identity.fingerprint256 === identity.fingerprint256).length >=
136
+ policy.maxSessions
137
+ ) {
138
+ throw new Fault(
139
+ 429,
140
+ "session_limit",
141
+ "Local launched-session capacity reached; stop an owned session first.",
142
+ );
143
+ }
144
+ for (const entry of this.launches) {
145
+ if (this.launches.size >= 64 && entry.process?.status === "exited") {
146
+ this.launches.delete(entry);
147
+ }
148
+ }
149
+ }
150
+ private async journal(entry: Launch, policy: LaunchPolicy): Promise<void> {
151
+ const { SessionManager } = await wait(
152
+ loadNativeSession(policy.packageRoot),
153
+ entry.controller.signal,
154
+ );
155
+ cancelled(entry.controller.signal);
156
+ const storage = statSync(policy.sessionDir);
157
+ if (
158
+ !storage.isDirectory() ||
159
+ storage.uid !== process.getuid?.() ||
160
+ (storage.mode & 0o777) !== 0o700
161
+ ) {
162
+ throw new Fault(503, "storage_invalid", "Private launch storage is no longer valid.");
163
+ }
164
+ const previous = process.umask(0o077);
165
+ try {
166
+ const manager = SessionManager.create(entry.identity.cwd, policy.sessionDir);
167
+ manager.appendSessionInfo(entry.name);
168
+ manager.appendMessage({ role: "user", content: policy.context, timestamp: Date.now() });
169
+ entry.sessionId = manager.getSessionId();
170
+ entry.sessionFile = manager.getSessionFile();
171
+ entry.peerId = `pi-${createHash("sha256").update(entry.sessionId).digest("hex").slice(0, 32)}`;
172
+ if (entry.sessionFile === undefined || (statSync(entry.sessionFile).mode & 0o777) !== 0o600) {
173
+ throw new Fault(503, "journal_invalid", "Native journal was not privately persisted.");
174
+ }
175
+ const saved = SessionManager.open(entry.sessionFile, policy.sessionDir);
176
+ if (
177
+ saved.getSessionId() !== entry.sessionId ||
178
+ saved.getSessionName() !== entry.name ||
179
+ saved.getCwd() !== entry.identity.cwd
180
+ ) {
181
+ throw new Fault(503, "journal_invalid", "Native journal identity did not persist.");
182
+ }
183
+ } finally {
184
+ process.umask(previous);
185
+ }
186
+ }
187
+ private async establish(
188
+ entry: Launch,
189
+ policy: LaunchPolicy,
190
+ context: StartContext,
191
+ ): Promise<UnknownRecord> {
192
+ const signal = AbortSignal.any([context.signal, entry.controller.signal]);
193
+ const deadline = setTimeout(
194
+ () =>
195
+ entry.controller.abort(
196
+ new Fault(504, "launch_timeout", "Pi startup readiness deadline exceeded."),
197
+ ),
198
+ policy.startupTimeoutMs,
199
+ );
200
+ const abort = () =>
201
+ entry.controller.abort(
202
+ new Fault(499, "launch_cancelled", "Session startup cancelled or exceeded its deadline."),
203
+ );
204
+ signal.addEventListener("abort", abort, { once: true });
205
+ try {
206
+ await this.journal(entry, policy);
207
+ cancelled(signal);
208
+ if (entry.sessionFile === undefined) {
209
+ throw new Fault(503, "journal_invalid", "Native journal path unavailable.");
210
+ }
211
+ entry.process = new RpcProcess(policy, entry.identity.cwd, entry.sessionFile);
212
+ this.expiry(entry);
213
+ const state = await entry.process.state(signal);
214
+ this.validateState(entry, policy, state);
215
+ await this.peerReady(entry, context.peers, signal);
216
+ cancelled(signal);
217
+ entry.process.ready();
218
+ entry.admitted = true;
219
+ log("launch_ready", {
220
+ fingerprint: entry.identity.fingerprint256,
221
+ name: entry.name,
222
+ peer: entry.peerId,
223
+ pid: entry.process.pid,
224
+ result: "ready",
225
+ });
226
+ return { session: view(entry), reused: false };
227
+ } finally {
228
+ clearTimeout(deadline);
229
+ signal.removeEventListener("abort", abort);
230
+ }
231
+ }
232
+ private validateState(entry: Readonly<Launch>, policy: LaunchPolicy, state: UnknownRecord): void {
233
+ if (
234
+ state.sessionId !== entry.sessionId ||
235
+ state.sessionFile !== entry.sessionFile ||
236
+ state.sessionName !== entry.name ||
237
+ state.isStreaming !== false ||
238
+ state.pendingMessageCount !== 0 ||
239
+ !isRecord(state.model) ||
240
+ state.model.provider !== policy.provider ||
241
+ state.model.id !== policy.model
242
+ ) {
243
+ throw new Fault(
244
+ 503,
245
+ "readiness_failed",
246
+ "Pi did not start idle with the exact saved session and configured model.",
247
+ );
248
+ }
249
+ }
250
+ private async peerReady(
251
+ entry: Readonly<Launch>,
252
+ peers: StartContext["peers"],
253
+ signal: AbortSignal,
254
+ ): Promise<void> {
255
+ while (!entry.process?.failure) {
256
+ cancelled(signal);
257
+ // Registration is asynchronous after native session_start; observe real broker publication.
258
+ // oxlint-disable-next-line no-await-in-loop
259
+ const rows = await peers(signal);
260
+ if (
261
+ rows.some(
262
+ (peer) =>
263
+ peer.id === entry.peerId && peer.name === entry.name && peer.cwd === entry.identity.cwd,
264
+ )
265
+ ) {
266
+ return;
267
+ }
268
+ // Bounded readiness polling is not an acknowledgement substitute; only the row above succeeds.
269
+ // oxlint-disable-next-line no-await-in-loop
270
+ await delay(100, undefined, { signal });
271
+ }
272
+ throw entry.process.failure;
273
+ }
274
+ private expiry(entry: Launch): void {
275
+ const expire = () => {
276
+ if (entry.expires <= Date.now()) {
277
+ this.retire(
278
+ entry,
279
+ new Fault(403, "certificate_expired", "Launch owner's certificate expired."),
280
+ ).catch(() => log("launch_cleanup", { result: "failed" }));
281
+ } else {
282
+ entry.timer = setTimeout(expire, Math.min(entry.expires - Date.now(), 0x7fffffff));
283
+ }
284
+ };
285
+ expire();
286
+ entry.process?.exited
287
+ .then(() => clearTimeout(entry.timer))
288
+ .catch(() => log("launch_cleanup", { result: "failed" }));
289
+ }
290
+ private async retire(entry: Launch, fault: Readonly<Fault>): Promise<void> {
291
+ entry.controller.abort(fault);
292
+ clearTimeout(entry.timer);
293
+ await entry.process?.stop();
294
+ }
295
+ async revoke(clients: Readonly<ReadonlyMap<string, Identity>>): Promise<void> {
296
+ await Promise.all(
297
+ [...this.launches]
298
+ .filter(
299
+ (entry) => !sameIdentity(entry.identity, clients.get(entry.identity.fingerprint256)),
300
+ )
301
+ .map((entry) =>
302
+ this.retire(
303
+ entry,
304
+ new Fault(403, "revoked", "Launch owner's identity or policy was revoked."),
305
+ ),
306
+ ),
307
+ );
308
+ }
309
+ async shutdown(): Promise<void> {
310
+ this.stopping = true;
311
+ await Promise.all(
312
+ [...this.launches].map((entry) =>
313
+ this.retire(entry, new Fault(503, "shutting_down", "Bridge is shutting down.")),
314
+ ),
315
+ );
316
+ }
317
+ }
@@ -5,6 +5,9 @@ import { body } from "./bridge-http.ts";
5
5
 
6
6
  export type Operation =
7
7
  | { readonly route: "GET /v1/list" | "GET /v1/inbox" | "POST /v1/register" }
8
+ | { readonly route: "GET /v1/sessions" }
9
+ | { readonly route: "POST /v1/start"; readonly name: string }
10
+ | { readonly route: "POST /v1/stop"; readonly sessionId: string }
8
11
  | { readonly route: "POST /v1/ack"; readonly ids: readonly string[] }
9
12
  | { readonly route: "POST /v1/send"; readonly to: string; readonly message: string }
10
13
  | {
@@ -14,6 +17,10 @@ export type Operation =
14
17
  readonly timeoutMs: number;
15
18
  }
16
19
  | { readonly route: "POST /v1/reply"; readonly replyTo: string; readonly message: string };
20
+ export type MessagingOperation = Exclude<
21
+ Operation,
22
+ { readonly route: "POST /v1/start" | "POST /v1/stop" | "GET /v1/sessions" }
23
+ >;
17
24
  type Route = Operation["route"];
18
25
 
19
26
  const fields = {
@@ -24,6 +31,9 @@ const fields = {
24
31
  "POST /v1/send": ["to", "message"],
25
32
  "POST /v1/ask": ["to", "message", "timeoutMs"],
26
33
  "POST /v1/reply": ["replyTo", "message"],
34
+ "POST /v1/start": ["name"],
35
+ "GET /v1/sessions": [],
36
+ "POST /v1/stop": ["sessionId"],
27
37
  };
28
38
 
29
39
  function isRoute(route: string): route is Route {
@@ -68,7 +78,12 @@ export async function operationFor(
68
78
  case "GET /v1/list":
69
79
  case "GET /v1/inbox":
70
80
  case "POST /v1/register":
81
+ case "GET /v1/sessions":
71
82
  return { route };
83
+ case "POST /v1/start":
84
+ return { route, name: label(input.name, "session name", 64).trim() };
85
+ case "POST /v1/stop":
86
+ return { route, sessionId: label(input.sessionId, "session ID", 128) };
72
87
  case "POST /v1/ack": {
73
88
  if (!isUnknownArray(input.ids) || input.ids.length > INBOX_COUNT) {
74
89
  throw new Fault(400, "invalid_input", "ids must be an array of at most 256 message IDs.");
@@ -0,0 +1,293 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ import type { RpcCommand, RpcExtensionUIResponse } from "@earendil-works/pi-coding-agent";
4
+ import { trySignalChildTree } from "../shared/post-exit-stdio-guard.ts";
5
+ import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
6
+ import { Fault, log, wait } from "./bridge-protocol.ts";
7
+ import type { LaunchPolicy } from "./bridge-launch-policy.ts";
8
+
9
+ const FRAME_LIMIT = 4 * 1024 * 1024;
10
+ function launchEnvironment(packageRoot: string): NodeJS.ProcessEnv {
11
+ const environment = Object.fromEntries(
12
+ Object.entries(process.env).filter(([key]) => !key.startsWith("PI_SUBAGENT_")),
13
+ );
14
+ return { ...environment, PI_PACKAGE_DIR: packageRoot };
15
+ }
16
+
17
+ // Native RpcClient lacks public EOF/exit/UI control and retains/reprints unbounded stderr.
18
+ // This owner implements only get_state and dialog cancellation, never a remote RPC proxy.
19
+ export class RpcProcess {
20
+ private readonly child: ChildProcessWithoutNullStreams;
21
+ readonly exited: Promise<void>;
22
+ status: "starting" | "idle" | "running" | "stopping" | "exited" = "starting";
23
+ failure?: Fault;
24
+ private readonly decoder = new StringDecoder("utf8");
25
+ private buffer = "";
26
+ private discarding = false;
27
+ private sequence = 0;
28
+ private pending?: {
29
+ readonly id: string;
30
+ readonly resolve: (data: UnknownRecord) => void;
31
+ readonly reject: (error: Readonly<Error>) => void;
32
+ };
33
+ private stopping?: Promise<void>;
34
+ private stderrBytes = 0;
35
+ private exitObserved = false;
36
+
37
+ constructor(policy: LaunchPolicy, cwd: string, sessionFile: string) {
38
+ this.child = spawn(process.execPath, this.arguments(policy, sessionFile), {
39
+ cwd,
40
+ detached: true,
41
+ env: launchEnvironment(policy.packageRoot),
42
+ stdio: ["pipe", "pipe", "pipe"],
43
+ });
44
+ this.exited = new Promise((resolveExit) => {
45
+ this.child.once("close", (code, signal) => {
46
+ this.exitObserved = true;
47
+ this.buffer = "";
48
+ this.discarding = false;
49
+ this.status = "exited";
50
+ this.fail(new Fault(503, "session_exited", "Launched Pi process exited."));
51
+ log("launch_exit", { pid: this.child.pid, code, signal, stderrBytes: this.stderrBytes });
52
+ resolveExit();
53
+ });
54
+ });
55
+ this.child.once("error", () =>
56
+ this.fail(new Fault(503, "launch_failed", "Pi process could not start.")),
57
+ );
58
+ this.child.stdin.on("error", () => {
59
+ this.fail(new Fault(503, "rpc_closed", "Pi RPC input closed."));
60
+ this.cleanup();
61
+ });
62
+ this.child.stdout.once("end", () => {
63
+ this.fail(new Fault(503, "rpc_closed", "Pi RPC output closed."));
64
+ this.cleanup();
65
+ });
66
+ for (const stream of [this.child.stdout, this.child.stderr]) {
67
+ stream.on("error", () => {
68
+ this.fail(new Fault(503, "rpc_closed", "Pi RPC output failed."));
69
+ this.cleanup();
70
+ });
71
+ }
72
+ this.child.stdout.on("data", (chunk: Buffer) => this.read(this.decoder.write(chunk)));
73
+ this.child.stderr.on("data", (chunk: Buffer) => {
74
+ // Drain without retaining or exposing diagnostics, including credential-bearing extension errors.
75
+ this.stderrBytes = Math.min(Number.MAX_SAFE_INTEGER, this.stderrBytes + chunk.length);
76
+ });
77
+ }
78
+ get pid(): number | undefined {
79
+ return this.child.pid;
80
+ }
81
+ private arguments(policy: LaunchPolicy, sessionFile: string): string[] {
82
+ const args = [
83
+ policy.cli,
84
+ "--mode",
85
+ "rpc",
86
+ "--session",
87
+ sessionFile,
88
+ "--provider",
89
+ policy.provider,
90
+ "--model",
91
+ policy.model,
92
+ policy.trustProject ? "--approve" : "--no-approve",
93
+ ];
94
+ if (!policy.discoverResources) {
95
+ args.push(
96
+ "--no-extensions",
97
+ "--no-skills",
98
+ "--no-prompt-templates",
99
+ "--no-themes",
100
+ "--no-context-files",
101
+ );
102
+ }
103
+ if (policy.offline) {
104
+ args.push("--offline");
105
+ }
106
+ for (const extension of policy.extensions) {
107
+ args.push("--extension", extension);
108
+ }
109
+ return args;
110
+ }
111
+ private fail(error: Readonly<Fault>): void {
112
+ this.failure ??= error;
113
+ this.pending?.reject(this.failure);
114
+ this.pending = undefined;
115
+ }
116
+ private retainedChunk(chunk: string): string {
117
+ if (!this.discarding) {
118
+ return chunk;
119
+ }
120
+ const end = chunk.indexOf("\n");
121
+ if (end < 0) {
122
+ return "";
123
+ }
124
+ this.discarding = false;
125
+ return chunk.slice(end + 1);
126
+ }
127
+ private read(chunk: string): void {
128
+ this.buffer += this.retainedChunk(chunk);
129
+ let newline = this.buffer.indexOf("\n");
130
+ while (newline >= 0) {
131
+ const line = this.buffer.slice(0, newline);
132
+ this.buffer = this.buffer.slice(newline + 1);
133
+ if (Buffer.byteLength(line) > FRAME_LIMIT) {
134
+ this.oversize(line);
135
+ } else {
136
+ this.record(line);
137
+ }
138
+ newline = this.buffer.indexOf("\n");
139
+ }
140
+ if (Buffer.byteLength(this.buffer) > FRAME_LIMIT) {
141
+ this.discarding = this.oversize(this.buffer);
142
+ this.buffer = "";
143
+ }
144
+ }
145
+ private oversize(prefix: string): boolean {
146
+ // Native event serializers put the discriminant first; responses may put their request ID first.
147
+ // ponytail: drain large native data events through LF; use token streaming if producer headers change.
148
+ const header = /^\{"type":"([a-z_]+)"[,}]/.exec(prefix.slice(0, 256));
149
+ const event = header?.[1];
150
+ if (
151
+ event !== undefined &&
152
+ !["response", "extension_ui_request", "agent_start", "agent_settled"].includes(event)
153
+ ) {
154
+ log("rpc_event_discarded", { pid: this.pid, result: "oversized" });
155
+ return true;
156
+ }
157
+ this.protocolFailure();
158
+ return false;
159
+ }
160
+ private protocolFailure(): void {
161
+ this.fail(new Fault(503, "rpc_invalid", "Pi RPC output violated its bounded JSONL contract."));
162
+ this.cleanup();
163
+ }
164
+ private record(line: string): void {
165
+ let data: unknown;
166
+ try {
167
+ data = JSON.parse(line);
168
+ } catch {
169
+ this.protocolFailure();
170
+ return;
171
+ }
172
+ if (!isRecord(data)) {
173
+ this.protocolFailure();
174
+ return;
175
+ }
176
+ if (data.type === "response" && data.id === this.pending?.id) {
177
+ this.response(data);
178
+ } else if (data.type === "extension_ui_request") {
179
+ this.dialog(data);
180
+ } else if (!this.failure) {
181
+ this.event(data.type);
182
+ }
183
+ }
184
+ private event(type: unknown): void {
185
+ if (type === "agent_start") {
186
+ this.status = "running";
187
+ } else if (type === "agent_settled") {
188
+ this.status = "idle";
189
+ }
190
+ }
191
+ private response(data: UnknownRecord): void {
192
+ const pending = this.pending;
193
+ this.pending = undefined;
194
+ if (data.success === true && data.command === "get_state" && isRecord(data.data)) {
195
+ pending?.resolve(data.data);
196
+ } else {
197
+ pending?.reject(new Fault(503, "rpc_failed", "Pi RPC state request failed."));
198
+ }
199
+ }
200
+ private dialog(data: UnknownRecord): void {
201
+ if (
202
+ typeof data.method !== "string" ||
203
+ !["confirm", "select", "input", "editor"].includes(data.method)
204
+ ) {
205
+ return;
206
+ }
207
+ if (typeof data.id === "string") {
208
+ this.write({ type: "extension_ui_response", id: data.id, cancelled: true });
209
+ }
210
+ this.fail(
211
+ new Fault(
212
+ 409,
213
+ "interaction_required",
214
+ "Pi requested unsupported interactive input; dialog cancelled and session stopped.",
215
+ ),
216
+ );
217
+ this.cleanup();
218
+ }
219
+ private write(
220
+ command: Readonly<
221
+ | Extract<RpcCommand, { type: "get_state" }>
222
+ | Extract<RpcExtensionUIResponse, { cancelled: true }>
223
+ >,
224
+ ): void {
225
+ // Only one bounded state request can be in flight; no arbitrary input or growing write queue.
226
+ if (!this.child.stdin.write(`${JSON.stringify(command)}\n`)) {
227
+ this.fail(new Fault(503, "rpc_backpressure", "Pi RPC input is not being consumed."));
228
+ this.cleanup();
229
+ }
230
+ }
231
+ async state(signal: AbortSignal): Promise<UnknownRecord> {
232
+ if (this.failure) {
233
+ throw this.failure;
234
+ }
235
+ const id = `state-${++this.sequence}`;
236
+ const result = new Promise<UnknownRecord>((resolve, reject) => {
237
+ this.pending = { id, resolve, reject };
238
+ this.write({ type: "get_state", id });
239
+ });
240
+ try {
241
+ return await wait(result, signal);
242
+ } finally {
243
+ this.pending = undefined;
244
+ }
245
+ }
246
+ ready(): void {
247
+ if (this.failure || this.exitObserved) {
248
+ throw this.failure ?? new Fault(503, "session_exited", "Pi exited during startup.");
249
+ }
250
+ if (this.status === "starting") {
251
+ this.status = "idle";
252
+ }
253
+ }
254
+ private cleanup(): void {
255
+ this.stop().catch(() => log("launch_cleanup", { pid: this.child.pid, result: "failed" }));
256
+ }
257
+ stop(): Promise<void> {
258
+ if (this.exitObserved) {
259
+ return this.exited;
260
+ }
261
+ this.stopping ??= this.shutdown().finally(() => {
262
+ this.stopping = undefined;
263
+ });
264
+ return this.stopping;
265
+ }
266
+ private async shutdown(): Promise<void> {
267
+ if (this.exitObserved) {
268
+ return;
269
+ }
270
+ this.status = "stopping";
271
+ this.fail(new Fault(410, "session_stopped", "Launched session is stopping."));
272
+ this.child.stdin.end();
273
+ const terminate = setTimeout(() => {
274
+ trySignalChildTree(this.child, "SIGTERM");
275
+ }, 1000);
276
+ const kill = setTimeout(() => {
277
+ trySignalChildTree(this.child, "SIGKILL");
278
+ }, 3000);
279
+ const deadline = AbortSignal.timeout(8000);
280
+ try {
281
+ await wait(this.exited, deadline);
282
+ } catch {
283
+ throw new Fault(
284
+ 503,
285
+ "stop_timeout",
286
+ "Launched Pi did not close within the cleanup deadline; inspect sessions before retrying.",
287
+ );
288
+ } finally {
289
+ clearTimeout(terminate);
290
+ clearTimeout(kill);
291
+ }
292
+ }
293
+ }
@@ -9,7 +9,7 @@ import type { Message, SendResult, SessionInfo } from "./types.ts";
9
9
  import type { Identity } from "./bridge-config.ts";
10
10
  import { Fault, INBOX_BYTES, INBOX_COUNT, cancelled, log, wait } from "./bridge-protocol.ts";
11
11
  import type { UnknownRecord } from "../shared/unknown.ts";
12
- import type { Operation } from "./bridge-routes.ts";
12
+ import type { MessagingOperation as Operation } from "./bridge-routes.ts";
13
13
 
14
14
  import { WaitingAsk, type Envelope } from "./bridge-ask.ts";
15
15
  const MAX_REQUESTS = 8;
@@ -307,6 +307,10 @@ export class Session {
307
307
  this.guard(signal);
308
308
  return peers;
309
309
  }
310
+ async connectedPeers(signal: AbortSignal): Promise<SessionInfo[]> {
311
+ await this.connect(signal);
312
+ return this.peers(signal);
313
+ }
310
314
  private async target(to: string, signal: AbortSignal): Promise<SessionInfo> {
311
315
  const resolution = resolveSessionTarget(await this.peers(signal), to);
312
316
  if (resolution.status === "none") {