@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.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- 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 +47 -26
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-files.js +47 -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 +324 -0
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +190 -17
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -85
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +84 -44
- 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 +293 -161
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +196 -31
- package/scripts/package-smoke.mjs +1 -1
- 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 +64 -37
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-files.ts +59 -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 +395 -0
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +281 -32
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +106 -105
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +88 -54
- 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,10 +1,12 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { statSync } from "node:fs";
|
|
3
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
-
import { loadNativeSession } from "../shared/native-session-loader.js";
|
|
5
2
|
import { isRecord } from "../shared/unknown.js";
|
|
3
|
+
import { launchCwd, spawnPolicy } from "./bridge-launch-policy.js";
|
|
6
4
|
import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
|
|
7
5
|
import { RpcProcess } from "./bridge-rpc-process.js";
|
|
6
|
+
import { exactPeer, savedView, browseSessions, } from "./bridge-catalog.js";
|
|
7
|
+
import { createLaunchJournal, resumeLaunchJournal, validateLaunchState, } from "./bridge-launch-journal.js";
|
|
8
|
+
import { spawnBrokerIfNeeded } from "./broker/spawn.js";
|
|
9
|
+
import { resolveIntercomPresenceName } from "./identity.js";
|
|
8
10
|
export function sameIdentity(left, right) {
|
|
9
11
|
return (left.name === right?.name &&
|
|
10
12
|
left.cwd === right.cwd &&
|
|
@@ -12,18 +14,27 @@ export function sameIdentity(left, right) {
|
|
|
12
14
|
}
|
|
13
15
|
function view(entry) {
|
|
14
16
|
return {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
id: entry.journal?.sessionId,
|
|
18
|
+
sessionId: entry.journal?.sessionId,
|
|
19
|
+
peerId: entry.journal?.peerId,
|
|
17
20
|
name: entry.name,
|
|
18
|
-
|
|
21
|
+
cwd: entry.cwd,
|
|
22
|
+
sessionFile: entry.journal?.sessionFile,
|
|
19
23
|
pid: entry.process?.pid,
|
|
20
24
|
status: entry.process?.status ?? "starting",
|
|
21
25
|
error: entry.process?.failure?.code,
|
|
26
|
+
saved: entry.journal !== undefined,
|
|
27
|
+
owned: true,
|
|
22
28
|
};
|
|
23
29
|
}
|
|
24
30
|
export class Launcher {
|
|
25
31
|
launches = new Set();
|
|
32
|
+
catalog;
|
|
33
|
+
resuming = new Set();
|
|
26
34
|
stopping = false;
|
|
35
|
+
constructor(catalog) {
|
|
36
|
+
this.catalog = catalog;
|
|
37
|
+
}
|
|
27
38
|
list(identity) {
|
|
28
39
|
return {
|
|
29
40
|
sessions: [...this.launches]
|
|
@@ -31,36 +42,175 @@ export class Launcher {
|
|
|
31
42
|
.map(view),
|
|
32
43
|
};
|
|
33
44
|
}
|
|
34
|
-
async
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
async browse(query, context) {
|
|
46
|
+
return browseSessions(this.catalog, query, {
|
|
47
|
+
policy: context.identity.launch,
|
|
48
|
+
signal: context.signal,
|
|
49
|
+
peers: context.peers,
|
|
50
|
+
activeState: (id) => {
|
|
51
|
+
const entry = this.active(id) ?? this.activePeer(id);
|
|
52
|
+
return entry?.identity.fingerprint256 === context.identity.fingerprint256
|
|
53
|
+
? (entry.process?.status ?? "starting")
|
|
54
|
+
: undefined;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
active(sessionId) {
|
|
59
|
+
return [...this.launches].find((entry) => entry.journal?.sessionId === sessionId && entry.process?.status !== "exited");
|
|
60
|
+
}
|
|
61
|
+
activePeer(peerId) {
|
|
62
|
+
return [...this.launches].find((entry) => entry.journal?.peerId === peerId && entry.process?.status !== "exited");
|
|
63
|
+
}
|
|
64
|
+
async owned(identity, id, context) {
|
|
65
|
+
const candidates = [...this.launches].filter((candidate) => candidate.identity.fingerprint256 === identity.fingerprint256);
|
|
66
|
+
const byId = candidates.findLast((candidate) => candidate.journal?.sessionId === id || candidate.journal?.peerId === id);
|
|
67
|
+
const named = candidates.filter((candidate) => candidate.process?.status !== "exited" &&
|
|
68
|
+
(candidate.name === id || candidate.name === `remote:${identity.name}:${id}`));
|
|
69
|
+
if (!byId && named.length > 1) {
|
|
70
|
+
throw new Fault(409, "ambiguous_session", "Multiple owned live processes match this name; use their native IDs.");
|
|
71
|
+
}
|
|
72
|
+
const entry = byId ?? named.at(0);
|
|
73
|
+
if (!entry?.process) {
|
|
74
|
+
throw new Fault(404, "unknown_session", "Control is available only for this identity's helper-owned subprocesses, not unrelated live peers or saved history.");
|
|
75
|
+
}
|
|
76
|
+
if (entry.process.status === "exited") {
|
|
77
|
+
const peers = await context.peers(context.signal);
|
|
78
|
+
if (peers.some((peer) => peer.id === entry.journal?.peerId)) {
|
|
79
|
+
throw new Fault(404, "unknown_session", "This journal has a newer live owner; the historical process is not current ownership.");
|
|
80
|
+
}
|
|
39
81
|
}
|
|
82
|
+
return entry;
|
|
83
|
+
}
|
|
84
|
+
async stop(identity, sessionId, context) {
|
|
85
|
+
const entry = await this.owned(identity, sessionId, context);
|
|
40
86
|
await this.retire(entry, new Fault(410, "session_stopped", "Session stopped by its owner."));
|
|
41
87
|
return { session: view(entry) };
|
|
42
88
|
}
|
|
89
|
+
async cancel(identity, sessionId, context) {
|
|
90
|
+
const entry = await this.owned(identity, sessionId, context);
|
|
91
|
+
if (!entry.admitted || entry.process?.status === "exited" || !entry.process) {
|
|
92
|
+
throw new Fault(409, "session_not_ready", "Only an admitted live owned session can be cancelled.");
|
|
93
|
+
}
|
|
94
|
+
const state = await entry.process.cancel(context.signal);
|
|
95
|
+
return { session: view(entry), cancelled: true, state, queuesPreserved: true };
|
|
96
|
+
}
|
|
43
97
|
async start(name, context) {
|
|
98
|
+
return this.spawn({ name }, context, true);
|
|
99
|
+
}
|
|
100
|
+
async spawn(input, context, legacy = false) {
|
|
101
|
+
const policy = spawnPolicy(this.policy(context), input);
|
|
102
|
+
const cwd = legacy
|
|
103
|
+
? context.identity.cwd
|
|
104
|
+
: launchCwd(policy, input.cwd ?? context.identity.cwd);
|
|
105
|
+
const name = `remote:${context.identity.name}:${input.name}`;
|
|
106
|
+
const existing = [...this.launches].find((entry) => entry.identity.fingerprint256 === context.identity.fingerprint256 &&
|
|
107
|
+
entry.name === name &&
|
|
108
|
+
entry.process?.status !== "exited");
|
|
109
|
+
const result = existing
|
|
110
|
+
? this.reuseSpawn(existing, input, policy, cwd)
|
|
111
|
+
: await this.launch({ name, cwd }, context, policy);
|
|
112
|
+
return this.task(result, input.task, context);
|
|
113
|
+
}
|
|
114
|
+
reuseSpawn(entry, input, policy, cwd) {
|
|
115
|
+
if (entry.cwd !== cwd) {
|
|
116
|
+
throw new Fault(409, "cwd_conflict", "This live name already belongs to a different workspace.");
|
|
117
|
+
}
|
|
118
|
+
if (input.model !== undefined &&
|
|
119
|
+
(entry.journal?.model?.provider !== policy.provider ||
|
|
120
|
+
entry.journal?.model?.modelId !== policy.model)) {
|
|
121
|
+
throw new Fault(409, "model_conflict", "This live name already belongs to a different model selection; resume it without overriding history.");
|
|
122
|
+
}
|
|
123
|
+
return this.reuse(entry);
|
|
124
|
+
}
|
|
125
|
+
async resume(input, context) {
|
|
126
|
+
const policy = this.policy(context);
|
|
127
|
+
const selector = input.sessionId ?? input.target ?? `name:${input.name ?? ""}`;
|
|
128
|
+
if (this.resuming.has(selector)) {
|
|
129
|
+
throw new Fault(409, "launch_in_progress", "This resume selector is already starting.");
|
|
130
|
+
}
|
|
131
|
+
this.resuming.add(selector);
|
|
132
|
+
try {
|
|
133
|
+
const saved = await this.catalog.resolve(policy, input, context.signal);
|
|
134
|
+
launchCwd(policy, saved.cwd);
|
|
135
|
+
const owned = this.active(saved.sessionId);
|
|
136
|
+
if (owned) {
|
|
137
|
+
const result = this.reuse(owned);
|
|
138
|
+
const reused = owned.identity.fingerprint256 === context.identity.fingerprint256
|
|
139
|
+
? result
|
|
140
|
+
: {
|
|
141
|
+
...result,
|
|
142
|
+
session: {
|
|
143
|
+
...savedView(saved),
|
|
144
|
+
peerId: saved.peerId,
|
|
145
|
+
status: owned.process?.status,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
return await this.task(reused, input.task, context);
|
|
149
|
+
}
|
|
150
|
+
await wait(spawnBrokerIfNeeded(process.execPath, []), context.signal);
|
|
151
|
+
const peer = exactPeer(saved, await context.peers(context.signal));
|
|
152
|
+
if (peer) {
|
|
153
|
+
return await this.task({ session: savedView(saved, peer), id: saved.sessionId, reused: true }, input.task, context);
|
|
154
|
+
}
|
|
155
|
+
// A different selector/identity may have completed catalog resolution during the peer lookup.
|
|
156
|
+
if (this.active(saved.sessionId)) {
|
|
157
|
+
throw new Fault(409, "launch_in_progress", "This native session is already starting.");
|
|
158
|
+
}
|
|
159
|
+
const result = await this.launch(saved, context, policy);
|
|
160
|
+
return await this.task(result, input.task, context);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
this.resuming.delete(selector);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
policy(context) {
|
|
44
167
|
cancelled(context.signal);
|
|
45
168
|
const policy = context.identity.launch;
|
|
46
169
|
if (!policy || this.stopping) {
|
|
47
170
|
throw new Fault(403, "launch_disabled", "Session launching is not enabled by local policy.");
|
|
48
171
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
return
|
|
172
|
+
return policy;
|
|
173
|
+
}
|
|
174
|
+
async task(result, task, context) {
|
|
175
|
+
const session = result.session;
|
|
176
|
+
if (task === undefined || !isRecord(session) || typeof session.peerId !== "string") {
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const receipt = await context.submit(session.peerId, task);
|
|
181
|
+
return { ...result, id: session.sessionId, task: { ...receipt, completed: false } };
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
const fault = error instanceof Fault
|
|
185
|
+
? error
|
|
186
|
+
: new Fault(503, "task_unconfirmed", "Task acceptance is unknown; inspect the session before retrying.");
|
|
187
|
+
throw new Fault(fault.status, fault.code, fault.message, {
|
|
188
|
+
...fault.details,
|
|
189
|
+
session,
|
|
190
|
+
reused: result.reused,
|
|
191
|
+
});
|
|
55
192
|
}
|
|
193
|
+
}
|
|
194
|
+
async launch(target, context, policy) {
|
|
56
195
|
this.capacity(context.identity, policy);
|
|
57
196
|
const entry = {
|
|
58
197
|
identity: context.identity,
|
|
59
|
-
name:
|
|
198
|
+
name: target.name,
|
|
199
|
+
cwd: target.cwd,
|
|
60
200
|
controller: new AbortController(),
|
|
61
201
|
expires: context.expires,
|
|
62
202
|
admitted: false,
|
|
63
203
|
};
|
|
204
|
+
if (target.sessionId !== undefined &&
|
|
205
|
+
target.peerId !== undefined &&
|
|
206
|
+
target.sessionFile !== undefined) {
|
|
207
|
+
entry.journal = {
|
|
208
|
+
...target,
|
|
209
|
+
sessionId: target.sessionId,
|
|
210
|
+
peerId: target.peerId,
|
|
211
|
+
sessionFile: target.sessionFile,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
64
214
|
this.launches.add(entry);
|
|
65
215
|
try {
|
|
66
216
|
return await this.establish(entry, policy, context);
|
|
@@ -72,15 +222,15 @@ export class Launcher {
|
|
|
72
222
|
}
|
|
73
223
|
throw error instanceof Fault
|
|
74
224
|
? error
|
|
75
|
-
: new Fault(503, "launch_failed", "Pi startup failed; inspect
|
|
225
|
+
: new Fault(503, "launch_failed", "Pi startup failed; inspect local policy and resources.");
|
|
76
226
|
}
|
|
77
227
|
}
|
|
78
228
|
reuse(entry) {
|
|
79
229
|
if (entry.admitted &&
|
|
80
230
|
(entry.process?.status === "idle" || entry.process?.status === "running")) {
|
|
81
|
-
return { session: view(entry), reused: true };
|
|
231
|
+
return { session: view(entry), id: entry.journal?.sessionId, reused: true };
|
|
82
232
|
}
|
|
83
|
-
throw new Fault(409, "launch_in_progress", "This
|
|
233
|
+
throw new Fault(409, "launch_in_progress", "This session is starting or stopping; inspect sessions before retrying.");
|
|
84
234
|
}
|
|
85
235
|
capacity(identity, policy) {
|
|
86
236
|
const active = [...this.launches].filter((entry) => entry.process?.status !== "exited");
|
|
@@ -95,52 +245,23 @@ export class Launcher {
|
|
|
95
245
|
}
|
|
96
246
|
}
|
|
97
247
|
}
|
|
98
|
-
async journal(entry, policy) {
|
|
99
|
-
const { SessionManager } = await wait(loadNativeSession(policy.packageRoot), entry.controller.signal);
|
|
100
|
-
cancelled(entry.controller.signal);
|
|
101
|
-
const storage = statSync(policy.sessionDir);
|
|
102
|
-
if (!storage.isDirectory() ||
|
|
103
|
-
storage.uid !== process.getuid?.() ||
|
|
104
|
-
(storage.mode & 0o777) !== 0o700) {
|
|
105
|
-
throw new Fault(503, "storage_invalid", "Private launch storage is no longer valid.");
|
|
106
|
-
}
|
|
107
|
-
const previous = process.umask(0o077);
|
|
108
|
-
try {
|
|
109
|
-
const manager = SessionManager.create(entry.identity.cwd, policy.sessionDir);
|
|
110
|
-
manager.appendSessionInfo(entry.name);
|
|
111
|
-
manager.appendMessage({ role: "user", content: policy.context, timestamp: Date.now() });
|
|
112
|
-
entry.sessionId = manager.getSessionId();
|
|
113
|
-
entry.sessionFile = manager.getSessionFile();
|
|
114
|
-
entry.peerId = `pi-${createHash("sha256").update(entry.sessionId).digest("hex").slice(0, 32)}`;
|
|
115
|
-
if (entry.sessionFile === undefined || (statSync(entry.sessionFile).mode & 0o777) !== 0o600) {
|
|
116
|
-
throw new Fault(503, "journal_invalid", "Native journal was not privately persisted.");
|
|
117
|
-
}
|
|
118
|
-
const saved = SessionManager.open(entry.sessionFile, policy.sessionDir);
|
|
119
|
-
if (saved.getSessionId() !== entry.sessionId ||
|
|
120
|
-
saved.getSessionName() !== entry.name ||
|
|
121
|
-
saved.getCwd() !== entry.identity.cwd) {
|
|
122
|
-
throw new Fault(503, "journal_invalid", "Native journal identity did not persist.");
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
finally {
|
|
126
|
-
process.umask(previous);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
248
|
async establish(entry, policy, context) {
|
|
130
249
|
const signal = AbortSignal.any([context.signal, entry.controller.signal]);
|
|
131
250
|
const deadline = setTimeout(() => entry.controller.abort(new Fault(504, "launch_timeout", "Pi startup readiness deadline exceeded.")), policy.startupTimeoutMs);
|
|
132
251
|
const abort = () => entry.controller.abort(new Fault(499, "launch_cancelled", "Session startup cancelled or exceeded its deadline."));
|
|
133
252
|
signal.addEventListener("abort", abort, { once: true });
|
|
134
253
|
try {
|
|
135
|
-
|
|
254
|
+
const resuming = entry.journal !== undefined;
|
|
255
|
+
entry.journal = entry.journal
|
|
256
|
+
? await resumeLaunchJournal(policy, entry.journal, signal)
|
|
257
|
+
: await createLaunchJournal(policy, { cwd: entry.cwd, name: entry.name ?? "" }, signal);
|
|
136
258
|
cancelled(signal);
|
|
137
|
-
|
|
138
|
-
throw new Fault(503, "journal_invalid", "Native journal path unavailable.");
|
|
139
|
-
}
|
|
140
|
-
entry.process = new RpcProcess(policy, entry.identity.cwd, entry.sessionFile);
|
|
259
|
+
entry.process = new RpcProcess(policy, entry.cwd, entry.journal.sessionFile, resuming);
|
|
141
260
|
this.expiry(entry);
|
|
142
|
-
|
|
143
|
-
|
|
261
|
+
validateLaunchState(entry.journal, await entry.process.state(signal));
|
|
262
|
+
await wait(spawnBrokerIfNeeded(process.execPath, []).catch(() => {
|
|
263
|
+
throw new Fault(503, "broker_unavailable", "Local broker could not be acquired safely.");
|
|
264
|
+
}), signal);
|
|
144
265
|
await this.peerReady(entry, context.peers, signal);
|
|
145
266
|
cancelled(signal);
|
|
146
267
|
entry.process.ready();
|
|
@@ -148,39 +269,28 @@ export class Launcher {
|
|
|
148
269
|
log("launch_ready", {
|
|
149
270
|
fingerprint: entry.identity.fingerprint256,
|
|
150
271
|
name: entry.name,
|
|
151
|
-
peer: entry.peerId,
|
|
272
|
+
peer: entry.journal.peerId,
|
|
152
273
|
pid: entry.process.pid,
|
|
153
274
|
result: "ready",
|
|
154
275
|
});
|
|
155
|
-
return { session: view(entry), reused: false };
|
|
276
|
+
return { session: view(entry), id: entry.journal.sessionId, reused: false };
|
|
156
277
|
}
|
|
157
278
|
finally {
|
|
158
279
|
clearTimeout(deadline);
|
|
159
280
|
signal.removeEventListener("abort", abort);
|
|
160
281
|
}
|
|
161
282
|
}
|
|
162
|
-
validateState(entry, policy, state) {
|
|
163
|
-
if (state.sessionId !== entry.sessionId ||
|
|
164
|
-
state.sessionFile !== entry.sessionFile ||
|
|
165
|
-
state.sessionName !== entry.name ||
|
|
166
|
-
state.isStreaming !== false ||
|
|
167
|
-
state.pendingMessageCount !== 0 ||
|
|
168
|
-
!isRecord(state.model) ||
|
|
169
|
-
state.model.provider !== policy.provider ||
|
|
170
|
-
state.model.id !== policy.model) {
|
|
171
|
-
throw new Fault(503, "readiness_failed", "Pi did not start idle with the exact saved session and configured model.");
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
283
|
async peerReady(entry, peers, signal) {
|
|
175
284
|
while (!entry.process?.failure) {
|
|
176
285
|
cancelled(signal);
|
|
177
|
-
//
|
|
286
|
+
// Native startup alone is insufficient: observe the exact Intercom publication.
|
|
178
287
|
// oxlint-disable-next-line no-await-in-loop
|
|
179
288
|
const rows = await peers(signal);
|
|
180
|
-
if (rows.some((peer) => peer.id === entry.peerId &&
|
|
289
|
+
if (rows.some((peer) => peer.id === entry.journal?.peerId &&
|
|
290
|
+
peer.name === resolveIntercomPresenceName(entry.name, entry.journal.sessionId) &&
|
|
291
|
+
peer.cwd === entry.cwd)) {
|
|
181
292
|
return;
|
|
182
293
|
}
|
|
183
|
-
// Bounded readiness polling is not an acknowledgement substitute; only the row above succeeds.
|
|
184
294
|
// oxlint-disable-next-line no-await-in-loop
|
|
185
295
|
await delay(100, undefined, { signal });
|
|
186
296
|
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { isRecord } from "../shared/unknown.js";
|
|
5
|
+
import { normalizeMessage, normalizeSessionInfo } from "./types.js";
|
|
6
|
+
import { Fault, INBOX_BYTES, INBOX_COUNT, fingerprint as certificateFingerprint, label, } from "./bridge-protocol.js";
|
|
7
|
+
export function stateDirectory(value) {
|
|
8
|
+
if (value === undefined) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const path = label(value, "stateDir", 4096);
|
|
12
|
+
const stat = lstatSync(path);
|
|
13
|
+
if (!isAbsolute(path) ||
|
|
14
|
+
!stat.isDirectory() ||
|
|
15
|
+
stat.uid !== process.getuid?.() ||
|
|
16
|
+
(stat.mode & 0o777) !== 0o700) {
|
|
17
|
+
throw new Error("stateDir must be an absolute owned directory with mode 0700, not a symlink.");
|
|
18
|
+
}
|
|
19
|
+
return realpathSync(path);
|
|
20
|
+
}
|
|
21
|
+
function privateFile(path, create = false) {
|
|
22
|
+
const flags = create ? constants.O_RDWR | constants.O_CREAT : constants.O_RDONLY;
|
|
23
|
+
const fd = openSync(path, flags | constants.O_NOFOLLOW, 0o600);
|
|
24
|
+
try {
|
|
25
|
+
const stat = fstatSync(fd);
|
|
26
|
+
if (!stat.isFile() ||
|
|
27
|
+
stat.uid !== process.getuid?.() ||
|
|
28
|
+
(stat.mode & 0o777) !== 0o600 ||
|
|
29
|
+
stat.nlink !== 1) {
|
|
30
|
+
throw new Error("Mailbox files must be owned regular files with mode 0600 and one link.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function database(stateDir, fingerprint) {
|
|
38
|
+
stateDirectory(stateDir);
|
|
39
|
+
const path = join(stateDir, `${certificateFingerprint(fingerprint)}.sqlite`);
|
|
40
|
+
privateFile(path, true);
|
|
41
|
+
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
|
42
|
+
try {
|
|
43
|
+
lstatSync(`${path}${suffix}`);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
privateFile(`${path}${suffix}`);
|
|
52
|
+
}
|
|
53
|
+
const db = new DatabaseSync(path, { enableDoubleQuotedStringLiterals: false });
|
|
54
|
+
try {
|
|
55
|
+
db.exec(`
|
|
56
|
+
PRAGMA journal_mode=DELETE;
|
|
57
|
+
PRAGMA synchronous=FULL;
|
|
58
|
+
PRAGMA secure_delete=ON;
|
|
59
|
+
PRAGMA cache_size=-1024;
|
|
60
|
+
PRAGMA busy_timeout=1000;
|
|
61
|
+
CREATE TABLE IF NOT EXISTS mailbox (
|
|
62
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
id TEXT NOT NULL UNIQUE,
|
|
64
|
+
envelope TEXT,
|
|
65
|
+
bytes INTEGER NOT NULL,
|
|
66
|
+
ask INTEGER NOT NULL,
|
|
67
|
+
receivedAt INTEGER NOT NULL,
|
|
68
|
+
acked INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
notified INTEGER NOT NULL DEFAULT 1
|
|
70
|
+
);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS unread ON mailbox(sequence) WHERE acked=0;
|
|
72
|
+
CREATE INDEX IF NOT EXISTS asks ON mailbox(ask) WHERE ask=1 AND acked=0;
|
|
73
|
+
CREATE INDEX IF NOT EXISTS notifications ON mailbox(sequence) WHERE notified=0;
|
|
74
|
+
CREATE TABLE IF NOT EXISTS losses (
|
|
75
|
+
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
|
76
|
+
count INTEGER NOT NULL
|
|
77
|
+
);
|
|
78
|
+
INSERT OR IGNORE INTO losses VALUES(1, 0);
|
|
79
|
+
`);
|
|
80
|
+
return db;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
db.close();
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function decode(value) {
|
|
88
|
+
const raw = typeof value === "string" ? JSON.parse(value) : undefined;
|
|
89
|
+
const from = isRecord(raw) ? normalizeSessionInfo(raw.from) : null;
|
|
90
|
+
const message = isRecord(raw) ? normalizeMessage(raw.message) : null;
|
|
91
|
+
if (!from || !message) {
|
|
92
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored mailbox entry is invalid.");
|
|
93
|
+
}
|
|
94
|
+
return { from, message };
|
|
95
|
+
}
|
|
96
|
+
/** Persistent payloads leave only ID tombstones after acknowledgement, preventing replay resurrection. */
|
|
97
|
+
export class Mailbox {
|
|
98
|
+
db;
|
|
99
|
+
entries = new Map();
|
|
100
|
+
bytes = 0;
|
|
101
|
+
sequence = 0;
|
|
102
|
+
failure;
|
|
103
|
+
admissionFailed = false;
|
|
104
|
+
notifications;
|
|
105
|
+
constructor(stateDir, fingerprint, notifications = false) {
|
|
106
|
+
try {
|
|
107
|
+
this.db = stateDir === undefined ? undefined : database(stateDir, fingerprint);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new Fault(507, "mailbox_storage_failed", "Cannot open private mailbox storage.");
|
|
111
|
+
}
|
|
112
|
+
this.notifications = notifications;
|
|
113
|
+
}
|
|
114
|
+
get persistent() {
|
|
115
|
+
return this.db !== undefined;
|
|
116
|
+
}
|
|
117
|
+
stored(action) {
|
|
118
|
+
if (this.failure) {
|
|
119
|
+
throw this.failure;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return action();
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
throw error instanceof Fault
|
|
126
|
+
? error
|
|
127
|
+
: new Fault(507, "mailbox_storage_failed", "Mailbox storage operation failed.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
add(envelope) {
|
|
131
|
+
if (!this.db) {
|
|
132
|
+
return this.addVolatile(envelope);
|
|
133
|
+
}
|
|
134
|
+
const db = this.db;
|
|
135
|
+
return this.stored(() => {
|
|
136
|
+
try {
|
|
137
|
+
const payload = JSON.stringify(envelope);
|
|
138
|
+
const inserted = db
|
|
139
|
+
.prepare("INSERT OR IGNORE INTO mailbox(id,envelope,bytes,ask,receivedAt,notified) VALUES(?,?,?,?,?,?)")
|
|
140
|
+
.run(envelope.message.id, payload, Buffer.byteLength(payload), envelope.message.expectsReply === true ? 1 : 0, Date.now(), this.notifications ? 0 : 1);
|
|
141
|
+
this.admissionFailed = false;
|
|
142
|
+
return inserted.changes === 0 ? "duplicate" : "stored";
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
this.admissionFailed = true;
|
|
146
|
+
try {
|
|
147
|
+
db.prepare("UPDATE losses SET count=MIN(count+1,9007199254740991) WHERE singleton=1").run();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
this.failure = new Fault(507, "mailbox_storage_failed", "Mailbox admission and loss recording failed; loss count is not durable.", { lossRecorded: false });
|
|
151
|
+
throw this.failure;
|
|
152
|
+
}
|
|
153
|
+
return "lost";
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
addVolatile(envelope) {
|
|
158
|
+
if (this.entries.has(envelope.message.id)) {
|
|
159
|
+
return "duplicate";
|
|
160
|
+
}
|
|
161
|
+
const bytes = Buffer.byteLength(JSON.stringify(envelope));
|
|
162
|
+
if (this.entries.size >= INBOX_COUNT || this.bytes + bytes > INBOX_BYTES) {
|
|
163
|
+
return "lost";
|
|
164
|
+
}
|
|
165
|
+
this.entries.set(envelope.message.id, {
|
|
166
|
+
...envelope,
|
|
167
|
+
bytes,
|
|
168
|
+
sequence: ++this.sequence,
|
|
169
|
+
receivedAt: Date.now(),
|
|
170
|
+
acked: 0,
|
|
171
|
+
});
|
|
172
|
+
this.bytes += bytes;
|
|
173
|
+
return "stored";
|
|
174
|
+
}
|
|
175
|
+
get(id) {
|
|
176
|
+
if (!this.db) {
|
|
177
|
+
return this.entries.get(id);
|
|
178
|
+
}
|
|
179
|
+
const db = this.db;
|
|
180
|
+
return this.stored(() => {
|
|
181
|
+
const row = db.prepare("SELECT envelope FROM mailbox WHERE id=? AND acked=0").get(id);
|
|
182
|
+
return row ? decode(row.envelope) : undefined;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
remove(id) {
|
|
186
|
+
if (this.db) {
|
|
187
|
+
const db = this.db;
|
|
188
|
+
this.stored(() => db
|
|
189
|
+
.prepare("UPDATE mailbox SET acked=1, envelope=CASE WHEN notified=1 THEN NULL ELSE envelope END, bytes=CASE WHEN notified=1 THEN 0 ELSE bytes END WHERE id=?")
|
|
190
|
+
.run(id));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const entry = this.entries.get(id);
|
|
194
|
+
if (entry) {
|
|
195
|
+
this.bytes -= entry.bytes;
|
|
196
|
+
this.entries.delete(id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
ack(ids) {
|
|
200
|
+
const acked = [];
|
|
201
|
+
const retained = [];
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
const entry = this.get(id);
|
|
204
|
+
if (entry?.message.expectsReply === true) {
|
|
205
|
+
// Durable consumer intake owns the primary wake; keep only the reply obligation here.
|
|
206
|
+
this.notificationAccepted(id);
|
|
207
|
+
retained.push(id);
|
|
208
|
+
}
|
|
209
|
+
else if (entry) {
|
|
210
|
+
this.remove(id);
|
|
211
|
+
acked.push(id);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { acked, retained };
|
|
215
|
+
}
|
|
216
|
+
page(after, limit) {
|
|
217
|
+
return this.readPage(after, limit, false);
|
|
218
|
+
}
|
|
219
|
+
pendingNotifications(after = 0, limit = INBOX_COUNT) {
|
|
220
|
+
return this.readPage(after, limit, true);
|
|
221
|
+
}
|
|
222
|
+
notificationAccepted(id) {
|
|
223
|
+
const db = this.db;
|
|
224
|
+
if (db) {
|
|
225
|
+
this.stored(() => db
|
|
226
|
+
.prepare("UPDATE mailbox SET notified=1, envelope=CASE WHEN acked=1 THEN NULL ELSE envelope END, bytes=CASE WHEN acked=1 THEN 0 ELSE bytes END WHERE id=?")
|
|
227
|
+
.run(id));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
readPage(after, limit, notifications) {
|
|
231
|
+
if (!Number.isSafeInteger(after) ||
|
|
232
|
+
after < 0 ||
|
|
233
|
+
!Number.isInteger(limit) ||
|
|
234
|
+
limit < 1 ||
|
|
235
|
+
limit > INBOX_COUNT) {
|
|
236
|
+
throw new Fault(400, "invalid_input", "Invalid mailbox page bounds.");
|
|
237
|
+
}
|
|
238
|
+
return this.stored(() => {
|
|
239
|
+
const candidates = this.candidates(after, limit, notifications);
|
|
240
|
+
const messages = [];
|
|
241
|
+
let bytes = 0;
|
|
242
|
+
let cursor = after;
|
|
243
|
+
let hasMore = false;
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
const row = indexRow(candidate);
|
|
246
|
+
if (messages.length === limit || (messages.length > 0 && bytes + row.bytes > INBOX_BYTES)) {
|
|
247
|
+
hasMore = true;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
const envelope = decode(isRecord(candidate) && typeof candidate.envelope === "string"
|
|
251
|
+
? candidate.envelope
|
|
252
|
+
: JSON.stringify(candidate));
|
|
253
|
+
messages.push(notifications
|
|
254
|
+
? { ...envelope, receivedAt: row.receivedAt, acknowledged: row.acked === 1 }
|
|
255
|
+
: envelope);
|
|
256
|
+
bytes += row.bytes;
|
|
257
|
+
cursor = row.sequence;
|
|
258
|
+
}
|
|
259
|
+
return { messages, nextCursor: String(cursor), hasMore };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
candidates(after, limit, notifications) {
|
|
263
|
+
if (this.db) {
|
|
264
|
+
return this.db
|
|
265
|
+
.prepare(`SELECT sequence,bytes,receivedAt,acked,envelope FROM mailbox WHERE ${notifications ? "notified=0" : "acked=0"} AND sequence>? ORDER BY sequence LIMIT ?`)
|
|
266
|
+
.iterate(after, limit + 1);
|
|
267
|
+
}
|
|
268
|
+
return notifications
|
|
269
|
+
? []
|
|
270
|
+
: [...this.entries.values()].filter((entry) => entry.sequence > after).slice(0, limit + 1);
|
|
271
|
+
}
|
|
272
|
+
status(volatileLosses) {
|
|
273
|
+
const db = this.db;
|
|
274
|
+
const lostMessages = db
|
|
275
|
+
? this.stored(() => db.prepare("SELECT count FROM losses WHERE singleton=1").get()?.count)
|
|
276
|
+
: volatileLosses;
|
|
277
|
+
if (typeof lostMessages !== "number") {
|
|
278
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored loss counter is invalid.");
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
lostMessages,
|
|
282
|
+
overflow: lostMessages > 0,
|
|
283
|
+
storageError: this.persistent && lostMessages > 0,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
presence() {
|
|
287
|
+
const db = this.db;
|
|
288
|
+
if (!db) {
|
|
289
|
+
return {
|
|
290
|
+
pendingAsks: [...this.entries.values()].filter((entry) => entry.message.expectsReply === true).length,
|
|
291
|
+
acceptsAsks: this.entries.size < INBOX_COUNT && this.bytes < INBOX_BYTES,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return this.stored(() => {
|
|
295
|
+
const pendingAsks = db
|
|
296
|
+
.prepare("SELECT COUNT(*) AS count FROM mailbox WHERE ask=1 AND acked=0")
|
|
297
|
+
.get()?.count;
|
|
298
|
+
if (typeof pendingAsks !== "number") {
|
|
299
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored ask counter is invalid.");
|
|
300
|
+
}
|
|
301
|
+
return { pendingAsks, acceptsAsks: !this.admissionFailed };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
close() {
|
|
305
|
+
this.db?.close();
|
|
306
|
+
const discarded = this.entries.size;
|
|
307
|
+
this.entries.clear();
|
|
308
|
+
this.bytes = 0;
|
|
309
|
+
return discarded;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function indexRow(row) {
|
|
313
|
+
if (!isRecord(row)) {
|
|
314
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored mailbox index is invalid.");
|
|
315
|
+
}
|
|
316
|
+
const { sequence, bytes, receivedAt, acked } = row;
|
|
317
|
+
if (typeof sequence !== "number" ||
|
|
318
|
+
typeof bytes !== "number" ||
|
|
319
|
+
typeof receivedAt !== "number" ||
|
|
320
|
+
typeof acked !== "number") {
|
|
321
|
+
throw new Fault(507, "mailbox_storage_failed", "Stored mailbox index is invalid.");
|
|
322
|
+
}
|
|
323
|
+
return { sequence, bytes, receivedAt, acked };
|
|
324
|
+
}
|