@fitchmultz/pi-subagents 0.47.0 → 0.49.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 (73) hide show
  1. package/CHANGELOG.md +29 -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 +15 -3
  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-mailbox.js +3 -55
  13. package/dist/pi-intercom/bridge-operations.js +127 -0
  14. package/dist/pi-intercom/bridge-presence.js +5 -1
  15. package/dist/pi-intercom/bridge-protocol.js +7 -0
  16. package/dist/pi-intercom/bridge-routes.js +195 -27
  17. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  18. package/dist/pi-intercom/bridge-session.js +83 -66
  19. package/dist/pi-intercom/bridge-storage.js +73 -0
  20. package/dist/pi-intercom/bridge-target.js +22 -0
  21. package/dist/pi-intercom/bridge-wake-owner.js +57 -0
  22. package/dist/pi-intercom/bridge-wake-state.js +293 -0
  23. package/dist/pi-intercom/bridge-wake.js +190 -99
  24. package/dist/pi-intercom/bridge.js +39 -39
  25. package/dist/pi-intercom/broker/client.js +1 -0
  26. package/dist/pi-intercom/connection.js +2 -3
  27. package/dist/pi-intercom/inbound.js +2 -4
  28. package/dist/pi-intercom/index.js +7 -1
  29. package/dist/pi-intercom/native-control-auth.js +80 -0
  30. package/dist/pi-intercom/native-control-protocol.js +111 -0
  31. package/dist/pi-intercom/native-control.js +130 -0
  32. package/dist/pi-intercom/saved-transcript.js +135 -0
  33. package/dist/pi-intercom/session-targets.js +3 -0
  34. package/dist/pi-intercom/transcript.js +175 -0
  35. package/docs/intercom-bridge.md +315 -174
  36. package/docs/intercom.md +1 -1
  37. package/docs/reference.md +1 -1
  38. package/package.json +1 -1
  39. package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
  40. package/scripts/intercom-remote.mjs +213 -38
  41. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  42. package/src/pi-intercom/bridge-catalog.ts +489 -0
  43. package/src/pi-intercom/bridge-config.ts +20 -3
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-groups.ts +169 -0
  46. package/src/pi-intercom/bridge-http.ts +15 -1
  47. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  48. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  49. package/src/pi-intercom/bridge-launch.ts +296 -107
  50. package/src/pi-intercom/bridge-mailbox.ts +3 -59
  51. package/src/pi-intercom/bridge-operations.ts +169 -0
  52. package/src/pi-intercom/bridge-presence.ts +7 -1
  53. package/src/pi-intercom/bridge-protocol.ts +8 -0
  54. package/src/pi-intercom/bridge-routes.ts +291 -49
  55. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  56. package/src/pi-intercom/bridge-session.ts +98 -96
  57. package/src/pi-intercom/bridge-storage.ts +83 -0
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake-owner.ts +58 -0
  60. package/src/pi-intercom/bridge-wake-state.ts +321 -0
  61. package/src/pi-intercom/bridge-wake.ts +224 -130
  62. package/src/pi-intercom/bridge.ts +41 -49
  63. package/src/pi-intercom/broker/client.ts +3 -0
  64. package/src/pi-intercom/connection.ts +2 -3
  65. package/src/pi-intercom/inbound.ts +2 -4
  66. package/src/pi-intercom/index.ts +7 -1
  67. package/src/pi-intercom/native-control-auth.ts +89 -0
  68. package/src/pi-intercom/native-control-protocol.ts +180 -0
  69. package/src/pi-intercom/native-control.ts +160 -0
  70. package/src/pi-intercom/saved-transcript.ts +175 -0
  71. package/src/pi-intercom/session-targets.ts +4 -0
  72. package/src/pi-intercom/transcript.ts +226 -0
  73. package/src/pi-intercom/types.ts +2 -0
@@ -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(),
@@ -0,0 +1,89 @@
1
+ import { X509Certificate } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { existsSync } from "node:fs";
5
+ import type { TLSSocket } from "node:tls";
6
+ import { loadControlPolicy } from "./bridge-config.ts";
7
+ import { getPiAgentDir } from "./agent-dir.ts";
8
+ import { fingerprint } from "./bridge-protocol.ts";
9
+
10
+ export function authenticatedCertificateChain(socket: Readonly<TLSSocket>): string {
11
+ const blocks: string[] = [];
12
+ const seen = new Set<string>();
13
+ let cert = socket.getPeerCertificate(true);
14
+ for (let depth = 0; depth < 8; depth += 1) {
15
+ const parsed = new X509Certificate(cert.raw);
16
+ if (seen.has(parsed.fingerprint256)) {
17
+ break;
18
+ }
19
+ seen.add(parsed.fingerprint256);
20
+ blocks.push(parsed.toString());
21
+ cert = cert.issuerCertificate;
22
+ }
23
+ return blocks.join("\n");
24
+ }
25
+ function certificates(pem: string): X509Certificate[] {
26
+ const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g);
27
+ if (!blocks || blocks.length > 8) {
28
+ throw new Error("Invalid bounded certificate chain.");
29
+ }
30
+ return blocks.map((block) => new X509Certificate(block));
31
+ }
32
+ function validNow(cert: X509Certificate): boolean {
33
+ return Date.parse(cert.validFrom) <= Date.now() && Date.parse(cert.validTo) > Date.now();
34
+ }
35
+ function trustedChain(
36
+ chain: readonly X509Certificate[],
37
+ roots: readonly X509Certificate[],
38
+ ): boolean {
39
+ let current = chain.at(0);
40
+ if (current === undefined || current.ca || !validNow(current)) {
41
+ return false;
42
+ }
43
+ for (let depth = 0; depth < 8; depth += 1) {
44
+ const subject: X509Certificate = current;
45
+ const issuer: X509Certificate | undefined = [...roots, ...chain.slice(1)].find(
46
+ (candidate) =>
47
+ candidate.ca &&
48
+ validNow(candidate) &&
49
+ subject.checkIssued(candidate) &&
50
+ subject.verify(candidate.publicKey),
51
+ );
52
+ if (!issuer) {
53
+ return false;
54
+ }
55
+ if (roots.some((root) => root.fingerprint256 === issuer.fingerprint256)) {
56
+ return true;
57
+ }
58
+ current = issuer;
59
+ }
60
+ return false;
61
+ }
62
+ function controlPolicyPath(): string {
63
+ const configured = process.env.PI_INTERCOM_BRIDGE_CONFIG;
64
+ if (configured !== undefined) {
65
+ return configured;
66
+ }
67
+ const standard = join(getPiAgentDir(), "intercom", "bridge.json");
68
+ return existsSync(standard) ? standard : join(homedir(), ".pi", "intercom-bridge", "config.json");
69
+ }
70
+ /** Unix broker peers are same-user trusted. Names/payload claims grant no authority. */
71
+ export function authorizedControlPeer(peerId: string, certificate: string): boolean {
72
+ try {
73
+ const configPath = controlPolicyPath();
74
+ const config = loadControlPolicy(configPath);
75
+ const chain = certificates(certificate);
76
+ const client = chain.at(0);
77
+ if (client === undefined) {
78
+ return false;
79
+ }
80
+ const fp = fingerprint(client.fingerprint256);
81
+ return (
82
+ peerId === `remote-${fp.toLowerCase()}` &&
83
+ config.clients.has(fp) &&
84
+ trustedChain(chain, certificates(config.ca.toString("utf8")))
85
+ );
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
@@ -0,0 +1,180 @@
1
+ import { isRecord, isUnknownArray } from "../shared/unknown.ts";
2
+ import type { TranscriptOptions, TranscriptPage } from "./transcript.ts";
3
+
4
+ export const CONTROL_SCHEMA = "pi-intercom/control-v1";
5
+ export const CONTROL_FALLBACK =
6
+ "Native Intercom metadata exchange (no conversation response needed).";
7
+ export type NativeControlVerb = "capabilities" | "cancel" | "stop" | "transcript";
8
+ export interface NativeControlOperation {
9
+ readonly to: string;
10
+ readonly verb: NativeControlVerb;
11
+ readonly options?: TranscriptOptions;
12
+ readonly timeoutMs: number;
13
+ }
14
+ export interface NativeControlRequest {
15
+ readonly schema: typeof CONTROL_SCHEMA;
16
+ readonly kind: "request";
17
+ readonly verb: NativeControlVerb;
18
+ /** Public TLS certificate, supplied by the bridge, never a caller-selected identity. */
19
+ readonly certificate: string;
20
+ readonly options?: TranscriptOptions;
21
+ }
22
+ export interface NativeControlResult {
23
+ readonly schema: typeof CONTROL_SCHEMA;
24
+ readonly kind: "result";
25
+ readonly verb: NativeControlVerb;
26
+ readonly status:
27
+ | "ok"
28
+ | "denied"
29
+ | "cancel_requested"
30
+ | "shutdown_requested"
31
+ | "unsupported"
32
+ | "failed";
33
+ readonly verbs?: readonly NativeControlVerb[];
34
+ readonly mode?: string;
35
+ readonly idle?: boolean;
36
+ readonly pendingInput?: boolean;
37
+ readonly queueCleared?: false;
38
+ readonly processExited?: false;
39
+ readonly stopSupport?: "native_shutdown" | "requires_activity" | "host_dependent";
40
+ readonly reason?: string;
41
+ readonly transcript?: TranscriptPage;
42
+ }
43
+ export type NativeControlEnvelope = NativeControlRequest | NativeControlResult;
44
+
45
+ export function isControlVerb(value: unknown): value is NativeControlVerb {
46
+ return (
47
+ value === "capabilities" || value === "cancel" || value === "stop" || value === "transcript"
48
+ );
49
+ }
50
+ export function isControlRequest(value: unknown): value is NativeControlRequest {
51
+ return (
52
+ isRecord(value) &&
53
+ value.schema === CONTROL_SCHEMA &&
54
+ value.kind === "request" &&
55
+ isControlVerb(value.verb) &&
56
+ typeof value.certificate === "string" &&
57
+ value.certificate.length <= 16384 &&
58
+ Object.keys(value).every((key) =>
59
+ ["schema", "kind", "verb", "certificate", "options"].includes(key),
60
+ )
61
+ );
62
+ }
63
+ export function isControlResult(value: unknown): value is NativeControlResult {
64
+ if (
65
+ !isRecord(value) ||
66
+ value.schema !== CONTROL_SCHEMA ||
67
+ value.kind !== "result" ||
68
+ !isControlVerb(value.verb)
69
+ ) {
70
+ return false;
71
+ }
72
+ return (
73
+ validResultFields(value) &&
74
+ validResultPayload(value) &&
75
+ Buffer.byteLength(JSON.stringify(value)) <= 1024 * 1024
76
+ );
77
+ }
78
+ function optionalText(value: unknown, max: number): boolean {
79
+ return value === undefined || (typeof value === "string" && value.length <= max);
80
+ }
81
+ function validResultFields(value: Readonly<Record<string, unknown>>): boolean {
82
+ const fields = new Set([
83
+ "schema",
84
+ "kind",
85
+ "verb",
86
+ "status",
87
+ "mode",
88
+ "idle",
89
+ "pendingInput",
90
+ "queueCleared",
91
+ "processExited",
92
+ "stopSupport",
93
+ "reason",
94
+ "transcript",
95
+ "verbs",
96
+ ]);
97
+ return (
98
+ Object.keys(value).every((key) => fields.has(key)) &&
99
+ typeof value.status === "string" &&
100
+ ["ok", "denied", "cancel_requested", "shutdown_requested", "unsupported", "failed"].includes(
101
+ value.status,
102
+ ) &&
103
+ optionalText(value.reason, 2048) &&
104
+ optionalText(value.mode, 64) &&
105
+ [value.idle, value.pendingInput].every(
106
+ (flag) => flag === undefined || typeof flag === "boolean",
107
+ ) &&
108
+ [value.queueCleared, value.processExited].every((flag) => flag === undefined || flag === false)
109
+ );
110
+ }
111
+ function validResultPayload(value: Readonly<Record<string, unknown>>): boolean {
112
+ if (
113
+ value.stopSupport !== undefined &&
114
+ (typeof value.stopSupport !== "string" ||
115
+ !["native_shutdown", "requires_activity", "host_dependent"].includes(value.stopSupport))
116
+ ) {
117
+ return false;
118
+ }
119
+ if (
120
+ value.verbs !== undefined &&
121
+ (!isUnknownArray(value.verbs) || !value.verbs.every(isControlVerb))
122
+ ) {
123
+ return false;
124
+ }
125
+ return value.transcript === undefined || validTranscript(value.transcript);
126
+ }
127
+ function validTranscript(value: unknown): boolean {
128
+ if (!isRecord(value) || !isUnknownArray(value.records) || value.records.length > 100) {
129
+ return false;
130
+ }
131
+ const fields = new Set([
132
+ "sessionId",
133
+ "leafId",
134
+ "records",
135
+ "bytes",
136
+ "truncated",
137
+ "cursor",
138
+ "scanLimitReached",
139
+ "finalizedOnly",
140
+ "streaming",
141
+ ]);
142
+ return (
143
+ Object.keys(value).every((key) => fields.has(key)) &&
144
+ typeof value.sessionId === "string" &&
145
+ value.sessionId.length <= 512 &&
146
+ (value.leafId === null || typeof value.leafId === "string") &&
147
+ value.records.every(validTranscriptRecord) &&
148
+ validTranscriptMetadata(value)
149
+ );
150
+ }
151
+ function validTranscriptMetadata(value: Readonly<Record<string, unknown>>): boolean {
152
+ return (
153
+ typeof value.bytes === "number" &&
154
+ Number.isInteger(value.bytes) &&
155
+ value.bytes >= 0 &&
156
+ value.bytes <= 131072 &&
157
+ typeof value.truncated === "boolean" &&
158
+ optionalText(value.cursor, 512) &&
159
+ typeof value.scanLimitReached === "boolean" &&
160
+ value.finalizedOnly === true &&
161
+ value.streaming === "unavailable"
162
+ );
163
+ }
164
+ function validTranscriptRecord(value: unknown): boolean {
165
+ if (!isRecord(value)) {
166
+ return false;
167
+ }
168
+ const fields = new Set(["id", "timestamp", "role", "text", "truncated"]);
169
+ return (
170
+ Object.keys(value).every((key) => fields.has(key)) &&
171
+ [value.id, value.timestamp].every(
172
+ (field) => typeof field === "string" && field.length <= 512,
173
+ ) &&
174
+ typeof value.role === "string" &&
175
+ ["user", "assistant", "toolResult", "custom"].includes(value.role) &&
176
+ typeof value.text === "string" &&
177
+ Buffer.byteLength(value.text) <= 131072 &&
178
+ typeof value.truncated === "boolean"
179
+ );
180
+ }
@@ -0,0 +1,160 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { IntercomLifecycle } from "./lifecycle.ts";
3
+ import type { IntercomConnection } from "./connection.ts";
4
+ import type { Message, SessionInfo } from "./types.ts";
5
+ import { authorizedControlPeer } from "./native-control-auth.ts";
6
+ import {
7
+ CONTROL_FALLBACK,
8
+ CONTROL_SCHEMA,
9
+ isControlRequest,
10
+ type NativeControlRequest,
11
+ type NativeControlResult,
12
+ } from "./native-control-protocol.ts";
13
+ import { readLiveTranscript, transcriptOptions } from "./transcript.ts";
14
+
15
+ export class NativeControls {
16
+ private readonly lifecycle: Readonly<Pick<IntercomLifecycle, "live" | "generation">>;
17
+ private readonly connection: Readonly<Pick<IntercomConnection, "active">>;
18
+ constructor(lifecycle: NativeControls["lifecycle"], connection: NativeControls["connection"]) {
19
+ this.lifecycle = lifecycle;
20
+ this.connection = connection;
21
+ }
22
+ private result(ctx: ExtensionContext, request: NativeControlRequest): NativeControlResult {
23
+ const base = {
24
+ schema: CONTROL_SCHEMA,
25
+ kind: "result",
26
+ verb: request.verb,
27
+ mode: ctx.mode,
28
+ idle: ctx.isIdle(),
29
+ pendingInput: ctx.hasPendingMessages(),
30
+ queueCleared: false,
31
+ processExited: false,
32
+ } satisfies Partial<NativeControlResult>;
33
+ if (request.verb !== "transcript" && request.options !== undefined) {
34
+ throw new Error("Only transcript accepts options.");
35
+ }
36
+ const stopSupport = stopCapability(ctx);
37
+ switch (request.verb) {
38
+ case "capabilities":
39
+ return {
40
+ ...base,
41
+ status: "ok",
42
+ stopSupport,
43
+ verbs: ["capabilities", "cancel", "stop", "transcript"],
44
+ };
45
+ case "transcript":
46
+ return {
47
+ ...base,
48
+ status: "ok",
49
+ transcript: readLiveTranscript(ctx.sessionManager, transcriptOptions(request.options)),
50
+ };
51
+ case "cancel":
52
+ return {
53
+ ...base,
54
+ status: "cancel_requested",
55
+ reason: "Native current-operation abort; accepted queued input is not cleared.",
56
+ };
57
+ case "stop":
58
+ return {
59
+ ...base,
60
+ status:
61
+ ctx.mode === "print" || ctx.mode === "json" ? "unsupported" : "shutdown_requested",
62
+ stopSupport,
63
+ reason:
64
+ "Native shutdown/current-operation abort requested, not process exit. Idle RPC is deferred until a native command/settlement; print/JSON/SDK have no guaranteed shutdown binding.",
65
+ };
66
+ }
67
+ }
68
+ async receive(ctx: ExtensionContext, from: SessionInfo, message: Message): Promise<boolean> {
69
+ if (!Object.hasOwn(message, "control")) {
70
+ return false;
71
+ }
72
+ // Even malformed/response envelopes never enter conversation journaling or inference.
73
+ const request = message.control;
74
+ if (
75
+ !isControlRequest(request) ||
76
+ message.replyTo !== undefined ||
77
+ message.delivery !== "passive" ||
78
+ message.expectsReply === true
79
+ ) {
80
+ return true;
81
+ }
82
+ const generation = this.lifecycle.generation;
83
+ const active = this.connection.active;
84
+ if (!active || !this.lifecycle.live(ctx, generation)) {
85
+ return true;
86
+ }
87
+ const result = this.admit(ctx, from.id, request);
88
+ // Receipt is acknowledged before shutdown can retire this socket/context.
89
+ await active.send(from.id, {
90
+ text: CONTROL_FALLBACK,
91
+ delivery: "passive",
92
+ replyTo: message.id,
93
+ control: result,
94
+ });
95
+ this.apply(ctx, generation, { active, peer: from.id, request, result });
96
+ return true;
97
+ }
98
+ private apply(
99
+ ctx: ExtensionContext,
100
+ generation: number,
101
+ state: {
102
+ readonly active: NonNullable<IntercomConnection["active"]>;
103
+ readonly peer: string;
104
+ readonly request: NativeControlRequest;
105
+ readonly result: NativeControlResult;
106
+ },
107
+ ): void {
108
+ const { active, peer, request, result } = state;
109
+ if (
110
+ !this.lifecycle.live(ctx, generation) ||
111
+ this.connection.active !== active ||
112
+ !authorizedControlPeer(peer, request.certificate)
113
+ ) {
114
+ return;
115
+ }
116
+ if (
117
+ result.status === "shutdown_requested" ||
118
+ (result.status === "unsupported" && request.verb === "stop")
119
+ ) {
120
+ ctx.shutdown();
121
+ if (this.lifecycle.live(ctx, generation)) {
122
+ ctx.abort();
123
+ }
124
+ } else if (result.status === "cancel_requested") {
125
+ ctx.abort();
126
+ }
127
+ }
128
+ private admit(
129
+ ctx: ExtensionContext,
130
+ peer: string,
131
+ request: NativeControlRequest,
132
+ ): NativeControlResult {
133
+ if (!authorizedControlPeer(peer, request.certificate)) {
134
+ return {
135
+ schema: CONTROL_SCHEMA,
136
+ kind: "result",
137
+ verb: request.verb,
138
+ status: "denied",
139
+ reason: "Sender certificate is not currently authorized by private bridge configuration.",
140
+ };
141
+ }
142
+ try {
143
+ return this.result(ctx, request);
144
+ } catch {
145
+ return {
146
+ schema: CONTROL_SCHEMA,
147
+ kind: "result",
148
+ verb: request.verb,
149
+ status: "failed",
150
+ reason: "Native control input/state is unavailable.",
151
+ };
152
+ }
153
+ }
154
+ }
155
+ function stopCapability(ctx: ExtensionContext): NativeControlResult["stopSupport"] {
156
+ if (ctx.mode === "tui") {
157
+ return "native_shutdown";
158
+ }
159
+ return ctx.mode === "rpc" ? "requires_activity" : "host_dependent";
160
+ }
@@ -0,0 +1,175 @@
1
+ import { constants, openSync, closeSync, fstatSync } from "node:fs";
2
+ import type * as NativeSession from "@earendil-works/pi-coding-agent";
3
+ import { loadNativeSession } from "../shared/native-session-loader.ts";
4
+ import { scanJournal, type Projection } from "../shared/journal-reader.ts";
5
+ import { Fault } from "./bridge-protocol.ts";
6
+ import { readLiveTranscript, type TranscriptOptions, type TranscriptPage } from "./transcript.ts";
7
+
8
+ const FILE_BYTES = 64 * 1024 * 1024;
9
+ const ENTRY_COUNT = 50000;
10
+ const TEXT_LIMIT = 131072;
11
+ const fields = new Set([
12
+ "type",
13
+ "id",
14
+ "parentId",
15
+ "timestamp",
16
+ "version",
17
+ "cwd",
18
+ "customType",
19
+ "display",
20
+ "content",
21
+ ]);
22
+ /** Only branch structure and public textual content are hydrated, never image/provider/custom data. */
23
+ const transcriptProjection: Projection = (path) => {
24
+ if (path.length === 0) {
25
+ return true;
26
+ }
27
+ if (path[0] === "message") {
28
+ return messageField(path.slice(1));
29
+ }
30
+ if (path[0] === "content") {
31
+ return contentField(path.slice(1));
32
+ }
33
+ return path.length === 1 && fields.has(String(path[0])) ? 4096 : false;
34
+ };
35
+ function messageField(path: readonly (string | number)[]): boolean | number {
36
+ if (path.length === 0) {
37
+ return true;
38
+ }
39
+ if (path[0] === "content") {
40
+ return contentField(path.slice(1));
41
+ }
42
+ return path.length === 1 && ["role", "output", "display", "truncated"].includes(String(path[0]))
43
+ ? TEXT_LIMIT
44
+ : false;
45
+ }
46
+ function contentField(path: readonly (string | number)[]): boolean | number {
47
+ return path.length < 2 || (path.length === 2 && ["type", "text"].includes(String(path[1])))
48
+ ? TEXT_LIMIT
49
+ : false;
50
+ }
51
+ function textField(path: readonly (string | number)[]): boolean {
52
+ if (path[0] === "message") {
53
+ return (
54
+ (path.length === 2 && ["content", "output"].includes(String(path[1]))) ||
55
+ (path.length === 4 && path[1] === "content" && path[3] === "text")
56
+ );
57
+ }
58
+ return path[0] === "content" && (path.length === 1 || (path.length === 3 && path[2] === "text"));
59
+ }
60
+ function savedLines(
61
+ fd: number,
62
+ size: number,
63
+ ): { readonly lines: readonly string[]; readonly cut: ReadonlySet<number> } {
64
+ const lines: string[] = [];
65
+ const cut = new Set<number>();
66
+ const lengths = new Map<string, number>();
67
+ scanJournal(
68
+ fd,
69
+ transcriptProjection,
70
+ ({ value }) => {
71
+ if (lines.length >= ENTRY_COUNT) {
72
+ throw new Fault(
73
+ 413,
74
+ "transcript_too_large",
75
+ "Saved journal exceeds the 50000-entry budget.",
76
+ );
77
+ }
78
+ lines.push(JSON.stringify(value));
79
+ lengths.clear();
80
+ },
81
+ {
82
+ policy: "inspect",
83
+ requireNewline: true,
84
+ end: size,
85
+ limits: { depth: 64, nodes: 16384, arrayLength: 4096, keyLength: 512 },
86
+ malformed: (_start, _end, error) => {
87
+ if (error instanceof RangeError) {
88
+ throw new Fault(413, "transcript_too_large", "Saved record exceeds projection limits.");
89
+ }
90
+ lengths.clear();
91
+ cut.delete(lines.length);
92
+ },
93
+ stringChunk: (path, chunk) => {
94
+ if (textField(path)) {
95
+ const key = JSON.stringify(path);
96
+ const count = (lengths.get(key) ?? 0) + chunk.length;
97
+ lengths.set(key, count);
98
+ if (count > TEXT_LIMIT) {
99
+ cut.add(lines.length);
100
+ }
101
+ }
102
+ },
103
+ },
104
+ );
105
+ return { lines, cut };
106
+ }
107
+ /** Internal hook: caller MUST resolve a catalog-approved saved ID/name, never accept an HTTP filepath. */
108
+ export async function readSavedTranscript(
109
+ packageRoot: string,
110
+ approvedFile: string,
111
+ options: TranscriptOptions = {},
112
+ ): Promise<TranscriptPage> {
113
+ const sdk = await loadNativeSession(packageRoot);
114
+ const fd = openSync(approvedFile, constants.O_RDONLY | constants.O_NOFOLLOW);
115
+ try {
116
+ const before = fstatSync(fd);
117
+ if (!before.isFile() || before.size > FILE_BYTES) {
118
+ throw new Fault(413, "transcript_too_large", "Saved journal exceeds the 64 MiB read budget.");
119
+ }
120
+ const { lines, cut } = savedLines(fd, before.size);
121
+ const after = fstatSync(fd);
122
+ if (
123
+ after.size !== before.size ||
124
+ after.mtimeMs !== before.mtimeMs ||
125
+ after.ctimeMs !== before.ctimeMs
126
+ ) {
127
+ throw new Fault(
128
+ 409,
129
+ "session_changed",
130
+ "Saved journal changed during reading; refresh the catalog.",
131
+ );
132
+ }
133
+ const entries = sdk.parseSessionEntries(lines.join("\n"));
134
+ return savedPage(entries, cut, options, sdk);
135
+ } finally {
136
+ closeSync(fd);
137
+ }
138
+ }
139
+ function savedPage(
140
+ entries: ReturnType<typeof NativeSession.parseSessionEntries>,
141
+ cut: ReadonlySet<number>,
142
+ options: TranscriptOptions,
143
+ sdk: typeof NativeSession,
144
+ ): TranscriptPage {
145
+ const header = entries.find((entry) => entry.type === "session");
146
+ if (header === undefined) {
147
+ throw new Fault(409, "invalid_session", "Saved journal has no native session header.");
148
+ }
149
+ const legacy = (header.version ?? 1) < 2;
150
+ if (legacy && options.cursor !== undefined) {
151
+ throw new Fault(
152
+ 409,
153
+ "cursor_unavailable",
154
+ "Legacy journals have no stable entry IDs; request a fresh bounded recent transcript.",
155
+ );
156
+ }
157
+ // Native inMemory reconstructs branch state/migrations without ever opening or writing the file.
158
+ const manager = sdk.SessionManager.inMemory(header.cwd, undefined, entries);
159
+ const page = readLiveTranscript(manager, options);
160
+ const cutIds = new Set(
161
+ entries.flatMap((entry, index) =>
162
+ entry.type !== "session" && cut.has(index) ? [entry.id] : [],
163
+ ),
164
+ );
165
+ const records = page.records.map((entry) =>
166
+ cutIds.has(entry.id) ? { ...entry, truncated: true } : entry,
167
+ );
168
+ return {
169
+ ...page,
170
+ // ponytail: v1 has no persisted entry IDs; use catalog snapshot cursors if legacy pagination is needed.
171
+ cursor: legacy ? undefined : page.cursor,
172
+ records,
173
+ truncated: page.truncated || records.some((entry) => entry.truncated),
174
+ };
175
+ }
@@ -14,6 +14,10 @@ export interface ProjectSessionIdentity extends TargetIdentity {
14
14
 
15
15
  export const MIN_SESSION_TARGET_PREFIX_LENGTH = 8;
16
16
 
17
+ export function nativePeerId(sessionId: string): string {
18
+ return `pi-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`;
19
+ }
20
+
17
21
  export interface TargetResolution<T extends TargetIdentity> {
18
22
  readonly status: "none" | "found" | "ambiguous" | "prefix_too_short";
19
23
  readonly target?: T;