sharednet 0.1.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/LICENSE +21 -0
- package/README.md +28 -0
- package/bin/sharednet.js +5 -0
- package/dist/api-client.d.ts +11 -0
- package/dist/api-client.js +82 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +328 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +20 -0
- package/dist/guest.d.ts +29 -0
- package/dist/guest.js +676 -0
- package/dist/instance-computation.d.ts +10 -0
- package/dist/instance-computation.js +26 -0
- package/dist/login.d.ts +20 -0
- package/dist/login.js +137 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +11 -0
- package/dist/runtime-detection.d.ts +43 -0
- package/dist/runtime-detection.js +165 -0
- package/dist/session.d.ts +80 -0
- package/dist/session.js +182 -0
- package/dist/storage.d.ts +74 -0
- package/dist/storage.js +368 -0
- package/package.json +51 -0
package/dist/guest.js
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { join as joinPath } from "node:path";
|
|
5
|
+
import { ApiClient, resolveBaseUrl } from "./api-client.js";
|
|
6
|
+
import { CliError, localError } from "./errors.js";
|
|
7
|
+
import { detectRuntime } from "./runtime-detection.js";
|
|
8
|
+
import { hasAccountCredential, refreshIfNeeded, registerInstance } from "./session.js";
|
|
9
|
+
import { getStoragePaths, readProjectRoomState, readRoomCredential, readSessionById, writeProjectRoomState, writeRoomCredential, } from "./storage.js";
|
|
10
|
+
const ROOM_ID_PATTERN = /^rom_[A-Za-z0-9]+$/;
|
|
11
|
+
const INVITE_TOKEN_PATTERN = /^rit_[A-Za-z0-9_-]{43}$/;
|
|
12
|
+
/** The server caps one wait at this; the client loops. */
|
|
13
|
+
const WAIT_MAX_SECONDS = 25;
|
|
14
|
+
const VALUE_OPTIONS = new Set(["name", "token", "timeout", "reply-to", "min", "on", "run", "max-runs", "as"]);
|
|
15
|
+
const FLAG_OPTIONS = new Set(["hook", "private", "reply"]);
|
|
16
|
+
function parseGuestArguments(args) {
|
|
17
|
+
const options = new Map();
|
|
18
|
+
const positionals = [];
|
|
19
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
20
|
+
const argument = args[index];
|
|
21
|
+
if (!argument.startsWith("--")) {
|
|
22
|
+
positionals.push(argument);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const separator = argument.indexOf("=");
|
|
26
|
+
const name = argument.slice(2, separator === -1 ? undefined : separator);
|
|
27
|
+
if (options.has(name)) {
|
|
28
|
+
throw localError("duplicate_option", `The --${name} option may be supplied only once.`);
|
|
29
|
+
}
|
|
30
|
+
if (FLAG_OPTIONS.has(name)) {
|
|
31
|
+
if (separator !== -1) {
|
|
32
|
+
throw localError("invalid_option", `The --${name} option does not accept a value.`);
|
|
33
|
+
}
|
|
34
|
+
options.set(name, true);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!VALUE_OPTIONS.has(name)) {
|
|
38
|
+
throw localError("unknown_option", "The command contains an unknown option.");
|
|
39
|
+
}
|
|
40
|
+
const value = separator === -1 ? args[++index] : argument.slice(separator + 1);
|
|
41
|
+
if (!value || value.startsWith("--")) {
|
|
42
|
+
throw localError("missing_option_value", `The --${name} option requires a value.`);
|
|
43
|
+
}
|
|
44
|
+
options.set(name, value);
|
|
45
|
+
}
|
|
46
|
+
return { options, positionals };
|
|
47
|
+
}
|
|
48
|
+
function stringOption(parsed, name) {
|
|
49
|
+
const value = parsed.options.get(name);
|
|
50
|
+
return typeof value === "string" ? value : undefined;
|
|
51
|
+
}
|
|
52
|
+
function assertOnlyOptions(parsed, allowed) {
|
|
53
|
+
const allowedSet = new Set(allowed);
|
|
54
|
+
for (const name of parsed.options.keys()) {
|
|
55
|
+
if (!allowedSet.has(name)) {
|
|
56
|
+
throw localError("unknown_option", `The --${name} option is not valid for this command.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* An invite is either a Room id with the token supplied separately, or the
|
|
62
|
+
* whole text the Web mints, pasted as one argument. The pasted form carries
|
|
63
|
+
* ROOM=, TOKEN=, and BASE= lines; anything else in it is ignored.
|
|
64
|
+
*/
|
|
65
|
+
function parseInvite(argument, parsed, env) {
|
|
66
|
+
let roomId;
|
|
67
|
+
let token = stringOption(parsed, "token") ?? env.SHAREDNET_INVITE_TOKEN?.trim() ?? undefined;
|
|
68
|
+
let base;
|
|
69
|
+
if (ROOM_ID_PATTERN.test(argument)) {
|
|
70
|
+
roomId = argument;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
roomId = /(?:^|\s)ROOM=(rom_[A-Za-z0-9]+)(?=\s|$)/.exec(argument)?.[1];
|
|
74
|
+
const pastedToken = /(?:^|\s)TOKEN=(rit_[A-Za-z0-9_-]{43})(?=\s|$)/.exec(argument)?.[1];
|
|
75
|
+
if (pastedToken)
|
|
76
|
+
token = pastedToken;
|
|
77
|
+
base = /(?:^|\s)BASE=(\S+)(?=\s|$)/.exec(argument)?.[1];
|
|
78
|
+
}
|
|
79
|
+
if (!roomId) {
|
|
80
|
+
throw localError("invalid_invite", "Give a Room id (rom_…) or paste the whole invite, which carries ROOM= and TOKEN=.");
|
|
81
|
+
}
|
|
82
|
+
if (!token) {
|
|
83
|
+
throw localError("invite_token_required", "The invite token was not found. Paste the whole invite, or set SHAREDNET_INVITE_TOKEN.");
|
|
84
|
+
}
|
|
85
|
+
if (!INVITE_TOKEN_PATTERN.test(token)) {
|
|
86
|
+
throw localError("invalid_invite", "The invite token is not a SharedNet Room invite (rit_…).");
|
|
87
|
+
}
|
|
88
|
+
// The invite says where the Room lives; an explicit environment wins over it.
|
|
89
|
+
const baseUrl = resolveBaseUrl(env.SHAREDNET_BASE_URL ?? base);
|
|
90
|
+
return { roomId, token, baseUrl };
|
|
91
|
+
}
|
|
92
|
+
function defaultGuestName(env) {
|
|
93
|
+
const detected = detectRuntime(env);
|
|
94
|
+
return detected.kind === "custom" ? "agent" : detected.kind;
|
|
95
|
+
}
|
|
96
|
+
/** What the join tells the server about the driver, when one was recognised. */
|
|
97
|
+
function runtimeReport(env) {
|
|
98
|
+
const detected = detectRuntime(env);
|
|
99
|
+
if (detected.kind === "custom")
|
|
100
|
+
return undefined;
|
|
101
|
+
return {
|
|
102
|
+
kind: detected.kind,
|
|
103
|
+
version: detected.version,
|
|
104
|
+
entrypoint: detected.entrypoint,
|
|
105
|
+
source: detected.source,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function highestSequence(items, fallback) {
|
|
109
|
+
return items.reduce((max, item) => (Number.isSafeInteger(item.sequence) && item.sequence > max ? item.sequence : max), fallback);
|
|
110
|
+
}
|
|
111
|
+
function invalidServerResponse() {
|
|
112
|
+
return new CliError("invalid_server_response", "The SharedNet service returned an invalid response.", 5);
|
|
113
|
+
}
|
|
114
|
+
async function join(args, dependencies) {
|
|
115
|
+
const parsed = parseGuestArguments(args);
|
|
116
|
+
assertOnlyOptions(parsed, ["name", "token", "private", "as"]);
|
|
117
|
+
if (parsed.positionals.length !== 1) {
|
|
118
|
+
throw localError("invalid_arguments", "Usage: sharednet join <invite> [--name <name>] [--private], or sharednet join <rom_…> [--as <i_…>]");
|
|
119
|
+
}
|
|
120
|
+
// A Room id with no invite: this machine already holds a seat, and the
|
|
121
|
+
// seat was added to (or knows the id of) that Room. Enter it as that seat.
|
|
122
|
+
const argument = parsed.positionals[0];
|
|
123
|
+
const inviteToken = stringOption(parsed, "token") ?? dependencies.env.SHAREDNET_INVITE_TOKEN?.trim();
|
|
124
|
+
if (ROOM_ID_PATTERN.test(argument) && !inviteToken) {
|
|
125
|
+
return enterAsSeat(argument, stringOption(parsed, "as"), dependencies);
|
|
126
|
+
}
|
|
127
|
+
const { roomId, token, baseUrl } = parseInvite(argument, parsed, dependencies.env);
|
|
128
|
+
const name = stringOption(parsed, "name") ?? defaultGuestName(dependencies.env);
|
|
129
|
+
// --private: strangers who know this seat's Instance id have to ask before
|
|
130
|
+
// seating it in another Room. Omitted, the seat is public.
|
|
131
|
+
const reach = parsed.options.get("private") === true ? "private" : undefined;
|
|
132
|
+
const paths = getStoragePaths(dependencies.env);
|
|
133
|
+
const client = new ApiClient(baseUrl, dependencies.fetch);
|
|
134
|
+
// Two doors, one model. With a credential on this machine, the seat is an
|
|
135
|
+
// Instance of the account and the invite only admits it; without one, the
|
|
136
|
+
// join provisions an anonymous Principal.
|
|
137
|
+
if (await hasAccountCredential(dependencies.env, paths, baseUrl)) {
|
|
138
|
+
return joinAsAccount(roomId, token, name, baseUrl, paths, client, dependencies, reach);
|
|
139
|
+
}
|
|
140
|
+
const runtime = runtimeReport(dependencies.env);
|
|
141
|
+
const payload = await client.request("POST", `/rooms/${encodeURIComponent(roomId)}/join`, token, { name, ...(runtime ? { runtime } : {}), ...(reach === undefined ? {} : { reach }) });
|
|
142
|
+
const memberId = payload.membership?.member_id;
|
|
143
|
+
const memberToken = payload.member_token;
|
|
144
|
+
if (!payload.room?.id || !memberId || !memberToken || !Array.isArray(payload.history?.items)) {
|
|
145
|
+
throw invalidServerResponse();
|
|
146
|
+
}
|
|
147
|
+
const credential = {
|
|
148
|
+
schema_version: 1,
|
|
149
|
+
base_url: baseUrl,
|
|
150
|
+
room_id: payload.room.id,
|
|
151
|
+
member_id: memberId,
|
|
152
|
+
name,
|
|
153
|
+
member_token: memberToken,
|
|
154
|
+
joined_at: dependencies.now().toISOString(),
|
|
155
|
+
};
|
|
156
|
+
await writeRoomCredential(paths, credential);
|
|
157
|
+
const state = {
|
|
158
|
+
schema_version: 1,
|
|
159
|
+
base_url: baseUrl,
|
|
160
|
+
room_id: payload.room.id,
|
|
161
|
+
member_id: memberId,
|
|
162
|
+
last_sequence: highestSequence(payload.history.items, 0),
|
|
163
|
+
};
|
|
164
|
+
await writeProjectRoomState(dependencies.cwd, state);
|
|
165
|
+
// Everything the Agent should report, and nothing it should not: the tokens
|
|
166
|
+
// stay in the credential file.
|
|
167
|
+
return {
|
|
168
|
+
room: payload.room,
|
|
169
|
+
member_id: memberId,
|
|
170
|
+
principal_id: payload.membership.principal_id ?? null,
|
|
171
|
+
as: "anonymous",
|
|
172
|
+
name,
|
|
173
|
+
last_sequence: state.last_sequence,
|
|
174
|
+
history: payload.history,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Every seat credential this machine holds, one per (Room, member). */
|
|
178
|
+
async function heldSeats(paths) {
|
|
179
|
+
let roomDirs;
|
|
180
|
+
try {
|
|
181
|
+
roomDirs = await readdir(paths.roomsDir);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error.code === "ENOENT")
|
|
185
|
+
return [];
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
const seats = [];
|
|
189
|
+
for (const roomId of roomDirs) {
|
|
190
|
+
if (!ROOM_ID_PATTERN.test(roomId))
|
|
191
|
+
continue;
|
|
192
|
+
let files;
|
|
193
|
+
try {
|
|
194
|
+
files = await readdir(joinPath(paths.roomsDir, roomId));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
for (const file of files) {
|
|
200
|
+
const memberId = file.replace(/\.json$/, "");
|
|
201
|
+
if (!/^(?:i|mem)_[A-Za-z0-9]+$/.test(memberId))
|
|
202
|
+
continue;
|
|
203
|
+
const credential = await readRoomCredential(paths, roomId, memberId).catch(() => null);
|
|
204
|
+
if (credential)
|
|
205
|
+
seats.push(credential);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return seats;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Enter a Room by id as a seat this machine already holds: the one named by
|
|
212
|
+
* --as, or the only one there is. The server's join by Room id is idempotent
|
|
213
|
+
* for a member, so a seat that was added (decision 2026-09-06 reach, §3)
|
|
214
|
+
* lands in its new Room's directory with the same token.
|
|
215
|
+
*/
|
|
216
|
+
async function enterAsSeat(roomId, memberId, dependencies) {
|
|
217
|
+
const paths = getStoragePaths(dependencies.env);
|
|
218
|
+
const seats = await heldSeats(paths);
|
|
219
|
+
const byMember = new Map();
|
|
220
|
+
for (const seat of seats)
|
|
221
|
+
if (!byMember.has(seat.member_id))
|
|
222
|
+
byMember.set(seat.member_id, seat);
|
|
223
|
+
let seat;
|
|
224
|
+
if (memberId !== undefined) {
|
|
225
|
+
seat = byMember.get(memberId);
|
|
226
|
+
if (!seat)
|
|
227
|
+
throw localError("seat_not_found", `No seat ${memberId} is stored on this machine.`);
|
|
228
|
+
}
|
|
229
|
+
else if (byMember.size === 1) {
|
|
230
|
+
seat = [...byMember.values()][0];
|
|
231
|
+
}
|
|
232
|
+
else if (byMember.size === 0) {
|
|
233
|
+
throw localError("invite_token_required", "No seat is stored on this machine; join with an invite first.");
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
throw localError("seat_selection_required", `This machine holds ${byMember.size} seats; say which with --as <member_id>: ${[...byMember.keys()].join(", ")}`);
|
|
237
|
+
}
|
|
238
|
+
const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL ?? seat.base_url);
|
|
239
|
+
if (baseUrl !== seat.base_url) {
|
|
240
|
+
throw localError("credential_origin_mismatch", "The stored seat belongs to a different SharedNet origin.");
|
|
241
|
+
}
|
|
242
|
+
const client = new ApiClient(baseUrl, dependencies.fetch);
|
|
243
|
+
const payload = await client.request("POST", `/rooms/${encodeURIComponent(roomId)}/join`, seat.member_token, undefined, { "idempotency-key": randomUUID() });
|
|
244
|
+
if (!payload.room?.id || !payload.membership?.member_id)
|
|
245
|
+
throw invalidServerResponse();
|
|
246
|
+
const history = await client.request("GET", `/rooms/${encodeURIComponent(roomId)}/messages?after=0&limit=100`, seat.member_token);
|
|
247
|
+
if (!Array.isArray(history?.items))
|
|
248
|
+
throw invalidServerResponse();
|
|
249
|
+
await writeRoomCredential(paths, {
|
|
250
|
+
...seat,
|
|
251
|
+
room_id: payload.room.id,
|
|
252
|
+
member_id: payload.membership.member_id,
|
|
253
|
+
joined_at: dependencies.now().toISOString(),
|
|
254
|
+
});
|
|
255
|
+
const state = {
|
|
256
|
+
schema_version: 1,
|
|
257
|
+
base_url: baseUrl,
|
|
258
|
+
room_id: payload.room.id,
|
|
259
|
+
member_id: payload.membership.member_id,
|
|
260
|
+
last_sequence: highestSequence(history.items, 0),
|
|
261
|
+
};
|
|
262
|
+
await writeProjectRoomState(dependencies.cwd, state);
|
|
263
|
+
return {
|
|
264
|
+
room: payload.room,
|
|
265
|
+
member_id: payload.membership.member_id,
|
|
266
|
+
as: "seat",
|
|
267
|
+
name: seat.name,
|
|
268
|
+
admitted_by: payload.membership.admitted_by ?? null,
|
|
269
|
+
last_sequence: state.last_sequence,
|
|
270
|
+
history,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
async function joinAsAccount(roomId, invite, name, baseUrl, paths, client, dependencies, reach) {
|
|
274
|
+
// This session is registered as an Instance of the account first; a
|
|
275
|
+
// detected driver session reuses its Instance, an undetected one gets a
|
|
276
|
+
// fresh Instance, since a join is not the place to refuse.
|
|
277
|
+
const { session } = await registerInstance(dependencies.env, dependencies.fetch, paths, baseUrl, {
|
|
278
|
+
forceNew: false,
|
|
279
|
+
freshWhenUndetected: true,
|
|
280
|
+
...(reach === undefined ? {} : { reach }),
|
|
281
|
+
});
|
|
282
|
+
const payload = await client.request("POST", `/rooms/${encodeURIComponent(roomId)}/join`, session.instance_token, { invite }, { "idempotency-key": randomUUID() });
|
|
283
|
+
if (!payload.room?.id || !payload.membership?.member_id)
|
|
284
|
+
throw invalidServerResponse();
|
|
285
|
+
const history = await client.request("GET", `/rooms/${encodeURIComponent(roomId)}/messages?after=0&limit=100`, session.instance_token);
|
|
286
|
+
if (!Array.isArray(history?.items))
|
|
287
|
+
throw invalidServerResponse();
|
|
288
|
+
// The seat file mirrors the session so say/wait need no second lookup; the
|
|
289
|
+
// session file stays the source of a fresh token when the lease is renewed.
|
|
290
|
+
await writeRoomCredential(paths, {
|
|
291
|
+
schema_version: 1,
|
|
292
|
+
base_url: baseUrl,
|
|
293
|
+
room_id: payload.room.id,
|
|
294
|
+
member_id: session.instance_id,
|
|
295
|
+
name,
|
|
296
|
+
member_token: session.instance_token,
|
|
297
|
+
joined_at: dependencies.now().toISOString(),
|
|
298
|
+
});
|
|
299
|
+
const state = {
|
|
300
|
+
schema_version: 1,
|
|
301
|
+
base_url: baseUrl,
|
|
302
|
+
room_id: payload.room.id,
|
|
303
|
+
member_id: session.instance_id,
|
|
304
|
+
last_sequence: highestSequence(history.items, 0),
|
|
305
|
+
};
|
|
306
|
+
await writeProjectRoomState(dependencies.cwd, state);
|
|
307
|
+
return {
|
|
308
|
+
room: payload.room,
|
|
309
|
+
member_id: session.instance_id,
|
|
310
|
+
principal_id: session.principal_id,
|
|
311
|
+
as: "account",
|
|
312
|
+
name,
|
|
313
|
+
last_sequence: state.last_sequence,
|
|
314
|
+
history,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async function currentSeat(dependencies) {
|
|
318
|
+
const state = await readProjectRoomState(dependencies.cwd);
|
|
319
|
+
if (!state) {
|
|
320
|
+
throw localError("not_in_a_room", "This directory is not in a Room. Run: sharednet join <invite>");
|
|
321
|
+
}
|
|
322
|
+
const paths = getStoragePaths(dependencies.env);
|
|
323
|
+
const credential = await readRoomCredential(paths, state.room_id, state.member_id);
|
|
324
|
+
if (!credential) {
|
|
325
|
+
throw localError("room_credential_missing", "The member token for this Room is not on this machine. Join again with a new invite.");
|
|
326
|
+
}
|
|
327
|
+
if (credential.base_url !== state.base_url) {
|
|
328
|
+
throw localError("credential_origin_mismatch", "The stored Room credential belongs to a different SharedNet origin.");
|
|
329
|
+
}
|
|
330
|
+
const client = new ApiClient(credential.base_url, dependencies.fetch);
|
|
331
|
+
// A seat that is one of the account's Instances has a session file too, and
|
|
332
|
+
// that is where a lease gets renewed; use its token so the seat outlives the
|
|
333
|
+
// 24-hour lease the seat file alone would not.
|
|
334
|
+
const session = state.member_id.startsWith("i_")
|
|
335
|
+
? await readSessionById(paths, state.member_id).catch(() => null)
|
|
336
|
+
: null;
|
|
337
|
+
if (session && session.base_url === state.base_url) {
|
|
338
|
+
const fresh = await refreshIfNeeded(client, paths, session, dependencies.now());
|
|
339
|
+
return { client, state, credential: { ...credential, member_token: fresh.instance_token } };
|
|
340
|
+
}
|
|
341
|
+
return { client, state, credential };
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Post one message to the Room. `--reply-to msg_…` threads it under an
|
|
345
|
+
* earlier message; the server checks that the message is in this Room.
|
|
346
|
+
*/
|
|
347
|
+
async function say(args, dependencies) {
|
|
348
|
+
const parsed = parseGuestArguments(args);
|
|
349
|
+
assertOnlyOptions(parsed, ["reply-to"]);
|
|
350
|
+
if (parsed.positionals.length !== 1 || !parsed.positionals[0].trim()) {
|
|
351
|
+
throw localError("invalid_arguments", 'Usage: sharednet say "<message>" [--reply-to <msg_id>]');
|
|
352
|
+
}
|
|
353
|
+
const replyTo = stringOption(parsed, "reply-to");
|
|
354
|
+
if (replyTo !== undefined && !/^msg_[A-Za-z0-9]{10}$/.test(replyTo)) {
|
|
355
|
+
throw localError("invalid_reply_to", "--reply-to must be a message id such as msg_AbCdEfGhIj.");
|
|
356
|
+
}
|
|
357
|
+
const { client, state, credential } = await currentSeat(dependencies);
|
|
358
|
+
return client.request("POST", `/rooms/${encodeURIComponent(state.room_id)}/messages`, credential.member_token, { content: parsed.positionals[0], ...(replyTo === undefined ? {} : { reply_to_message_id: replyTo }) }, { "idempotency-key": randomUUID() });
|
|
359
|
+
}
|
|
360
|
+
/** One long-poll from the cursor; the server answers within `timeout` seconds. */
|
|
361
|
+
async function waitPage(client, roomId, token, after, timeout) {
|
|
362
|
+
const query = new URLSearchParams({ after: String(after), timeout: String(timeout) });
|
|
363
|
+
const page = await client.request("GET", `/rooms/${encodeURIComponent(roomId)}/wait?${query.toString()}`, token);
|
|
364
|
+
if (!Array.isArray(page?.items))
|
|
365
|
+
throw invalidServerResponse();
|
|
366
|
+
return page;
|
|
367
|
+
}
|
|
368
|
+
function parseCount(value, option) {
|
|
369
|
+
if (value === undefined)
|
|
370
|
+
return null;
|
|
371
|
+
if (!/^[1-9]\d*$/.test(value)) {
|
|
372
|
+
throw localError("invalid_count", `${option} must be a whole number of at least 1.`);
|
|
373
|
+
}
|
|
374
|
+
return Number(value);
|
|
375
|
+
}
|
|
376
|
+
/** "30s", "10m", "1h", or plain seconds, as milliseconds. */
|
|
377
|
+
function parseDuration(value, option) {
|
|
378
|
+
const match = value === undefined ? null : /^(\d+)(s|m|h)?$/.exec(value);
|
|
379
|
+
if (!match || Number(match[1]) < 1) {
|
|
380
|
+
throw localError("invalid_duration", `${option} takes a duration such as 30s, 10m, or 1h.`);
|
|
381
|
+
}
|
|
382
|
+
const unit = match[2] === "h" ? 3_600_000 : match[2] === "m" ? 60_000 : 1000;
|
|
383
|
+
return Number(match[1]) * unit;
|
|
384
|
+
}
|
|
385
|
+
/** `--on message | every 10m | count 5 | idle 30s`; the parameter may be its own argument. */
|
|
386
|
+
function parseTrigger(parsed) {
|
|
387
|
+
const raw = stringOption(parsed, "on");
|
|
388
|
+
if (!raw)
|
|
389
|
+
throw localError("invalid_arguments", "Usage: sharednet watch --on <trigger> --run '<command>'");
|
|
390
|
+
const [kind, inline] = raw.trim().split(/\s+/, 2);
|
|
391
|
+
const parameter = inline ?? parsed.positionals.shift();
|
|
392
|
+
if (kind === "message") {
|
|
393
|
+
if (parameter !== undefined)
|
|
394
|
+
throw localError("invalid_trigger", "--on message takes no parameter.");
|
|
395
|
+
return { kind: "message" };
|
|
396
|
+
}
|
|
397
|
+
if (kind === "every")
|
|
398
|
+
return { kind: "every", ms: parseDuration(parameter, "--on every") };
|
|
399
|
+
if (kind === "idle")
|
|
400
|
+
return { kind: "idle", ms: parseDuration(parameter, "--on idle") };
|
|
401
|
+
if (kind === "count") {
|
|
402
|
+
const count = parseCount(parameter, "--on count");
|
|
403
|
+
if (count === null)
|
|
404
|
+
throw localError("invalid_trigger", "--on count takes a number of messages.");
|
|
405
|
+
return { kind: "count", count };
|
|
406
|
+
}
|
|
407
|
+
throw localError("invalid_trigger", "--on must be message, every <duration>, count <n>, or idle <duration>.");
|
|
408
|
+
}
|
|
409
|
+
function defaultExec(command, input, env) {
|
|
410
|
+
const shell = process.platform === "win32" ? ["cmd", "/c", command] : ["sh", "-c", command];
|
|
411
|
+
return new Promise((resolve) => {
|
|
412
|
+
let stdout = "";
|
|
413
|
+
let stderr = "";
|
|
414
|
+
try {
|
|
415
|
+
const child = spawn(shell[0], shell.slice(1), {
|
|
416
|
+
env: { ...process.env, ...env },
|
|
417
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
418
|
+
});
|
|
419
|
+
child.stdout.on("data", (chunk) => (stdout += chunk.toString()));
|
|
420
|
+
child.stderr.on("data", (chunk) => (stderr += chunk.toString()));
|
|
421
|
+
child.once("error", (error) => resolve({ exitCode: 127, stdout, stderr: stderr + String(error) }));
|
|
422
|
+
child.once("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr }));
|
|
423
|
+
child.stdin.end(input);
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
resolve({ exitCode: 127, stdout, stderr: String(error) });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Sit in the Room and wake a command: on every message, every so often, once
|
|
432
|
+
* N messages have piled up, or once the Room has gone quiet for a while. The
|
|
433
|
+
* batch goes to the command's stdin as JSON; with --reply, what it prints is
|
|
434
|
+
* said back into the Room. The seat's own messages never wake it, which is
|
|
435
|
+
* what keeps a replying watcher from talking to itself. The cursor moves only
|
|
436
|
+
* when a batch has been handed over, so a watcher that dies mid-way replays.
|
|
437
|
+
*/
|
|
438
|
+
async function watch(args, dependencies) {
|
|
439
|
+
const parsed = parseGuestArguments(args);
|
|
440
|
+
assertOnlyOptions(parsed, ["on", "run", "reply", "max-runs"]);
|
|
441
|
+
const trigger = parseTrigger(parsed);
|
|
442
|
+
const command = stringOption(parsed, "run");
|
|
443
|
+
if (!command || parsed.positionals.length !== 0) {
|
|
444
|
+
throw localError("invalid_arguments", "Usage: sharednet watch --on <message | every 10m | count 5 | idle 30s> --run '<command>' [--reply] [--max-runs <n>]");
|
|
445
|
+
}
|
|
446
|
+
const reply = parsed.options.get("reply") === true;
|
|
447
|
+
const maxRuns = parseCount(stringOption(parsed, "max-runs"), "--max-runs");
|
|
448
|
+
const { client, state, credential } = await currentSeat(dependencies);
|
|
449
|
+
const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
450
|
+
const exec = dependencies.exec ?? defaultExec;
|
|
451
|
+
const log = dependencies.stderr ?? (() => undefined);
|
|
452
|
+
const triggerLabel = trigger.kind === "message"
|
|
453
|
+
? "message"
|
|
454
|
+
: trigger.kind === "count"
|
|
455
|
+
? `count ${trigger.count}`
|
|
456
|
+
: `${trigger.kind} ${trigger.ms / 1000}s`;
|
|
457
|
+
// Who "I" am, from the server: a seat file written before migration 0007
|
|
458
|
+
// still names a mem_ id, while senders are reported by Instance id now.
|
|
459
|
+
const me = await client
|
|
460
|
+
.request("GET", "/instances/current", credential.member_token)
|
|
461
|
+
.then((payload) => payload?.instance?.id ?? state.member_id)
|
|
462
|
+
.catch(() => state.member_id);
|
|
463
|
+
let cursor = state.last_sequence;
|
|
464
|
+
let batch = [];
|
|
465
|
+
let lastRunAt = dependencies.now().getTime();
|
|
466
|
+
let lastMessageAt = null;
|
|
467
|
+
const runs = [];
|
|
468
|
+
log(`watch: ${triggerLabel} in ${state.room_id} as ${me}, from sequence ${cursor}\n`);
|
|
469
|
+
for (;;) {
|
|
470
|
+
const now = dependencies.now().getTime();
|
|
471
|
+
let budgetMs = WAIT_MAX_SECONDS * 1000;
|
|
472
|
+
if (trigger.kind === "every")
|
|
473
|
+
budgetMs = trigger.ms - (now - lastRunAt);
|
|
474
|
+
if (trigger.kind === "idle" && lastMessageAt !== null)
|
|
475
|
+
budgetMs = trigger.ms - (now - lastMessageAt);
|
|
476
|
+
const timeout = Math.min(WAIT_MAX_SECONDS, Math.max(0, Math.ceil(budgetMs / 1000)));
|
|
477
|
+
const page = await waitPage(client, state.room_id, credential.member_token, cursor, timeout);
|
|
478
|
+
cursor = highestSequence(page.items, cursor);
|
|
479
|
+
const others = page.items.filter((item) => item.sender?.member_id !== me && item.sender?.member_id !== state.member_id);
|
|
480
|
+
if (others.length > 0) {
|
|
481
|
+
batch.push(...others);
|
|
482
|
+
lastMessageAt = dependencies.now().getTime();
|
|
483
|
+
}
|
|
484
|
+
const at = dependencies.now().getTime();
|
|
485
|
+
const fire = trigger.kind === "message"
|
|
486
|
+
? batch.length > 0
|
|
487
|
+
: trigger.kind === "count"
|
|
488
|
+
? batch.length >= trigger.count
|
|
489
|
+
: trigger.kind === "idle"
|
|
490
|
+
? batch.length > 0 && lastMessageAt !== null && at - lastMessageAt >= trigger.ms
|
|
491
|
+
: at - lastRunAt >= trigger.ms;
|
|
492
|
+
if (!fire) {
|
|
493
|
+
if (page.items.length === 0)
|
|
494
|
+
await sleep(0);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const input = `${JSON.stringify({ room_id: state.room_id, member_id: state.member_id, trigger: triggerLabel, messages: batch })}\n`;
|
|
498
|
+
const result = await exec(command, input, {
|
|
499
|
+
SHAREDNET_ROOM_ID: state.room_id,
|
|
500
|
+
SHAREDNET_MEMBER_ID: state.member_id,
|
|
501
|
+
SHAREDNET_MESSAGE_COUNT: String(batch.length),
|
|
502
|
+
SHAREDNET_LAST_SEQUENCE: String(cursor),
|
|
503
|
+
});
|
|
504
|
+
if (result.stderr)
|
|
505
|
+
log(result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`);
|
|
506
|
+
// Handed over: the batch counts as seen even if the command failed.
|
|
507
|
+
await writeProjectRoomState(dependencies.cwd, { ...state, last_sequence: cursor });
|
|
508
|
+
let replyMessageId = null;
|
|
509
|
+
const answer = result.stdout.trim();
|
|
510
|
+
if (reply && result.exitCode === 0 && answer.length > 0) {
|
|
511
|
+
const posted = await client.request("POST", `/rooms/${encodeURIComponent(state.room_id)}/messages`, credential.member_token, { content: answer }, { "idempotency-key": randomUUID() });
|
|
512
|
+
replyMessageId = posted?.message?.id ?? null;
|
|
513
|
+
}
|
|
514
|
+
const record = {
|
|
515
|
+
run: runs.length + 1,
|
|
516
|
+
trigger: triggerLabel,
|
|
517
|
+
messages: batch.length,
|
|
518
|
+
exit_code: result.exitCode,
|
|
519
|
+
reply_message_id: replyMessageId,
|
|
520
|
+
last_sequence: cursor,
|
|
521
|
+
};
|
|
522
|
+
runs.push(record);
|
|
523
|
+
log(`watch: run ${record.run}, ${record.messages} message(s), exit ${record.exit_code}` +
|
|
524
|
+
(replyMessageId ? `, replied ${replyMessageId}` : "") +
|
|
525
|
+
"\n");
|
|
526
|
+
batch = [];
|
|
527
|
+
lastRunAt = dependencies.now().getTime();
|
|
528
|
+
if (maxRuns !== null && runs.length >= maxRuns) {
|
|
529
|
+
return { room_id: state.room_id, trigger: triggerLabel, runs };
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function parseTimeout(value) {
|
|
534
|
+
if (value === undefined)
|
|
535
|
+
return null;
|
|
536
|
+
if (!/^\d+$/.test(value)) {
|
|
537
|
+
throw localError("invalid_timeout", "--timeout must be a whole number of seconds.");
|
|
538
|
+
}
|
|
539
|
+
return Number(value);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Sit in the Room until something new is said, then print it and advance the
|
|
543
|
+
* cursor. `--timeout N` bounds the sit in seconds (0 checks once and returns);
|
|
544
|
+
* `--hook` is the shape a Claude Code hook wants: one immediate check, plain
|
|
545
|
+
* lines, exit 0 whether or not anything arrived.
|
|
546
|
+
*/
|
|
547
|
+
async function wait(args, dependencies) {
|
|
548
|
+
const parsed = parseGuestArguments(args);
|
|
549
|
+
assertOnlyOptions(parsed, ["timeout", "hook", "min"]);
|
|
550
|
+
if (parsed.positionals.length !== 0) {
|
|
551
|
+
throw localError("invalid_arguments", "Usage: sharednet wait [--timeout <seconds>] [--min <count>] [--hook]");
|
|
552
|
+
}
|
|
553
|
+
const hook = parsed.options.get("hook") === true;
|
|
554
|
+
const totalSeconds = hook ? 0 : parseTimeout(stringOption(parsed, "timeout"));
|
|
555
|
+
const minimum = parseCount(stringOption(parsed, "min"), "--min") ?? 1;
|
|
556
|
+
const { client, state, credential } = await currentSeat(dependencies);
|
|
557
|
+
const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
558
|
+
const deadline = totalSeconds === null ? null : dependencies.now().getTime() + totalSeconds * 1000;
|
|
559
|
+
// --min N: keep sitting until N messages have arrived, or the deadline.
|
|
560
|
+
const items = [];
|
|
561
|
+
let cursor = state.last_sequence;
|
|
562
|
+
for (;;) {
|
|
563
|
+
const remaining = deadline === null
|
|
564
|
+
? WAIT_MAX_SECONDS
|
|
565
|
+
: Math.max(0, Math.ceil((deadline - dependencies.now().getTime()) / 1000));
|
|
566
|
+
const timeout = Math.min(WAIT_MAX_SECONDS, remaining);
|
|
567
|
+
const page = await waitPage(client, state.room_id, credential.member_token, cursor, timeout);
|
|
568
|
+
items.push(...page.items);
|
|
569
|
+
cursor = highestSequence(page.items, cursor);
|
|
570
|
+
if (items.length >= minimum)
|
|
571
|
+
break;
|
|
572
|
+
if (deadline !== null && dependencies.now().getTime() >= deadline)
|
|
573
|
+
break;
|
|
574
|
+
// The server answered at its cap; ask again from the cursor.
|
|
575
|
+
await sleep(0);
|
|
576
|
+
}
|
|
577
|
+
const page = { items, next_cursor: null, has_more: false };
|
|
578
|
+
if (page.items.length > 0) {
|
|
579
|
+
await writeProjectRoomState(dependencies.cwd, { ...state, last_sequence: cursor });
|
|
580
|
+
}
|
|
581
|
+
if (hook) {
|
|
582
|
+
return {
|
|
583
|
+
hook: true,
|
|
584
|
+
lines: page.items.map((item) => `#${item.sequence} ${item.sender?.name ?? item.sender?.member_id ?? "member"}: ${item.content}`),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
return page;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Seat more Instances in this directory's Room, by id: public ones at once,
|
|
591
|
+
* private ones by asking (decision 2026-09-06 reach, §3).
|
|
592
|
+
*/
|
|
593
|
+
async function add(args, dependencies) {
|
|
594
|
+
const parsed = parseGuestArguments(args);
|
|
595
|
+
assertOnlyOptions(parsed, []);
|
|
596
|
+
const ids = parsed.positionals;
|
|
597
|
+
if (ids.length === 0 || ids.some((id) => !/^i_[0-9A-Za-z]{10}$/.test(id))) {
|
|
598
|
+
throw localError("invalid_arguments", "Usage: sharednet add <i_…> [<i_…> …]");
|
|
599
|
+
}
|
|
600
|
+
const { client, state, credential } = await currentSeat(dependencies);
|
|
601
|
+
return client.request("POST", `/rooms/${encodeURIComponent(state.room_id)}/members`, credential.member_token, { with: ids });
|
|
602
|
+
}
|
|
603
|
+
/** `sharednet reach private|public`: whether strangers who know this seat's id must ask first. */
|
|
604
|
+
async function reach(args, dependencies) {
|
|
605
|
+
const parsed = parseGuestArguments(args);
|
|
606
|
+
assertOnlyOptions(parsed, []);
|
|
607
|
+
const value = parsed.positionals[0];
|
|
608
|
+
if (parsed.positionals.length !== 1 || (value !== "public" && value !== "private")) {
|
|
609
|
+
throw localError("invalid_arguments", "Usage: sharednet reach public|private");
|
|
610
|
+
}
|
|
611
|
+
const { client, credential } = await currentSeat(dependencies);
|
|
612
|
+
return client.request("PATCH", "/instances/current", credential.member_token, { reach: value });
|
|
613
|
+
}
|
|
614
|
+
/** The Rooms this seat sits in, newest first; where a seat that was added finds its new Room. */
|
|
615
|
+
async function rooms(args, dependencies) {
|
|
616
|
+
const parsed = parseGuestArguments(args);
|
|
617
|
+
assertOnlyOptions(parsed, []);
|
|
618
|
+
if (parsed.positionals.length !== 0)
|
|
619
|
+
throw localError("invalid_arguments", "Usage: sharednet rooms");
|
|
620
|
+
const { client, credential } = await currentSeat(dependencies);
|
|
621
|
+
return client.request("GET", "/rooms", credential.member_token);
|
|
622
|
+
}
|
|
623
|
+
/** Requests waiting on this seat: someone wants it in a Room while it is private. */
|
|
624
|
+
async function requests(args, dependencies) {
|
|
625
|
+
const parsed = parseGuestArguments(args);
|
|
626
|
+
assertOnlyOptions(parsed, []);
|
|
627
|
+
if (parsed.positionals.length !== 0)
|
|
628
|
+
throw localError("invalid_arguments", "Usage: sharednet requests");
|
|
629
|
+
const { client, credential } = await currentSeat(dependencies);
|
|
630
|
+
return client.request("GET", "/decisions?status=pending", credential.member_token);
|
|
631
|
+
}
|
|
632
|
+
/** The seat answers for itself: accept takes the seat, deny refuses it. */
|
|
633
|
+
async function answer(resolution, args, dependencies) {
|
|
634
|
+
const parsed = parseGuestArguments(args);
|
|
635
|
+
assertOnlyOptions(parsed, []);
|
|
636
|
+
const decisionId = parsed.positionals[0];
|
|
637
|
+
if (parsed.positionals.length !== 1 || !decisionId || !/^dec_[0-9A-Za-z]{10}$/.test(decisionId)) {
|
|
638
|
+
const verb = resolution === "approved" ? "accept" : "deny";
|
|
639
|
+
throw localError("invalid_arguments", `Usage: sharednet ${verb} <dec_…>`);
|
|
640
|
+
}
|
|
641
|
+
const { client, credential } = await currentSeat(dependencies);
|
|
642
|
+
return client.request("POST", `/decisions/${encodeURIComponent(decisionId)}/resolve`, credential.member_token, { resolution });
|
|
643
|
+
}
|
|
644
|
+
export function isGuestVerb(value) {
|
|
645
|
+
return (value === "join" ||
|
|
646
|
+
value === "say" ||
|
|
647
|
+
value === "wait" ||
|
|
648
|
+
value === "watch" ||
|
|
649
|
+
value === "add" ||
|
|
650
|
+
value === "rooms" ||
|
|
651
|
+
value === "requests" ||
|
|
652
|
+
value === "accept" ||
|
|
653
|
+
value === "deny" ||
|
|
654
|
+
value === "reach");
|
|
655
|
+
}
|
|
656
|
+
export async function runGuestVerb(verb, args, dependencies) {
|
|
657
|
+
if (verb === "join")
|
|
658
|
+
return join(args, dependencies);
|
|
659
|
+
if (verb === "say")
|
|
660
|
+
return say(args, dependencies);
|
|
661
|
+
if (verb === "watch")
|
|
662
|
+
return watch(args, dependencies);
|
|
663
|
+
if (verb === "add")
|
|
664
|
+
return add(args, dependencies);
|
|
665
|
+
if (verb === "rooms")
|
|
666
|
+
return rooms(args, dependencies);
|
|
667
|
+
if (verb === "requests")
|
|
668
|
+
return requests(args, dependencies);
|
|
669
|
+
if (verb === "accept")
|
|
670
|
+
return answer("approved", args, dependencies);
|
|
671
|
+
if (verb === "deny")
|
|
672
|
+
return answer("denied", args, dependencies);
|
|
673
|
+
if (verb === "reach")
|
|
674
|
+
return reach(args, dependencies);
|
|
675
|
+
return wait(args, dependencies);
|
|
676
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type RuntimeKind = string;
|
|
2
|
+
export interface RuntimeSession {
|
|
3
|
+
runtimeKind: RuntimeKind;
|
|
4
|
+
anchor: string;
|
|
5
|
+
}
|
|
6
|
+
type Environment = Record<string, string | undefined>;
|
|
7
|
+
/** The driver's session, when the driver exposes one. See runtime-detection.ts. */
|
|
8
|
+
export declare function detectRuntimeSession(env: Environment): RuntimeSession | null;
|
|
9
|
+
export declare function computeLocalInstanceKey(installationSecret: string, runtimeKind: RuntimeKind, providerSessionAnchor: string): string;
|
|
10
|
+
export {};
|