@fitchmultz/pi-subagents 0.47.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +29 -3
  2. package/dist/extension/build-info.js +1 -1
  3. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  4. package/dist/pi-intercom/bridge-catalog.js +372 -0
  5. package/dist/pi-intercom/bridge-config.js +15 -3
  6. package/dist/pi-intercom/bridge-control.js +104 -0
  7. package/dist/pi-intercom/bridge-groups.js +135 -0
  8. package/dist/pi-intercom/bridge-http.js +8 -1
  9. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  10. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  11. package/dist/pi-intercom/bridge-launch.js +186 -76
  12. package/dist/pi-intercom/bridge-mailbox.js +3 -55
  13. package/dist/pi-intercom/bridge-operations.js +127 -0
  14. package/dist/pi-intercom/bridge-presence.js +5 -1
  15. package/dist/pi-intercom/bridge-protocol.js +7 -0
  16. package/dist/pi-intercom/bridge-routes.js +195 -27
  17. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  18. package/dist/pi-intercom/bridge-session.js +83 -66
  19. package/dist/pi-intercom/bridge-storage.js +73 -0
  20. package/dist/pi-intercom/bridge-target.js +22 -0
  21. package/dist/pi-intercom/bridge-wake-owner.js +57 -0
  22. package/dist/pi-intercom/bridge-wake-state.js +293 -0
  23. package/dist/pi-intercom/bridge-wake.js +190 -99
  24. package/dist/pi-intercom/bridge.js +39 -39
  25. package/dist/pi-intercom/broker/client.js +1 -0
  26. package/dist/pi-intercom/connection.js +2 -3
  27. package/dist/pi-intercom/inbound.js +2 -4
  28. package/dist/pi-intercom/index.js +7 -1
  29. package/dist/pi-intercom/native-control-auth.js +80 -0
  30. package/dist/pi-intercom/native-control-protocol.js +111 -0
  31. package/dist/pi-intercom/native-control.js +130 -0
  32. package/dist/pi-intercom/saved-transcript.js +135 -0
  33. package/dist/pi-intercom/session-targets.js +3 -0
  34. package/dist/pi-intercom/transcript.js +175 -0
  35. package/docs/intercom-bridge.md +315 -174
  36. package/docs/intercom.md +1 -1
  37. package/docs/reference.md +1 -1
  38. package/package.json +1 -1
  39. package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
  40. package/scripts/intercom-remote.mjs +213 -38
  41. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  42. package/src/pi-intercom/bridge-catalog.ts +489 -0
  43. package/src/pi-intercom/bridge-config.ts +20 -3
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-groups.ts +169 -0
  46. package/src/pi-intercom/bridge-http.ts +15 -1
  47. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  48. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  49. package/src/pi-intercom/bridge-launch.ts +296 -107
  50. package/src/pi-intercom/bridge-mailbox.ts +3 -59
  51. package/src/pi-intercom/bridge-operations.ts +169 -0
  52. package/src/pi-intercom/bridge-presence.ts +7 -1
  53. package/src/pi-intercom/bridge-protocol.ts +8 -0
  54. package/src/pi-intercom/bridge-routes.ts +291 -49
  55. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  56. package/src/pi-intercom/bridge-session.ts +98 -96
  57. package/src/pi-intercom/bridge-storage.ts +83 -0
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake-owner.ts +58 -0
  60. package/src/pi-intercom/bridge-wake-state.ts +321 -0
  61. package/src/pi-intercom/bridge-wake.ts +224 -130
  62. package/src/pi-intercom/bridge.ts +41 -49
  63. package/src/pi-intercom/broker/client.ts +3 -0
  64. package/src/pi-intercom/connection.ts +2 -3
  65. package/src/pi-intercom/inbound.ts +2 -4
  66. package/src/pi-intercom/index.ts +7 -1
  67. package/src/pi-intercom/native-control-auth.ts +89 -0
  68. package/src/pi-intercom/native-control-protocol.ts +180 -0
  69. package/src/pi-intercom/native-control.ts +160 -0
  70. package/src/pi-intercom/saved-transcript.ts +175 -0
  71. package/src/pi-intercom/session-targets.ts +4 -0
  72. package/src/pi-intercom/transcript.ts +226 -0
  73. package/src/pi-intercom/types.ts +2 -0
@@ -0,0 +1,169 @@
1
+ import { lstatSync, mkdirSync } from "node:fs";
2
+ import { join, matchesGlob } from "node:path";
3
+ import { readBridgeFile, writeBridgeJson } from "./bridge-files.ts";
4
+ import { isUnknownArray, hasErrorCode } from "../shared/unknown.ts";
5
+ import { Fault, label, object } from "./bridge-protocol.ts";
6
+ import { resolveSessionTarget, nativePeerId } from "./session-targets.ts";
7
+ import type { SessionInfo } from "./types.ts";
8
+
9
+ export interface GroupDefinition {
10
+ readonly name: string;
11
+ readonly members?: readonly string[];
12
+ readonly namePattern?: string;
13
+ readonly cwdPattern?: string;
14
+ }
15
+ const GROUP_LIMIT = 64;
16
+ function normalizedGroup(value: unknown): Record<string, unknown> {
17
+ const input = object(value, ["name", "members", "namePattern", "cwdPattern", "match"]);
18
+ if (input.match === undefined) {
19
+ return input;
20
+ }
21
+ if ([input.members, input.namePattern, input.cwdPattern].some((field) => field !== undefined)) {
22
+ throw new Fault(400, "invalid_input", "Use match or canonical selectors, not both.");
23
+ }
24
+ const match =
25
+ typeof input.match === "string"
26
+ ? { nameglob: input.match }
27
+ : object(input.match, ["id", "nameglob", "cwd"]);
28
+ return {
29
+ name: input.name,
30
+ members: typeof match.id === "string" ? [match.id] : match.id,
31
+ namePattern: match.nameglob,
32
+ cwdPattern: match.cwd,
33
+ };
34
+ }
35
+ export function groupDefinition(value: unknown): GroupDefinition {
36
+ const input = normalizedGroup(value);
37
+ const name = label(input.name, "group name", 64).trim();
38
+ if (
39
+ input.members !== undefined &&
40
+ (!isUnknownArray(input.members) || input.members.length > 256)
41
+ ) {
42
+ throw new Fault(400, "invalid_input", "members must contain at most 256 peer IDs/names.");
43
+ }
44
+ const members = isUnknownArray(input.members)
45
+ ? [...new Set(input.members.map((member) => label(member, "member", 512).trim()))]
46
+ : undefined;
47
+ const namePattern =
48
+ input.namePattern === undefined ? undefined : label(input.namePattern, "name pattern", 128);
49
+ const cwdPattern =
50
+ input.cwdPattern === undefined ? undefined : label(input.cwdPattern, "cwd pattern", 512);
51
+ if (members === undefined && namePattern === undefined && cwdPattern === undefined) {
52
+ throw new Fault(400, "invalid_input", "Define explicit members or at least one glob pattern.");
53
+ }
54
+ return { name, members, namePattern, cwdPattern };
55
+ }
56
+ function privateDirectory(path: string): void {
57
+ mkdirSync(path, { recursive: true, mode: 0o700 });
58
+ const stat = lstatSync(path);
59
+ if (
60
+ !stat.isDirectory() ||
61
+ stat.isSymbolicLink() ||
62
+ stat.uid !== process.getuid?.() ||
63
+ (stat.mode & 0o777) !== 0o700
64
+ ) {
65
+ throw new Fault(
66
+ 503,
67
+ "state_unavailable",
68
+ "Group stateDir must be owned by the current user with mode 0700.",
69
+ );
70
+ }
71
+ }
72
+ export function nativePeers(
73
+ peers: readonly SessionInfo[],
74
+ source?: string | null,
75
+ ): readonly SessionInfo[] {
76
+ return peers.filter((peer) => /^pi-[a-f0-9]{32}$/.test(peer.id) && peer.id !== source);
77
+ }
78
+ export interface GroupTargets {
79
+ readonly peers: readonly SessionInfo[];
80
+ readonly errors: readonly { readonly member: string; readonly code: string }[];
81
+ }
82
+ export class Groups {
83
+ private readonly stateDir: string;
84
+ constructor(stateDir: string) {
85
+ this.stateDir = stateDir;
86
+ privateDirectory(stateDir);
87
+ }
88
+ private file(fingerprint: string): string {
89
+ if (!/^[a-fA-F0-9]{64}$/.test(fingerprint)) {
90
+ throw new Fault(403, "unauthorized", "Invalid identity.");
91
+ }
92
+ return join(this.stateDir, `groups-${fingerprint.toLowerCase()}.json`);
93
+ }
94
+ list(fingerprint: string): readonly GroupDefinition[] {
95
+ let raw: unknown;
96
+ try {
97
+ raw = JSON.parse(readBridgeFile(this.file(fingerprint), true, 1024 * 1024).toString("utf8"));
98
+ } catch (error) {
99
+ if (hasErrorCode(error, "ENOENT")) {
100
+ return [];
101
+ }
102
+ throw error;
103
+ }
104
+ if (!isUnknownArray(raw) || raw.length > GROUP_LIMIT) {
105
+ throw new Fault(503, "state_unavailable", "Invalid group state.");
106
+ }
107
+ return raw.map(groupDefinition);
108
+ }
109
+ upsert(fingerprint: string, group: GroupDefinition): readonly GroupDefinition[] {
110
+ privateDirectory(this.stateDir);
111
+ const groups = this.list(fingerprint).filter((entry) => entry.name !== group.name);
112
+ if (groups.length >= GROUP_LIMIT) {
113
+ throw new Fault(409, "group_limit", "At most 64 groups are allowed per identity.");
114
+ }
115
+ groups.push(groupDefinition(group));
116
+ const file = this.file(fingerprint);
117
+ const serialized = JSON.stringify(groups);
118
+ if (Buffer.byteLength(serialized) > 1024 * 1024) {
119
+ throw new Fault(
120
+ 413,
121
+ "state_too_large",
122
+ "Group definitions exceed the 1 MiB identity budget.",
123
+ );
124
+ }
125
+ writeBridgeJson(file, groups);
126
+ return groups;
127
+ }
128
+ targets(
129
+ fingerprint: string,
130
+ name: string | undefined,
131
+ peers: readonly SessionInfo[],
132
+ ): GroupTargets {
133
+ if (name === undefined) {
134
+ return { peers: nativePeers(peers), errors: [] };
135
+ }
136
+ const group = this.list(fingerprint).find((entry) => entry.name === name);
137
+ if (!group) {
138
+ throw new Fault(404, "unknown_group", "Define this group before sending to it.");
139
+ }
140
+ return selectTargets(group, nativePeers(peers));
141
+ }
142
+ }
143
+ function selectTargets(group: GroupDefinition, peers: readonly SessionInfo[]): GroupTargets {
144
+ const selected = new Map<string, SessionInfo>();
145
+ const errors: { member: string; code: string }[] = [];
146
+ for (const member of group.members ?? []) {
147
+ const result = resolveSessionTarget(peers, member);
148
+ const target = result.target ?? peers.find((peer) => peer.id === nativePeerId(member));
149
+ if (target) {
150
+ selected.set(target.id, target);
151
+ } else {
152
+ errors.push({ member, code: result.status === "none" ? "peer_offline" : "ambiguous_target" });
153
+ }
154
+ }
155
+ if (group.namePattern !== undefined || group.cwdPattern !== undefined) {
156
+ for (const peer of peers) {
157
+ if (matchesSelectors(group, peer)) {
158
+ selected.set(peer.id, peer);
159
+ }
160
+ }
161
+ }
162
+ return { peers: [...selected.values()], errors };
163
+ }
164
+ function matchesSelectors(group: GroupDefinition, peer: SessionInfo): boolean {
165
+ return (
166
+ (group.namePattern === undefined || matchesGlob(peer.name ?? "", group.namePattern)) &&
167
+ (group.cwdPattern === undefined || matchesGlob(peer.cwd, group.cwdPattern))
168
+ );
169
+ }
@@ -1,7 +1,13 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { BODY_LIMIT, Fault } from "./bridge-protocol.ts";
2
+ import { BODY_LIMIT, Fault, BroadcastFault } from "./bridge-protocol.ts";
3
3
  import { errorMessage, type UnknownRecord } from "../shared/unknown.ts";
4
4
 
5
+ export interface RequestContext {
6
+ readonly signal: AbortSignal;
7
+ readonly requestId: string;
8
+ readonly onPeer: (peer: string) => void;
9
+ }
10
+
5
11
  function validateHeaders(req: IncomingMessage): void {
6
12
  const type = req.headers["content-type"];
7
13
  if (typeof type !== "string" || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(type)) {
@@ -95,3 +101,11 @@ export function reply(
95
101
  });
96
102
  res.end(payload);
97
103
  }
104
+
105
+ export function replyFault(res: ServerResponse, requestId: string, fault: Fault): void {
106
+ reply(res, requestId, fault.status, {
107
+ ok: false,
108
+ ...(fault instanceof BroadcastFault ? fault.outcome : {}),
109
+ error: { code: fault.code, message: fault.message, ...fault.details },
110
+ });
111
+ }
@@ -0,0 +1,122 @@
1
+ import { statSync } from "node:fs";
2
+ import type { SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import { loadNativeSession } from "../shared/native-session-loader.ts";
4
+ import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
5
+ import { Fault, cancelled, wait } from "./bridge-protocol.ts";
6
+ import { trustedJournal, inspectResumeIdentity } from "./bridge-catalog.ts";
7
+ import { nativePeerId } from "./session-targets.ts";
8
+ import type { LaunchPolicy } from "./bridge-launch-policy.ts";
9
+
10
+ export interface LaunchJournal {
11
+ readonly sessionId: string;
12
+ readonly peerId: string;
13
+ readonly sessionFile: string;
14
+ readonly name?: string;
15
+ readonly cwd: string;
16
+ readonly model?: { readonly provider: string; readonly modelId: string };
17
+ }
18
+ export function validateLaunchState(saved: LaunchJournal, state: UnknownRecord): void {
19
+ if (
20
+ state.sessionId !== saved.sessionId ||
21
+ state.sessionFile !== saved.sessionFile ||
22
+ state.sessionName !== saved.name ||
23
+ state.isStreaming !== false ||
24
+ state.pendingMessageCount !== 0 ||
25
+ !isRecord(state.model)
26
+ ) {
27
+ throw new Fault(503, "readiness_failed", "Pi did not start idle with the exact saved session.");
28
+ }
29
+ if (
30
+ saved.model &&
31
+ (state.model.provider !== saved.model.provider || state.model.id !== saved.model.modelId)
32
+ ) {
33
+ throw new Fault(
34
+ 503,
35
+ "model_restore_failed",
36
+ "Pi could not restore the saved model exactly; no task submitted and no silent fallback allowed.",
37
+ );
38
+ }
39
+ }
40
+ function journal(manager: Readonly<SessionManager>): LaunchJournal {
41
+ const sessionFile = manager.getSessionFile();
42
+ if (sessionFile === undefined) {
43
+ throw new Fault(503, "journal_invalid", "Native journal was not persisted.");
44
+ }
45
+ const branch = manager.getBranch();
46
+ const change = branch.findLast((entry) => entry.type === "model_change");
47
+ const model =
48
+ change?.type === "model_change"
49
+ ? { provider: change.provider, modelId: change.modelId }
50
+ : (manager.buildSessionContext().model ?? undefined);
51
+ return {
52
+ sessionId: manager.getSessionId(),
53
+ peerId: nativePeerId(manager.getSessionId()),
54
+ sessionFile,
55
+ name: manager.getSessionName(),
56
+ cwd: manager.getCwd(),
57
+ model,
58
+ };
59
+ }
60
+ export async function createLaunchJournal(
61
+ policy: LaunchPolicy,
62
+ target: { readonly cwd: string; readonly name: string },
63
+ signal: AbortSignal,
64
+ ): Promise<LaunchJournal> {
65
+ const { SessionManager: Manager } = await wait(loadNativeSession(policy.packageRoot), signal);
66
+ cancelled(signal);
67
+ const storage = statSync(policy.sessionDir);
68
+ if (
69
+ !storage.isDirectory() ||
70
+ storage.uid !== process.getuid?.() ||
71
+ (storage.mode & 0o777) !== 0o700
72
+ ) {
73
+ throw new Fault(503, "storage_invalid", "Private launch storage is no longer valid.");
74
+ }
75
+ const previous = process.umask(0o077);
76
+ try {
77
+ const manager = Manager.create(target.cwd, policy.sessionDir);
78
+ manager.appendSessionInfo(target.name);
79
+ if (policy.provider !== undefined && policy.model !== undefined) {
80
+ manager.appendModelChange(policy.provider, policy.model);
81
+ }
82
+ manager.appendMessage({ role: "user", content: policy.context, timestamp: Date.now() });
83
+ const result = journal(manager);
84
+ trustedJournal(policy, result.sessionFile);
85
+ if ((statSync(result.sessionFile).mode & 0o777) !== 0o600) {
86
+ throw new Fault(503, "journal_invalid", "New native journal was not privately persisted.");
87
+ }
88
+ return result;
89
+ } finally {
90
+ process.umask(previous);
91
+ }
92
+ }
93
+ export async function resumeLaunchJournal(
94
+ policy: LaunchPolicy,
95
+ saved: LaunchJournal,
96
+ signal: AbortSignal,
97
+ ): Promise<LaunchJournal> {
98
+ const sdk = await wait(loadNativeSession(policy.packageRoot), signal);
99
+ cancelled(signal);
100
+ trustedJournal(policy, saved.sessionFile);
101
+ validateResumeIdentity(saved, await inspectResumeIdentity(saved.sessionFile, sdk, signal));
102
+ cancelled(signal);
103
+ const result = journal(sdk.SessionManager.open(saved.sessionFile));
104
+ validateResumeIdentity(saved, result);
105
+ return result;
106
+ }
107
+ function validateResumeIdentity(
108
+ saved: LaunchJournal,
109
+ actual: Pick<LaunchJournal, "sessionId" | "cwd" | "name">,
110
+ ): void {
111
+ if (
112
+ actual.sessionId !== saved.sessionId ||
113
+ actual.cwd !== saved.cwd ||
114
+ actual.name !== saved.name
115
+ ) {
116
+ throw new Fault(
117
+ 409,
118
+ "journal_changed",
119
+ "Native session identity changed while preparing resume.",
120
+ );
121
+ }
122
+ }
@@ -1,14 +1,16 @@
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.ts";
4
- import { label, object, text } from "./bridge-protocol.ts";
4
+ import { Fault, label, object, text } from "./bridge-protocol.ts";
5
5
 
6
6
  export interface LaunchPolicy {
7
7
  readonly packageRoot: string;
8
8
  readonly cli: string;
9
9
  readonly sessionDir: string;
10
- readonly provider: string;
11
- readonly model: string;
10
+ readonly provider?: string;
11
+ readonly model?: string;
12
+ readonly workspaceRoots: readonly string[];
13
+ readonly resumeDirs: readonly string[];
12
14
  readonly context: string;
13
15
  readonly extensions: readonly string[];
14
16
  readonly discoverResources: boolean;
@@ -52,8 +54,8 @@ function executable(packageRoot: string): string {
52
54
  }
53
55
  const bin = label(manifest.bin.pi, "Pi manifest bin", 4096);
54
56
  const cli = realpathSync(join(packageRoot, bin));
55
- const within = relative(packageRoot, cli);
56
- if (within.startsWith("..") || isAbsolute(within) || !statSync(cli).isFile()) {
57
+ const suffix = relative(packageRoot, cli);
58
+ if (suffix.startsWith("..") || isAbsolute(suffix) || !statSync(cli).isFile()) {
57
59
  throw new Error("Pi bin must be a file inside the pinned package.");
58
60
  }
59
61
  return cli;
@@ -65,6 +67,78 @@ function extensions(value: unknown): readonly string[] {
65
67
  return value.map((entry) => absolute(entry, "extension path"));
66
68
  }
67
69
 
70
+ function directories(value: unknown, owned = true): readonly string[] {
71
+ if (value === undefined) {
72
+ return [];
73
+ }
74
+ if (!isUnknownArray(value) || value.length > 32) {
75
+ throw new Error("Launch roots must contain at most 32 local directories.");
76
+ }
77
+ return [
78
+ ...new Set(
79
+ value.map((entry) => {
80
+ const path = absolute(entry, "trusted root");
81
+ const stat = lstatSync(path);
82
+ if (!stat.isDirectory() || (owned && stat.uid !== process.getuid?.())) {
83
+ throw new Error("Launch roots must be owned directories.");
84
+ }
85
+ return path;
86
+ }),
87
+ ),
88
+ ];
89
+ }
90
+
91
+ export function within(root: string, path: string): boolean {
92
+ const suffix = relative(root, path);
93
+ return (
94
+ suffix !== ".." &&
95
+ !suffix.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) &&
96
+ !isAbsolute(suffix)
97
+ );
98
+ }
99
+ export function launchCwd(policy: LaunchPolicy, requested: string): string {
100
+ if (!isAbsolute(requested)) {
101
+ throw new Fault(400, "invalid_cwd", "cwd must be an absolute approved workspace directory.");
102
+ }
103
+ let cwd: string;
104
+ try {
105
+ cwd = realpathSync(requested);
106
+ if (!statSync(cwd).isDirectory()) {
107
+ throw new Error("Not a directory");
108
+ }
109
+ } catch {
110
+ throw new Fault(400, "invalid_cwd", "cwd must be an existing workspace directory.");
111
+ }
112
+ if (!policy.workspaceRoots.some((root) => within(root, cwd))) {
113
+ throw new Fault(403, "cwd_forbidden", "cwd is outside locally approved workspaceRoots.");
114
+ }
115
+ return cwd;
116
+ }
117
+
118
+ export function spawnPolicy(
119
+ policy: LaunchPolicy,
120
+ input: { readonly provider?: string; readonly model?: string },
121
+ ): LaunchPolicy {
122
+ if (input.model === undefined) {
123
+ if (input.provider !== undefined) {
124
+ throw new Fault(400, "invalid_input", "Spawn provider requires model.");
125
+ }
126
+ return policy;
127
+ }
128
+ // Native provider/model convention splits at the first slash; model IDs may contain further slashes.
129
+ const slash = input.model.indexOf("/");
130
+ const provider = input.provider ?? (slash > 0 ? input.model.slice(0, slash) : policy.provider);
131
+ const model =
132
+ input.provider === undefined && slash > 0 ? input.model.slice(slash + 1) : input.model;
133
+ if (provider === undefined || provider.trim() === "" || model.trim() === "") {
134
+ throw new Fault(
135
+ 400,
136
+ "invalid_input",
137
+ "Use an exact provider/model reference or a provider and model pair.",
138
+ );
139
+ }
140
+ return { ...policy, provider: provider.trim(), model: model.trim() };
141
+ }
68
142
  export function launchPolicy(value: unknown): LaunchPolicy | undefined {
69
143
  if (value === undefined) {
70
144
  return;
@@ -81,6 +155,8 @@ export function launchPolicy(value: unknown): LaunchPolicy | undefined {
81
155
  "offline",
82
156
  "maxSessions",
83
157
  "startupTimeoutMs",
158
+ "workspaceRoots",
159
+ "resumeDirs",
84
160
  ]);
85
161
  const packageRoot = absolute(raw.packageRoot, "Pi package root");
86
162
  const sessionDir = absolute(raw.sessionDir, "session directory");
@@ -92,12 +168,17 @@ export function launchPolicy(value: unknown): LaunchPolicy | undefined {
92
168
  ) {
93
169
  throw new Error("Launch storage must be an owned directory with mode 0700.");
94
170
  }
171
+ if ((raw.provider === undefined) !== (raw.model === undefined)) {
172
+ throw new Error("Launch provider and model must both be supplied or both omitted.");
173
+ }
95
174
  return {
96
175
  packageRoot,
97
176
  cli: executable(packageRoot),
98
177
  sessionDir,
99
- provider: label(raw.provider, "provider", 128),
100
- model: label(raw.model, "model", 256),
178
+ provider: raw.provider === undefined ? undefined : label(raw.provider, "provider", 128),
179
+ model: raw.model === undefined ? undefined : label(raw.model, "model", 256),
180
+ workspaceRoots: directories(raw.workspaceRoots, false),
181
+ resumeDirs: directories(raw.resumeDirs),
101
182
  context: text(raw.context),
102
183
  extensions: extensions(raw.extensions),
103
184
  discoverResources: boolean(raw.discoverResources, false),