@fitchmultz/pi-subagents 0.45.0 → 0.46.1

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 (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/dist/agents/agent-selection.js +5 -17
  4. package/dist/agents/agents.js +2 -2
  5. package/dist/extension/build-info.js +1 -1
  6. package/dist/pi-intercom/bridge-config.js +15 -5
  7. package/dist/pi-intercom/bridge-launch-policy.js +88 -0
  8. package/dist/pi-intercom/bridge-launch.js +217 -0
  9. package/dist/pi-intercom/bridge-routes.js +8 -0
  10. package/dist/pi-intercom/bridge-rpc-process.js +264 -0
  11. package/dist/pi-intercom/bridge-session.js +4 -0
  12. package/dist/pi-intercom/bridge.js +43 -7
  13. package/dist/runs/background/runner-step-output.js +1 -3
  14. package/dist/runs/shared/acceptance-finalization.js +2 -2
  15. package/dist/runs/shared/acceptance.js +1 -1
  16. package/dist/runs/shared/model-fallback.js +3 -0
  17. package/dist/runs/shared/parallel-utils.js +1 -21
  18. package/dist/runs/shared/single-output.js +1 -4
  19. package/dist/runs/shared/subagent-prompt-runtime.js +2 -2
  20. package/dist/runs/shared/workflow-policy.js +2 -1
  21. package/dist/shared/native-session-loader.js +17 -0
  22. package/dist/shared/native-session.js +2 -16
  23. package/dist/shared/settings.js +1 -4
  24. package/docs/intercom-bridge.md +64 -14
  25. package/docs/reference.md +2 -2
  26. package/package.json +1 -1
  27. package/scripts/intercom-bridge-credentials.mjs +3 -1
  28. package/scripts/intercom-remote.mjs +40 -4
  29. package/src/agents/agent-selection.ts +8 -19
  30. package/src/agents/agents.ts +2 -2
  31. package/src/pi-intercom/bridge-config.ts +24 -5
  32. package/src/pi-intercom/bridge-launch-policy.ts +109 -0
  33. package/src/pi-intercom/bridge-launch.ts +317 -0
  34. package/src/pi-intercom/bridge-routes.ts +15 -0
  35. package/src/pi-intercom/bridge-rpc-process.ts +293 -0
  36. package/src/pi-intercom/bridge-session.ts +5 -1
  37. package/src/pi-intercom/bridge.ts +53 -8
  38. package/src/runs/background/runner-step-output.ts +1 -3
  39. package/src/runs/shared/acceptance-finalization.ts +2 -2
  40. package/src/runs/shared/acceptance.ts +0 -2
  41. package/src/runs/shared/model-fallback.ts +3 -0
  42. package/src/runs/shared/parallel-utils.ts +1 -25
  43. package/src/runs/shared/single-output.ts +0 -4
  44. package/src/runs/shared/subagent-prompt-runtime.ts +2 -2
  45. package/src/runs/shared/workflow-policy.ts +1 -1
  46. package/src/shared/native-session-loader.ts +13 -0
  47. package/src/shared/native-session.ts +2 -9
  48. package/src/shared/settings.ts +1 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.46.1] - 2026-10-11
4
+
5
+ ### Fixed
6
+
7
+ - A child that fails with Node's bare `terminated` network error (the response stream dropped mid-body, common during Wi-Fi drops) now gets the same-model retry and configured fallback models like other transport failures, instead of failing outright. Pi already retries this error inside the child.
8
+
9
+ ## [0.46.0] - 2026-10-11
10
+
11
+ ### Added
12
+
13
+ - Opt-in controlled destination Pi-session launching over the secure Intercom bridge: locally pinned native RPC sessions, private native journals and inference-free startup, actual peer readiness, name reuse, own-session status/stop, caps, and remote CLI commands. Cancellation, expiry, revocation and shutdown reap the helper-owned process group, including unresponsive native workers, while retaining journals and observing actual closure. Cleanup deadlines report `stop_timeout` and remain retryable; cleanup failures preserve the original startup fault. Messaging-only and standalone credential installs remain independent of Pi SDK imports.
14
+
3
15
  ## [0.45.0] - 2026-10-11
4
16
 
5
17
  ### Changed
package/README.md CHANGED
@@ -79,7 +79,7 @@ Children normally inherit Pi's tools, extensions, project instructions and skill
79
79
 
80
80
  Intercom is included. It lets Pi sessions exchange messages and lets a child ask its parent for a decision.
81
81
 
82
- Read the [Intercom guide](docs/intercom.md) for setup and host limits. The [secure bridge](docs/intercom-bridge.md) supports a remote Linux agent.
82
+ Read the [Intercom guide](docs/intercom.md) for setup and host limits. The [secure bridge](docs/intercom-bridge.md) supports messaging and optional locally controlled Pi-session launches for a remote Linux agent.
83
83
 
84
84
  Indexed history requires SQLite 3.53.4 through Node's built-in `node:sqlite` binding. Other SQLite versions fail. See the [storage requirements](docs/performance.md#storage-budgets-and-recovery) and [host startup costs](docs/performance.md#necessary-costs-and-limits).
85
85
 
@@ -1,25 +1,13 @@
1
- export function mergeAgentsForScope(scope, userAgents, projectAgents, builtinAgents = []) {
1
+ export function mergeAgents(userAgents, projectAgents, builtinAgents) {
2
2
  const agentMap = new Map();
3
3
  for (const agent of builtinAgents) {
4
4
  agentMap.set(agent.name, agent);
5
5
  }
6
- if (scope === "both") {
7
- for (const agent of userAgents) {
8
- agentMap.set(agent.name, agent);
9
- }
10
- for (const agent of projectAgents) {
11
- agentMap.set(agent.name, agent);
12
- }
13
- }
14
- else if (scope === "user") {
15
- for (const agent of userAgents) {
16
- agentMap.set(agent.name, agent);
17
- }
6
+ for (const agent of userAgents) {
7
+ agentMap.set(agent.name, agent);
18
8
  }
19
- else {
20
- for (const agent of projectAgents) {
21
- agentMap.set(agent.name, agent);
22
- }
9
+ for (const agent of projectAgents) {
10
+ agentMap.set(agent.name, agent);
23
11
  }
24
12
  return Array.from(agentMap.values());
25
13
  }
@@ -9,7 +9,7 @@ import { loadAgentsFromDir, clearAgentDiagnosticsForDirs, agentDiagnosticsForDir
9
9
  import { EMPTY_SUBAGENT_SETTINGS, readSubagentSettings, applyBuiltinOverrides, } from "./builtin-overrides.js";
10
10
  import { loadConfiguredPackageAgents } from "./package-agents.js";
11
11
  import { parseChain, parseJsonChain } from "./chain-serializer.js";
12
- import { mergeAgentsForScope } from "./agent-selection.js";
12
+ import { mergeAgents } from "./agent-selection.js";
13
13
  import { errorMessage } from "./config-values.js";
14
14
  export { buildRuntimeName, frontmatterNameForConfig, parsePackageName } from "./identity.js";
15
15
  export { defaultSystemPromptMode, defaultInheritProjectContext, defaultInheritSkills, } from "./agent-defaults.js";
@@ -80,7 +80,7 @@ export function discoverAgents(cwd, scope, options = {}) {
80
80
  const paths = profilePaths(cwd, options);
81
81
  const profiles = discoverLocalProfiles(paths, scope);
82
82
  const packages = loadConfiguredPackageAgents(cwd, scope, options);
83
- const agents = mergeAgentsForScope(scope, profiles.user, profiles.project, [
83
+ const agents = mergeAgents(profiles.user, profiles.project, [
84
84
  ...profiles.builtin,
85
85
  ...packages.agents,
86
86
  ]).filter((agent) => agent.disabled !== true);
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.45.0","sha256":"cfeabcf0312c82d925049312c2368f93a90a8ec6ff1205c1e3122866ef50faac"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.46.1","sha256":"c04a71cfa57811b1a2d45d43b71aa2edd0f47cd86f3b8ca91730a87ffe854304"});
@@ -1,8 +1,9 @@
1
- import { constants, closeSync, fstatSync, openSync, readFileSync, statSync } from "node:fs";
1
+ import { constants, closeSync, fstatSync, openSync, readFileSync, realpathSync, statSync, } from "node:fs";
2
2
  import { isAbsolute } from "node:path";
3
3
  import { createSecureContext } from "node:tls";
4
4
  import { isUnknownArray } from "../shared/unknown.js";
5
5
  import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.js";
6
+ import { launchPolicy } from "./bridge-launch-policy.js";
6
7
  const MAX_CLIENTS = 64;
7
8
  function readFile(path, privateFile, limit) {
8
9
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -31,17 +32,26 @@ function identities(value) {
31
32
  const clients = new Map();
32
33
  const names = new Set();
33
34
  for (const raw of value) {
34
- const entry = object(raw, ["fingerprint256", "name", "cwd"]);
35
+ const entry = object(raw, ["fingerprint256", "name", "cwd", "launch"]);
35
36
  const fingerprint256 = fingerprint(entry.fingerprint256);
36
37
  const name = label(entry.name, "name", 128).trim();
37
38
  const cwd = label(entry.cwd, "cwd", 4096);
38
- if (!isAbsolute(cwd) || !statSync(cwd).isDirectory()) {
39
- throw new Error("Client cwd must be an existing absolute directory.");
39
+ if (!isAbsolute(cwd)) {
40
+ throw new Error("Client cwd must be absolute.");
41
+ }
42
+ const launch = launchPolicy(entry.launch);
43
+ if (launch && !statSync(cwd).isDirectory()) {
44
+ throw new Error("Launch cwd must be an existing directory.");
40
45
  }
41
46
  if (clients.has(fingerprint256) || names.has(name.toLowerCase())) {
42
47
  throw new Error("Client fingerprints and names must be unique.");
43
48
  }
44
- clients.set(fingerprint256, { fingerprint256, name, cwd });
49
+ clients.set(fingerprint256, {
50
+ fingerprint256,
51
+ name,
52
+ cwd: launch ? realpathSync(cwd) : cwd,
53
+ launch,
54
+ });
45
55
  names.add(name.toLowerCase());
46
56
  }
47
57
  return clients;
@@ -0,0 +1,88 @@
1
+ import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+ import { isRecord, isUnknownArray } from "../shared/unknown.js";
4
+ import { label, object, text } from "./bridge-protocol.js";
5
+ function absolute(value, field) {
6
+ const path = label(value, field, 4096);
7
+ if (!isAbsolute(path)) {
8
+ throw new Error(`${field} must be absolute.`);
9
+ }
10
+ return realpathSync(path);
11
+ }
12
+ function bounded(value, fallback, max) {
13
+ const number = value ?? fallback;
14
+ if (typeof number !== "number" || !Number.isInteger(number) || number < 1 || number > max) {
15
+ throw new Error("Invalid launch limit.");
16
+ }
17
+ return number;
18
+ }
19
+ function boolean(value, fallback) {
20
+ if (value === undefined) {
21
+ return fallback;
22
+ }
23
+ if (typeof value !== "boolean") {
24
+ throw new Error("Launch switches must be boolean.");
25
+ }
26
+ return value;
27
+ }
28
+ function executable(packageRoot) {
29
+ const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
30
+ if (!isRecord(manifest) ||
31
+ manifest.name !== "@earendil-works/pi-coding-agent" ||
32
+ !isRecord(manifest.bin)) {
33
+ throw new Error("Launch package must be an installed Pi package.");
34
+ }
35
+ const bin = label(manifest.bin.pi, "Pi manifest bin", 4096);
36
+ const cli = realpathSync(join(packageRoot, bin));
37
+ const within = relative(packageRoot, cli);
38
+ if (within.startsWith("..") || isAbsolute(within) || !statSync(cli).isFile()) {
39
+ throw new Error("Pi bin must be a file inside the pinned package.");
40
+ }
41
+ return cli;
42
+ }
43
+ function extensions(value) {
44
+ if (!isUnknownArray(value) || value.length < 1 || value.length > 32) {
45
+ throw new Error("Launch extensions must contain 1..32 local paths.");
46
+ }
47
+ return value.map((entry) => absolute(entry, "extension path"));
48
+ }
49
+ export function launchPolicy(value) {
50
+ if (value === undefined) {
51
+ return;
52
+ }
53
+ const raw = object(value, [
54
+ "packageRoot",
55
+ "sessionDir",
56
+ "provider",
57
+ "model",
58
+ "context",
59
+ "extensions",
60
+ "discoverResources",
61
+ "trustProject",
62
+ "offline",
63
+ "maxSessions",
64
+ "startupTimeoutMs",
65
+ ]);
66
+ const packageRoot = absolute(raw.packageRoot, "Pi package root");
67
+ const sessionDir = absolute(raw.sessionDir, "session directory");
68
+ const storage = lstatSync(sessionDir);
69
+ if (!storage.isDirectory() ||
70
+ storage.uid !== process.getuid?.() ||
71
+ (storage.mode & 0o777) !== 0o700) {
72
+ throw new Error("Launch storage must be an owned directory with mode 0700.");
73
+ }
74
+ return {
75
+ packageRoot,
76
+ cli: executable(packageRoot),
77
+ sessionDir,
78
+ provider: label(raw.provider, "provider", 128),
79
+ model: label(raw.model, "model", 256),
80
+ context: text(raw.context),
81
+ extensions: extensions(raw.extensions),
82
+ discoverResources: boolean(raw.discoverResources, false),
83
+ trustProject: boolean(raw.trustProject, false),
84
+ offline: boolean(raw.offline, false),
85
+ maxSessions: bounded(raw.maxSessions, 4, 16),
86
+ startupTimeoutMs: bounded(raw.startupTimeoutMs, 15000, 60000),
87
+ };
88
+ }
@@ -0,0 +1,217 @@
1
+ import { createHash } from "node:crypto";
2
+ import { statSync } from "node:fs";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { loadNativeSession } from "../shared/native-session-loader.js";
5
+ import { isRecord } from "../shared/unknown.js";
6
+ import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
7
+ import { RpcProcess } from "./bridge-rpc-process.js";
8
+ export function sameIdentity(left, right) {
9
+ return (left.name === right?.name &&
10
+ left.cwd === right.cwd &&
11
+ JSON.stringify(left.launch) === JSON.stringify(right.launch));
12
+ }
13
+ function view(entry) {
14
+ return {
15
+ sessionId: entry.sessionId,
16
+ peerId: entry.peerId,
17
+ name: entry.name,
18
+ sessionFile: entry.sessionFile,
19
+ pid: entry.process?.pid,
20
+ status: entry.process?.status ?? "starting",
21
+ error: entry.process?.failure?.code,
22
+ };
23
+ }
24
+ export class Launcher {
25
+ launches = new Set();
26
+ stopping = false;
27
+ list(identity) {
28
+ return {
29
+ sessions: [...this.launches]
30
+ .filter((entry) => entry.identity.fingerprint256 === identity.fingerprint256)
31
+ .map(view),
32
+ };
33
+ }
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.");
39
+ }
40
+ await this.retire(entry, new Fault(410, "session_stopped", "Session stopped by its owner."));
41
+ return { session: view(entry) };
42
+ }
43
+ async start(name, context) {
44
+ cancelled(context.signal);
45
+ const policy = context.identity.launch;
46
+ if (!policy || this.stopping) {
47
+ throw new Fault(403, "launch_disabled", "Session launching is not enabled by local policy.");
48
+ }
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);
55
+ }
56
+ this.capacity(context.identity, policy);
57
+ const entry = {
58
+ identity: context.identity,
59
+ name: fullName,
60
+ controller: new AbortController(),
61
+ expires: context.expires,
62
+ admitted: false,
63
+ };
64
+ this.launches.add(entry);
65
+ try {
66
+ return await this.establish(entry, policy, context);
67
+ }
68
+ catch (error) {
69
+ await this.retire(entry, new Fault(503, "launch_failed", "Pi startup failed.")).catch(() => log("launch_cleanup", { pid: entry.process?.pid, result: "failed" }));
70
+ if (!entry.process) {
71
+ this.launches.delete(entry);
72
+ }
73
+ throw error instanceof Fault
74
+ ? error
75
+ : new Fault(503, "launch_failed", "Pi startup failed; inspect the pinned local policy and resources.");
76
+ }
77
+ }
78
+ reuse(entry) {
79
+ if (entry.admitted &&
80
+ (entry.process?.status === "idle" || entry.process?.status === "running")) {
81
+ return { session: view(entry), reused: true };
82
+ }
83
+ throw new Fault(409, "launch_in_progress", "This named session is starting or stopping; inspect sessions before retrying.");
84
+ }
85
+ capacity(identity, policy) {
86
+ const active = [...this.launches].filter((entry) => entry.process?.status !== "exited");
87
+ if (active.length >= 32 ||
88
+ active.filter((entry) => entry.identity.fingerprint256 === identity.fingerprint256).length >=
89
+ policy.maxSessions) {
90
+ throw new Fault(429, "session_limit", "Local launched-session capacity reached; stop an owned session first.");
91
+ }
92
+ for (const entry of this.launches) {
93
+ if (this.launches.size >= 64 && entry.process?.status === "exited") {
94
+ this.launches.delete(entry);
95
+ }
96
+ }
97
+ }
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
+ async establish(entry, policy, context) {
130
+ const signal = AbortSignal.any([context.signal, entry.controller.signal]);
131
+ const deadline = setTimeout(() => entry.controller.abort(new Fault(504, "launch_timeout", "Pi startup readiness deadline exceeded.")), policy.startupTimeoutMs);
132
+ const abort = () => entry.controller.abort(new Fault(499, "launch_cancelled", "Session startup cancelled or exceeded its deadline."));
133
+ signal.addEventListener("abort", abort, { once: true });
134
+ try {
135
+ await this.journal(entry, policy);
136
+ 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);
141
+ this.expiry(entry);
142
+ const state = await entry.process.state(signal);
143
+ this.validateState(entry, policy, state);
144
+ await this.peerReady(entry, context.peers, signal);
145
+ cancelled(signal);
146
+ entry.process.ready();
147
+ entry.admitted = true;
148
+ log("launch_ready", {
149
+ fingerprint: entry.identity.fingerprint256,
150
+ name: entry.name,
151
+ peer: entry.peerId,
152
+ pid: entry.process.pid,
153
+ result: "ready",
154
+ });
155
+ return { session: view(entry), reused: false };
156
+ }
157
+ finally {
158
+ clearTimeout(deadline);
159
+ signal.removeEventListener("abort", abort);
160
+ }
161
+ }
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
+ async peerReady(entry, peers, signal) {
175
+ while (!entry.process?.failure) {
176
+ cancelled(signal);
177
+ // Registration is asynchronous after native session_start; observe real broker publication.
178
+ // oxlint-disable-next-line no-await-in-loop
179
+ const rows = await peers(signal);
180
+ if (rows.some((peer) => peer.id === entry.peerId && peer.name === entry.name && peer.cwd === entry.identity.cwd)) {
181
+ return;
182
+ }
183
+ // Bounded readiness polling is not an acknowledgement substitute; only the row above succeeds.
184
+ // oxlint-disable-next-line no-await-in-loop
185
+ await delay(100, undefined, { signal });
186
+ }
187
+ throw entry.process.failure;
188
+ }
189
+ expiry(entry) {
190
+ const expire = () => {
191
+ if (entry.expires <= Date.now()) {
192
+ this.retire(entry, new Fault(403, "certificate_expired", "Launch owner's certificate expired.")).catch(() => log("launch_cleanup", { result: "failed" }));
193
+ }
194
+ else {
195
+ entry.timer = setTimeout(expire, Math.min(entry.expires - Date.now(), 0x7fffffff));
196
+ }
197
+ };
198
+ expire();
199
+ entry.process?.exited
200
+ .then(() => clearTimeout(entry.timer))
201
+ .catch(() => log("launch_cleanup", { result: "failed" }));
202
+ }
203
+ async retire(entry, fault) {
204
+ entry.controller.abort(fault);
205
+ clearTimeout(entry.timer);
206
+ await entry.process?.stop();
207
+ }
208
+ async revoke(clients) {
209
+ await Promise.all([...this.launches]
210
+ .filter((entry) => !sameIdentity(entry.identity, clients.get(entry.identity.fingerprint256)))
211
+ .map((entry) => this.retire(entry, new Fault(403, "revoked", "Launch owner's identity or policy was revoked."))));
212
+ }
213
+ async shutdown() {
214
+ this.stopping = true;
215
+ await Promise.all([...this.launches].map((entry) => this.retire(entry, new Fault(503, "shutting_down", "Bridge is shutting down."))));
216
+ }
217
+ }
@@ -9,6 +9,9 @@ const fields = {
9
9
  "POST /v1/send": ["to", "message"],
10
10
  "POST /v1/ask": ["to", "message", "timeoutMs"],
11
11
  "POST /v1/reply": ["replyTo", "message"],
12
+ "POST /v1/start": ["name"],
13
+ "GET /v1/sessions": [],
14
+ "POST /v1/stop": ["sessionId"],
12
15
  };
13
16
  function isRoute(route) {
14
17
  return Object.hasOwn(fields, route);
@@ -42,7 +45,12 @@ export async function operationFor(req, route, signal) {
42
45
  case "GET /v1/list":
43
46
  case "GET /v1/inbox":
44
47
  case "POST /v1/register":
48
+ case "GET /v1/sessions":
45
49
  return { route };
50
+ case "POST /v1/start":
51
+ return { route, name: label(input.name, "session name", 64).trim() };
52
+ case "POST /v1/stop":
53
+ return { route, sessionId: label(input.sessionId, "session ID", 128) };
46
54
  case "POST /v1/ack": {
47
55
  if (!isUnknownArray(input.ids) || input.ids.length > INBOX_COUNT) {
48
56
  throw new Fault(400, "invalid_input", "ids must be an array of at most 256 message IDs.");