@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,10 +1,12 @@
1
- import { createHash } from "node:crypto";
2
- import { statSync } from "node:fs";
3
1
  import { setTimeout as delay } from "node:timers/promises";
4
- import { loadNativeSession } from "../shared/native-session-loader.js";
5
2
  import { isRecord } from "../shared/unknown.js";
3
+ import { launchCwd, spawnPolicy } from "./bridge-launch-policy.js";
6
4
  import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
7
5
  import { RpcProcess } from "./bridge-rpc-process.js";
6
+ import { exactPeer, savedView, browseSessions, } from "./bridge-catalog.js";
7
+ import { createLaunchJournal, resumeLaunchJournal, validateLaunchState, } from "./bridge-launch-journal.js";
8
+ import { spawnBrokerIfNeeded } from "./broker/spawn.js";
9
+ import { resolveIntercomPresenceName } from "./identity.js";
8
10
  export function sameIdentity(left, right) {
9
11
  return (left.name === right?.name &&
10
12
  left.cwd === right.cwd &&
@@ -12,18 +14,27 @@ export function sameIdentity(left, right) {
12
14
  }
13
15
  function view(entry) {
14
16
  return {
15
- sessionId: entry.sessionId,
16
- peerId: entry.peerId,
17
+ id: entry.journal?.sessionId,
18
+ sessionId: entry.journal?.sessionId,
19
+ peerId: entry.journal?.peerId,
17
20
  name: entry.name,
18
- sessionFile: entry.sessionFile,
21
+ cwd: entry.cwd,
22
+ sessionFile: entry.journal?.sessionFile,
19
23
  pid: entry.process?.pid,
20
24
  status: entry.process?.status ?? "starting",
21
25
  error: entry.process?.failure?.code,
26
+ saved: entry.journal !== undefined,
27
+ owned: true,
22
28
  };
23
29
  }
24
30
  export class Launcher {
25
31
  launches = new Set();
32
+ catalog;
33
+ resuming = new Set();
26
34
  stopping = false;
35
+ constructor(catalog) {
36
+ this.catalog = catalog;
37
+ }
27
38
  list(identity) {
28
39
  return {
29
40
  sessions: [...this.launches]
@@ -31,36 +42,175 @@ export class Launcher {
31
42
  .map(view),
32
43
  };
33
44
  }
34
- async stop(identity, sessionId) {
35
- const entry = [...this.launches].find((candidate) => candidate.identity.fingerprint256 === identity.fingerprint256 &&
36
- candidate.sessionId === sessionId);
37
- if (!entry) {
38
- throw new Fault(404, "unknown_session", "Session is not owned by this identity and helper.");
45
+ async browse(query, context) {
46
+ return browseSessions(this.catalog, query, {
47
+ policy: context.identity.launch,
48
+ signal: context.signal,
49
+ peers: context.peers,
50
+ activeState: (id) => {
51
+ const entry = this.active(id) ?? this.activePeer(id);
52
+ return entry?.identity.fingerprint256 === context.identity.fingerprint256
53
+ ? (entry.process?.status ?? "starting")
54
+ : undefined;
55
+ },
56
+ });
57
+ }
58
+ active(sessionId) {
59
+ return [...this.launches].find((entry) => entry.journal?.sessionId === sessionId && entry.process?.status !== "exited");
60
+ }
61
+ activePeer(peerId) {
62
+ return [...this.launches].find((entry) => entry.journal?.peerId === peerId && entry.process?.status !== "exited");
63
+ }
64
+ async owned(identity, id, context) {
65
+ const candidates = [...this.launches].filter((candidate) => candidate.identity.fingerprint256 === identity.fingerprint256);
66
+ const byId = candidates.findLast((candidate) => candidate.journal?.sessionId === id || candidate.journal?.peerId === id);
67
+ const named = candidates.filter((candidate) => candidate.process?.status !== "exited" &&
68
+ (candidate.name === id || candidate.name === `remote:${identity.name}:${id}`));
69
+ if (!byId && named.length > 1) {
70
+ throw new Fault(409, "ambiguous_session", "Multiple owned live processes match this name; use their native IDs.");
71
+ }
72
+ const entry = byId ?? named.at(0);
73
+ if (!entry?.process) {
74
+ throw new Fault(404, "unknown_session", "Control is available only for this identity's helper-owned subprocesses, not unrelated live peers or saved history.");
75
+ }
76
+ if (entry.process.status === "exited") {
77
+ const peers = await context.peers(context.signal);
78
+ if (peers.some((peer) => peer.id === entry.journal?.peerId)) {
79
+ throw new Fault(404, "unknown_session", "This journal has a newer live owner; the historical process is not current ownership.");
80
+ }
39
81
  }
82
+ return entry;
83
+ }
84
+ async stop(identity, sessionId, context) {
85
+ const entry = await this.owned(identity, sessionId, context);
40
86
  await this.retire(entry, new Fault(410, "session_stopped", "Session stopped by its owner."));
41
87
  return { session: view(entry) };
42
88
  }
89
+ async cancel(identity, sessionId, context) {
90
+ const entry = await this.owned(identity, sessionId, context);
91
+ if (!entry.admitted || entry.process?.status === "exited" || !entry.process) {
92
+ throw new Fault(409, "session_not_ready", "Only an admitted live owned session can be cancelled.");
93
+ }
94
+ const state = await entry.process.cancel(context.signal);
95
+ return { session: view(entry), cancelled: true, state, queuesPreserved: true };
96
+ }
43
97
  async start(name, context) {
98
+ return this.spawn({ name }, context, true);
99
+ }
100
+ async spawn(input, context, legacy = false) {
101
+ const policy = spawnPolicy(this.policy(context), input);
102
+ const cwd = legacy
103
+ ? context.identity.cwd
104
+ : launchCwd(policy, input.cwd ?? context.identity.cwd);
105
+ const name = `remote:${context.identity.name}:${input.name}`;
106
+ const existing = [...this.launches].find((entry) => entry.identity.fingerprint256 === context.identity.fingerprint256 &&
107
+ entry.name === name &&
108
+ entry.process?.status !== "exited");
109
+ const result = existing
110
+ ? this.reuseSpawn(existing, input, policy, cwd)
111
+ : await this.launch({ name, cwd }, context, policy);
112
+ return this.task(result, input.task, context);
113
+ }
114
+ reuseSpawn(entry, input, policy, cwd) {
115
+ if (entry.cwd !== cwd) {
116
+ throw new Fault(409, "cwd_conflict", "This live name already belongs to a different workspace.");
117
+ }
118
+ if (input.model !== undefined &&
119
+ (entry.journal?.model?.provider !== policy.provider ||
120
+ entry.journal?.model?.modelId !== policy.model)) {
121
+ throw new Fault(409, "model_conflict", "This live name already belongs to a different model selection; resume it without overriding history.");
122
+ }
123
+ return this.reuse(entry);
124
+ }
125
+ async resume(input, context) {
126
+ const policy = this.policy(context);
127
+ const selector = input.sessionId ?? input.target ?? `name:${input.name ?? ""}`;
128
+ if (this.resuming.has(selector)) {
129
+ throw new Fault(409, "launch_in_progress", "This resume selector is already starting.");
130
+ }
131
+ this.resuming.add(selector);
132
+ try {
133
+ const saved = await this.catalog.resolve(policy, input, context.signal);
134
+ launchCwd(policy, saved.cwd);
135
+ const owned = this.active(saved.sessionId);
136
+ if (owned) {
137
+ const result = this.reuse(owned);
138
+ const reused = owned.identity.fingerprint256 === context.identity.fingerprint256
139
+ ? result
140
+ : {
141
+ ...result,
142
+ session: {
143
+ ...savedView(saved),
144
+ peerId: saved.peerId,
145
+ status: owned.process?.status,
146
+ },
147
+ };
148
+ return await this.task(reused, input.task, context);
149
+ }
150
+ await wait(spawnBrokerIfNeeded(process.execPath, []), context.signal);
151
+ const peer = exactPeer(saved, await context.peers(context.signal));
152
+ if (peer) {
153
+ return await this.task({ session: savedView(saved, peer), id: saved.sessionId, reused: true }, input.task, context);
154
+ }
155
+ // A different selector/identity may have completed catalog resolution during the peer lookup.
156
+ if (this.active(saved.sessionId)) {
157
+ throw new Fault(409, "launch_in_progress", "This native session is already starting.");
158
+ }
159
+ const result = await this.launch(saved, context, policy);
160
+ return await this.task(result, input.task, context);
161
+ }
162
+ finally {
163
+ this.resuming.delete(selector);
164
+ }
165
+ }
166
+ policy(context) {
44
167
  cancelled(context.signal);
45
168
  const policy = context.identity.launch;
46
169
  if (!policy || this.stopping) {
47
170
  throw new Fault(403, "launch_disabled", "Session launching is not enabled by local policy.");
48
171
  }
49
- const fullName = `remote:${context.identity.name}:${name}`;
50
- const existing = [...this.launches].find((entry) => entry.identity.fingerprint256 === context.identity.fingerprint256 &&
51
- entry.name === fullName &&
52
- entry.process?.status !== "exited");
53
- if (existing) {
54
- return this.reuse(existing);
172
+ return policy;
173
+ }
174
+ async task(result, task, context) {
175
+ const session = result.session;
176
+ if (task === undefined || !isRecord(session) || typeof session.peerId !== "string") {
177
+ return result;
178
+ }
179
+ try {
180
+ const receipt = await context.submit(session.peerId, task);
181
+ return { ...result, id: session.sessionId, task: { ...receipt, completed: false } };
182
+ }
183
+ catch (error) {
184
+ const fault = error instanceof Fault
185
+ ? error
186
+ : new Fault(503, "task_unconfirmed", "Task acceptance is unknown; inspect the session before retrying.");
187
+ throw new Fault(fault.status, fault.code, fault.message, {
188
+ ...fault.details,
189
+ session,
190
+ reused: result.reused,
191
+ });
55
192
  }
193
+ }
194
+ async launch(target, context, policy) {
56
195
  this.capacity(context.identity, policy);
57
196
  const entry = {
58
197
  identity: context.identity,
59
- name: fullName,
198
+ name: target.name,
199
+ cwd: target.cwd,
60
200
  controller: new AbortController(),
61
201
  expires: context.expires,
62
202
  admitted: false,
63
203
  };
204
+ if (target.sessionId !== undefined &&
205
+ target.peerId !== undefined &&
206
+ target.sessionFile !== undefined) {
207
+ entry.journal = {
208
+ ...target,
209
+ sessionId: target.sessionId,
210
+ peerId: target.peerId,
211
+ sessionFile: target.sessionFile,
212
+ };
213
+ }
64
214
  this.launches.add(entry);
65
215
  try {
66
216
  return await this.establish(entry, policy, context);
@@ -72,15 +222,15 @@ export class Launcher {
72
222
  }
73
223
  throw error instanceof Fault
74
224
  ? error
75
- : new Fault(503, "launch_failed", "Pi startup failed; inspect the pinned local policy and resources.");
225
+ : new Fault(503, "launch_failed", "Pi startup failed; inspect local policy and resources.");
76
226
  }
77
227
  }
78
228
  reuse(entry) {
79
229
  if (entry.admitted &&
80
230
  (entry.process?.status === "idle" || entry.process?.status === "running")) {
81
- return { session: view(entry), reused: true };
231
+ return { session: view(entry), id: entry.journal?.sessionId, reused: true };
82
232
  }
83
- throw new Fault(409, "launch_in_progress", "This named session is starting or stopping; inspect sessions before retrying.");
233
+ throw new Fault(409, "launch_in_progress", "This session is starting or stopping; inspect sessions before retrying.");
84
234
  }
85
235
  capacity(identity, policy) {
86
236
  const active = [...this.launches].filter((entry) => entry.process?.status !== "exited");
@@ -95,52 +245,23 @@ export class Launcher {
95
245
  }
96
246
  }
97
247
  }
98
- async journal(entry, policy) {
99
- const { SessionManager } = await wait(loadNativeSession(policy.packageRoot), entry.controller.signal);
100
- cancelled(entry.controller.signal);
101
- const storage = statSync(policy.sessionDir);
102
- if (!storage.isDirectory() ||
103
- storage.uid !== process.getuid?.() ||
104
- (storage.mode & 0o777) !== 0o700) {
105
- throw new Fault(503, "storage_invalid", "Private launch storage is no longer valid.");
106
- }
107
- const previous = process.umask(0o077);
108
- try {
109
- const manager = SessionManager.create(entry.identity.cwd, policy.sessionDir);
110
- manager.appendSessionInfo(entry.name);
111
- manager.appendMessage({ role: "user", content: policy.context, timestamp: Date.now() });
112
- entry.sessionId = manager.getSessionId();
113
- entry.sessionFile = manager.getSessionFile();
114
- entry.peerId = `pi-${createHash("sha256").update(entry.sessionId).digest("hex").slice(0, 32)}`;
115
- if (entry.sessionFile === undefined || (statSync(entry.sessionFile).mode & 0o777) !== 0o600) {
116
- throw new Fault(503, "journal_invalid", "Native journal was not privately persisted.");
117
- }
118
- const saved = SessionManager.open(entry.sessionFile, policy.sessionDir);
119
- if (saved.getSessionId() !== entry.sessionId ||
120
- saved.getSessionName() !== entry.name ||
121
- saved.getCwd() !== entry.identity.cwd) {
122
- throw new Fault(503, "journal_invalid", "Native journal identity did not persist.");
123
- }
124
- }
125
- finally {
126
- process.umask(previous);
127
- }
128
- }
129
248
  async establish(entry, policy, context) {
130
249
  const signal = AbortSignal.any([context.signal, entry.controller.signal]);
131
250
  const deadline = setTimeout(() => entry.controller.abort(new Fault(504, "launch_timeout", "Pi startup readiness deadline exceeded.")), policy.startupTimeoutMs);
132
251
  const abort = () => entry.controller.abort(new Fault(499, "launch_cancelled", "Session startup cancelled or exceeded its deadline."));
133
252
  signal.addEventListener("abort", abort, { once: true });
134
253
  try {
135
- await this.journal(entry, policy);
254
+ const resuming = entry.journal !== undefined;
255
+ entry.journal = entry.journal
256
+ ? await resumeLaunchJournal(policy, entry.journal, signal)
257
+ : await createLaunchJournal(policy, { cwd: entry.cwd, name: entry.name ?? "" }, signal);
136
258
  cancelled(signal);
137
- if (entry.sessionFile === undefined) {
138
- throw new Fault(503, "journal_invalid", "Native journal path unavailable.");
139
- }
140
- entry.process = new RpcProcess(policy, entry.identity.cwd, entry.sessionFile);
259
+ entry.process = new RpcProcess(policy, entry.cwd, entry.journal.sessionFile, resuming);
141
260
  this.expiry(entry);
142
- const state = await entry.process.state(signal);
143
- this.validateState(entry, policy, state);
261
+ validateLaunchState(entry.journal, await entry.process.state(signal));
262
+ await wait(spawnBrokerIfNeeded(process.execPath, []).catch(() => {
263
+ throw new Fault(503, "broker_unavailable", "Local broker could not be acquired safely.");
264
+ }), signal);
144
265
  await this.peerReady(entry, context.peers, signal);
145
266
  cancelled(signal);
146
267
  entry.process.ready();
@@ -148,39 +269,28 @@ export class Launcher {
148
269
  log("launch_ready", {
149
270
  fingerprint: entry.identity.fingerprint256,
150
271
  name: entry.name,
151
- peer: entry.peerId,
272
+ peer: entry.journal.peerId,
152
273
  pid: entry.process.pid,
153
274
  result: "ready",
154
275
  });
155
- return { session: view(entry), reused: false };
276
+ return { session: view(entry), id: entry.journal.sessionId, reused: false };
156
277
  }
157
278
  finally {
158
279
  clearTimeout(deadline);
159
280
  signal.removeEventListener("abort", abort);
160
281
  }
161
282
  }
162
- validateState(entry, policy, state) {
163
- if (state.sessionId !== entry.sessionId ||
164
- state.sessionFile !== entry.sessionFile ||
165
- state.sessionName !== entry.name ||
166
- state.isStreaming !== false ||
167
- state.pendingMessageCount !== 0 ||
168
- !isRecord(state.model) ||
169
- state.model.provider !== policy.provider ||
170
- state.model.id !== policy.model) {
171
- throw new Fault(503, "readiness_failed", "Pi did not start idle with the exact saved session and configured model.");
172
- }
173
- }
174
283
  async peerReady(entry, peers, signal) {
175
284
  while (!entry.process?.failure) {
176
285
  cancelled(signal);
177
- // Registration is asynchronous after native session_start; observe real broker publication.
286
+ // Native startup alone is insufficient: observe the exact Intercom publication.
178
287
  // oxlint-disable-next-line no-await-in-loop
179
288
  const rows = await peers(signal);
180
- if (rows.some((peer) => peer.id === entry.peerId && peer.name === entry.name && peer.cwd === entry.identity.cwd)) {
289
+ if (rows.some((peer) => peer.id === entry.journal?.peerId &&
290
+ peer.name === resolveIntercomPresenceName(entry.name, entry.journal.sessionId) &&
291
+ peer.cwd === entry.cwd)) {
181
292
  return;
182
293
  }
183
- // Bounded readiness polling is not an acknowledgement substitute; only the row above succeeds.
184
294
  // oxlint-disable-next-line no-await-in-loop
185
295
  await delay(100, undefined, { signal });
186
296
  }
@@ -0,0 +1,127 @@
1
+ import { join } from "node:path";
2
+ import { isRecord } from "../shared/unknown.js";
3
+ import { Launcher } from "./bridge-launch.js";
4
+ import { SessionCatalog, savedView, trustedJournal } from "./bridge-catalog.js";
5
+ import { Groups } from "./bridge-groups.js";
6
+ import { broadcastTargets } from "./bridge-broadcast.js";
7
+ import { getPiAgentDir } from "./agent-dir.js";
8
+ import { Fault } from "./bridge-protocol.js";
9
+ import { resolveBridgeTarget } from "./bridge-target.js";
10
+ import { readSavedTranscript } from "./saved-transcript.js";
11
+ /** Request operations own launch/catalog/group state; transport owns admission and cancellation. */
12
+ export class BridgeOperations {
13
+ catalog = new SessionCatalog();
14
+ launcher = new Launcher(this.catalog);
15
+ stateDir;
16
+ groups;
17
+ constructor(stateDir) {
18
+ this.stateDir = stateDir ?? join(getPiAgentDir(), "intercom", "bridge-state");
19
+ }
20
+ revoke(clients) {
21
+ return this.launcher.revoke(clients);
22
+ }
23
+ shutdown() {
24
+ return this.launcher.shutdown();
25
+ }
26
+ groupStore() {
27
+ this.groups ??= new Groups(this.stateDir);
28
+ return this.groups;
29
+ }
30
+ async execute(operation, session, context) {
31
+ const launchContext = {
32
+ identity: session.identity,
33
+ expires: context.expires,
34
+ signal: context.signal,
35
+ peers: (signal) => session.connectedPeers(signal),
36
+ submit: (peer, task) => session.send(peer, task, context),
37
+ };
38
+ switch (operation.route) {
39
+ case "POST /v1/start":
40
+ return this.launcher.start(operation.name, launchContext);
41
+ case "POST /v1/spawn":
42
+ return this.launcher.spawn(operation, launchContext);
43
+ case "POST /v1/resume":
44
+ return this.launcher.resume(operation, launchContext);
45
+ case "GET /v1/sessions":
46
+ return operation.query === undefined
47
+ ? this.launcher.list(session.identity)
48
+ : this.launcher.browse(operation.query, launchContext);
49
+ case "POST /v1/stop":
50
+ case "POST /v1/cancel":
51
+ return this.stop(operation, session, context);
52
+ case "GET /v1/groups":
53
+ return { groups: this.groupStore().list(session.identity.fingerprint256) };
54
+ case "POST /v1/groups":
55
+ return {
56
+ groups: this.groupStore().upsert(session.identity.fingerprint256, operation.group),
57
+ };
58
+ case "POST /v1/broadcast":
59
+ return broadcastTargets(session, this.groupStore().targets(session.identity.fingerprint256, operation.group, await session.connectedPeers(context.signal)), operation.message, context);
60
+ case "POST /v1/control":
61
+ return session.nativeControl(operation, context.certificate, context);
62
+ case "GET /v1/transcript":
63
+ return this.transcript(operation, session, context);
64
+ case "GET /v1/inbox":
65
+ case "GET /v1/list":
66
+ case "POST /v1/ack":
67
+ case "POST /v1/ask":
68
+ case "POST /v1/register":
69
+ case "POST /v1/reply":
70
+ case "POST /v1/send":
71
+ return session.execute(operation, context);
72
+ }
73
+ }
74
+ async stop(operation, session, context) {
75
+ const cancel = operation.route === "POST /v1/cancel";
76
+ const control = {
77
+ signal: context.signal,
78
+ peers: (signal) => session.connectedPeers(signal),
79
+ };
80
+ try {
81
+ return cancel
82
+ ? await this.launcher.cancel(session.identity, operation.sessionId, control)
83
+ : await this.launcher.stop(session.identity, operation.sessionId, control);
84
+ }
85
+ catch (error) {
86
+ // Only a pre-effect ownership miss may dispatch elsewhere; never retry an unknown outcome.
87
+ if (!(error instanceof Fault) || error.code !== "unknown_session") {
88
+ throw error;
89
+ }
90
+ }
91
+ return session.nativeControl({ to: operation.sessionId, verb: cancel ? "cancel" : "stop", timeoutMs: operation.timeoutMs }, context.certificate, context);
92
+ }
93
+ async transcript(operation, session, context) {
94
+ const peers = await session.connectedPeers(context.signal);
95
+ try {
96
+ const peer = resolveBridgeTarget(peers, operation.target, null);
97
+ const result = await session.nativeControl({
98
+ to: peer.id,
99
+ verb: "transcript",
100
+ options: operation.options,
101
+ timeoutMs: operation.timeoutMs,
102
+ }, context.certificate, context);
103
+ return {
104
+ ...result,
105
+ source: "live",
106
+ transcript: isRecord(result.native) ? result.native.transcript : undefined,
107
+ };
108
+ }
109
+ catch (error) {
110
+ if (!(error instanceof Fault) || error.code !== "peer_offline") {
111
+ throw error;
112
+ }
113
+ }
114
+ const policy = session.identity.launch;
115
+ if (!policy) {
116
+ throw new Fault(403, "launch_disabled", "Saved transcripts require local resume policy.");
117
+ }
118
+ const saved = await this.catalog.resolve(policy, { target: operation.target }, context.signal);
119
+ trustedJournal(policy, saved.sessionFile);
120
+ const page = await readSavedTranscript(policy.packageRoot, saved.sessionFile, operation.options);
121
+ session.guard(context.signal);
122
+ if (page.sessionId !== saved.sessionId) {
123
+ throw new Fault(409, "session_changed", "Saved journal identity changed during reading.");
124
+ }
125
+ return { status: "ok", source: "saved", session: savedView(saved), transcript: page };
126
+ }
127
+ }
@@ -14,6 +14,13 @@ export class Fault extends Error {
14
14
  this.details = details;
15
15
  }
16
16
  }
17
+ export class BroadcastFault extends Fault {
18
+ outcome;
19
+ constructor(fault, outcome) {
20
+ super(fault.status, fault.code, fault.message, fault.details);
21
+ this.outcome = { ...outcome, status: "partial" };
22
+ }
23
+ }
17
24
  export function object(value, fields) {
18
25
  if (!isRecord(value)) {
19
26
  throw new Fault(400, "invalid_input", "Expected a JSON object.");