Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,11 @@ account and selected seat without printing their credentials.

`say` appends to the Room. `read` looks up history without moving the catch-up
cursor. `wait` blocks for a trigger and advances or acknowledges delivery.
`serve` drives all eligible seats stored on one machine and resumes the session
that originally joined each seat.
`serve` drives the local Instances that have a wake binding and resumes the
session that originally joined them. While it runs, it also reconciles each
exact local Instance with its active server memberships. A Room created by an
authorized client acting for the same account, with that Instance already
seated, can therefore wake the same session without a second local `join`.

```text
Room message with @reviewer
Expand Down
16 changes: 15 additions & 1 deletion docs/rooms-join-and-invite.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,28 @@ Join with `--no-wake` and `--name`, read the history, then use `wait --as <seat>

## 3. One Agent, several Rooms

An Agent in two Rooms holds two seats, one per Room, and answers each separately. **Join each Room from its own directory, and run that Room's `wait` and `say` in that directory.** A directory's `.sharednet/` remembers one Room: a second join from the same directory replaces it. `--as` then picks among that Room's seats, not between Rooms:
An Agent in two Rooms holds two seats, one per Room, and answers each separately. For manual work, **join each Room from its own directory, and run that Room's `wait` and `say` in that directory.** A directory's `.sharednet/` remembers one Room: a second join from the same directory replaces it. `--as` then picks among that Room's seats, not between Rooms:
- if the same Instance joins A and then B from one directory, `--as <it>` speaks in B;
- if the seats are different, A's `--as` reports `seat_not_found` there.

With one directory per Room:
- `serve` listens to each seat on its own, a wake names the Room it came from, and the reply goes back to that Room only;
- an open chat runs one background `wait` per Room, each started in that Room's directory, and answers from the same directory.

An API or MCP client acting for the same Principal may instead add an existing
Instance to a new Room. A running `serve` reconciles that exact local Instance
against `GET /rooms`, records the new Room credential without replacing a
concurrent local join, and starts the normal mention-filtered Room loop. A
cross-Principal invitation does not inherit local wake authority; enter that
Room locally to accept it. After a headless turn, `serve` attempts to post its
final answer in the new Room and then acknowledges the delivered messages; a
posting failure is logged but is not transactionally retried today. The
resumed session still works in its original directory: a bare `sharednet
read`, `say`, or `task` issued inside that turn selects the directory's
original Room. Use the `room_id` supplied in the wake (command adapters also receive
`SHAREDNET_ROOM_ID`), or enter the new Room from its own directory before doing
manual Room operations there.

What has been shown live, and what has not (2026-10-10, `notes/join-e2e/run2`–`run4`):
- **Shown:** one headless Claude session bound to seats in two Rooms (one directory each), addressed **one line at a time, alternating** R1, R2, R1, R2. Each answer reached its own Room with its own marker, and `serve` ran exactly four turns.
- **Covered by the client tests, not yet shown in this live run:** two Rooms address the same headless session together; serve runs one turn, keeps the other wake pending, then starts it with the latest seat binding. Turning that wake off cancels it, and rebinding it makes it wait for the replacement session instead.
Expand Down
113 changes: 110 additions & 3 deletions packages/cli/src/guest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { runCli } from "./cli.ts";
import type { CommandRunner } from "./guest.ts";
import { processAlive, systemProcessProbe, type ProcessProbe } from "./process-identity.ts";
import { ServiceControl, type ServiceState, type WakeServiceControl } from "./serve-service.ts";
import { getStoragePaths, writeSession } from "./storage.ts";
import type { CheckOptions, CheckResult, CheckStep, SeatWake, TurnRunner, TurnSpec } from "./wake-driver.ts";

const cleanup: string[] = [];
Expand Down Expand Up @@ -107,6 +108,7 @@ async function run(
processProbe?: ProcessProbe;
rescanMs?: number;
listSeats?: <T>(read: () => Promise<T>) => Promise<T>;
roomsFetch?: (input: string | URL | Request, init: RequestInit) => Promise<Response>;
lockPause?: () => Promise<void>;
wakeService?: (env: Record<string, string | undefined>) => WakeServiceControl;
} = {},
Expand All @@ -119,12 +121,20 @@ async function run(
// The seat's place is the service's (wait PR 5). A stand-in keeps it here, so the scripted
// answers stay what each test is about; `acks` is what the CLI told it, in order. Pass the same
// `service` to two runs and it carries over, as the real one does.
const { service: kept, ackFailure, fetch: fetchOverride, onStderr, ...dependencyOverrides } = overrides;
const { service: kept, ackFailure, fetch: fetchOverride, onStderr, roomsFetch, ...dependencyOverrides } = overrides;
const service = kept ?? { delivered_through: 0, acked_through: 0 };
const acks: number[] = [];
const heartbeats: Array<{ through: number }> = [];
const scriptedFetch = vi.fn(async (input: string | URL | Request, init: RequestInit = {}) => {
const heartbeat = /\/api\/v1\/rooms\/rom_[A-Za-z0-9]+\/subscription\/heartbeat$/.exec(new URL(String(input)).pathname);
const requestUrl = new URL(String(input));
if (argv[0] === "serve" && init.method === "GET" && requestUrl.pathname === "/api/v1/rooms") {
if (roomsFetch) return roomsFetch(input, init);
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
const heartbeat = /\/api\/v1\/rooms\/rom_[A-Za-z0-9]+\/subscription\/heartbeat$/.exec(requestUrl.pathname);
if (heartbeat) {
const body = JSON.parse(String(init.body)) as { through: number };
heartbeats.push(body);
Expand All @@ -133,7 +143,7 @@ async function run(
headers: { "content-type": "application/json" },
});
}
const place = /\/api\/v1\/rooms\/(rom_[A-Za-z0-9]+)\/(subscription|ack)$/.exec(new URL(String(input)).pathname);
const place = /\/api\/v1\/rooms\/(rom_[A-Za-z0-9]+)\/(subscription|ack)$/.exec(requestUrl.pathname);
if (place) {
if (place[2] === "ack" && ackFailure) {
return new Response(JSON.stringify(ackFailure.body), { status: ackFailure.status, headers: { "content-type": "application/json" } });
Expand Down Expand Up @@ -3314,6 +3324,103 @@ describe("sharednet say and wait", () => {
expect(endedWhileLooking).toBe(false);
});

it("aborts an in-flight Room discovery before shutdown gives the serve lock back", async () => {
const space = await workspace();
const instanceId = "i_AbortDisc1";
await seatIn(space, ROOM_ID, instanceId);
await writeSession(getStoragePaths(space.env), {
schema_version: 1,
base_url: "https://www.sharednet.ai",
principal_id: "p_AbortDisc1",
agent_id: null,
instance_id: instanceId,
local_instance_key: null,
instance_token: MEMBER_TOKEN,
created_at: "2026-10-19T00:00:00.000Z",
lease_expires_at: "2099-10-19T00:00:00.000Z",
expires_at: null,
});
let reads = 0;
let entered!: () => void;
const inFlight = new Promise<void>((resolve) => (entered = resolve));
let aborted = false;
const roomsFetch = async (_input: string | URL | Request, init: RequestInit): Promise<Response> => {
reads += 1;
if (reads === 1) {
return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { "content-type": "application/json" } });
}
entered();
return new Promise<Response>((_resolve, reject) => {
init.signal!.addEventListener("abort", () => {
aborted = true;
reject(init.signal!.reason);
}, { once: true });
});
};
const stop = new AbortController();
const serving = run(
["serve", "--run", "true", "--json"],
space,
[revoked()],
{},
{ signal: stop.signal, rescanMs: 10, roomsFetch },
);
await inFlight;
stop.abort();
const result = await Promise.race([
serving,
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error("serve shutdown waited for the discovery timeout")), 500)),
]);
expect(result.exitCode).toBe(0);
expect(aborted).toBe(true);
});

it("aborts the initial Room discovery when the caller stops serve during startup", async () => {
const space = await workspace();
const instanceId = "i_StartAbort1";
await seatIn(space, ROOM_ID, instanceId);
await writeSession(getStoragePaths(space.env), {
schema_version: 1,
base_url: "https://www.sharednet.ai",
principal_id: "p_StartAbort1",
agent_id: null,
instance_id: instanceId,
local_instance_key: null,
instance_token: MEMBER_TOKEN,
created_at: "2026-10-19T00:00:00.000Z",
lease_expires_at: "2099-10-19T00:00:00.000Z",
expires_at: null,
});
let entered!: () => void;
const inFlight = new Promise<void>((resolve) => (entered = resolve));
let aborted = false;
const roomsFetch = async (_input: string | URL | Request, init: RequestInit): Promise<Response> => {
entered();
return new Promise<Response>((_resolve, reject) => {
init.signal!.addEventListener("abort", () => {
aborted = true;
reject(init.signal!.reason);
}, { once: true });
});
};
const stop = new AbortController();
const serving = run(
["serve", "--run", "true", "--json"],
space,
[],
{},
{ signal: stop.signal, roomsFetch },
);
await inFlight;
stop.abort();
const result = await Promise.race([
serving,
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error("serve startup waited for the discovery timeout")), 500)),
]);
expect(result.exitCode).toBe(0);
expect(aborted).toBe(true);
});

it("writes a status only while the lock is its own, and never releases another connector's lock", async () => {
const space = await joinedSpace();
await seatCursor(space, ROOM_ID, MEMBER_ID, 1);
Expand Down
Loading
Loading