@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
@@ -1,8 +1,7 @@
1
- import { createHash } from "node:crypto";
2
1
  import { IntercomClient } from "./broker/client.js";
3
2
  import { spawnBrokerIfNeeded } from "./broker/spawn.js";
4
3
  import { asError, errorMessage } from "./validation.js";
5
- import { formatTargetOptions, resolveSessionTarget } from "./session-targets.js";
4
+ import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.js";
6
5
  /** Owns socket registration, coalesced connects and the reconnect/startup timers. */
7
6
  export class IntercomConnection {
8
7
  client = null;
@@ -195,7 +194,7 @@ export class IntercomConnection {
195
194
  if (reason !== "peer-awareness") {
196
195
  await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs);
197
196
  }
198
- await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()), `pi-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`);
197
+ await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()), nativePeerId(sessionId));
199
198
  if (!this.lifecycle.live(ctx, generation)) {
200
199
  await next.disconnect();
201
200
  throw new Error("Intercom runtime no longer active");
@@ -1,7 +1,7 @@
1
- import { createHash } from "node:crypto";
2
1
  import { requestedDelivery, isBlockingSupervisorMessage } from "./inbound-record.js";
3
2
  import { formatAttachments } from "./message-format.js";
4
3
  import { readQuestionOwner } from "../runs/shared/supervisor-questions.js";
4
+ import { nativePeerId } from "./session-targets.js";
5
5
  /** Validates origin and chooses admission; state/resources remain in their owners. */
6
6
  export class IntercomInbound {
7
7
  lifecycle;
@@ -19,9 +19,7 @@ export class IntercomInbound {
19
19
  return false;
20
20
  }
21
21
  const owner = readQuestionOwner(origin.runId);
22
- return (owner?.sessionId === origin.ownerSessionId &&
23
- from.id ===
24
- `pi-${createHash("sha256").update(origin.ownerSessionId).digest("hex").slice(0, 32)}`);
22
+ return (owner?.sessionId === origin.ownerSessionId && from.id === nativePeerId(origin.ownerSessionId));
25
23
  }
26
24
  normalize(from, input) {
27
25
  const message = input.topic
@@ -8,6 +8,7 @@ import { InboundDeliveryOwner } from "./inbound-delivery.js";
8
8
  import { ReplyWait } from "./reply-wait.js";
9
9
  import { IntercomConnection } from "./connection.js";
10
10
  import { IntercomInbound } from "./inbound.js";
11
+ import { NativeControls } from "./native-control.js";
11
12
  import { IntercomOutbound } from "./outbound.js";
12
13
  import { IntercomInspection } from "./inspection.js";
13
14
  import { IntercomTopicActions } from "./topic-actions.js";
@@ -31,7 +32,11 @@ export default function piIntercomExtension(pi) {
31
32
  const connection = new IntercomConnection(lifecycle, config, topics, {
32
33
  restoring: () => delivery.restoring,
33
34
  pendingAsks: () => replies.listPending().length,
34
- incoming: (ctx, from, message) => inbound.receive(ctx, from, message),
35
+ incoming: async (ctx, from, message) => {
36
+ if (!(await controls.receive(ctx, from, message))) {
37
+ await inbound.receive(ctx, from, message);
38
+ }
39
+ },
35
40
  peerLeft: (id) => {
36
41
  wait.peerDisconnected(id);
37
42
  journal.peerLeft(id);
@@ -42,6 +47,7 @@ export default function piIntercomExtension(pi) {
42
47
  }
43
48
  },
44
49
  });
50
+ const controls = new NativeControls(lifecycle, connection);
45
51
  const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
46
52
  replies,
47
53
  syncStatus: () => connection.syncStatus(),
@@ -0,0 +1,80 @@
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 { loadControlPolicy } from "./bridge-config.js";
6
+ import { getPiAgentDir } from "./agent-dir.js";
7
+ import { fingerprint } from "./bridge-protocol.js";
8
+ export function authenticatedCertificateChain(socket) {
9
+ const blocks = [];
10
+ const seen = new Set();
11
+ let cert = socket.getPeerCertificate(true);
12
+ for (let depth = 0; depth < 8; depth += 1) {
13
+ const parsed = new X509Certificate(cert.raw);
14
+ if (seen.has(parsed.fingerprint256)) {
15
+ break;
16
+ }
17
+ seen.add(parsed.fingerprint256);
18
+ blocks.push(parsed.toString());
19
+ cert = cert.issuerCertificate;
20
+ }
21
+ return blocks.join("\n");
22
+ }
23
+ function certificates(pem) {
24
+ const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g);
25
+ if (!blocks || blocks.length > 8) {
26
+ throw new Error("Invalid bounded certificate chain.");
27
+ }
28
+ return blocks.map((block) => new X509Certificate(block));
29
+ }
30
+ function validNow(cert) {
31
+ return Date.parse(cert.validFrom) <= Date.now() && Date.parse(cert.validTo) > Date.now();
32
+ }
33
+ function trustedChain(chain, roots) {
34
+ let current = chain.at(0);
35
+ if (current === undefined || current.ca || !validNow(current)) {
36
+ return false;
37
+ }
38
+ for (let depth = 0; depth < 8; depth += 1) {
39
+ const subject = current;
40
+ const issuer = [...roots, ...chain.slice(1)].find((candidate) => candidate.ca &&
41
+ validNow(candidate) &&
42
+ subject.checkIssued(candidate) &&
43
+ subject.verify(candidate.publicKey));
44
+ if (!issuer) {
45
+ return false;
46
+ }
47
+ if (roots.some((root) => root.fingerprint256 === issuer.fingerprint256)) {
48
+ return true;
49
+ }
50
+ current = issuer;
51
+ }
52
+ return false;
53
+ }
54
+ function controlPolicyPath() {
55
+ const configured = process.env.PI_INTERCOM_BRIDGE_CONFIG;
56
+ if (configured !== undefined) {
57
+ return configured;
58
+ }
59
+ const standard = join(getPiAgentDir(), "intercom", "bridge.json");
60
+ return existsSync(standard) ? standard : join(homedir(), ".pi", "intercom-bridge", "config.json");
61
+ }
62
+ /** Unix broker peers are same-user trusted. Names/payload claims grant no authority. */
63
+ export function authorizedControlPeer(peerId, certificate) {
64
+ try {
65
+ const configPath = controlPolicyPath();
66
+ const config = loadControlPolicy(configPath);
67
+ const chain = certificates(certificate);
68
+ const client = chain.at(0);
69
+ if (client === undefined) {
70
+ return false;
71
+ }
72
+ const fp = fingerprint(client.fingerprint256);
73
+ return (peerId === `remote-${fp.toLowerCase()}` &&
74
+ config.clients.has(fp) &&
75
+ trustedChain(chain, certificates(config.ca.toString("utf8"))));
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
@@ -0,0 +1,111 @@
1
+ import { isRecord, isUnknownArray } from "../shared/unknown.js";
2
+ export const CONTROL_SCHEMA = "pi-intercom/control-v1";
3
+ export const CONTROL_FALLBACK = "Native Intercom metadata exchange (no conversation response needed).";
4
+ export function isControlVerb(value) {
5
+ return (value === "capabilities" || value === "cancel" || value === "stop" || value === "transcript");
6
+ }
7
+ export function isControlRequest(value) {
8
+ return (isRecord(value) &&
9
+ value.schema === CONTROL_SCHEMA &&
10
+ value.kind === "request" &&
11
+ isControlVerb(value.verb) &&
12
+ typeof value.certificate === "string" &&
13
+ value.certificate.length <= 16384 &&
14
+ Object.keys(value).every((key) => ["schema", "kind", "verb", "certificate", "options"].includes(key)));
15
+ }
16
+ export function isControlResult(value) {
17
+ if (!isRecord(value) ||
18
+ value.schema !== CONTROL_SCHEMA ||
19
+ value.kind !== "result" ||
20
+ !isControlVerb(value.verb)) {
21
+ return false;
22
+ }
23
+ return (validResultFields(value) &&
24
+ validResultPayload(value) &&
25
+ Buffer.byteLength(JSON.stringify(value)) <= 1024 * 1024);
26
+ }
27
+ function optionalText(value, max) {
28
+ return value === undefined || (typeof value === "string" && value.length <= max);
29
+ }
30
+ function validResultFields(value) {
31
+ const fields = new Set([
32
+ "schema",
33
+ "kind",
34
+ "verb",
35
+ "status",
36
+ "mode",
37
+ "idle",
38
+ "pendingInput",
39
+ "queueCleared",
40
+ "processExited",
41
+ "stopSupport",
42
+ "reason",
43
+ "transcript",
44
+ "verbs",
45
+ ]);
46
+ return (Object.keys(value).every((key) => fields.has(key)) &&
47
+ typeof value.status === "string" &&
48
+ ["ok", "denied", "cancel_requested", "shutdown_requested", "unsupported", "failed"].includes(value.status) &&
49
+ optionalText(value.reason, 2048) &&
50
+ optionalText(value.mode, 64) &&
51
+ [value.idle, value.pendingInput].every((flag) => flag === undefined || typeof flag === "boolean") &&
52
+ [value.queueCleared, value.processExited].every((flag) => flag === undefined || flag === false));
53
+ }
54
+ function validResultPayload(value) {
55
+ if (value.stopSupport !== undefined &&
56
+ (typeof value.stopSupport !== "string" ||
57
+ !["native_shutdown", "requires_activity", "host_dependent"].includes(value.stopSupport))) {
58
+ return false;
59
+ }
60
+ if (value.verbs !== undefined &&
61
+ (!isUnknownArray(value.verbs) || !value.verbs.every(isControlVerb))) {
62
+ return false;
63
+ }
64
+ return value.transcript === undefined || validTranscript(value.transcript);
65
+ }
66
+ function validTranscript(value) {
67
+ if (!isRecord(value) || !isUnknownArray(value.records) || value.records.length > 100) {
68
+ return false;
69
+ }
70
+ const fields = new Set([
71
+ "sessionId",
72
+ "leafId",
73
+ "records",
74
+ "bytes",
75
+ "truncated",
76
+ "cursor",
77
+ "scanLimitReached",
78
+ "finalizedOnly",
79
+ "streaming",
80
+ ]);
81
+ return (Object.keys(value).every((key) => fields.has(key)) &&
82
+ typeof value.sessionId === "string" &&
83
+ value.sessionId.length <= 512 &&
84
+ (value.leafId === null || typeof value.leafId === "string") &&
85
+ value.records.every(validTranscriptRecord) &&
86
+ validTranscriptMetadata(value));
87
+ }
88
+ function validTranscriptMetadata(value) {
89
+ return (typeof value.bytes === "number" &&
90
+ Number.isInteger(value.bytes) &&
91
+ value.bytes >= 0 &&
92
+ value.bytes <= 131072 &&
93
+ typeof value.truncated === "boolean" &&
94
+ optionalText(value.cursor, 512) &&
95
+ typeof value.scanLimitReached === "boolean" &&
96
+ value.finalizedOnly === true &&
97
+ value.streaming === "unavailable");
98
+ }
99
+ function validTranscriptRecord(value) {
100
+ if (!isRecord(value)) {
101
+ return false;
102
+ }
103
+ const fields = new Set(["id", "timestamp", "role", "text", "truncated"]);
104
+ return (Object.keys(value).every((key) => fields.has(key)) &&
105
+ [value.id, value.timestamp].every((field) => typeof field === "string" && field.length <= 512) &&
106
+ typeof value.role === "string" &&
107
+ ["user", "assistant", "toolResult", "custom"].includes(value.role) &&
108
+ typeof value.text === "string" &&
109
+ Buffer.byteLength(value.text) <= 131072 &&
110
+ typeof value.truncated === "boolean");
111
+ }
@@ -0,0 +1,130 @@
1
+ import { authorizedControlPeer } from "./native-control-auth.js";
2
+ import { CONTROL_FALLBACK, CONTROL_SCHEMA, isControlRequest, } from "./native-control-protocol.js";
3
+ import { readLiveTranscript, transcriptOptions } from "./transcript.js";
4
+ export class NativeControls {
5
+ lifecycle;
6
+ connection;
7
+ constructor(lifecycle, connection) {
8
+ this.lifecycle = lifecycle;
9
+ this.connection = connection;
10
+ }
11
+ result(ctx, request) {
12
+ const base = {
13
+ schema: CONTROL_SCHEMA,
14
+ kind: "result",
15
+ verb: request.verb,
16
+ mode: ctx.mode,
17
+ idle: ctx.isIdle(),
18
+ pendingInput: ctx.hasPendingMessages(),
19
+ queueCleared: false,
20
+ processExited: false,
21
+ };
22
+ if (request.verb !== "transcript" && request.options !== undefined) {
23
+ throw new Error("Only transcript accepts options.");
24
+ }
25
+ const stopSupport = stopCapability(ctx);
26
+ switch (request.verb) {
27
+ case "capabilities":
28
+ return {
29
+ ...base,
30
+ status: "ok",
31
+ stopSupport,
32
+ verbs: ["capabilities", "cancel", "stop", "transcript"],
33
+ };
34
+ case "transcript":
35
+ return {
36
+ ...base,
37
+ status: "ok",
38
+ transcript: readLiveTranscript(ctx.sessionManager, transcriptOptions(request.options)),
39
+ };
40
+ case "cancel":
41
+ return {
42
+ ...base,
43
+ status: "cancel_requested",
44
+ reason: "Native current-operation abort; accepted queued input is not cleared.",
45
+ };
46
+ case "stop":
47
+ return {
48
+ ...base,
49
+ status: ctx.mode === "print" || ctx.mode === "json" ? "unsupported" : "shutdown_requested",
50
+ stopSupport,
51
+ reason: "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.",
52
+ };
53
+ }
54
+ }
55
+ async receive(ctx, from, message) {
56
+ if (!Object.hasOwn(message, "control")) {
57
+ return false;
58
+ }
59
+ // Even malformed/response envelopes never enter conversation journaling or inference.
60
+ const request = message.control;
61
+ if (!isControlRequest(request) ||
62
+ message.replyTo !== undefined ||
63
+ message.delivery !== "passive" ||
64
+ message.expectsReply === true) {
65
+ return true;
66
+ }
67
+ const generation = this.lifecycle.generation;
68
+ const active = this.connection.active;
69
+ if (!active || !this.lifecycle.live(ctx, generation)) {
70
+ return true;
71
+ }
72
+ const result = this.admit(ctx, from.id, request);
73
+ // Receipt is acknowledged before shutdown can retire this socket/context.
74
+ await active.send(from.id, {
75
+ text: CONTROL_FALLBACK,
76
+ delivery: "passive",
77
+ replyTo: message.id,
78
+ control: result,
79
+ });
80
+ this.apply(ctx, generation, { active, peer: from.id, request, result });
81
+ return true;
82
+ }
83
+ apply(ctx, generation, state) {
84
+ const { active, peer, request, result } = state;
85
+ if (!this.lifecycle.live(ctx, generation) ||
86
+ this.connection.active !== active ||
87
+ !authorizedControlPeer(peer, request.certificate)) {
88
+ return;
89
+ }
90
+ if (result.status === "shutdown_requested" ||
91
+ (result.status === "unsupported" && request.verb === "stop")) {
92
+ ctx.shutdown();
93
+ if (this.lifecycle.live(ctx, generation)) {
94
+ ctx.abort();
95
+ }
96
+ }
97
+ else if (result.status === "cancel_requested") {
98
+ ctx.abort();
99
+ }
100
+ }
101
+ admit(ctx, peer, request) {
102
+ if (!authorizedControlPeer(peer, request.certificate)) {
103
+ return {
104
+ schema: CONTROL_SCHEMA,
105
+ kind: "result",
106
+ verb: request.verb,
107
+ status: "denied",
108
+ reason: "Sender certificate is not currently authorized by private bridge configuration.",
109
+ };
110
+ }
111
+ try {
112
+ return this.result(ctx, request);
113
+ }
114
+ catch {
115
+ return {
116
+ schema: CONTROL_SCHEMA,
117
+ kind: "result",
118
+ verb: request.verb,
119
+ status: "failed",
120
+ reason: "Native control input/state is unavailable.",
121
+ };
122
+ }
123
+ }
124
+ }
125
+ function stopCapability(ctx) {
126
+ if (ctx.mode === "tui") {
127
+ return "native_shutdown";
128
+ }
129
+ return ctx.mode === "rpc" ? "requires_activity" : "host_dependent";
130
+ }
@@ -0,0 +1,135 @@
1
+ import { constants, openSync, closeSync, fstatSync } from "node:fs";
2
+ import { loadNativeSession } from "../shared/native-session-loader.js";
3
+ import { scanJournal } from "../shared/journal-reader.js";
4
+ import { Fault } from "./bridge-protocol.js";
5
+ import { readLiveTranscript } from "./transcript.js";
6
+ const FILE_BYTES = 64 * 1024 * 1024;
7
+ const ENTRY_COUNT = 50000;
8
+ const TEXT_LIMIT = 131072;
9
+ const fields = new Set([
10
+ "type",
11
+ "id",
12
+ "parentId",
13
+ "timestamp",
14
+ "version",
15
+ "cwd",
16
+ "customType",
17
+ "display",
18
+ "content",
19
+ ]);
20
+ /** Only branch structure and public textual content are hydrated, never image/provider/custom data. */
21
+ const transcriptProjection = (path) => {
22
+ if (path.length === 0) {
23
+ return true;
24
+ }
25
+ if (path[0] === "message") {
26
+ return messageField(path.slice(1));
27
+ }
28
+ if (path[0] === "content") {
29
+ return contentField(path.slice(1));
30
+ }
31
+ return path.length === 1 && fields.has(String(path[0])) ? 4096 : false;
32
+ };
33
+ function messageField(path) {
34
+ if (path.length === 0) {
35
+ return true;
36
+ }
37
+ if (path[0] === "content") {
38
+ return contentField(path.slice(1));
39
+ }
40
+ return path.length === 1 && ["role", "output", "display", "truncated"].includes(String(path[0]))
41
+ ? TEXT_LIMIT
42
+ : false;
43
+ }
44
+ function contentField(path) {
45
+ return path.length < 2 || (path.length === 2 && ["type", "text"].includes(String(path[1])))
46
+ ? TEXT_LIMIT
47
+ : false;
48
+ }
49
+ function textField(path) {
50
+ if (path[0] === "message") {
51
+ return ((path.length === 2 && ["content", "output"].includes(String(path[1]))) ||
52
+ (path.length === 4 && path[1] === "content" && path[3] === "text"));
53
+ }
54
+ return path[0] === "content" && (path.length === 1 || (path.length === 3 && path[2] === "text"));
55
+ }
56
+ function savedLines(fd, size) {
57
+ const lines = [];
58
+ const cut = new Set();
59
+ const lengths = new Map();
60
+ scanJournal(fd, transcriptProjection, ({ value }) => {
61
+ if (lines.length >= ENTRY_COUNT) {
62
+ throw new Fault(413, "transcript_too_large", "Saved journal exceeds the 50000-entry budget.");
63
+ }
64
+ lines.push(JSON.stringify(value));
65
+ lengths.clear();
66
+ }, {
67
+ policy: "inspect",
68
+ requireNewline: true,
69
+ end: size,
70
+ limits: { depth: 64, nodes: 16384, arrayLength: 4096, keyLength: 512 },
71
+ malformed: (_start, _end, error) => {
72
+ if (error instanceof RangeError) {
73
+ throw new Fault(413, "transcript_too_large", "Saved record exceeds projection limits.");
74
+ }
75
+ lengths.clear();
76
+ cut.delete(lines.length);
77
+ },
78
+ stringChunk: (path, chunk) => {
79
+ if (textField(path)) {
80
+ const key = JSON.stringify(path);
81
+ const count = (lengths.get(key) ?? 0) + chunk.length;
82
+ lengths.set(key, count);
83
+ if (count > TEXT_LIMIT) {
84
+ cut.add(lines.length);
85
+ }
86
+ }
87
+ },
88
+ });
89
+ return { lines, cut };
90
+ }
91
+ /** Internal hook: caller MUST resolve a catalog-approved saved ID/name, never accept an HTTP filepath. */
92
+ export async function readSavedTranscript(packageRoot, approvedFile, options = {}) {
93
+ const sdk = await loadNativeSession(packageRoot);
94
+ const fd = openSync(approvedFile, constants.O_RDONLY | constants.O_NOFOLLOW);
95
+ try {
96
+ const before = fstatSync(fd);
97
+ if (!before.isFile() || before.size > FILE_BYTES) {
98
+ throw new Fault(413, "transcript_too_large", "Saved journal exceeds the 64 MiB read budget.");
99
+ }
100
+ const { lines, cut } = savedLines(fd, before.size);
101
+ const after = fstatSync(fd);
102
+ if (after.size !== before.size ||
103
+ after.mtimeMs !== before.mtimeMs ||
104
+ after.ctimeMs !== before.ctimeMs) {
105
+ throw new Fault(409, "session_changed", "Saved journal changed during reading; refresh the catalog.");
106
+ }
107
+ const entries = sdk.parseSessionEntries(lines.join("\n"));
108
+ return savedPage(entries, cut, options, sdk);
109
+ }
110
+ finally {
111
+ closeSync(fd);
112
+ }
113
+ }
114
+ function savedPage(entries, cut, options, sdk) {
115
+ const header = entries.find((entry) => entry.type === "session");
116
+ if (header === undefined) {
117
+ throw new Fault(409, "invalid_session", "Saved journal has no native session header.");
118
+ }
119
+ const legacy = (header.version ?? 1) < 2;
120
+ if (legacy && options.cursor !== undefined) {
121
+ throw new Fault(409, "cursor_unavailable", "Legacy journals have no stable entry IDs; request a fresh bounded recent transcript.");
122
+ }
123
+ // Native inMemory reconstructs branch state/migrations without ever opening or writing the file.
124
+ const manager = sdk.SessionManager.inMemory(header.cwd, undefined, entries);
125
+ const page = readLiveTranscript(manager, options);
126
+ const cutIds = new Set(entries.flatMap((entry, index) => entry.type !== "session" && cut.has(index) ? [entry.id] : []));
127
+ const records = page.records.map((entry) => cutIds.has(entry.id) ? { ...entry, truncated: true } : entry);
128
+ return {
129
+ ...page,
130
+ // ponytail: v1 has no persisted entry IDs; use catalog snapshot cursors if legacy pagination is needed.
131
+ cursor: legacy ? undefined : page.cursor,
132
+ records,
133
+ truncated: page.truncated || records.some((entry) => entry.truncated),
134
+ };
135
+ }
@@ -2,6 +2,9 @@ import { execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import path from "node:path";
4
4
  export const MIN_SESSION_TARGET_PREFIX_LENGTH = 8;
5
+ export function nativePeerId(sessionId) {
6
+ return `pi-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`;
7
+ }
5
8
  export function shortSessionId(sessionId) {
6
9
  return sessionId.slice(0, MIN_SESSION_TARGET_PREFIX_LENGTH);
7
10
  }