@fitchmultz/pi-subagents 0.47.0 → 0.48.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +15 -3
  2. package/dist/extension/build-info.js +1 -1
  3. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  4. package/dist/pi-intercom/bridge-catalog.js +372 -0
  5. package/dist/pi-intercom/bridge-config.js +14 -2
  6. package/dist/pi-intercom/bridge-control.js +104 -0
  7. package/dist/pi-intercom/bridge-groups.js +135 -0
  8. package/dist/pi-intercom/bridge-http.js +8 -1
  9. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  10. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  11. package/dist/pi-intercom/bridge-launch.js +186 -76
  12. package/dist/pi-intercom/bridge-operations.js +127 -0
  13. package/dist/pi-intercom/bridge-protocol.js +7 -0
  14. package/dist/pi-intercom/bridge-routes.js +171 -22
  15. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  16. package/dist/pi-intercom/bridge-session.js +24 -22
  17. package/dist/pi-intercom/bridge-target.js +22 -0
  18. package/dist/pi-intercom/bridge.js +29 -37
  19. package/dist/pi-intercom/broker/client.js +1 -0
  20. package/dist/pi-intercom/connection.js +2 -3
  21. package/dist/pi-intercom/inbound.js +2 -4
  22. package/dist/pi-intercom/index.js +7 -1
  23. package/dist/pi-intercom/native-control-auth.js +80 -0
  24. package/dist/pi-intercom/native-control-protocol.js +111 -0
  25. package/dist/pi-intercom/native-control.js +130 -0
  26. package/dist/pi-intercom/saved-transcript.js +135 -0
  27. package/dist/pi-intercom/session-targets.js +3 -0
  28. package/dist/pi-intercom/transcript.js +175 -0
  29. package/docs/intercom-bridge.md +285 -175
  30. package/docs/intercom.md +1 -1
  31. package/docs/reference.md +1 -1
  32. package/package.json +1 -1
  33. package/scripts/intercom-remote.mjs +176 -35
  34. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  35. package/src/pi-intercom/bridge-catalog.ts +489 -0
  36. package/src/pi-intercom/bridge-config.ts +19 -2
  37. package/src/pi-intercom/bridge-control.ts +179 -0
  38. package/src/pi-intercom/bridge-groups.ts +169 -0
  39. package/src/pi-intercom/bridge-http.ts +15 -1
  40. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  41. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  42. package/src/pi-intercom/bridge-launch.ts +296 -107
  43. package/src/pi-intercom/bridge-operations.ts +169 -0
  44. package/src/pi-intercom/bridge-protocol.ts +8 -0
  45. package/src/pi-intercom/bridge-routes.ts +254 -42
  46. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  47. package/src/pi-intercom/bridge-session.ts +35 -36
  48. package/src/pi-intercom/bridge-target.ts +35 -0
  49. package/src/pi-intercom/bridge.ts +30 -47
  50. package/src/pi-intercom/broker/client.ts +3 -0
  51. package/src/pi-intercom/connection.ts +2 -3
  52. package/src/pi-intercom/inbound.ts +2 -4
  53. package/src/pi-intercom/index.ts +7 -1
  54. package/src/pi-intercom/native-control-auth.ts +89 -0
  55. package/src/pi-intercom/native-control-protocol.ts +180 -0
  56. package/src/pi-intercom/native-control.ts +160 -0
  57. package/src/pi-intercom/saved-transcript.ts +175 -0
  58. package/src/pi-intercom/session-targets.ts +4 -0
  59. package/src/pi-intercom/transcript.ts +226 -0
  60. package/src/pi-intercom/types.ts +2 -0
@@ -3,7 +3,7 @@ import { StringDecoder } from "node:string_decoder";
3
3
  import type { RpcCommand, RpcExtensionUIResponse } from "@earendil-works/pi-coding-agent";
4
4
  import { trySignalChildTree } from "../shared/post-exit-stdio-guard.ts";
5
5
  import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
6
- import { Fault, log, wait } from "./bridge-protocol.ts";
6
+ import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
7
7
  import type { LaunchPolicy } from "./bridge-launch-policy.ts";
8
8
 
9
9
  const FRAME_LIMIT = 4 * 1024 * 1024;
@@ -15,7 +15,7 @@ function launchEnvironment(packageRoot: string): NodeJS.ProcessEnv {
15
15
  }
16
16
 
17
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.
18
+ // This owner implements state, owned-run cancellation and dialog dismissal, never a raw RPC proxy.
19
19
  export class RpcProcess {
20
20
  private readonly child: ChildProcessWithoutNullStreams;
21
21
  readonly exited: Promise<void>;
@@ -27,15 +27,17 @@ export class RpcProcess {
27
27
  private sequence = 0;
28
28
  private pending?: {
29
29
  readonly id: string;
30
+ readonly command: "get_state" | "abort";
30
31
  readonly resolve: (data: UnknownRecord) => void;
31
32
  readonly reject: (error: Readonly<Error>) => void;
32
33
  };
33
34
  private stopping?: Promise<void>;
34
35
  private stderrBytes = 0;
35
36
  private exitObserved = false;
37
+ private cancelling = false;
36
38
 
37
- constructor(policy: LaunchPolicy, cwd: string, sessionFile: string) {
38
- this.child = spawn(process.execPath, this.arguments(policy, sessionFile), {
39
+ constructor(policy: LaunchPolicy, cwd: string, sessionFile: string, resuming = false) {
40
+ this.child = spawn(process.execPath, this.arguments(policy, sessionFile, resuming), {
39
41
  cwd,
40
42
  detached: true,
41
43
  env: launchEnvironment(policy.packageRoot),
@@ -78,19 +80,19 @@ export class RpcProcess {
78
80
  get pid(): number | undefined {
79
81
  return this.child.pid;
80
82
  }
81
- private arguments(policy: LaunchPolicy, sessionFile: string): string[] {
83
+ private arguments(policy: LaunchPolicy, sessionFile: string, resuming: boolean): string[] {
82
84
  const args = [
83
85
  policy.cli,
84
86
  "--mode",
85
87
  "rpc",
86
88
  "--session",
87
89
  sessionFile,
88
- "--provider",
89
- policy.provider,
90
- "--model",
91
- policy.model,
92
90
  policy.trustProject ? "--approve" : "--no-approve",
93
91
  ];
92
+ // Resume leaves native model/thinking restoration authoritative; never force the launch default.
93
+ if (!resuming && policy.provider !== undefined && policy.model !== undefined) {
94
+ args.push("--provider", policy.provider, "--model", policy.model);
95
+ }
94
96
  if (!policy.discoverResources) {
95
97
  args.push(
96
98
  "--no-extensions",
@@ -191,8 +193,12 @@ export class RpcProcess {
191
193
  private response(data: UnknownRecord): void {
192
194
  const pending = this.pending;
193
195
  this.pending = undefined;
194
- if (data.success === true && data.command === "get_state" && isRecord(data.data)) {
195
- pending?.resolve(data.data);
196
+ if (
197
+ data.success === true &&
198
+ data.command === pending?.command &&
199
+ (pending?.command !== "get_state" || isRecord(data.data))
200
+ ) {
201
+ pending?.resolve(isRecord(data.data) ? data.data : {});
196
202
  } else {
197
203
  pending?.reject(new Fault(503, "rpc_failed", "Pi RPC state request failed."));
198
204
  }
@@ -218,7 +224,7 @@ export class RpcProcess {
218
224
  }
219
225
  private write(
220
226
  command: Readonly<
221
- | Extract<RpcCommand, { type: "get_state" }>
227
+ | Extract<RpcCommand, { type: "get_state" | "abort" }>
222
228
  | Extract<RpcExtensionUIResponse, { cancelled: true }>
223
229
  >,
224
230
  ): void {
@@ -228,14 +234,25 @@ export class RpcProcess {
228
234
  this.cleanup();
229
235
  }
230
236
  }
231
- async state(signal: AbortSignal): Promise<UnknownRecord> {
237
+ private async command(
238
+ command: "get_state" | "abort",
239
+ signal: AbortSignal,
240
+ ): Promise<UnknownRecord> {
241
+ cancelled(signal);
232
242
  if (this.failure) {
233
243
  throw this.failure;
234
244
  }
235
- const id = `state-${++this.sequence}`;
245
+ if (this.pending) {
246
+ throw new Fault(
247
+ 409,
248
+ "control_in_progress",
249
+ "A native control operation is already in flight.",
250
+ );
251
+ }
252
+ const id = `control-${++this.sequence}`;
236
253
  const result = new Promise<UnknownRecord>((resolve, reject) => {
237
- this.pending = { id, resolve, reject };
238
- this.write({ type: "get_state", id });
254
+ this.pending = { id, command, resolve, reject };
255
+ this.write({ type: command, id });
239
256
  });
240
257
  try {
241
258
  return await wait(result, signal);
@@ -243,6 +260,26 @@ export class RpcProcess {
243
260
  this.pending = undefined;
244
261
  }
245
262
  }
263
+ async state(signal: AbortSignal): Promise<UnknownRecord> {
264
+ return this.command("get_state", signal);
265
+ }
266
+ async cancel(signal: AbortSignal): Promise<UnknownRecord> {
267
+ if (this.cancelling) {
268
+ throw new Fault(409, "control_in_progress", "Native cancellation is already in flight.");
269
+ }
270
+ this.cancelling = true;
271
+ try {
272
+ await this.command("abort", signal);
273
+ const state = await this.state(signal);
274
+ if (state.isStreaming !== false) {
275
+ throw new Fault(409, "cancel_unconfirmed", "Native Pi has not reached idle after abort.");
276
+ }
277
+ this.status = "idle";
278
+ return { isStreaming: false, pendingMessageCount: state.pendingMessageCount };
279
+ } finally {
280
+ this.cancelling = false;
281
+ }
282
+ }
246
283
  ready(): void {
247
284
  if (this.failure || this.exitObserved) {
248
285
  throw this.failure ?? new Fault(503, "session_exited", "Pi exited during startup.");
@@ -1,16 +1,16 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { IntercomClient } from "./broker/client.ts";
3
- import {
4
- formatSessionTarget,
5
- resolveSessionProjectId,
6
- resolveSessionTarget,
7
- } from "./session-targets.ts";
3
+ import { resolveSessionProjectId } from "./session-targets.ts";
8
4
  import type { Message, SendResult, SessionInfo } from "./types.ts";
9
5
  import type { Identity } from "./bridge-config.ts";
10
6
  import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
11
7
  import { Mailbox } from "./bridge-mailbox.ts";
12
8
  import type { UnknownRecord } from "../shared/unknown.ts";
13
9
  import type { MessagingOperation as Operation } from "./bridge-routes.ts";
10
+ import type { RequestContext } from "./bridge-http.ts";
11
+ import { resolveBridgeTarget, bridgeSessionList } from "./bridge-target.ts";
12
+ import { ControlReplies } from "./bridge-control.ts";
13
+ import type { NativeControlOperation } from "./native-control-protocol.ts";
14
14
 
15
15
  import { WaitingAsk } from "./bridge-ask.ts";
16
16
  const MAX_REQUESTS = 8;
@@ -18,11 +18,6 @@ const IDLE_TIMEOUT = 5 * 60 * 1000;
18
18
  const SEND_TIMEOUT = 8000;
19
19
  const LIST_TIMEOUT = 5000;
20
20
 
21
- interface RequestContext {
22
- readonly signal: AbortSignal;
23
- readonly requestId: string;
24
- readonly onPeer: (peer: string) => void;
25
- }
26
21
  interface SendOptions {
27
22
  readonly messageId?: string;
28
23
  readonly replyTo?: string;
@@ -50,6 +45,7 @@ export class Session {
50
45
  private readonly inbox: Mailbox;
51
46
  private readonly replying = new Set<string>();
52
47
  private pending?: WaitingAsk;
48
+ private readonly controls = new ControlReplies();
53
49
  private closed = false;
54
50
  private connecting?: Promise<void>;
55
51
  private disconnecting?: Promise<void>;
@@ -86,6 +82,9 @@ export class Session {
86
82
  if (this.closed) {
87
83
  throw this.closeReason;
88
84
  }
85
+ this.authorize();
86
+ }
87
+ authorize(): void {
89
88
  if (!this.owner.allowed(this.identity)) {
90
89
  throw new Fault(403, "revoked", "Identity is no longer authorized.");
91
90
  }
@@ -197,21 +196,19 @@ export class Session {
197
196
  this.owner.retired(this.identity.fingerprint256, this.generation);
198
197
  log("session_closed", { ...this.fields(), result: this.closeReason.code });
199
198
  }
200
- close(error: Readonly<Fault>): void {
199
+ close(fault: Readonly<Fault>): void {
201
200
  if (this.closed) {
202
201
  return;
203
202
  }
204
203
  this.closed = true;
205
- this.closeReason = error;
204
+ this.closeReason = fault;
206
205
  clearTimeout(this.lease);
207
- this.pending?.reject(error);
206
+ this.pending?.reject(fault);
207
+ this.controls.close(fault);
208
208
  for (const controller of this.requests) {
209
- controller.abort(error);
209
+ controller.abort(fault);
210
210
  }
211
211
  // Retain the generation until acquisition/registration AND final disconnect settle.
212
- this.startRetirement();
213
- }
214
- private startRetirement(): void {
215
212
  this.retirement = this.retire().catch((caught: unknown) => this.cleanupFailed(caught));
216
213
  }
217
214
  async settled(): Promise<void> {
@@ -219,6 +216,7 @@ export class Session {
219
216
  }
220
217
 
221
218
  private peerLeft(id: string): void {
219
+ this.controls.peerLeft(id);
222
220
  if (this.pending?.peer === id && !this.pending.settled) {
223
221
  this.pending.reject(
224
222
  new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }),
@@ -240,6 +238,9 @@ export class Session {
240
238
  );
241
239
  return;
242
240
  }
241
+ if (this.controls.receive(from.id, message)) {
242
+ return;
243
+ }
243
244
  if (!this.inbox.persistent && this.resolveReply(from, message)) {
244
245
  return;
245
246
  }
@@ -320,29 +321,29 @@ export class Session {
320
321
  await this.connect(signal);
321
322
  return this.peers(signal);
322
323
  }
324
+ async nativeControl(
325
+ operation: NativeControlOperation,
326
+ certificate: string,
327
+ context: RequestContext,
328
+ ): Promise<UnknownRecord> {
329
+ await this.connect(context.signal);
330
+ const peer = await this.target(operation.to, context.signal);
331
+ return this.controls.request(
332
+ this.client,
333
+ { ...operation, peer: peer.id, certificate },
334
+ { ...context, guard: () => this.guard(context.signal) },
335
+ );
336
+ }
323
337
  private async target(to: string, signal: AbortSignal): Promise<SessionInfo> {
324
- const resolution = resolveSessionTarget(await this.peers(signal), to);
325
- if (resolution.status === "none") {
326
- throw new Fault(404, "peer_offline", "Peer is not connected.");
327
- }
328
- if (resolution.status !== "found" || !resolution.target) {
329
- throw new Fault(
330
- 409,
331
- "ambiguous_target",
332
- "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
333
- );
334
- }
335
- if (resolution.target.id === this.client.sessionId) {
336
- throw new Fault(400, "self_target", "Choose another peer.");
337
- }
338
- return resolution.target;
338
+ return resolveBridgeTarget(await this.peers(signal), to, this.client.sessionId);
339
339
  }
340
- private async send(
340
+ async send(
341
341
  peer: string,
342
342
  message: string,
343
343
  context: RequestContext,
344
344
  options: SendOptions = {},
345
345
  ): Promise<SendResult> {
346
+ await this.connect(context.signal);
346
347
  const { signal, requestId } = context;
347
348
  context.onPeer(peer);
348
349
  this.guard(signal);
@@ -492,9 +493,7 @@ export class Session {
492
493
  const sessions = await this.peers(context.signal);
493
494
  return {
494
495
  sessionId: this.client.sessionId,
495
- sessions: sessions.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => {
496
- return Object.assign(entry, { target: formatSessionTarget(entry, sessions) });
497
- }),
496
+ sessions: bridgeSessionList(sessions),
498
497
  };
499
498
  }
500
499
  case "POST /v1/send":
@@ -0,0 +1,35 @@
1
+ import { resolveSessionTarget, formatSessionTarget, nativePeerId } from "./session-targets.ts";
2
+ import { Fault } from "./bridge-protocol.ts";
3
+ import type { SessionInfo } from "./types.ts";
4
+
5
+ export function bridgeSessionList(
6
+ peers: readonly SessionInfo[],
7
+ ): readonly (SessionInfo & { readonly target: string })[] {
8
+ return peers.map(({ topics: _topics, subscriptions: _subscriptions, ...entry }) => ({
9
+ ...entry,
10
+ target: formatSessionTarget(entry, peers),
11
+ }));
12
+ }
13
+
14
+ export function resolveBridgeTarget(
15
+ peers: readonly SessionInfo[],
16
+ to: string,
17
+ self: string | null,
18
+ ): SessionInfo {
19
+ const exactNative = peers.find((peer) => peer.id === nativePeerId(to));
20
+ const resolution = resolveSessionTarget(peers, exactNative?.id ?? to);
21
+ if (resolution.status === "none") {
22
+ throw new Fault(404, "peer_offline", "Peer is not connected.");
23
+ }
24
+ if (resolution.status !== "found" || !resolution.target) {
25
+ throw new Fault(
26
+ 409,
27
+ "ambiguous_target",
28
+ "Use an unambiguous peer name or session ID (prefixes need at least 8 characters).",
29
+ );
30
+ }
31
+ if (resolution.target.id === self) {
32
+ throw new Fault(400, "self_target", "Choose another peer.");
33
+ }
34
+ return resolution.target;
35
+ }
@@ -8,14 +8,23 @@ import { getBrokerSocketPath } from "./broker/paths.ts";
8
8
  import { loadConfig, sameListener, type Config, type Identity } from "./bridge-config.ts";
9
9
  import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.ts";
10
10
  import { Session } from "./bridge-session.ts";
11
- import { operationFor, routeFor, type Operation } from "./bridge-routes.ts";
12
- import { reply } from "./bridge-http.ts";
13
- import { Launcher, sameIdentity } from "./bridge-launch.ts";
11
+ import { operationFor, routeFor } from "./bridge-routes.ts";
12
+ import { reply, replyFault } from "./bridge-http.ts";
13
+ import { sameIdentity } from "./bridge-launch.ts";
14
+ import { BridgeOperations } from "./bridge-operations.ts";
15
+ import { authenticatedCertificateChain } from "./native-control-auth.ts";
14
16
  import { PersistentPresence } from "./bridge-presence.ts";
15
17
  import { WakeDeliveries } from "./bridge-wake.ts";
16
18
  import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
17
19
 
18
20
  const MAX_CONNECTIONS = 128;
21
+ const LONG_ROUTES = new Set([
22
+ "POST /v1/start",
23
+ "POST /v1/spawn",
24
+ "POST /v1/resume",
25
+ "GET /v1/transcript",
26
+ "POST /v1/transcript",
27
+ ]);
19
28
 
20
29
  function validCertificateDates(starts: number, expires: number): boolean {
21
30
  return (
@@ -37,7 +46,7 @@ class Bridge {
37
46
  private readonly configPath: string;
38
47
  private readonly sessions = new Map<string, Session>();
39
48
  private readonly losses = new Map<string, number>();
40
- private readonly launcher = new Launcher();
49
+ private readonly operations: BridgeOperations;
41
50
  private readonly sockets = new Set<Socket>();
42
51
  private readonly connections = new Map<TLSSocket, Connection>();
43
52
  private readonly server: Server;
@@ -49,6 +58,7 @@ class Bridge {
49
58
  constructor(config: Config, configPath: string) {
50
59
  this.config = config;
51
60
  this.configPath = configPath;
61
+ this.operations = new BridgeOperations(config.stateDir);
52
62
  this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
53
63
  if (config.stateDir !== undefined) {
54
64
  this.presence = new PersistentPresence({
@@ -245,12 +255,12 @@ class Bridge {
245
255
  }
246
256
  this.config = next;
247
257
  this.revokeRemoved();
248
- this.launcher.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
258
+ this.operations.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
249
259
  log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
250
260
  } catch {
251
261
  this.config = { ...this.config, clients: new Map() };
252
262
  this.deliveries.reload();
253
- this.launcher
263
+ this.operations
254
264
  .revoke(this.config.clients)
255
265
  .catch(() => log("launch_cleanup", { result: "failed" }));
256
266
  for (const session of this.sessions.values()) {
@@ -312,7 +322,7 @@ class Bridge {
312
322
  await Promise.all([
313
323
  ...this.handlers,
314
324
  ...sessions.map((session) => session.settled()),
315
- this.launcher.shutdown(),
325
+ this.operations.shutdown(),
316
326
  this.presence?.stop(),
317
327
  this.deliveries.stop(),
318
328
  ]);
@@ -321,6 +331,7 @@ class Bridge {
321
331
  private admission(req: IncomingMessage): {
322
332
  readonly identity: Identity;
323
333
  readonly expires: number;
334
+ readonly certificate: string;
324
335
  } {
325
336
  const socket = req.socket;
326
337
  const connection = socket instanceof TLSSocket ? this.connections.get(socket) : undefined;
@@ -339,7 +350,11 @@ class Bridge {
339
350
  this.drop(connection.fingerprint, fault);
340
351
  throw fault;
341
352
  }
342
- return { identity, expires: connection.expires };
353
+ return {
354
+ identity,
355
+ expires: connection.expires,
356
+ certificate: authenticatedCertificateChain(socket),
357
+ };
343
358
  }
344
359
  private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
345
360
  const requestId = randomUUID();
@@ -372,7 +387,7 @@ class Bridge {
372
387
  );
373
388
  };
374
389
  try {
375
- const { identity, expires } = this.admission(req);
390
+ const { identity, expires, certificate } = this.admission(req);
376
391
  const route = routeFor(req);
377
392
  session = this.session(identity, expires);
378
393
  controller = session.begin();
@@ -380,15 +395,18 @@ class Bridge {
380
395
  req.once("aborted", abort);
381
396
  res.once("close", onClose);
382
397
  const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
383
- setDeadline(route === "POST /v1/start" ? 75000 : duration);
398
+ setDeadline(LONG_ROUTES.has(route) ? 75000 : duration);
384
399
  const operation = await operationFor(req, route, signal);
385
400
  if (operation.route === "POST /v1/ask") {
386
401
  setDeadline(operation.timeoutMs + 15000);
402
+ } else if (operation.route === "GET /v1/transcript") {
403
+ setDeadline(75000);
387
404
  }
388
- const data = await this.execute(operation, session, {
405
+ const data = await this.operations.execute(operation, session, {
389
406
  signal,
390
407
  requestId,
391
408
  expires,
409
+ certificate,
392
410
  onPeer: (id) => {
393
411
  peer = id;
394
412
  },
@@ -402,10 +420,7 @@ class Bridge {
402
420
  ? error
403
421
  : new Fault(503, "broker_unavailable", "Local broker operation failed.");
404
422
  log("request", { ...session?.fields(), requestId, peer, result: fault.code });
405
- reply(res, requestId, fault.status, {
406
- ok: false,
407
- error: { code: fault.code, message: fault.message, ...fault.details },
408
- });
423
+ replyFault(res, requestId, fault);
409
424
  } finally {
410
425
  clearTimeout(deadline);
411
426
  req.off("aborted", abort);
@@ -415,38 +430,6 @@ class Bridge {
415
430
  }
416
431
  }
417
432
  }
418
- private async execute(
419
- operation: Operation,
420
- session: Readonly<Session>,
421
- context: {
422
- readonly signal: AbortSignal;
423
- readonly requestId: string;
424
- readonly expires: number;
425
- readonly onPeer: (id: string) => void;
426
- },
427
- ) {
428
- switch (operation.route) {
429
- case "POST /v1/start":
430
- return this.launcher.start(operation.name, {
431
- identity: session.identity,
432
- expires: context.expires,
433
- signal: context.signal,
434
- peers: (signal) => session.connectedPeers(signal),
435
- });
436
- case "GET /v1/sessions":
437
- return this.launcher.list(session.identity);
438
- case "POST /v1/stop":
439
- return this.launcher.stop(session.identity, operation.sessionId);
440
- case "GET /v1/list":
441
- case "GET /v1/inbox":
442
- case "POST /v1/register":
443
- case "POST /v1/ask":
444
- case "POST /v1/send":
445
- case "POST /v1/reply":
446
- case "POST /v1/ack":
447
- return session.execute(operation, context);
448
- }
449
- }
450
433
  }
451
434
 
452
435
  function main(): void {
@@ -1,6 +1,7 @@
1
1
  import { EventEmitter } from "events";
2
2
  import type net from "net";
3
3
  import { randomUUID } from "crypto";
4
+ import type { NativeControlEnvelope } from "../native-control-protocol.ts";
4
5
  import { writeMessage } from "./framing.ts";
5
6
  import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
6
7
  import { SnapshotReader } from "./snapshot-reader.ts";
@@ -51,6 +52,7 @@ interface SendOptions {
51
52
  readonly messageId?: string;
52
53
  readonly human?: HumanMessageOrigin;
53
54
  readonly topic?: TopicUpdate;
55
+ readonly control?: NativeControlEnvelope;
54
56
  }
55
57
 
56
58
  function toError(error: unknown): Error {
@@ -405,6 +407,7 @@ export class IntercomClient extends EventEmitter {
405
407
  expectsReply: options.expectsReply,
406
408
  ...(options.human ? { human: options.human } : {}),
407
409
  ...(options.topic ? { topic: options.topic } : {}),
410
+ ...(options.control ? { control: options.control } : {}),
408
411
  delivery: messageDelivery(options),
409
412
  queueMode: options.queueMode,
410
413
  threadId: options.threadId,
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
2
  import { IntercomClient } from "./broker/client.ts";
4
3
  import type { IntercomTransport } from "./transport.ts";
@@ -8,7 +7,7 @@ import type { IntercomLifecycle } from "./lifecycle.ts";
8
7
  import type { IntercomTopics } from "./topics.ts";
9
8
  import type { Message, SessionInfo, TopicChange } from "./types.ts";
10
9
  import { asError, errorMessage } from "./validation.ts";
11
- import { formatTargetOptions, resolveSessionTarget } from "./session-targets.ts";
10
+ import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.ts";
12
11
 
13
12
  export type ConnectionReason = "startup" | "background" | "tool" | "overlay" | "peer-awareness";
14
13
  type Lifecycle = Readonly<
@@ -233,7 +232,7 @@ export class IntercomConnection {
233
232
  }
234
233
  await next.connect(
235
234
  await this.lifecycle.registration(this.hooks.pendingAsks()),
236
- `pi-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`,
235
+ nativePeerId(sessionId),
237
236
  );
238
237
  if (!this.lifecycle.live(ctx, generation)) {
239
238
  await next.disconnect();
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
2
  import type { IntercomConfig } from "./config.ts";
4
3
  import type { IntercomLifecycle } from "./lifecycle.ts";
@@ -13,6 +12,7 @@ import type { Message, SessionInfo } from "./types.ts";
13
12
  import { requestedDelivery, isBlockingSupervisorMessage } from "./inbound-record.ts";
14
13
  import { formatAttachments } from "./message-format.ts";
15
14
  import { readQuestionOwner } from "../runs/shared/supervisor-questions.ts";
15
+ import { nativePeerId } from "./session-targets.ts";
16
16
 
17
17
  type Lifecycle = Readonly<
18
18
  Pick<IntercomLifecycle, "generation" | "isIdle" | "live" | "markActivity">
@@ -53,9 +53,7 @@ export class IntercomInbound {
53
53
  }
54
54
  const owner = readQuestionOwner(origin.runId);
55
55
  return (
56
- owner?.sessionId === origin.ownerSessionId &&
57
- from.id ===
58
- `pi-${createHash("sha256").update(origin.ownerSessionId).digest("hex").slice(0, 32)}`
56
+ owner?.sessionId === origin.ownerSessionId && from.id === nativePeerId(origin.ownerSessionId)
59
57
  );
60
58
  }
61
59
  private normalize(from: SessionInfo, input: Message): Message {
@@ -9,6 +9,7 @@ import { InboundDeliveryOwner } from "./inbound-delivery.ts";
9
9
  import { ReplyWait } from "./reply-wait.ts";
10
10
  import { IntercomConnection } from "./connection.ts";
11
11
  import { IntercomInbound } from "./inbound.ts";
12
+ import { NativeControls } from "./native-control.ts";
12
13
  import { IntercomOutbound } from "./outbound.ts";
13
14
  import { IntercomInspection } from "./inspection.ts";
14
15
  import { IntercomTopicActions } from "./topic-actions.ts";
@@ -33,7 +34,11 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
33
34
  const connection = new IntercomConnection(lifecycle, config, topics, {
34
35
  restoring: () => delivery.restoring,
35
36
  pendingAsks: () => replies.listPending().length,
36
- incoming: (ctx, from, message) => inbound.receive(ctx, from, message),
37
+ incoming: async (ctx, from, message) => {
38
+ if (!(await controls.receive(ctx, from, message))) {
39
+ await inbound.receive(ctx, from, message);
40
+ }
41
+ },
37
42
  peerLeft: (id) => {
38
43
  wait.peerDisconnected(id);
39
44
  journal.peerLeft(id);
@@ -46,6 +51,7 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
46
51
  }
47
52
  },
48
53
  });
54
+ const controls = new NativeControls(lifecycle, connection);
49
55
  const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
50
56
  replies,
51
57
  syncStatus: () => connection.syncStatus(),