@fitchmultz/pi-subagents 0.46.2 → 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 (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/dist/extension/build-info.js +1 -1
  4. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  5. package/dist/pi-intercom/bridge-catalog.js +372 -0
  6. package/dist/pi-intercom/bridge-config.js +47 -26
  7. package/dist/pi-intercom/bridge-control.js +104 -0
  8. package/dist/pi-intercom/bridge-files.js +47 -0
  9. package/dist/pi-intercom/bridge-groups.js +135 -0
  10. package/dist/pi-intercom/bridge-http.js +8 -1
  11. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  12. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  13. package/dist/pi-intercom/bridge-launch.js +186 -76
  14. package/dist/pi-intercom/bridge-mailbox.js +324 -0
  15. package/dist/pi-intercom/bridge-operations.js +127 -0
  16. package/dist/pi-intercom/bridge-presence.js +100 -0
  17. package/dist/pi-intercom/bridge-protocol.js +7 -0
  18. package/dist/pi-intercom/bridge-routes.js +190 -17
  19. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  20. package/dist/pi-intercom/bridge-session.js +83 -85
  21. package/dist/pi-intercom/bridge-target.js +22 -0
  22. package/dist/pi-intercom/bridge-wake.js +158 -0
  23. package/dist/pi-intercom/bridge.js +84 -44
  24. package/dist/pi-intercom/broker/client.js +1 -0
  25. package/dist/pi-intercom/connection.js +2 -3
  26. package/dist/pi-intercom/inbound.js +2 -4
  27. package/dist/pi-intercom/index.js +7 -1
  28. package/dist/pi-intercom/native-control-auth.js +80 -0
  29. package/dist/pi-intercom/native-control-protocol.js +111 -0
  30. package/dist/pi-intercom/native-control.js +130 -0
  31. package/dist/pi-intercom/saved-transcript.js +135 -0
  32. package/dist/pi-intercom/session-targets.js +3 -0
  33. package/dist/pi-intercom/transcript.js +175 -0
  34. package/docs/intercom-bridge.md +293 -161
  35. package/docs/intercom.md +1 -1
  36. package/docs/reference.md +1 -1
  37. package/package.json +1 -1
  38. package/scripts/intercom-bridge-credentials.mjs +54 -12
  39. package/scripts/intercom-remote.mjs +196 -31
  40. package/scripts/package-smoke.mjs +1 -1
  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 +64 -37
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-files.ts +59 -0
  46. package/src/pi-intercom/bridge-groups.ts +169 -0
  47. package/src/pi-intercom/bridge-http.ts +15 -1
  48. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  49. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  50. package/src/pi-intercom/bridge-launch.ts +296 -107
  51. package/src/pi-intercom/bridge-mailbox.ts +395 -0
  52. package/src/pi-intercom/bridge-operations.ts +169 -0
  53. package/src/pi-intercom/bridge-presence.ts +128 -0
  54. package/src/pi-intercom/bridge-protocol.ts +8 -0
  55. package/src/pi-intercom/bridge-routes.ts +281 -32
  56. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  57. package/src/pi-intercom/bridge-session.ts +106 -105
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake.ts +188 -0
  60. package/src/pi-intercom/bridge.ts +88 -54
  61. package/src/pi-intercom/broker/client.ts +3 -0
  62. package/src/pi-intercom/connection.ts +2 -3
  63. package/src/pi-intercom/inbound.ts +2 -4
  64. package/src/pi-intercom/index.ts +7 -1
  65. package/src/pi-intercom/native-control-auth.ts +89 -0
  66. package/src/pi-intercom/native-control-protocol.ts +180 -0
  67. package/src/pi-intercom/native-control.ts +160 -0
  68. package/src/pi-intercom/saved-transcript.ts +175 -0
  69. package/src/pi-intercom/session-targets.ts +4 -0
  70. package/src/pi-intercom/transcript.ts +226 -0
  71. package/src/pi-intercom/types.ts +2 -0
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Fault, wait } from "./bridge-protocol.js";
3
+ import { CONTROL_FALLBACK, CONTROL_SCHEMA, isControlResult, } from "./native-control-protocol.js";
4
+ /** One bounded request owner; exact socket-derived peer, replyTo, version and verb correlation. */
5
+ export class ControlReplies {
6
+ pending = new Map();
7
+ open(peer, verb, timeoutMs) {
8
+ if (this.pending.size >= 8) {
9
+ throw new Fault(429, "too_many_controls", "At most eight native controls may be in flight.");
10
+ }
11
+ const id = randomUUID();
12
+ const { promise: reply, resolve, reject } = Promise.withResolvers();
13
+ // A disconnect may reject while send is awaiting its broker receipt.
14
+ void reply.catch(() => {
15
+ /* Request owner observes rejection after its send receipt. */
16
+ });
17
+ const timer = setTimeout(() => reject(new Fault(504, "control_unconfirmed", "No correlated native response. Owner may be old/unavailable; execution is unknown. Do not automatically retry.", { id, deliveryUnknown: true })), timeoutMs);
18
+ const pending = { peer, verb, reply, resolve, reject, timer, disconnected: false };
19
+ this.pending.set(id, pending);
20
+ return {
21
+ id,
22
+ reply,
23
+ clear: () => this.clear(id),
24
+ get observedDisconnected() {
25
+ return pending.disconnected;
26
+ },
27
+ };
28
+ }
29
+ async request(client, operation, context) {
30
+ context.onPeer(operation.peer);
31
+ const waiting = this.open(operation.peer, operation.verb, operation.timeoutMs);
32
+ try {
33
+ context.guard();
34
+ const receipt = await wait(client.send(operation.peer, {
35
+ text: CONTROL_FALLBACK,
36
+ messageId: waiting.id,
37
+ delivery: "passive",
38
+ control: {
39
+ schema: CONTROL_SCHEMA,
40
+ kind: "request",
41
+ verb: operation.verb,
42
+ certificate: operation.certificate,
43
+ options: operation.options,
44
+ },
45
+ }), context.signal).catch((error) => {
46
+ context.guard();
47
+ if (error instanceof Fault) {
48
+ throw error;
49
+ }
50
+ throw new Fault(504, "control_unconfirmed", "Broker receipt was not received; native execution is unknown. Do not automatically retry.", { id: waiting.id, deliveryUnknown: true });
51
+ });
52
+ context.guard();
53
+ if (!receipt.accepted) {
54
+ throw new Fault(409, "delivery_failed", receipt.reason ?? "Broker rejected native control.", { id: receipt.id });
55
+ }
56
+ const result = await wait(waiting.reply, context.signal);
57
+ context.guard();
58
+ return {
59
+ ...receipt,
60
+ peer: operation.peer,
61
+ status: result.status,
62
+ native: result,
63
+ observedDisconnected: waiting.observedDisconnected,
64
+ processExited: false,
65
+ };
66
+ }
67
+ finally {
68
+ waiting.clear();
69
+ }
70
+ }
71
+ receive(peer, message) {
72
+ if (!Object.hasOwn(message, "control")) {
73
+ return false;
74
+ }
75
+ const pending = message.replyTo === undefined ? undefined : this.pending.get(message.replyTo);
76
+ if (pending &&
77
+ pending.peer === peer &&
78
+ message.delivery === "passive" &&
79
+ isControlResult(message.control) &&
80
+ message.control.verb === pending.verb) {
81
+ pending.resolve(message.control);
82
+ }
83
+ // Unsolicited/spoofed/malformed control results do not enter the remote mailbox.
84
+ return true;
85
+ }
86
+ peerLeft(peer) {
87
+ for (const [id, pending] of this.pending) {
88
+ if (pending.peer === peer) {
89
+ pending.disconnected = true;
90
+ pending.reject(new Fault(409, "peer_disconnected", "Native peer disconnected; this does not establish process exit.", { id, observedDisconnected: true, processExited: false, deliveryUnknown: true }));
91
+ }
92
+ }
93
+ }
94
+ close(error) {
95
+ for (const [id, pending] of this.pending) {
96
+ pending.reject(error);
97
+ this.clear(id);
98
+ }
99
+ }
100
+ clear(id) {
101
+ clearTimeout(this.pending.get(id)?.timer);
102
+ this.pending.delete(id);
103
+ }
104
+ }
@@ -0,0 +1,47 @@
1
+ import { constants, closeSync, fstatSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ export function readBridgeFile(path, privateFile, limit) {
5
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
6
+ try {
7
+ const stat = fstatSync(fd);
8
+ if (!stat.isFile() || stat.size > limit) {
9
+ throw new Error("Configuration/TLS file must be a bounded regular file.");
10
+ }
11
+ if (privateFile && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600)) {
12
+ throw new Error("Config and TLS key must be owned by the current user with mode 0600.");
13
+ }
14
+ const data = readFileSync(fd);
15
+ if (data.length > limit) {
16
+ throw new Error("Configuration/TLS file exceeds its size limit.");
17
+ }
18
+ return data;
19
+ }
20
+ finally {
21
+ closeSync(fd);
22
+ }
23
+ }
24
+ export function writeBridgeJson(path, value) {
25
+ const temporary = `${path}.${randomUUID()}.tmp`;
26
+ const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
27
+ try {
28
+ try {
29
+ writeFileSync(fd, JSON.stringify(value));
30
+ fsyncSync(fd);
31
+ }
32
+ finally {
33
+ closeSync(fd);
34
+ }
35
+ renameSync(temporary, path);
36
+ const directory = openSync(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY);
37
+ try {
38
+ fsyncSync(directory);
39
+ }
40
+ finally {
41
+ closeSync(directory);
42
+ }
43
+ }
44
+ finally {
45
+ rmSync(temporary, { force: true });
46
+ }
47
+ }
@@ -0,0 +1,135 @@
1
+ import { lstatSync, mkdirSync } from "node:fs";
2
+ import { join, matchesGlob } from "node:path";
3
+ import { readBridgeFile, writeBridgeJson } from "./bridge-files.js";
4
+ import { isUnknownArray, hasErrorCode } from "../shared/unknown.js";
5
+ import { Fault, label, object } from "./bridge-protocol.js";
6
+ import { resolveSessionTarget, nativePeerId } from "./session-targets.js";
7
+ const GROUP_LIMIT = 64;
8
+ function normalizedGroup(value) {
9
+ const input = object(value, ["name", "members", "namePattern", "cwdPattern", "match"]);
10
+ if (input.match === undefined) {
11
+ return input;
12
+ }
13
+ if ([input.members, input.namePattern, input.cwdPattern].some((field) => field !== undefined)) {
14
+ throw new Fault(400, "invalid_input", "Use match or canonical selectors, not both.");
15
+ }
16
+ const match = typeof input.match === "string"
17
+ ? { nameglob: input.match }
18
+ : object(input.match, ["id", "nameglob", "cwd"]);
19
+ return {
20
+ name: input.name,
21
+ members: typeof match.id === "string" ? [match.id] : match.id,
22
+ namePattern: match.nameglob,
23
+ cwdPattern: match.cwd,
24
+ };
25
+ }
26
+ export function groupDefinition(value) {
27
+ const input = normalizedGroup(value);
28
+ const name = label(input.name, "group name", 64).trim();
29
+ if (input.members !== undefined &&
30
+ (!isUnknownArray(input.members) || input.members.length > 256)) {
31
+ throw new Fault(400, "invalid_input", "members must contain at most 256 peer IDs/names.");
32
+ }
33
+ const members = isUnknownArray(input.members)
34
+ ? [...new Set(input.members.map((member) => label(member, "member", 512).trim()))]
35
+ : undefined;
36
+ const namePattern = input.namePattern === undefined ? undefined : label(input.namePattern, "name pattern", 128);
37
+ const cwdPattern = input.cwdPattern === undefined ? undefined : label(input.cwdPattern, "cwd pattern", 512);
38
+ if (members === undefined && namePattern === undefined && cwdPattern === undefined) {
39
+ throw new Fault(400, "invalid_input", "Define explicit members or at least one glob pattern.");
40
+ }
41
+ return { name, members, namePattern, cwdPattern };
42
+ }
43
+ function privateDirectory(path) {
44
+ mkdirSync(path, { recursive: true, mode: 0o700 });
45
+ const stat = lstatSync(path);
46
+ if (!stat.isDirectory() ||
47
+ stat.isSymbolicLink() ||
48
+ stat.uid !== process.getuid?.() ||
49
+ (stat.mode & 0o777) !== 0o700) {
50
+ throw new Fault(503, "state_unavailable", "Group stateDir must be owned by the current user with mode 0700.");
51
+ }
52
+ }
53
+ export function nativePeers(peers, source) {
54
+ return peers.filter((peer) => /^pi-[a-f0-9]{32}$/.test(peer.id) && peer.id !== source);
55
+ }
56
+ export class Groups {
57
+ stateDir;
58
+ constructor(stateDir) {
59
+ this.stateDir = stateDir;
60
+ privateDirectory(stateDir);
61
+ }
62
+ file(fingerprint) {
63
+ if (!/^[a-fA-F0-9]{64}$/.test(fingerprint)) {
64
+ throw new Fault(403, "unauthorized", "Invalid identity.");
65
+ }
66
+ return join(this.stateDir, `groups-${fingerprint.toLowerCase()}.json`);
67
+ }
68
+ list(fingerprint) {
69
+ let raw;
70
+ try {
71
+ raw = JSON.parse(readBridgeFile(this.file(fingerprint), true, 1024 * 1024).toString("utf8"));
72
+ }
73
+ catch (error) {
74
+ if (hasErrorCode(error, "ENOENT")) {
75
+ return [];
76
+ }
77
+ throw error;
78
+ }
79
+ if (!isUnknownArray(raw) || raw.length > GROUP_LIMIT) {
80
+ throw new Fault(503, "state_unavailable", "Invalid group state.");
81
+ }
82
+ return raw.map(groupDefinition);
83
+ }
84
+ upsert(fingerprint, group) {
85
+ privateDirectory(this.stateDir);
86
+ const groups = this.list(fingerprint).filter((entry) => entry.name !== group.name);
87
+ if (groups.length >= GROUP_LIMIT) {
88
+ throw new Fault(409, "group_limit", "At most 64 groups are allowed per identity.");
89
+ }
90
+ groups.push(groupDefinition(group));
91
+ const file = this.file(fingerprint);
92
+ const serialized = JSON.stringify(groups);
93
+ if (Buffer.byteLength(serialized) > 1024 * 1024) {
94
+ throw new Fault(413, "state_too_large", "Group definitions exceed the 1 MiB identity budget.");
95
+ }
96
+ writeBridgeJson(file, groups);
97
+ return groups;
98
+ }
99
+ targets(fingerprint, name, peers) {
100
+ if (name === undefined) {
101
+ return { peers: nativePeers(peers), errors: [] };
102
+ }
103
+ const group = this.list(fingerprint).find((entry) => entry.name === name);
104
+ if (!group) {
105
+ throw new Fault(404, "unknown_group", "Define this group before sending to it.");
106
+ }
107
+ return selectTargets(group, nativePeers(peers));
108
+ }
109
+ }
110
+ function selectTargets(group, peers) {
111
+ const selected = new Map();
112
+ const errors = [];
113
+ for (const member of group.members ?? []) {
114
+ const result = resolveSessionTarget(peers, member);
115
+ const target = result.target ?? peers.find((peer) => peer.id === nativePeerId(member));
116
+ if (target) {
117
+ selected.set(target.id, target);
118
+ }
119
+ else {
120
+ errors.push({ member, code: result.status === "none" ? "peer_offline" : "ambiguous_target" });
121
+ }
122
+ }
123
+ if (group.namePattern !== undefined || group.cwdPattern !== undefined) {
124
+ for (const peer of peers) {
125
+ if (matchesSelectors(group, peer)) {
126
+ selected.set(peer.id, peer);
127
+ }
128
+ }
129
+ }
130
+ return { peers: [...selected.values()], errors };
131
+ }
132
+ function matchesSelectors(group, peer) {
133
+ return ((group.namePattern === undefined || matchesGlob(peer.name ?? "", group.namePattern)) &&
134
+ (group.cwdPattern === undefined || matchesGlob(peer.cwd, group.cwdPattern)));
135
+ }
@@ -1,4 +1,4 @@
1
- import { BODY_LIMIT, Fault } from "./bridge-protocol.js";
1
+ import { BODY_LIMIT, Fault, BroadcastFault } from "./bridge-protocol.js";
2
2
  import { errorMessage } from "../shared/unknown.js";
3
3
  function validateHeaders(req) {
4
4
  const type = req.headers["content-type"];
@@ -84,3 +84,10 @@ export function reply(res, requestId, status, data) {
84
84
  });
85
85
  res.end(payload);
86
86
  }
87
+ export function replyFault(res, requestId, fault) {
88
+ reply(res, requestId, fault.status, {
89
+ ok: false,
90
+ ...(fault instanceof BroadcastFault ? fault.outcome : {}),
91
+ error: { code: fault.code, message: fault.message, ...fault.details },
92
+ });
93
+ }
@@ -0,0 +1,84 @@
1
+ import { statSync } from "node:fs";
2
+ import { loadNativeSession } from "../shared/native-session-loader.js";
3
+ import { isRecord } from "../shared/unknown.js";
4
+ import { Fault, cancelled, wait } from "./bridge-protocol.js";
5
+ import { trustedJournal, inspectResumeIdentity } from "./bridge-catalog.js";
6
+ import { nativePeerId } from "./session-targets.js";
7
+ export function validateLaunchState(saved, state) {
8
+ if (state.sessionId !== saved.sessionId ||
9
+ state.sessionFile !== saved.sessionFile ||
10
+ state.sessionName !== saved.name ||
11
+ state.isStreaming !== false ||
12
+ state.pendingMessageCount !== 0 ||
13
+ !isRecord(state.model)) {
14
+ throw new Fault(503, "readiness_failed", "Pi did not start idle with the exact saved session.");
15
+ }
16
+ if (saved.model &&
17
+ (state.model.provider !== saved.model.provider || state.model.id !== saved.model.modelId)) {
18
+ throw new Fault(503, "model_restore_failed", "Pi could not restore the saved model exactly; no task submitted and no silent fallback allowed.");
19
+ }
20
+ }
21
+ function journal(manager) {
22
+ const sessionFile = manager.getSessionFile();
23
+ if (sessionFile === undefined) {
24
+ throw new Fault(503, "journal_invalid", "Native journal was not persisted.");
25
+ }
26
+ const branch = manager.getBranch();
27
+ const change = branch.findLast((entry) => entry.type === "model_change");
28
+ const model = change?.type === "model_change"
29
+ ? { provider: change.provider, modelId: change.modelId }
30
+ : (manager.buildSessionContext().model ?? undefined);
31
+ return {
32
+ sessionId: manager.getSessionId(),
33
+ peerId: nativePeerId(manager.getSessionId()),
34
+ sessionFile,
35
+ name: manager.getSessionName(),
36
+ cwd: manager.getCwd(),
37
+ model,
38
+ };
39
+ }
40
+ export async function createLaunchJournal(policy, target, signal) {
41
+ const { SessionManager: Manager } = await wait(loadNativeSession(policy.packageRoot), signal);
42
+ cancelled(signal);
43
+ const storage = statSync(policy.sessionDir);
44
+ if (!storage.isDirectory() ||
45
+ storage.uid !== process.getuid?.() ||
46
+ (storage.mode & 0o777) !== 0o700) {
47
+ throw new Fault(503, "storage_invalid", "Private launch storage is no longer valid.");
48
+ }
49
+ const previous = process.umask(0o077);
50
+ try {
51
+ const manager = Manager.create(target.cwd, policy.sessionDir);
52
+ manager.appendSessionInfo(target.name);
53
+ if (policy.provider !== undefined && policy.model !== undefined) {
54
+ manager.appendModelChange(policy.provider, policy.model);
55
+ }
56
+ manager.appendMessage({ role: "user", content: policy.context, timestamp: Date.now() });
57
+ const result = journal(manager);
58
+ trustedJournal(policy, result.sessionFile);
59
+ if ((statSync(result.sessionFile).mode & 0o777) !== 0o600) {
60
+ throw new Fault(503, "journal_invalid", "New native journal was not privately persisted.");
61
+ }
62
+ return result;
63
+ }
64
+ finally {
65
+ process.umask(previous);
66
+ }
67
+ }
68
+ export async function resumeLaunchJournal(policy, saved, signal) {
69
+ const sdk = await wait(loadNativeSession(policy.packageRoot), signal);
70
+ cancelled(signal);
71
+ trustedJournal(policy, saved.sessionFile);
72
+ validateResumeIdentity(saved, await inspectResumeIdentity(saved.sessionFile, sdk, signal));
73
+ cancelled(signal);
74
+ const result = journal(sdk.SessionManager.open(saved.sessionFile));
75
+ validateResumeIdentity(saved, result);
76
+ return result;
77
+ }
78
+ function validateResumeIdentity(saved, actual) {
79
+ if (actual.sessionId !== saved.sessionId ||
80
+ actual.cwd !== saved.cwd ||
81
+ actual.name !== saved.name) {
82
+ throw new Fault(409, "journal_changed", "Native session identity changed while preparing resume.");
83
+ }
84
+ }
@@ -1,7 +1,7 @@
1
1
  import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { isAbsolute, join, relative } from "node:path";
3
3
  import { isRecord, isUnknownArray } from "../shared/unknown.js";
4
- import { label, object, text } from "./bridge-protocol.js";
4
+ import { Fault, label, object, text } from "./bridge-protocol.js";
5
5
  function absolute(value, field) {
6
6
  const path = label(value, field, 4096);
7
7
  if (!isAbsolute(path)) {
@@ -34,8 +34,8 @@ function executable(packageRoot) {
34
34
  }
35
35
  const bin = label(manifest.bin.pi, "Pi manifest bin", 4096);
36
36
  const cli = realpathSync(join(packageRoot, bin));
37
- const within = relative(packageRoot, cli);
38
- if (within.startsWith("..") || isAbsolute(within) || !statSync(cli).isFile()) {
37
+ const suffix = relative(packageRoot, cli);
38
+ if (suffix.startsWith("..") || isAbsolute(suffix) || !statSync(cli).isFile()) {
39
39
  throw new Error("Pi bin must be a file inside the pinned package.");
40
40
  }
41
41
  return cli;
@@ -46,6 +46,65 @@ function extensions(value) {
46
46
  }
47
47
  return value.map((entry) => absolute(entry, "extension path"));
48
48
  }
49
+ function directories(value, owned = true) {
50
+ if (value === undefined) {
51
+ return [];
52
+ }
53
+ if (!isUnknownArray(value) || value.length > 32) {
54
+ throw new Error("Launch roots must contain at most 32 local directories.");
55
+ }
56
+ return [
57
+ ...new Set(value.map((entry) => {
58
+ const path = absolute(entry, "trusted root");
59
+ const stat = lstatSync(path);
60
+ if (!stat.isDirectory() || (owned && stat.uid !== process.getuid?.())) {
61
+ throw new Error("Launch roots must be owned directories.");
62
+ }
63
+ return path;
64
+ })),
65
+ ];
66
+ }
67
+ export function within(root, path) {
68
+ const suffix = relative(root, path);
69
+ return (suffix !== ".." &&
70
+ !suffix.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) &&
71
+ !isAbsolute(suffix));
72
+ }
73
+ export function launchCwd(policy, requested) {
74
+ if (!isAbsolute(requested)) {
75
+ throw new Fault(400, "invalid_cwd", "cwd must be an absolute approved workspace directory.");
76
+ }
77
+ let cwd;
78
+ try {
79
+ cwd = realpathSync(requested);
80
+ if (!statSync(cwd).isDirectory()) {
81
+ throw new Error("Not a directory");
82
+ }
83
+ }
84
+ catch {
85
+ throw new Fault(400, "invalid_cwd", "cwd must be an existing workspace directory.");
86
+ }
87
+ if (!policy.workspaceRoots.some((root) => within(root, cwd))) {
88
+ throw new Fault(403, "cwd_forbidden", "cwd is outside locally approved workspaceRoots.");
89
+ }
90
+ return cwd;
91
+ }
92
+ export function spawnPolicy(policy, input) {
93
+ if (input.model === undefined) {
94
+ if (input.provider !== undefined) {
95
+ throw new Fault(400, "invalid_input", "Spawn provider requires model.");
96
+ }
97
+ return policy;
98
+ }
99
+ // Native provider/model convention splits at the first slash; model IDs may contain further slashes.
100
+ const slash = input.model.indexOf("/");
101
+ const provider = input.provider ?? (slash > 0 ? input.model.slice(0, slash) : policy.provider);
102
+ const model = input.provider === undefined && slash > 0 ? input.model.slice(slash + 1) : input.model;
103
+ if (provider === undefined || provider.trim() === "" || model.trim() === "") {
104
+ throw new Fault(400, "invalid_input", "Use an exact provider/model reference or a provider and model pair.");
105
+ }
106
+ return { ...policy, provider: provider.trim(), model: model.trim() };
107
+ }
49
108
  export function launchPolicy(value) {
50
109
  if (value === undefined) {
51
110
  return;
@@ -62,6 +121,8 @@ export function launchPolicy(value) {
62
121
  "offline",
63
122
  "maxSessions",
64
123
  "startupTimeoutMs",
124
+ "workspaceRoots",
125
+ "resumeDirs",
65
126
  ]);
66
127
  const packageRoot = absolute(raw.packageRoot, "Pi package root");
67
128
  const sessionDir = absolute(raw.sessionDir, "session directory");
@@ -71,12 +132,17 @@ export function launchPolicy(value) {
71
132
  (storage.mode & 0o777) !== 0o700) {
72
133
  throw new Error("Launch storage must be an owned directory with mode 0700.");
73
134
  }
135
+ if ((raw.provider === undefined) !== (raw.model === undefined)) {
136
+ throw new Error("Launch provider and model must both be supplied or both omitted.");
137
+ }
74
138
  return {
75
139
  packageRoot,
76
140
  cli: executable(packageRoot),
77
141
  sessionDir,
78
- provider: label(raw.provider, "provider", 128),
79
- model: label(raw.model, "model", 256),
142
+ provider: raw.provider === undefined ? undefined : label(raw.provider, "provider", 128),
143
+ model: raw.model === undefined ? undefined : label(raw.model, "model", 256),
144
+ workspaceRoots: directories(raw.workspaceRoots, false),
145
+ resumeDirs: directories(raw.resumeDirs),
80
146
  context: text(raw.context),
81
147
  extensions: extensions(raw.extensions),
82
148
  discoverResources: boolean(raw.discoverResources, false),