@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.
- package/CHANGELOG.md +29 -3
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +15 -3
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +3 -55
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +5 -1
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +195 -27
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -66
- package/dist/pi-intercom/bridge-storage.js +73 -0
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake-owner.js +57 -0
- package/dist/pi-intercom/bridge-wake-state.js +293 -0
- package/dist/pi-intercom/bridge-wake.js +190 -99
- package/dist/pi-intercom/bridge.js +39 -39
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +315 -174
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-wake-preflight.mjs +112 -0
- package/scripts/intercom-remote.mjs +213 -38
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +20 -3
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +3 -59
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +7 -1
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +291 -49
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +98 -96
- package/src/pi-intercom/bridge-storage.ts +83 -0
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake-owner.ts +58 -0
- package/src/pi-intercom/bridge-wake-state.ts +321 -0
- package/src/pi-intercom/bridge-wake.ts +224 -130
- package/src/pi-intercom/bridge.ts +41 -49
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -1,23 +1,34 @@
|
|
|
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.ts";
|
|
5
2
|
import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
|
|
6
|
-
import type { SessionInfo } from "./types.ts";
|
|
3
|
+
import type { SendResult, SessionInfo } from "./types.ts";
|
|
7
4
|
import type { Identity } from "./bridge-config.ts";
|
|
8
|
-
import type
|
|
5
|
+
import { launchCwd, spawnPolicy, type LaunchPolicy } from "./bridge-launch-policy.ts";
|
|
9
6
|
import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
|
|
10
7
|
import { RpcProcess } from "./bridge-rpc-process.ts";
|
|
8
|
+
import {
|
|
9
|
+
exactPeer,
|
|
10
|
+
savedView,
|
|
11
|
+
browseSessions,
|
|
12
|
+
type CatalogQuery,
|
|
13
|
+
type SessionCatalog,
|
|
14
|
+
} from "./bridge-catalog.ts";
|
|
15
|
+
import {
|
|
16
|
+
createLaunchJournal,
|
|
17
|
+
resumeLaunchJournal,
|
|
18
|
+
validateLaunchState,
|
|
19
|
+
type LaunchJournal,
|
|
20
|
+
} from "./bridge-launch-journal.ts";
|
|
21
|
+
import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
|
|
22
|
+
import { resolveIntercomPresenceName } from "./identity.ts";
|
|
11
23
|
|
|
12
24
|
interface Launch {
|
|
13
25
|
readonly identity: Identity;
|
|
14
|
-
readonly name
|
|
26
|
+
readonly name?: string;
|
|
27
|
+
readonly cwd: string;
|
|
15
28
|
readonly controller: Readonly<AbortController>;
|
|
16
29
|
readonly expires: number;
|
|
17
30
|
admitted: boolean;
|
|
18
|
-
|
|
19
|
-
peerId?: string;
|
|
20
|
-
sessionFile?: string;
|
|
31
|
+
journal?: LaunchJournal;
|
|
21
32
|
process?: RpcProcess;
|
|
22
33
|
timer?: NodeJS.Timeout;
|
|
23
34
|
}
|
|
@@ -25,11 +36,23 @@ interface StartContext {
|
|
|
25
36
|
readonly identity: Identity;
|
|
26
37
|
readonly expires: number;
|
|
27
38
|
readonly signal: AbortSignal;
|
|
28
|
-
readonly peers: (
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
readonly peers: (signal: AbortSignal) => Promise<readonly SessionInfo[]>;
|
|
40
|
+
readonly submit: (peer: string, task: string) => Promise<SendResult>;
|
|
41
|
+
}
|
|
42
|
+
type ControlContext = Pick<StartContext, "signal" | "peers">;
|
|
43
|
+
interface SpawnInput {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly cwd?: string;
|
|
46
|
+
readonly provider?: string;
|
|
47
|
+
readonly model?: string;
|
|
48
|
+
readonly task?: string;
|
|
49
|
+
}
|
|
50
|
+
interface ResumeInput {
|
|
51
|
+
readonly sessionId?: string;
|
|
52
|
+
readonly target?: string;
|
|
53
|
+
readonly name?: string;
|
|
54
|
+
readonly task?: string;
|
|
31
55
|
}
|
|
32
|
-
|
|
33
56
|
export function sameIdentity(left: Identity, right: Identity | undefined): boolean {
|
|
34
57
|
return (
|
|
35
58
|
left.name === right?.name &&
|
|
@@ -39,20 +62,29 @@ export function sameIdentity(left: Identity, right: Identity | undefined): boole
|
|
|
39
62
|
}
|
|
40
63
|
function view(entry: Readonly<Launch>): UnknownRecord {
|
|
41
64
|
return {
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
id: entry.journal?.sessionId,
|
|
66
|
+
sessionId: entry.journal?.sessionId,
|
|
67
|
+
peerId: entry.journal?.peerId,
|
|
44
68
|
name: entry.name,
|
|
45
|
-
|
|
69
|
+
cwd: entry.cwd,
|
|
70
|
+
sessionFile: entry.journal?.sessionFile,
|
|
46
71
|
pid: entry.process?.pid,
|
|
47
72
|
status: entry.process?.status ?? "starting",
|
|
48
73
|
error: entry.process?.failure?.code,
|
|
74
|
+
saved: entry.journal !== undefined,
|
|
75
|
+
owned: true,
|
|
49
76
|
};
|
|
50
77
|
}
|
|
51
|
-
|
|
52
78
|
export class Launcher {
|
|
53
79
|
private readonly launches = new Set<Launch>();
|
|
80
|
+
private readonly catalog: SessionCatalog;
|
|
81
|
+
private readonly resuming = new Set<string>();
|
|
54
82
|
private stopping = false;
|
|
55
83
|
|
|
84
|
+
constructor(catalog: SessionCatalog) {
|
|
85
|
+
this.catalog = catalog;
|
|
86
|
+
}
|
|
87
|
+
|
|
56
88
|
list(identity: Identity): UnknownRecord {
|
|
57
89
|
return {
|
|
58
90
|
sessions: [...this.launches]
|
|
@@ -60,42 +92,252 @@ export class Launcher {
|
|
|
60
92
|
.map(view),
|
|
61
93
|
};
|
|
62
94
|
}
|
|
63
|
-
async
|
|
64
|
-
|
|
95
|
+
async browse(query: CatalogQuery, context: StartContext): Promise<UnknownRecord> {
|
|
96
|
+
return browseSessions(this.catalog, query, {
|
|
97
|
+
policy: context.identity.launch,
|
|
98
|
+
signal: context.signal,
|
|
99
|
+
peers: context.peers,
|
|
100
|
+
activeState: (id) => {
|
|
101
|
+
const entry = this.active(id) ?? this.activePeer(id);
|
|
102
|
+
return entry?.identity.fingerprint256 === context.identity.fingerprint256
|
|
103
|
+
? (entry.process?.status ?? "starting")
|
|
104
|
+
: undefined;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
private active(sessionId: string): Launch | undefined {
|
|
109
|
+
return [...this.launches].find(
|
|
110
|
+
(entry) => entry.journal?.sessionId === sessionId && entry.process?.status !== "exited",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
private activePeer(peerId: string): Launch | undefined {
|
|
114
|
+
return [...this.launches].find(
|
|
115
|
+
(entry) => entry.journal?.peerId === peerId && entry.process?.status !== "exited",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
private async owned(identity: Identity, id: string, context: ControlContext): Promise<Launch> {
|
|
119
|
+
const candidates = [...this.launches].filter(
|
|
120
|
+
(candidate) => candidate.identity.fingerprint256 === identity.fingerprint256,
|
|
121
|
+
);
|
|
122
|
+
const byId = candidates.findLast(
|
|
123
|
+
(candidate) => candidate.journal?.sessionId === id || candidate.journal?.peerId === id,
|
|
124
|
+
);
|
|
125
|
+
const named = candidates.filter(
|
|
65
126
|
(candidate) =>
|
|
66
|
-
candidate.
|
|
67
|
-
candidate.
|
|
127
|
+
candidate.process?.status !== "exited" &&
|
|
128
|
+
(candidate.name === id || candidate.name === `remote:${identity.name}:${id}`),
|
|
68
129
|
);
|
|
69
|
-
if (!
|
|
70
|
-
throw new Fault(
|
|
130
|
+
if (!byId && named.length > 1) {
|
|
131
|
+
throw new Fault(
|
|
132
|
+
409,
|
|
133
|
+
"ambiguous_session",
|
|
134
|
+
"Multiple owned live processes match this name; use their native IDs.",
|
|
135
|
+
);
|
|
71
136
|
}
|
|
137
|
+
const entry = byId ?? named.at(0);
|
|
138
|
+
if (!entry?.process) {
|
|
139
|
+
throw new Fault(
|
|
140
|
+
404,
|
|
141
|
+
"unknown_session",
|
|
142
|
+
"Control is available only for this identity's helper-owned subprocesses, not unrelated live peers or saved history.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (entry.process.status === "exited") {
|
|
146
|
+
const peers = await context.peers(context.signal);
|
|
147
|
+
if (peers.some((peer) => peer.id === entry.journal?.peerId)) {
|
|
148
|
+
throw new Fault(
|
|
149
|
+
404,
|
|
150
|
+
"unknown_session",
|
|
151
|
+
"This journal has a newer live owner; the historical process is not current ownership.",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
157
|
+
async stop(
|
|
158
|
+
identity: Identity,
|
|
159
|
+
sessionId: string,
|
|
160
|
+
context: ControlContext,
|
|
161
|
+
): Promise<UnknownRecord> {
|
|
162
|
+
const entry = await this.owned(identity, sessionId, context);
|
|
72
163
|
await this.retire(entry, new Fault(410, "session_stopped", "Session stopped by its owner."));
|
|
73
164
|
return { session: view(entry) };
|
|
74
165
|
}
|
|
75
|
-
async
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
166
|
+
async cancel(
|
|
167
|
+
identity: Identity,
|
|
168
|
+
sessionId: string,
|
|
169
|
+
context: ControlContext,
|
|
170
|
+
): Promise<UnknownRecord> {
|
|
171
|
+
const entry = await this.owned(identity, sessionId, context);
|
|
172
|
+
if (!entry.admitted || entry.process?.status === "exited" || !entry.process) {
|
|
173
|
+
throw new Fault(
|
|
174
|
+
409,
|
|
175
|
+
"session_not_ready",
|
|
176
|
+
"Only an admitted live owned session can be cancelled.",
|
|
177
|
+
);
|
|
80
178
|
}
|
|
81
|
-
const
|
|
179
|
+
const state = await entry.process.cancel(context.signal);
|
|
180
|
+
return { session: view(entry), cancelled: true, state, queuesPreserved: true };
|
|
181
|
+
}
|
|
182
|
+
async start(name: string, context: StartContext): Promise<UnknownRecord> {
|
|
183
|
+
return this.spawn({ name }, context, true);
|
|
184
|
+
}
|
|
185
|
+
async spawn(input: SpawnInput, context: StartContext, legacy = false): Promise<UnknownRecord> {
|
|
186
|
+
const policy = spawnPolicy(this.policy(context), input);
|
|
187
|
+
const cwd = legacy
|
|
188
|
+
? context.identity.cwd
|
|
189
|
+
: launchCwd(policy, input.cwd ?? context.identity.cwd);
|
|
190
|
+
const name = `remote:${context.identity.name}:${input.name}`;
|
|
82
191
|
const existing = [...this.launches].find(
|
|
83
192
|
(entry) =>
|
|
84
193
|
entry.identity.fingerprint256 === context.identity.fingerprint256 &&
|
|
85
|
-
entry.name ===
|
|
194
|
+
entry.name === name &&
|
|
86
195
|
entry.process?.status !== "exited",
|
|
87
196
|
);
|
|
88
|
-
|
|
89
|
-
|
|
197
|
+
const result = existing
|
|
198
|
+
? this.reuseSpawn(existing, input, policy, cwd)
|
|
199
|
+
: await this.launch({ name, cwd }, context, policy);
|
|
200
|
+
return this.task(result, input.task, context);
|
|
201
|
+
}
|
|
202
|
+
private reuseSpawn(
|
|
203
|
+
entry: Readonly<Launch>,
|
|
204
|
+
input: SpawnInput,
|
|
205
|
+
policy: LaunchPolicy,
|
|
206
|
+
cwd: string,
|
|
207
|
+
): UnknownRecord {
|
|
208
|
+
if (entry.cwd !== cwd) {
|
|
209
|
+
throw new Fault(
|
|
210
|
+
409,
|
|
211
|
+
"cwd_conflict",
|
|
212
|
+
"This live name already belongs to a different workspace.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (
|
|
216
|
+
input.model !== undefined &&
|
|
217
|
+
(entry.journal?.model?.provider !== policy.provider ||
|
|
218
|
+
entry.journal?.model?.modelId !== policy.model)
|
|
219
|
+
) {
|
|
220
|
+
throw new Fault(
|
|
221
|
+
409,
|
|
222
|
+
"model_conflict",
|
|
223
|
+
"This live name already belongs to a different model selection; resume it without overriding history.",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return this.reuse(entry);
|
|
227
|
+
}
|
|
228
|
+
async resume(input: ResumeInput, context: StartContext): Promise<UnknownRecord> {
|
|
229
|
+
const policy = this.policy(context);
|
|
230
|
+
const selector = input.sessionId ?? input.target ?? `name:${input.name ?? ""}`;
|
|
231
|
+
if (this.resuming.has(selector)) {
|
|
232
|
+
throw new Fault(409, "launch_in_progress", "This resume selector is already starting.");
|
|
90
233
|
}
|
|
234
|
+
this.resuming.add(selector);
|
|
235
|
+
try {
|
|
236
|
+
const saved = await this.catalog.resolve(policy, input, context.signal);
|
|
237
|
+
launchCwd(policy, saved.cwd);
|
|
238
|
+
const owned = this.active(saved.sessionId);
|
|
239
|
+
if (owned) {
|
|
240
|
+
const result = this.reuse(owned);
|
|
241
|
+
const reused =
|
|
242
|
+
owned.identity.fingerprint256 === context.identity.fingerprint256
|
|
243
|
+
? result
|
|
244
|
+
: {
|
|
245
|
+
...result,
|
|
246
|
+
session: {
|
|
247
|
+
...savedView(saved),
|
|
248
|
+
peerId: saved.peerId,
|
|
249
|
+
status: owned.process?.status,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
return await this.task(reused, input.task, context);
|
|
253
|
+
}
|
|
254
|
+
await wait(spawnBrokerIfNeeded(process.execPath, []), context.signal);
|
|
255
|
+
const peer = exactPeer(saved, await context.peers(context.signal));
|
|
256
|
+
if (peer) {
|
|
257
|
+
return await this.task(
|
|
258
|
+
{ session: savedView(saved, peer), id: saved.sessionId, reused: true },
|
|
259
|
+
input.task,
|
|
260
|
+
context,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
// A different selector/identity may have completed catalog resolution during the peer lookup.
|
|
264
|
+
if (this.active(saved.sessionId)) {
|
|
265
|
+
throw new Fault(409, "launch_in_progress", "This native session is already starting.");
|
|
266
|
+
}
|
|
267
|
+
const result = await this.launch(saved, context, policy);
|
|
268
|
+
return await this.task(result, input.task, context);
|
|
269
|
+
} finally {
|
|
270
|
+
this.resuming.delete(selector);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
private policy(context: StartContext): LaunchPolicy {
|
|
274
|
+
cancelled(context.signal);
|
|
275
|
+
const policy = context.identity.launch;
|
|
276
|
+
if (!policy || this.stopping) {
|
|
277
|
+
throw new Fault(403, "launch_disabled", "Session launching is not enabled by local policy.");
|
|
278
|
+
}
|
|
279
|
+
return policy;
|
|
280
|
+
}
|
|
281
|
+
private async task(
|
|
282
|
+
result: UnknownRecord,
|
|
283
|
+
task: string | undefined,
|
|
284
|
+
context: StartContext,
|
|
285
|
+
): Promise<UnknownRecord> {
|
|
286
|
+
const session = result.session;
|
|
287
|
+
if (task === undefined || !isRecord(session) || typeof session.peerId !== "string") {
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const receipt = await context.submit(session.peerId, task);
|
|
292
|
+
return { ...result, id: session.sessionId, task: { ...receipt, completed: false } };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const fault =
|
|
295
|
+
error instanceof Fault
|
|
296
|
+
? error
|
|
297
|
+
: new Fault(
|
|
298
|
+
503,
|
|
299
|
+
"task_unconfirmed",
|
|
300
|
+
"Task acceptance is unknown; inspect the session before retrying.",
|
|
301
|
+
);
|
|
302
|
+
throw new Fault(fault.status, fault.code, fault.message, {
|
|
303
|
+
...fault.details,
|
|
304
|
+
session,
|
|
305
|
+
reused: result.reused,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
private async launch(
|
|
310
|
+
target: {
|
|
311
|
+
readonly name?: string;
|
|
312
|
+
readonly cwd: string;
|
|
313
|
+
readonly sessionFile?: string;
|
|
314
|
+
readonly sessionId?: string;
|
|
315
|
+
readonly peerId?: string;
|
|
316
|
+
},
|
|
317
|
+
context: StartContext,
|
|
318
|
+
policy: LaunchPolicy,
|
|
319
|
+
): Promise<UnknownRecord> {
|
|
91
320
|
this.capacity(context.identity, policy);
|
|
92
321
|
const entry: Launch = {
|
|
93
322
|
identity: context.identity,
|
|
94
|
-
name:
|
|
323
|
+
name: target.name,
|
|
324
|
+
cwd: target.cwd,
|
|
95
325
|
controller: new AbortController(),
|
|
96
326
|
expires: context.expires,
|
|
97
327
|
admitted: false,
|
|
98
328
|
};
|
|
329
|
+
if (
|
|
330
|
+
target.sessionId !== undefined &&
|
|
331
|
+
target.peerId !== undefined &&
|
|
332
|
+
target.sessionFile !== undefined
|
|
333
|
+
) {
|
|
334
|
+
entry.journal = {
|
|
335
|
+
...target,
|
|
336
|
+
sessionId: target.sessionId,
|
|
337
|
+
peerId: target.peerId,
|
|
338
|
+
sessionFile: target.sessionFile,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
99
341
|
this.launches.add(entry);
|
|
100
342
|
try {
|
|
101
343
|
return await this.establish(entry, policy, context);
|
|
@@ -108,11 +350,7 @@ export class Launcher {
|
|
|
108
350
|
}
|
|
109
351
|
throw error instanceof Fault
|
|
110
352
|
? error
|
|
111
|
-
: new Fault(
|
|
112
|
-
503,
|
|
113
|
-
"launch_failed",
|
|
114
|
-
"Pi startup failed; inspect the pinned local policy and resources.",
|
|
115
|
-
);
|
|
353
|
+
: new Fault(503, "launch_failed", "Pi startup failed; inspect local policy and resources.");
|
|
116
354
|
}
|
|
117
355
|
}
|
|
118
356
|
private reuse(entry: Readonly<Launch>): UnknownRecord {
|
|
@@ -120,12 +358,12 @@ export class Launcher {
|
|
|
120
358
|
entry.admitted &&
|
|
121
359
|
(entry.process?.status === "idle" || entry.process?.status === "running")
|
|
122
360
|
) {
|
|
123
|
-
return { session: view(entry), reused: true };
|
|
361
|
+
return { session: view(entry), id: entry.journal?.sessionId, reused: true };
|
|
124
362
|
}
|
|
125
363
|
throw new Fault(
|
|
126
364
|
409,
|
|
127
365
|
"launch_in_progress",
|
|
128
|
-
"This
|
|
366
|
+
"This session is starting or stopping; inspect sessions before retrying.",
|
|
129
367
|
);
|
|
130
368
|
}
|
|
131
369
|
private capacity(identity: Identity, policy: LaunchPolicy): void {
|
|
@@ -147,43 +385,6 @@ export class Launcher {
|
|
|
147
385
|
}
|
|
148
386
|
}
|
|
149
387
|
}
|
|
150
|
-
private async journal(entry: Launch, policy: LaunchPolicy): Promise<void> {
|
|
151
|
-
const { SessionManager } = await wait(
|
|
152
|
-
loadNativeSession(policy.packageRoot),
|
|
153
|
-
entry.controller.signal,
|
|
154
|
-
);
|
|
155
|
-
cancelled(entry.controller.signal);
|
|
156
|
-
const storage = statSync(policy.sessionDir);
|
|
157
|
-
if (
|
|
158
|
-
!storage.isDirectory() ||
|
|
159
|
-
storage.uid !== process.getuid?.() ||
|
|
160
|
-
(storage.mode & 0o777) !== 0o700
|
|
161
|
-
) {
|
|
162
|
-
throw new Fault(503, "storage_invalid", "Private launch storage is no longer valid.");
|
|
163
|
-
}
|
|
164
|
-
const previous = process.umask(0o077);
|
|
165
|
-
try {
|
|
166
|
-
const manager = SessionManager.create(entry.identity.cwd, policy.sessionDir);
|
|
167
|
-
manager.appendSessionInfo(entry.name);
|
|
168
|
-
manager.appendMessage({ role: "user", content: policy.context, timestamp: Date.now() });
|
|
169
|
-
entry.sessionId = manager.getSessionId();
|
|
170
|
-
entry.sessionFile = manager.getSessionFile();
|
|
171
|
-
entry.peerId = `pi-${createHash("sha256").update(entry.sessionId).digest("hex").slice(0, 32)}`;
|
|
172
|
-
if (entry.sessionFile === undefined || (statSync(entry.sessionFile).mode & 0o777) !== 0o600) {
|
|
173
|
-
throw new Fault(503, "journal_invalid", "Native journal was not privately persisted.");
|
|
174
|
-
}
|
|
175
|
-
const saved = SessionManager.open(entry.sessionFile, policy.sessionDir);
|
|
176
|
-
if (
|
|
177
|
-
saved.getSessionId() !== entry.sessionId ||
|
|
178
|
-
saved.getSessionName() !== entry.name ||
|
|
179
|
-
saved.getCwd() !== entry.identity.cwd
|
|
180
|
-
) {
|
|
181
|
-
throw new Fault(503, "journal_invalid", "Native journal identity did not persist.");
|
|
182
|
-
}
|
|
183
|
-
} finally {
|
|
184
|
-
process.umask(previous);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
388
|
private async establish(
|
|
188
389
|
entry: Launch,
|
|
189
390
|
policy: LaunchPolicy,
|
|
@@ -203,15 +404,20 @@ export class Launcher {
|
|
|
203
404
|
);
|
|
204
405
|
signal.addEventListener("abort", abort, { once: true });
|
|
205
406
|
try {
|
|
206
|
-
|
|
407
|
+
const resuming = entry.journal !== undefined;
|
|
408
|
+
entry.journal = entry.journal
|
|
409
|
+
? await resumeLaunchJournal(policy, entry.journal, signal)
|
|
410
|
+
: await createLaunchJournal(policy, { cwd: entry.cwd, name: entry.name ?? "" }, signal);
|
|
207
411
|
cancelled(signal);
|
|
208
|
-
|
|
209
|
-
throw new Fault(503, "journal_invalid", "Native journal path unavailable.");
|
|
210
|
-
}
|
|
211
|
-
entry.process = new RpcProcess(policy, entry.identity.cwd, entry.sessionFile);
|
|
412
|
+
entry.process = new RpcProcess(policy, entry.cwd, entry.journal.sessionFile, resuming);
|
|
212
413
|
this.expiry(entry);
|
|
213
|
-
|
|
214
|
-
|
|
414
|
+
validateLaunchState(entry.journal, await entry.process.state(signal));
|
|
415
|
+
await wait(
|
|
416
|
+
spawnBrokerIfNeeded(process.execPath, []).catch(() => {
|
|
417
|
+
throw new Fault(503, "broker_unavailable", "Local broker could not be acquired safely.");
|
|
418
|
+
}),
|
|
419
|
+
signal,
|
|
420
|
+
);
|
|
215
421
|
await this.peerReady(entry, context.peers, signal);
|
|
216
422
|
cancelled(signal);
|
|
217
423
|
entry.process.ready();
|
|
@@ -219,34 +425,16 @@ export class Launcher {
|
|
|
219
425
|
log("launch_ready", {
|
|
220
426
|
fingerprint: entry.identity.fingerprint256,
|
|
221
427
|
name: entry.name,
|
|
222
|
-
peer: entry.peerId,
|
|
428
|
+
peer: entry.journal.peerId,
|
|
223
429
|
pid: entry.process.pid,
|
|
224
430
|
result: "ready",
|
|
225
431
|
});
|
|
226
|
-
return { session: view(entry), reused: false };
|
|
432
|
+
return { session: view(entry), id: entry.journal.sessionId, reused: false };
|
|
227
433
|
} finally {
|
|
228
434
|
clearTimeout(deadline);
|
|
229
435
|
signal.removeEventListener("abort", abort);
|
|
230
436
|
}
|
|
231
437
|
}
|
|
232
|
-
private validateState(entry: Readonly<Launch>, policy: LaunchPolicy, state: UnknownRecord): void {
|
|
233
|
-
if (
|
|
234
|
-
state.sessionId !== entry.sessionId ||
|
|
235
|
-
state.sessionFile !== entry.sessionFile ||
|
|
236
|
-
state.sessionName !== entry.name ||
|
|
237
|
-
state.isStreaming !== false ||
|
|
238
|
-
state.pendingMessageCount !== 0 ||
|
|
239
|
-
!isRecord(state.model) ||
|
|
240
|
-
state.model.provider !== policy.provider ||
|
|
241
|
-
state.model.id !== policy.model
|
|
242
|
-
) {
|
|
243
|
-
throw new Fault(
|
|
244
|
-
503,
|
|
245
|
-
"readiness_failed",
|
|
246
|
-
"Pi did not start idle with the exact saved session and configured model.",
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
438
|
private async peerReady(
|
|
251
439
|
entry: Readonly<Launch>,
|
|
252
440
|
peers: StartContext["peers"],
|
|
@@ -254,18 +442,19 @@ export class Launcher {
|
|
|
254
442
|
): Promise<void> {
|
|
255
443
|
while (!entry.process?.failure) {
|
|
256
444
|
cancelled(signal);
|
|
257
|
-
//
|
|
445
|
+
// Native startup alone is insufficient: observe the exact Intercom publication.
|
|
258
446
|
// oxlint-disable-next-line no-await-in-loop
|
|
259
447
|
const rows = await peers(signal);
|
|
260
448
|
if (
|
|
261
449
|
rows.some(
|
|
262
450
|
(peer) =>
|
|
263
|
-
peer.id === entry.peerId &&
|
|
451
|
+
peer.id === entry.journal?.peerId &&
|
|
452
|
+
peer.name === resolveIntercomPresenceName(entry.name, entry.journal.sessionId) &&
|
|
453
|
+
peer.cwd === entry.cwd,
|
|
264
454
|
)
|
|
265
455
|
) {
|
|
266
456
|
return;
|
|
267
457
|
}
|
|
268
|
-
// Bounded readiness polling is not an acknowledgement substitute; only the row above succeeds.
|
|
269
458
|
// oxlint-disable-next-line no-await-in-loop
|
|
270
459
|
await delay(100, undefined, { signal });
|
|
271
460
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { DatabaseSync } from "node:sqlite";
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { privateDatabase } from "./bridge-storage.ts";
|
|
4
3
|
import { isRecord, type UnknownRecord } from "../shared/unknown.ts";
|
|
5
4
|
import { normalizeMessage, normalizeSessionInfo } from "./types.ts";
|
|
6
5
|
import type { Envelope } from "./bridge-ask.ts";
|
|
@@ -9,7 +8,6 @@ import {
|
|
|
9
8
|
INBOX_BYTES,
|
|
10
9
|
INBOX_COUNT,
|
|
11
10
|
fingerprint as certificateFingerprint,
|
|
12
|
-
label,
|
|
13
11
|
} from "./bridge-protocol.ts";
|
|
14
12
|
|
|
15
13
|
interface Entry extends Envelope {
|
|
@@ -32,64 +30,10 @@ export interface NotificationPage extends InboxPage {
|
|
|
32
30
|
readonly messages: readonly PendingNotification[];
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
export function stateDirectory(value: unknown): string | undefined {
|
|
36
|
-
if (value === undefined) {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
const path = label(value, "stateDir", 4096);
|
|
40
|
-
const stat = lstatSync(path);
|
|
41
|
-
if (
|
|
42
|
-
!isAbsolute(path) ||
|
|
43
|
-
!stat.isDirectory() ||
|
|
44
|
-
stat.uid !== process.getuid?.() ||
|
|
45
|
-
(stat.mode & 0o777) !== 0o700
|
|
46
|
-
) {
|
|
47
|
-
throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
|
|
48
|
-
}
|
|
49
|
-
return realpathSync(path);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function privateFile(path: string, create = false): void {
|
|
53
|
-
const flags = create ? constants.O_RDWR | constants.O_CREAT : constants.O_RDONLY;
|
|
54
|
-
const fd = openSync(path, flags | constants.O_NOFOLLOW, 0o600);
|
|
55
|
-
try {
|
|
56
|
-
const stat = fstatSync(fd);
|
|
57
|
-
if (
|
|
58
|
-
!stat.isFile() ||
|
|
59
|
-
stat.uid !== process.getuid?.() ||
|
|
60
|
-
(stat.mode & 0o777) !== 0o600 ||
|
|
61
|
-
stat.nlink !== 1
|
|
62
|
-
) {
|
|
63
|
-
throw new Error("Mailbox files must be owned regular files with mode 0600 and one link.");
|
|
64
|
-
}
|
|
65
|
-
} finally {
|
|
66
|
-
closeSync(fd);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
33
|
function database(stateDir: string, fingerprint: string): DatabaseSync {
|
|
71
|
-
|
|
72
|
-
const path = join(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
|
|
73
|
-
privateFile(path, true);
|
|
74
|
-
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
|
75
|
-
try {
|
|
76
|
-
lstatSync(`${path}${suffix}`);
|
|
77
|
-
} catch (error) {
|
|
78
|
-
if (isRecord(error) && error.code === "ENOENT") {
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
throw error;
|
|
82
|
-
}
|
|
83
|
-
privateFile(`${path}${suffix}`);
|
|
84
|
-
}
|
|
85
|
-
const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
|
|
34
|
+
const { db } = privateDatabase(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
|
|
86
35
|
try {
|
|
87
36
|
db.exec(`
|
|
88
|
-
PRAGMA journal_mode=DELETE;
|
|
89
|
-
PRAGMA synchronous=FULL;
|
|
90
|
-
PRAGMA secure_delete=ON;
|
|
91
|
-
PRAGMA cache_size=-1024;
|
|
92
|
-
PRAGMA busy_timeout=1000;
|
|
93
37
|
CREATE TABLE IF NOT EXISTS mailbox (
|
|
94
38
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
95
39
|
id TEXT NOT NULL UNIQUE,
|