diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..f62e9ac --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,8 @@ +# Exact historical SHA-256 manifest entries for api-client.js/.d.ts, not credentials. +# Commit/path/rule/line fingerprints avoid suppressing other values or files. +f0c5f4a6163ef47c8da443f4e566ae8ac27c426b:experiments/planner-quorum-client/baseline-provenance.json:generic-api-key:4 +f0c5f4a6163ef47c8da443f4e566ae8ac27c426b:experiments/planner-quorum-client/baseline-provenance.json:generic-api-key:5 +9f5c8b68287da92bb34294dacd3215b520842b1b:experiments/planner-quorum-client/runtime-sha256.json:generic-api-key:2 +9f5c8b68287da92bb34294dacd3215b520842b1b:experiments/planner-quorum-client/runtime-sha256.json:generic-api-key:3 +f0c5f4a6163ef47c8da443f4e566ae8ac27c426b:experiments/planner-quorum-client/runtime-sha256.json:generic-api-key:2 +f0c5f4a6163ef47c8da443f4e566ae8ac27c426b:experiments/planner-quorum-client/runtime-sha256.json:generic-api-key:3 diff --git a/experiments/planner-quorum-client/MIGRATION.md b/experiments/planner-quorum-client/MIGRATION.md new file mode 100644 index 0000000..e9c6dd8 --- /dev/null +++ b/experiments/planner-quorum-client/MIGRATION.md @@ -0,0 +1,11 @@ +# Experimental client deployment + +Version: 0.1.12-planner-quorum.3, based on the independently deployed 0.1.12 JavaScript client. + +Keep the default CLI unchanged. Install this client in a separate directory and invoke its bin/sharednet.js explicitly with Node >=22.18. Create fresh run IDs when changing versions. Update queue entry/package paths, exact-version guard and the affected frozen-file hashes together. Preserve existing run provenance; do not resume a partial attempt under a different version. + +Run `npm run check` and `npm test` inside this directory. The 40 offline tests cover runner integration with simulated Docker/room APIs, REOPEN ordering and deduplication, final-check races, member exit and bounded recovery cancellation. These are not live service contract tests. + +The deployed runtime passed the same 40 tests on Linux and in a network-disabled experiment container. Real Earth_001 runs exercised explicit reopen and recovery cancellation, including degraded completion following provider 429 failures. Those observations do not establish a scoring benefit or compatibility with every service version. + +Forced cancellation stops and restarts only the affected seat container; it can interrupt writes, so inspect and revalidate artifacts. Docker stop/inspection/start failures prevent further scheduling. Run time and token bounds are not reset. A client-side post-check room drain is not a server-side atomic completion transaction. diff --git a/experiments/planner-quorum-client/README.md b/experiments/planner-quorum-client/README.md new file mode 100644 index 0000000..267e131 --- /dev/null +++ b/experiments/planner-quorum-client/README.md @@ -0,0 +1,226 @@ +# SharedNet planner/quorum experiment client + +Local, independent runtime fork of the **deployed SharedNet 0.1.12 exact-DONE client**. +The older local TypeScript checkout is 0.1.10 and was not used as the runtime base. +Original file hashes and source location are in `baseline-provenance.json`; Git's +initial commit preserves the deployed files. This package edits the published +JavaScript directly; it is not a build of a corresponding upstream TypeScript tree. +Do not run an upstream build over these files. + +No flags means the original simultaneous startup and any-agent/check completion +path. The original local checkout and the installed server client are unchanged. +This package is private and has no Git remote. + +## Enable the experiment + +Use this package's absolute entry point, not the globally installed `sharednet`: + +```bash +node /path/to/sharednet-planner-experiment/bin/sharednet.js goal run goal.md \ + --agent codex:gpt-6-astra \ + --agent codex:gpt-6-luna \ + --agent codex:gpt-6-luna \ + --agent codex:gpt-6-luna \ + --startup-mode planner-first \ + --completion-policy all-agents-done \ + --until 'said /^DONE$/' \ + --until 'check bash check.sh' \ + --until 'after 45m' \ + --until 'budget 5M tokens' \ + --workspace /path/to/prepared-workspace \ + --out /path/to/fresh-run-record \ + --image YOUR_EXISTING_EXPERIMENT_IMAGE +``` + +The example does not select a reasoning effort or change the existing image, +task pack, scoring configuration, CPU allocation, or model credentials. Retain +the experiment harness's existing settings for those. No experiment was started +as part of this local change. Before server use, the queue must explicitly point +at this client and preserve the new status/coordination fields in its manifest. + +| Option | Default | Effect | +| --- | --- | --- | +| `--startup-mode` | `simultaneous` | `planner-first` gates initial model calls behind a planning turn. | +| `--completion-policy` | `any-agent` | `all-agents-done` requires the surviving quorum and final checks. | +| `--planner-seed` | Fresh cryptographic random seed per run | Optional reproducibility seed; recorded with every selection. | + +The switches are independent. For the requested experimental condition enable +both. `planner-first` alone retains the original completion triggers. Reusing a +seed with an identical ordered roster repeats the selection; omit it for fresh +randomization. Selection is uniform over **agents**, not model families: a +1-Astra/3-Luna roster gives Astra a 1/4 chance of being planner. + +## Startup and completion protocol + +1. All containers join the room and obtain member IDs. Only the randomly selected + planner receives a model call. Its prompt contains the task and full roster, + including names and models, and requests explicit ownership, dependencies and + verification responsibilities for every active member. +2. The planner publishes a plan, then a separate exact `PLAN_READY` message, and + ends its turn. The runner requires a preceding nonempty planner message and a + successful planning turn before releasing the workers. Plan quality and actual + assignment coverage are model responsibilities, not semantically certified by + the runner. The planner also participates in subsequent task execution. +3. Each member publishes an exact `DONE` message, then ends its turn. It is + temporarily paused, and may be reopened using the explicit protocol below. + Substrings, outsiders' claims and planning-phase DONEs do not satisfy the quorum. +4. When all active members are DONE, every turn has exited, and room messages have + drained, the runner executes **all** configured final checks. Every check must + pass. Scheduled checks and single-member claims cannot finish this mode. +5. A failed final check clears all DONE votes and wakes every active member with + the check output, including when `--quiet-checks` suppresses room feedback. + +### Reopening temporarily completed members (version 0.1.12-planner-quorum.2) + +An active member can send a standalone Room post: + +```text +@codex-2 REOPEN verify-001: Verify the revised figure and correct its caption. +``` + +Use the exact case-sensitive roster name (e.g. `codex-2` or `claude-code-1`). +`REOPEN` is uppercase. The request ID is 1–64 ASCII letters/digits/dots/underscores/ +hyphens, starts with a letter or digit, and is unique per sender for the entire +run. The `: ` separator and nonempty instruction are required. One post addresses +one recipient; use separate IDs/posts for multiple recipients. Reposting a sender's +ID, even with different text/recipient or after completion, is ignored. A different +sender may use the same ID independently. Receipt sequence replay is also ignored. + +Only this explicit syntax reopens DONE; natural-language requests, broadcasts, +casual/embedded mentions, self-requests, outsiders, exited senders and exited +recipients do not. This interpretation is enabled only by `all-agents-done`. +The agent prompt documents the protocol on every turn. + +Accepted requests invalidate the recipient's DONE immediately and are queued. +The runner never overlaps turns for one member. Requests arriving during a turn +are delivered in the next turn, even if that member reads them manually earlier; +that old turn's DONE cannot satisfy queued work. Multiple requests are delivered +together. Pending instructions remain available after turn failure or another +ordinary turn without DONE. After a successful turn and a new valid DONE, the +delivered requests are recorded as completed. Exit recovery still uses the random +replacement planner; requests cannot resurrect an exited member. + +Final checks capture a coordination revision. Their results cannot close the room +until a subsequent Room poll drains messages posted during the checks. A new +accepted request invalidates these results, even if they passed; the requested +work must finish, DONE must be renewed, and fresh checks must run. Service outages +do not bypass the post-check drain; time/token bounds still take precedence. +This is a client-side barrier using the existing Room API, not a server-side +atomic compare-and-close operation: the completion decision is made at the last +successful post-check empty poll. No stronger service-level atomicity is claimed. + +`coordination.ndjson` includes `request_queued`, `done_revoked`, +`agent_reawakened`, `request_completed`, `request_duplicate`, `request_ignored`, +`done_deferred` and `final_check_invalidated`. Request events carry sender and +recipient names/member IDs, request ID and source message sequence. Summaries +include the protocol, accepted-request count, coordination revision and each +agent's outstanding requests. A reawaken event is dispatch evidence; the following +turn completion/failure supplies execution evidence. + +See `MIGRATION.md` for deployment and queue pinning. This fix does not change +Claude authentication or address `oauth_not_allowed_for_organization`. + +Quorum mode requires at least one `check` trigger. Only `after`, `at`, `budget`, +`check`, and optional `said /^DONE$/` triggers are allowed. Other message/count/ +idle triggers are rejected before room creation, because they could bypass the +completion barrier. The service still receives its usual check trigger when +closing the room; the detailed completion status is stored locally. + +## Member failure and degraded completion + +- A 429 inside a turn does not remove the member. The model CLI first uses its + normal retries; if the entire harness turn fails, the runner retries that + member (1-second and 2-second backoff before attempts 2 and 3). +- Three consecutive failed turns mark the member exited. The original configured + roster remains in the record. Exits carry a reason, last exit code, whether + rate-limiting was observed, and a relative trace reference. No replacement agent + is silently added. +- All old DONE votes are cleared. In-flight turns drain, then a new planner is + selected uniformly from survivors. It must publish a redistribution plan and + `PLAN_READY` before survivors execute the next round. This also covers loss of + the original planner. Other running agents are not killed to force replanning. +- Survivors must all finish again and pass the final checks. Such a run is + `completed_degraded`. If everyone exits, the run is `failed`. Time/budget + exhaustion is `timeout` / `budget_exhausted`, never degraded completion. +- Model CLI processes have the existing turn limit; experimental Claude turns + also use the Linux `timeout` wrapper, like Codex. A container/setup/join failure + before coordination initializes still aborts the entire startup. + +This is a client-side scheduler and prompt protocol, not an access-control +boundary against an agent deliberately launching its own background processes. +Token limits retain upstream accounting: measured at completed turns, so an +in-flight turn can overshoot. No new fine-grained token cancellation is claimed. + +## Evidence and status + +Existing `episode.json`, room/wake/check logs, per-agent turns, homes and workspace +snapshots remain. Experimental runs additionally write: + +- `coordination.ndjson`: timestamped selections, round changes, starts/exits, + plan release, DONE votes, check failures and terminal status. +- `coordination.json`: terminal summary, fixed configured roster, active survivors, + random seed and selection candidates, exits, counts and model compositions. +- `episode.json.status` and `.coordination`: the same completion provenance; + also returned in CLI JSON for downstream queues. + +`started_count` counts agents that attempted a model turn. `participating_count` +counts agents with at least one successful harness turn **or** nonzero reported +fresh tokens; it does not prove a useful contribution. `remaining_count` counts +survivors. `participating_models` and `remaining_models` distinguish their model +compositions. Per-agent original usage and trace records support deeper audits. +Defaults omit these additional episode fields. + +The runner is not crash-resumable. Use a fresh record directory and room for each +attempt. On a handled runner error it writes a stopped coordination summary and +removes its containers; abrupt process termination may leave a partial journal. + +## Local verification and packaging + +Node >=22.18 is required. Runtime and tests use only Node standard libraries. + +```bash +npm run check +npm test +npm pack --pack-destination artifacts +``` + +Tests cover the state machine, actual `runGoal`/`goalWatch` integration with fake +Docker and room APIs, early CLI validation, and default/exact-DONE regressions. +They do not certify a live Linux Docker deployment or real provider retries. +Before paid use, validate a controlled live room separately. Independent release +deployment and host/container offline validation are recorded in the release-root +`deployment.json`. Offline tests do not make paid model calls. + +Primary changes: `dist/coordination.js` (scheduler), `dist/goal-run.js` (runner +wiring), `dist/goal.js` (quorum/check barrier), and `dist/cli.js` (optional flags). + + +### Local recovery fix: 0.1.12-planner-quorum.3 + +When a member exits after three failed turns, the optional coordinator invalidates +old DONE states, selects a surviving recovery planner uniformly, and posts a +runner notice naming the exited member. Old turns have 30 seconds to save and +return. This window starts at the first exit and is not extended by later exits. +At expiry, only still-busy seats are cancelled. The runner stops the seat's +container (5-second Docker grace), verifies it is stopped, and restarts the same +container, retaining home/session files and the shared workspace. Any loopback +forwarder is restored. No new planning/work turn starts until cancellation +operations AND old turn promises have settled. A failed stop/confirmation/restart +fails closed as a runner error instead of allowing overlapping turns. + +Recovery cancellations are not model failures or successful contributions and do +not evict members. They are recorded in wakes.ndjson as cancelled_for_recovery, +and coordination.ndjson records recovery_started, recovery_turn_cancel_requested, +recovery_turn_cancel_confirmed, turn_cancelled_for_recovery and failure events. +Outstanding REOPEN requests survive; exited members remain exited. All survivors +must finish again after PLAN_READY. Successful recovery is completed_degraded; +original roster/model provenance and the original time/token bounds remain. +Forced cancellation can interrupt file writes, so recovery planning must inspect +existing artifacts and revalidate them; there is no rollback or snapshot restore. +The notice is advisory, not a guarantee an in-flight model reads it immediately. +Default legacy mode is unchanged. This version was deployed independently and exercised in Earth_001 experiments; it does not change the repository default CLI. + + +## Upstream review boundary + +This directory preserves the independently tested 0.1.12 JavaScript experimental client, version 0.1.12-planner-quorum.3. It is outside the pnpm workspace and is not an npm release. The maintained TypeScript CLI in packages/cli remains unchanged. Use `node experiments/planner-quorum-client/bin/sharednet.js` explicitly from the repository root. This snapshot is intended for mechanism review and experiment reproduction; integration into the current TypeScript CLI is separate work. diff --git a/experiments/planner-quorum-client/baseline-provenance.json b/experiments/planner-quorum-client/baseline-provenance.json new file mode 100644 index 0000000..6530e5d --- /dev/null +++ b/experiments/planner-quorum-client/baseline-provenance.json @@ -0,0 +1,35 @@ +{ + "source": "server experiment 0.1.12 exact-DONE client", + "files": { + "dist/api-client.d.ts": "cf406ac7fa6deb95c04afb42780f5bbf0030b30bd50b8e375562533494f570cd", + "dist/api-client.js": "a1914420687eff38ab218016a9da9da46cd3ccf5b09f49dd31c7f6a1b58b0704", + "dist/cli.d.ts": "fef67dd1d16a47b385a2b38c713b896e72cecf6f6dc5b3e5e8241bfac5ea0121", + "dist/cli.js": "837f4aae0b777f7f3de7620c417d2d96570003cab972085d7e0d15b1534bf181", + "dist/errors.d.ts": "3c19e6ec4324068b45d6920291cfb5c7345cdc8c00b1e09f9e011bb30ac80ee4", + "dist/errors.js": "1e300dd352b6755f27d9f66008337bbabc74a27b1535b6c45453ce92ebd03804", + "dist/goal-run.d.ts": "744d945f812070519b523c019c95b57e6cc35182e574fab78588305a73120cd7", + "dist/goal-run.js": "52a12de9965c57530fafcfde31a4451a314c57e7ade37248708771bbb2122c82", + "dist/goal.d.ts": "704bcfea9b3bcc77024b93afd7e41a316356201c1440f3ba4cdd776a9811aa25", + "dist/goal.js": "3173f98a5b667a8143ef6a29d720f41882b8bc148d9251d1638c11b9f60c3c0c", + "dist/guest.d.ts": "600d56c3369ddc671160de0ee4e2f7ef505efab4b6b22dd2707f340c7b1fbbbb", + "dist/guest.js": "3ea21ae8d98ec88253d5386ae6de959fad9afccfb299919142cc4d3d283481ff", + "dist/instance-computation.d.ts": "c34eba4a9ebd0a9cf9b741f2b486d5794c33a61f3e3e0877161b0d3e5f0e398a", + "dist/instance-computation.js": "769e5885b50b20f4878a865e26602e40e2c717163c512bd2a68c4e98a30b6950", + "dist/login.d.ts": "bd663c9adb7740aae747ddd4500495e428bce2b24627af22d68a01543f5f1e64", + "dist/login.js": "2c15a89005d7e5d6dc932f2d29e5ba3d67d10b1f62c5603a7455894c6b0bd951", + "dist/main.d.ts": "ebda792fb03852cb03a240732ed0474dc09d207661f82ce8bea6d40b7692f06b", + "dist/main.js": "079598017f31ab85cfccb48bb62681c388eaeba75d0f44e2597d115fbd5c59ab", + "dist/runtime-detection.d.ts": "4ee9e1cbcb2dbaf44ff429ca800be0d284c6a9b6fbb1fec405f00377ceba6956", + "dist/runtime-detection.js": "c5f6d8e54745d19902c32ff63279be7ae77da118eb67a79aa18aa2ecf628fdc3", + "dist/session.d.ts": "28fa9c78f71ef4976b8d7df85023352afb84e91f3c1e8f67fc6aa2bae68eea5b", + "dist/session.js": "58b5c8538c3bdbe04681070abfc0b3c86f49d099cfd8a631fd1e786b03481b1b", + "dist/storage.d.ts": "d49e11a00944e414bcd3b3a74cdbf6e7940b4650584d71069357208571a624e5", + "dist/storage.js": "53069a7a10f3fb306b58fc4880624e4e8d0f7c3a5c72fca3d33a55e0abbf304a", + "dist/triggers.d.ts": "db2a65195a14baff1e9373e23a96e28e3265b380a5990006836b9e5578cfc21b", + "dist/triggers.js": "6732c9a17b8f4aae93a51a852fd77dac92e677de4a8893efd59e03006209c356", + "dist/wake-driver.d.ts": "79e81b0a0ff3eb0a5381af66007d119c92f6bd66bed89335966dc7ee38d0af10", + "dist/wake-driver.js": "589b118d07ef2c34eaaee71c1daa82ebbf4632ce2dc960bae266fb9ede25645b", + "bin/sharednet.js": "022d65c9ac92157f2f20696cde6658b65b68eebc874d1fc98e08dc1dda1367a3", + "package.json": "783b475eb72e62df381076fb1787ce94ca5792c289c2b651e3069f13513c8d2f" + } +} diff --git a/experiments/planner-quorum-client/bin/sharednet.js b/experiments/planner-quorum-client/bin/sharednet.js new file mode 100644 index 0000000..e1a5612 --- /dev/null +++ b/experiments/planner-quorum-client/bin/sharednet.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { main } from "../dist/main.js"; + +process.exitCode = await main(); diff --git a/experiments/planner-quorum-client/dist/api-client.d.ts b/experiments/planner-quorum-client/dist/api-client.d.ts new file mode 100644 index 0000000..5de3065 --- /dev/null +++ b/experiments/planner-quorum-client/dist/api-client.d.ts @@ -0,0 +1,30 @@ +import { CliError } from "./errors.ts"; +type Fetch = typeof globalThis.fetch; +/** Where SharedNet lives. The apex domain only redirects here. */ +export declare const DEFAULT_BASE_URL = "https://www.sharednet.ai"; +/** + * One name per SharedNet. `sharednet.ai` and `www.sharednet.ai` are the same + * service (the apex answers every request with a redirect to www), and a + * credential written under one must match an invite that names the other, + * or a machine that logged in would still join as nobody. + */ +export declare function canonicalOrigin(origin: string): string; +export declare function sameOrigin(left: string, right: string): boolean; +export declare function resolveBaseUrl(value: string | undefined): string; +export declare class ApiClient { + readonly baseUrl: string; + readonly fetch: Fetch; + constructor(baseUrl: string, fetchImplementation?: Fetch); + /** + * Fetches bytes rather than JSON: an artifact's content. The digest the + * server states travels beside them so the caller can check what it wrote. + */ + requestBytes(path: string, credential: string): Promise<{ + bytes: Uint8Array; + filename: string | null; + sha256: string | null; + }>; + request(method: string, path: string, credential: string, body?: unknown, requestHeaders?: Record): Promise; +} +export declare function transportError(error: unknown): CliError; +export {}; diff --git a/experiments/planner-quorum-client/dist/api-client.js b/experiments/planner-quorum-client/dist/api-client.js new file mode 100644 index 0000000..0c36813 --- /dev/null +++ b/experiments/planner-quorum-client/dist/api-client.js @@ -0,0 +1,147 @@ +import { CliError, asCliError, localError } from "./errors.js"; +/** Where SharedNet lives. The apex domain only redirects here. */ +export const DEFAULT_BASE_URL = "https://www.sharednet.ai"; +/** + * One name per SharedNet. `sharednet.ai` and `www.sharednet.ai` are the same + * service (the apex answers every request with a redirect to www), and a + * credential written under one must match an invite that names the other, + * or a machine that logged in would still join as nobody. + */ +export function canonicalOrigin(origin) { + try { + const url = new URL(origin); + if (url.hostname === "sharednet.ai") + url.hostname = "www.sharednet.ai"; + return url.origin; + } + catch { + return origin.replace(/\/+$/, ""); + } +} +export function sameOrigin(left, right) { + return canonicalOrigin(left) === canonicalOrigin(right); +} +export function resolveBaseUrl(value) { + const candidate = value?.trim() || DEFAULT_BASE_URL; + let url; + try { + url = new URL(candidate); + } + catch { + throw localError("invalid_base_url", "SHAREDNET_BASE_URL is not a valid URL."); + } + const localHttp = url.protocol === "http:" && url.hostname === "127.0.0.1"; + if (url.protocol !== "https:" && !localHttp) { + throw localError("invalid_base_url", "SharedNet requires HTTPS except for 127.0.0.1 development servers."); + } + if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) { + throw localError("invalid_base_url", "SHAREDNET_BASE_URL must be an origin."); + } + return canonicalOrigin(url.origin); +} +function safeErrorCode(value) { + return typeof value === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(value) + ? value + : "api_error"; +} +export class ApiClient { + baseUrl; + fetch; + constructor(baseUrl, fetchImplementation = globalThis.fetch) { + this.baseUrl = resolveBaseUrl(baseUrl); + this.fetch = fetchImplementation; + } + /** + * Fetches bytes rather than JSON: an artifact's content. The digest the + * server states travels beside them so the caller can check what it wrote. + */ + async requestBytes(path, credential) { + let response; + try { + response = await this.fetch(`${this.baseUrl}/api/v1${path}`, { + method: "GET", + headers: credential ? { authorization: `Bearer ${credential}` } : {}, + }); + } + catch { + throw new CliError("service_unavailable", "The SharedNet service could not be reached.", 5); + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + let code = "request_failed"; + let message = "SharedNet refused the request."; + try { + const parsed = JSON.parse(text); + code = parsed.error?.code ?? code; + message = parsed.error?.message ?? message; + } + catch { + /* A body that is not our error envelope tells us nothing more. */ + } + throw new CliError(code, message, response.status >= 500 ? 5 : 4); + } + const disposition = response.headers.get("content-disposition") ?? ""; + const named = /filename\*=UTF-8''([^;]+)/.exec(disposition); + return { + bytes: new Uint8Array(await response.arrayBuffer()), + filename: named ? decodeURIComponent(named[1]) : null, + sha256: response.headers.get("x-sharednet-sha256"), + }; + } + async request(method, path, credential, body, requestHeaders = {}) { + let response; + // Bytes go up as they are; anything else is JSON. + const raw = body instanceof Uint8Array; + try { + response = await this.fetch(`${this.baseUrl}/api/v1${path}`, { + method, + headers: { + // A public route is called with an empty credential and no header. + ...(credential ? { authorization: `Bearer ${credential}` } : {}), + ...(body === undefined || raw ? {} : { "content-type": "application/json" }), + ...requestHeaders, + }, + ...(body === undefined ? {} : { body: raw ? body : JSON.stringify(body) }), + }); + } + catch { + throw new CliError("service_unavailable", "The SharedNet service could not be reached.", 5); + } + if (response.ok) { + if (response.status === 204) + return undefined; + try { + return (await response.json()); + } + catch { + throw new CliError("invalid_server_response", "The SharedNet service returned an invalid response.", 5); + } + } + let envelope = {}; + try { + envelope = (await response.json()); + } + catch { + // Status and a bounded local message are enough; never echo arbitrary bodies. + } + const code = safeErrorCode(envelope.error?.code); + const requestId = typeof envelope.error?.request_id === "string" && + /^req_[A-Za-z0-9_-]+$/.test(envelope.error.request_id) + ? envelope.error.request_id + : undefined; + if (response.status === 401) { + throw new CliError(code, "SharedNet authentication failed.", 3, requestId); + } + if (code === "cli_upgrade_required") { + const message = typeof envelope.error?.message === "string" && envelope.error.message.length < 400 ? envelope.error.message : "This SharedNet CLI is too old."; + throw new CliError(code, message, 4, requestId); + } + if (response.status >= 400 && response.status < 500) { + throw new CliError(code, "SharedNet rejected the request.", 4, requestId); + } + throw new CliError(code, "The SharedNet service is unavailable.", 5, requestId); + } +} +export function transportError(error) { + return asCliError(error); +} diff --git a/experiments/planner-quorum-client/dist/cli.d.ts b/experiments/planner-quorum-client/dist/cli.d.ts new file mode 100644 index 0000000..e7e96b8 --- /dev/null +++ b/experiments/planner-quorum-client/dist/cli.d.ts @@ -0,0 +1,34 @@ +import { type CommandRunner } from "./guest.ts"; +import { type DockerRunner } from "./goal-run.ts"; +import type { TurnRunner } from "./wake-driver.ts"; +type Environment = Record; +export interface CliDependencies { + env?: Environment; + fetch?: typeof globalThis.fetch; + stdout?: (value: string) => void; + stderr?: (value: string) => void; + now?: () => Date; + /** Where per-project Room state lives; defaults to the process working directory. */ + cwd?: string; + /** Pause between empty long-polls in `wait`; tests shorten it. */ + sleep?: (ms: number) => Promise; + /** Opens the approve page during `login`; tests capture the URL instead. */ + openBrowser?: (url: string) => Promise; + /** Runs the `watch --run` command; tests capture it instead of shelling out. */ + exec?: CommandRunner; + /** Runs `docker` for `goal run`; tests replace it. */ + docker?: DockerRunner; + /** The running CLI's entry file, mounted into `goal run`'s container; defaults to this process's. */ + cliEntry?: string; + /** Ends a resident command such as `serve --push`; a person ends it with Ctrl-C instead. */ + signal?: AbortSignal; + /** Runs one resumed turn of a seat's own session in `serve`; tests replace it. */ + runTurn?: TurnRunner; + /** Starts `serve` in the background after a join; only the real process (main.ts) supplies it. */ + startWakeService?: (input: { + env: Environment; + logFile: string; + }) => number | null; +} +export declare function runCli(argv: string[], supplied?: CliDependencies): Promise; +export {}; diff --git a/experiments/planner-quorum-client/dist/cli.js b/experiments/planner-quorum-client/dist/cli.js new file mode 100644 index 0000000..920b6b8 --- /dev/null +++ b/experiments/planner-quorum-client/dist/cli.js @@ -0,0 +1,541 @@ +import { coordinationOptions } from "./coordination.js"; +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, join, resolve as resolvePathFrom } from "node:path"; +import { ApiClient, resolveBaseUrl } from "./api-client.js"; +import { CliError, asCliError, localError } from "./errors.js"; +import { READ_OPTIONS, isGuestVerb, messageQueryFrom, runGuestVerb } from "./guest.js"; +import { login } from "./login.js"; +import { goalExport, goalWatch, parseUntilList, readGoalFile, startGoal, withRequestLimit } from "./goal.js"; +import { dockerRunner, parseAgents, prepareRun, runContainers, runGoal } from "./goal-run.js"; +import { parseDuration } from "./triggers.js"; +import { refreshIfNeeded, registerInstance, resolveApiKey, selectSession } from "./session.js"; +import { deleteSession, getStoragePaths } from "./storage.js"; +/** Options that may be given more than once: a goal has as many end triggers as it needs. */ +const repeatableOptionNames = new Set(["until", "agent"]); +const optionValueNames = new Set([ + "after", + "before", + "limit", + "order", + "last", + "from-instance", + "from-agent", + "grep", + "agent", + "runtime", + "name", + "description", + "content", + "reply-to", + "after", + "limit", + "with", + "status", + "goal", + "until", + "workspace", + "out", + "check-every", + "agent", + "image", + "web-search", + "turn-limit", + "startup-mode", + "completion-policy", + "planner-seed", +]); +const booleanOptionNames = new Set(["new", "private", "quiet-checks"]); +/** `--with i_a,i_b`: the Instances to seat, as the API takes them. */ +function instanceList(value) { + if (value === undefined) + return undefined; + const ids = value.split(",").map((id) => id.trim()).filter((id) => id.length > 0); + if (ids.length === 0 || ids.some((id) => !/^i_[0-9A-Za-z]{10}$/.test(id))) { + throw localError("invalid_option", "--with takes Instance ids such as i_AbCdEfGhIj, separated by commas."); + } + return ids; +} +function extractGlobals(argv) { + const args = []; + let json = false; + let sessionId; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--api-key" || argument.startsWith("--api-key=")) { + throw localError("credential_flag_forbidden", "API keys are accepted only from SHAREDNET_API_KEY or secure local credentials."); + } + if (argument === "--json") { + json = true; + continue; + } + if (argument === "--session" || argument.startsWith("--session=")) { + if (sessionId !== undefined) { + throw localError("duplicate_option", "The --session option may be supplied only once."); + } + const value = argument === "--session" ? argv[++index] : argument.slice("--session=".length); + if (!value || !/^i_[A-Za-z0-9_-]+$/.test(value)) { + throw localError("invalid_session_id", "The --session value must be an Instance ID."); + } + sessionId = value; + continue; + } + args.push(argument); + } + return { args, json, sessionId }; +} +function parseArguments(args) { + const options = new Map(); + const positionals = []; + const repeated = new Map(); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (!argument.startsWith("--")) { + positionals.push(argument); + continue; + } + const separator = argument.indexOf("="); + const name = argument.slice(2, separator === -1 ? undefined : separator); + if (repeatableOptionNames.has(name)) { + const value = separator === -1 ? args[++index] : argument.slice(separator + 1); + if (!value || value.startsWith("--")) { + throw localError("missing_option_value", `The --${name} option requires a value.`); + } + repeated.set(name, [...(repeated.get(name) ?? []), value]); + if (!options.has(name)) + options.set(name, value); + continue; + } + if (options.has(name)) { + throw localError("duplicate_option", `The --${name} option may be supplied only once.`); + } + if (booleanOptionNames.has(name)) { + if (separator !== -1) { + throw localError("invalid_option", `The --${name} option does not accept a value.`); + } + options.set(name, true); + continue; + } + if (!optionValueNames.has(name)) { + throw localError("unknown_option", "The command contains an unknown option."); + } + const value = separator === -1 ? args[++index] : argument.slice(separator + 1); + if (!value || value.startsWith("--")) { + throw localError("missing_option_value", `The --${name} option requires a value.`); + } + options.set(name, value); + } + return { options, positionals, repeated }; +} +function option(arguments_, name) { + const value = arguments_.options.get(name); + return typeof value === "string" ? value : undefined; +} +function requiredOption(arguments_, name) { + const value = option(arguments_, name); + if (!value) + throw localError("missing_required_option", `The --${name} option is required.`); + return value; +} +function assertOnlyOptions(arguments_, allowed) { + const allowedSet = new Set(allowed); + for (const name of arguments_.options.keys()) { + if (!allowedSet.has(name)) { + throw localError("unknown_option", `The --${name} option is not valid for this command.`); + } + } +} +function assertPositionals(arguments_, count) { + if (arguments_.positionals.length !== count) { + throw localError("invalid_arguments", "The command received the wrong number of arguments."); + } +} +async function withSelectedSession(globals, dependencies, callback) { + const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL); + const paths = getStoragePaths(dependencies.env); + const client = new ApiClient(baseUrl, dependencies.fetch); + let session = await selectSession(paths, baseUrl, globals.sessionId, dependencies.env); + session = await refreshIfNeeded(client, paths, session, dependencies.now()); + try { + return await callback(client, session, paths); + } + catch (error) { + if (error instanceof CliError && error.exitCode === 3) { + await deleteSession(paths, session.instance_id); + } + throw error; + } +} +async function startSession(commandArgs, dependencies) { + const parsed = parseArguments(commandArgs); + assertPositionals(parsed, 0); + assertOnlyOptions(parsed, ["agent", "runtime", "new", "private"]); + const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL); + const paths = getStoragePaths(dependencies.env); + const { payload } = await registerInstance(dependencies.env, dependencies.fetch, paths, baseUrl, { + runtimeOverride: option(parsed, "runtime"), + forceNew: parsed.options.get("new") === true, + agent: option(parsed, "agent"), + freshWhenUndetected: false, + // --private: strangers who know this Instance's id have to ask before + // seating it. Omitted, the Principal's default applies (public). + ...(parsed.options.get("private") === true ? { reach: "private" } : {}), + }); + return { + instance: payload.instance, + session_id: payload.instance.id, + heartbeat_after_seconds: payload.heartbeat_after_seconds, + }; +} +async function sessionStatus(commandArgs, globals, dependencies) { + const parsed = parseArguments(commandArgs); + assertPositionals(parsed, 0); + assertOnlyOptions(parsed, []); + return withSelectedSession(globals, dependencies, (client, session) => client.request("GET", "/instances/current", session.instance_token)); +} +async function roomCommand(action, commandArgs, globals, dependencies) { + const parsed = parseArguments(commandArgs); + if (action === "create") { + assertPositionals(parsed, 0); + assertOnlyOptions(parsed, ["name", "description", "with", "goal", "until"]); + const name = requiredOption(parsed, "name"); + const description = option(parsed, "description"); + const withIds = instanceList(option(parsed, "with")); + // A goal Room (decision 2026-10-05): everything about the goal is checked before the Room exists. + const goalPath = option(parsed, "goal"); + if (goalPath === undefined && parsed.repeated.has("until")) { + throw localError("invalid_arguments", "--until ends a goal; give the goal with --goal ."); + } + const goal = goalPath === undefined + ? null + : { + content: await readGoalFile(dependencies.cwd, goalPath), + until: parseUntilList(parsed.repeated.get("until") ?? [], dependencies.now().getTime()).wire, + }; + const apiKey = goal === null ? null : await resolveApiKey(dependencies.env, getStoragePaths(dependencies.env), resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL)); + return withSelectedSession(globals, dependencies, async (client, session) => { + const created = await client.request("POST", "/rooms", session.instance_token, { + name, + ...(description === undefined ? {} : { description }), + ...(withIds === undefined ? {} : { with: withIds }), + }, { "idempotency-key": randomUUID() }); + if (goal === null || apiKey === null) + return created; + const roomId = created.room?.id; + if (!roomId) + throw new CliError("invalid_server_response", "The Room was created but its id did not come back.", 5); + const started = await startGoal(client, apiKey, roomId, goal.content, goal.until).catch((error) => { + // The Room stands without its goal; say which one, so it can be closed or given the goal again. + const refused = asCliError(error); + throw new CliError(refused.code, `${roomId} was created, but its goal was refused: ${refused.message}`, refused.exitCode, refused.requestId); + }); + return { ...created, ...started }; + }); + } + if (action === "list") { + assertPositionals(parsed, 0); + assertOnlyOptions(parsed, []); + return withSelectedSession(globals, dependencies, (client, session) => client.request("GET", "/rooms", session.instance_token)); + } + if (action === "invite") { + assertPositionals(parsed, 1); + assertOnlyOptions(parsed, []); + const roomId = parsed.positionals[0]; + if (!/^rom_[0-9A-Za-z]{10}$/.test(roomId)) { + throw localError("invalid_arguments", "Usage: sharednet room invite "); + } + return withSelectedSession(globals, dependencies, async (client, session) => { + const minted = await client.request("POST", `/rooms/${encodeURIComponent(roomId)}/invites`, session.instance_token); + // What the human forwards: the link for people, and the same invite as + // one line for an Agent. The token is the invite; it opens this Room + // and nothing else, so it may be handed on. + const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL); + return { + room_id: roomId, + invite: minted.invite, + link: minted.link, + for_agents: `ROOM=${roomId} TOKEN=${minted.token} BASE=${baseUrl}`, + command: `npx -y sharednet@latest join 'ROOM=${roomId} TOKEN=${minted.token} BASE=${baseUrl}'`, + }; + }); + } + if (action === "add") { + assertPositionals(parsed, 1); + assertOnlyOptions(parsed, ["with"]); + const roomId = parsed.positionals[0]; + const withIds = instanceList(requiredOption(parsed, "with")); + return withSelectedSession(globals, dependencies, (client, session) => client.request("POST", `/rooms/${encodeURIComponent(roomId)}/members`, session.instance_token, { + with: withIds, + })); + } + if (action === "join") { + assertPositionals(parsed, 1); + assertOnlyOptions(parsed, []); + const roomId = parsed.positionals[0]; + return withSelectedSession(globals, dependencies, (client, session) => client.request("POST", `/rooms/${encodeURIComponent(roomId)}/join`, session.instance_token, undefined, { "idempotency-key": randomUUID() })); + } + if (action === "post") { + assertPositionals(parsed, 1); + assertOnlyOptions(parsed, ["content", "reply-to"]); + const roomId = parsed.positionals[0]; + const content = requiredOption(parsed, "content"); + const replyTo = option(parsed, "reply-to"); + return withSelectedSession(globals, dependencies, (client, session) => client.request("POST", `/rooms/${encodeURIComponent(roomId)}/messages`, session.instance_token, { + content, + ...(replyTo === undefined ? {} : { reply_to_message_id: replyTo }), + }, { "idempotency-key": randomUUID() })); + } + if (action === "messages") { + assertPositionals(parsed, 1); + assertOnlyOptions(parsed, [...READ_OPTIONS]); + const roomId = parsed.positionals[0]; + const parameters = messageQueryFrom(parsed.options); + const query = parameters.size ? `?${parameters.toString()}` : ""; + return withSelectedSession(globals, dependencies, (client, session) => client.request("GET", `/rooms/${encodeURIComponent(roomId)}/messages${query}`, session.instance_token)); + } + throw localError("unknown_command", "Unknown room command."); +} +/** + * Decisions addressed to the selected Instance: today, requests to seat it + * in a Room while it is private. The Instance answers for itself. + */ +async function decisionCommand(action, commandArgs, globals, dependencies) { + const parsed = parseArguments(commandArgs); + if (action === "list") { + assertPositionals(parsed, 0); + assertOnlyOptions(parsed, ["status"]); + const status = option(parsed, "status"); + const query = status === undefined ? "" : `?status=${encodeURIComponent(status)}`; + return withSelectedSession(globals, dependencies, (client, session) => client.request("GET", `/decisions${query}`, session.instance_token)); + } + if (action === "approve" || action === "deny") { + assertPositionals(parsed, 1); + assertOnlyOptions(parsed, []); + const decisionId = parsed.positionals[0]; + return withSelectedSession(globals, dependencies, (client, session) => client.request("POST", `/decisions/${encodeURIComponent(decisionId)}/resolve`, session.instance_token, { + resolution: action === "approve" ? "approved" : "denied", + })); + } + throw localError("unknown_command", "Unknown decision command. Use decision list, approve , or deny ."); +} +/** + * `goal watch ` sits in a goal Room until one of its end triggers + * fires, closes it with that trigger, and writes the record; `goal export + * ` writes the record of a Room without watching it. + */ +async function goalCommand(action, commandArgs, globals, dependencies) { + const parsed = parseArguments(commandArgs); + if (action === "run") + return goalRunCommand(parsed, globals, dependencies); + const usage = "Use goal run --agent codex[:model] --until [...], goal watch [--workspace ] [--out ] [--check-every 1m] [--quiet-checks], or goal export [--out ]."; + const roomId = parsed.positionals[0]; + if ((action !== "watch" && action !== "export") || parsed.positionals.length !== 1 || !/^rom_[0-9A-Za-z]{10}$/.test(roomId ?? "")) { + throw localError("unknown_command", usage); + } + const out = resolvePathFrom(dependencies.cwd, option(parsed, "out") ?? join("runs", roomId)); + // A runner sits for hours: no one request may hold it longer than the long-poll it is. + const limited = { ...dependencies, fetch: withRequestLimit(dependencies.fetch) }; + if (action === "export") { + assertOnlyOptions(parsed, ["out"]); + return withSelectedSession(globals, limited, (client, session) => goalExport(client, session.instance_token, roomId, out)); + } + assertOnlyOptions(parsed, ["workspace", "out", "check-every", "quiet-checks"]); + const workspace = resolvePathFrom(dependencies.cwd, option(parsed, "workspace") ?? "."); + const checkEveryMs = parsed.options.has("check-every") ? parseDuration(option(parsed, "check-every"), "--check-every") : 60_000; + const apiKey = await resolveApiKey(dependencies.env, getStoragePaths(dependencies.env), resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL)); + const exec = dependencies.exec; + return withSelectedSession(globals, limited, (client, session) => goalWatch(client, session.instance_token, apiKey, { roomId: roomId, workspace, out, checkEveryMs, quietChecks: parsed.options.get("quiet-checks") === true }, { + now: dependencies.now, + ...(dependencies.sleep ? { sleep: dependencies.sleep } : {}), + stderr: dependencies.stderr, + // Tests hand the command runner in; a real run uses the shell, in the workspace. + ...(exec + ? { + check: async (command) => { + const result = await exec(command, "", {}); + return { exitCode: result.exitCode, output: `${result.stdout}${result.stderr}` }; + }, + } + : {}), + })); +} +/** + * `goal run --agent … --until …`: the goal Room, the + * Agents that work on it in a container, and the runner that ends it, in one command. + * Everything that could fail half-way (the goal, the triggers, Docker, each driver's sign-in) + * is checked before the Room exists. + */ +async function goalRunCommand(parsed, globals, dependencies) { + assertOnlyOptions(parsed, ["agent", "until", "workspace", "out", "name", "image", "check-every", "quiet-checks", "web-search", "turn-limit", "startup-mode", "completion-policy", "planner-seed"]); + if (parsed.positionals.length !== 1) { + throw localError("invalid_arguments", "Usage: sharednet goal run --agent codex[:model] [--agent claude-code[:model]]... --until [--until ...] [--workspace ] [--out ] [--name ] [--image ] [--check-every 1m] [--quiet-checks] [--web-search live|off] [--turn-limit 20m] [--startup-mode simultaneous|planner-first] [--completion-policy any-agent|all-agents-done] [--planner-seed ]"); + } + const webSearch = option(parsed, "web-search") ?? "live"; + if (webSearch !== "live" && webSearch !== "off") { + throw localError("invalid_arguments", "--web-search is live or off."); + } + const turnLimitMs = parsed.options.has("turn-limit") ? parseDuration(option(parsed, "turn-limit"), "--turn-limit") : undefined; + if (turnLimitMs !== undefined && turnLimitMs < 60_000) { + throw localError("invalid_arguments", "--turn-limit is at least 1m."); + } + const content = await readGoalFile(dependencies.cwd, parsed.positionals[0]); + const until = parseUntilList(parsed.repeated.get("until") ?? [], dependencies.now().getTime(), { budget: true }); + const coordination = coordinationOptions({ startupMode: option(parsed, "startup-mode"), + completionPolicy: option(parsed, "completion-policy"), plannerSeed: option(parsed, "planner-seed") }, until.wire); + const agents = parseAgents(parsed.repeated.get("agent") ?? []); + const workspace = resolvePathFrom(dependencies.cwd, option(parsed, "workspace") ?? "."); + if (!(await stat(workspace).then((entry) => entry.isDirectory(), () => false))) { + throw localError("invalid_arguments", `--workspace ${workspace} is not a directory.`); + } + const checkEveryMs = parsed.options.has("check-every") ? parseDuration(option(parsed, "check-every"), "--check-every") : 60_000; + const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL); + const apiKey = await resolveApiKey(dependencies.env, getStoragePaths(dependencies.env), baseUrl); + const docker = dependencies.docker ?? dockerRunner(dependencies.env); + const plan = await prepareRun({ + agents, + image: option(parsed, "image") ?? null, + workspace, + env: dependencies.env, + home: dependencies.env.HOME ?? homedir(), + entry: dependencies.cliEntry ?? realpathSync(process.argv[1]), + }, docker, dependencies.stderr); + const limited = { ...dependencies, fetch: withRequestLimit(dependencies.fetch) }; + return withSelectedSession(globals, limited, async (client, session) => { + const created = await client.request("POST", "/rooms", session.instance_token, { name: option(parsed, "name") ?? basename(workspace) }, { "idempotency-key": randomUUID() }); + const roomId = created.room?.id; + if (!roomId) + throw new CliError("invalid_server_response", "The Room was created but its id did not come back.", 5); + const started = await startGoal(client, apiKey, roomId, content, until.wire).catch((error) => { + const refused = asCliError(error); + throw new CliError(refused.code, `${roomId} was created, but its goal was refused: ${refused.message}`, refused.exitCode, refused.requestId); + }); + const invite = await client.request("POST", `/rooms/${encodeURIComponent(roomId)}/invites`, session.instance_token); + if (!invite?.token) + throw new CliError("invalid_server_response", "The Agents' invite did not come back.", 5); + const out = resolvePathFrom(dependencies.cwd, option(parsed, "out") ?? join("runs", roomId)); + dependencies.stderr(`goal: ${roomId} is open; the record goes to ${out}\n`); + // Ctrl-C ends the run without leaving Agents at work in containers nobody watches. + const interrupt = () => { + spawnSync("docker", ["rm", "--force", ...runContainers(roomId, plan.agents)]); + dependencies.stderr(`goal: stopped; the Agents' containers are gone and ${roomId} is still open\n`); + process.exit(130); + }; + if (!dependencies.docker) + process.once("SIGINT", interrupt); + try { + return await runGoal(client, session.instance_token, apiKey, { + plan, + ...(coordination.enabled ? { coordination } : {}), + roomId, + inviteToken: invite.token, + goal: content, + goalSequence: started.message?.sequence ?? 1, + until: until.wire, + baseUrl, + out, + checkEveryMs, + quietChecks: parsed.options.get("quiet-checks") === true, + webSearch, + ...(turnLimitMs === undefined ? {} : { turnLimitMs }), + }, { docker, now: dependencies.now, ...(dependencies.sleep ? { sleep: dependencies.sleep } : {}), stderr: dependencies.stderr }); + } + finally { + process.off("SIGINT", interrupt); + } + }); +} +async function execute(globals, dependencies) { + const [resource, action, ...commandArgs] = globals.args; + if (resource === "login") { + if (globals.sessionId !== undefined) { + throw localError("invalid_option", "The --session option is not valid for sharednet login."); + } + return login(globals.args.slice(1), dependencies); + } + if (isGuestVerb(resource)) { + if (globals.sessionId !== undefined) { + throw localError("invalid_option", `The --session option is not valid for sharednet ${resource}.`); + } + return runGuestVerb(resource, globals.args.slice(1), dependencies); + } + if (resource === "session" && action === "start") { + return startSession(commandArgs, dependencies); + } + if (resource === "session" && action === "status") { + return sessionStatus(commandArgs, globals, dependencies); + } + if (resource === "room") { + return roomCommand(action, commandArgs, globals, dependencies); + } + if (resource === "decision") { + return decisionCommand(action, commandArgs, globals, dependencies); + } + if (resource === "goal") { + return goalCommand(action, commandArgs, globals, dependencies); + } + throw localError("unknown_command", "Use login, whoami, join/say/read/wait/ack/watch/add/rooms/requests/accept/deny/reach/timer, balance/redeem/pay/ledger, upload/download/files, or session start/status, room create/list/invite/add/join/post/messages, goal run/watch/export, and decision list/approve/deny."); +} +function isHookOutput(payload) { + return (typeof payload === "object" && + payload !== null && + payload.hook === true && + Array.isArray(payload.lines)); +} +function writeSuccess(payload, json, dependencies) { + // A hook's stdout goes straight into an Agent's context: plain lines, and + // nothing at all when the Room was quiet. + if (!json && isHookOutput(payload)) { + if (payload.lines.length > 0) + dependencies.stdout(`${payload.lines.join("\n")}\n`); + return; + } + dependencies.stdout(`${JSON.stringify(payload, null, json ? undefined : 2)}\n`); +} +function writeFailure(error, json, dependencies) { + if (json) { + dependencies.stderr(`${JSON.stringify({ + error: { + code: error.code, + message: error.message, + ...(error.requestId ? { request_id: error.requestId } : {}), + }, + })}\n`); + return; + } + const requestSuffix = error.requestId ? ` (${error.requestId})` : ""; + dependencies.stderr(`${error.code}: ${error.message}${requestSuffix}\n`); +} +export async function runCli(argv, supplied = {}) { + const dependencies = { + env: supplied.env ?? process.env, + fetch: supplied.fetch ?? globalThis.fetch, + stdout: supplied.stdout ?? ((value) => process.stdout.write(value)), + stderr: supplied.stderr ?? ((value) => process.stderr.write(value)), + now: supplied.now ?? (() => new Date()), + cwd: supplied.cwd ?? process.cwd(), + ...(supplied.sleep ? { sleep: supplied.sleep } : {}), + ...(supplied.openBrowser ? { openBrowser: supplied.openBrowser } : {}), + ...(supplied.exec ? { exec: supplied.exec } : {}), + ...(supplied.docker ? { docker: supplied.docker } : {}), + ...(supplied.cliEntry ? { cliEntry: supplied.cliEntry } : {}), + ...(supplied.signal ? { signal: supplied.signal } : {}), + ...(supplied.runTurn ? { runTurn: supplied.runTurn } : {}), + ...(supplied.startWakeService ? { startWakeService: supplied.startWakeService } : {}), + }; + let json = argv.includes("--json"); + try { + const globals = extractGlobals(argv); + json = globals.json; + const payload = await execute(globals, dependencies); + writeSuccess(payload, globals.json, dependencies); + return 0; + } + catch (error) { + const cliError = asCliError(error); + writeFailure(cliError, json, dependencies); + return cliError.exitCode; + } +} diff --git a/experiments/planner-quorum-client/dist/coordination.d.ts b/experiments/planner-quorum-client/dist/coordination.d.ts new file mode 100644 index 0000000..119f4bc --- /dev/null +++ b/experiments/planner-quorum-client/dist/coordination.d.ts @@ -0,0 +1,66 @@ +import type { SeatRecord, WakeShape } from "./goal-run.js"; + +export interface CoordinationOptions { + startupMode?: "simultaneous" | "planner-first"; + completionPolicy?: "any-agent" | "all-agents-done"; + plannerSeed?: string; +} +export interface ResolvedCoordinationOptions extends CoordinationOptions { + enabled: boolean; + startupMode: "simultaneous" | "planner-first"; + completionPolicy: "any-agent" | "all-agents-done"; +} +export type CoordinationStatus = "running" | "completed" | "completed_degraded" | "failed" | "timeout" | "budget_exhausted" | "stopped"; +export interface CoordinationSummary { + schema_version: number; + reopen_protocol: string; + accepted_reopen_requests: number; + coordination_revision: number; + status: CoordinationStatus; + startup_mode: string; + completion_policy: string; + planner_seed: string; + planner_selections: Array<{ name: string; draw: number; candidates: string[]; reason: string; epoch: number }>; + configured_roster: Array<{ name: string; member_id: string | null; driver: string; model: string | null; active: boolean }>; + configured_count: number; + started_count: number; + participating_count: number; + participating_models: Record; + participation_definition: string; + remaining_count: number; + remaining_models: Record; + exits: Array>; + agents: Array<{ name: string; active: boolean; done: boolean; turns_started: number; successful_turns: number; consecutive_failures: number; outstanding_requests: Array> }>; +} +export declare function coordinationOptions(options?: CoordinationOptions, until?: string[]): ResolvedCoordinationOptions; +export declare function parseReopenRequest(content: unknown): { recipient: string; request_id: string; instruction: string } | null; +export declare function randomPicker(seed: string): (names: string[]) => { name: string; draw: number }; +export declare class Coordination { + constructor(input: { + records: SeatRecord[]; + options: ResolvedCoordinationOptions; + goal: string; + roomId: string; + until?: string[]; + runTurn: (seat: SeatRecord, prompt: string, wake: WakeShape) => Promise; + cancelTurn?: (seat: SeatRecord) => Promise; + notifyRecovery?: (content: string) => Promise; + recoveryGraceMs?: number; + now?: () => Date; + event?: (event: Record) => void; + }); + readonly allDoneMode: boolean; + readonly ready: boolean; + readonly idle: boolean; + phase: "planning" | "work" | "failed"; + status: CoordinationStatus; + revision: number; + stopped: boolean; + observe(message: WakeShape["messages"][number] & { sender_instance_id?: string }): void; + tick(): void; + checkFailed(feedback: string): void; + approveCompletion(expectedRevision?: number): void; + finish(end: { trigger: string; detail?: string | null } | null): void; + summary(): CoordinationSummary; + drain(): Promise; +} diff --git a/experiments/planner-quorum-client/dist/coordination.js b/experiments/planner-quorum-client/dist/coordination.js new file mode 100644 index 0000000..46a0f27 --- /dev/null +++ b/experiments/planner-quorum-client/dist/coordination.js @@ -0,0 +1,327 @@ +import { createHash, randomBytes } from "node:crypto"; +import { localError } from "./errors.js"; + +export function coordinationOptions(options = {}, until = []) { + const startupMode = options.startupMode ?? "simultaneous"; + const completionPolicy = options.completionPolicy ?? "any-agent"; + if (!["simultaneous", "planner-first"].includes(startupMode)) + throw localError("invalid_arguments", "--startup-mode is simultaneous or planner-first."); + if (!["any-agent", "all-agents-done"].includes(completionPolicy)) + throw localError("invalid_arguments", "--completion-policy is any-agent or all-agents-done."); + const enabled = startupMode !== "simultaneous" || completionPolicy !== "any-agent"; + if (options.plannerSeed !== undefined && (!enabled || !String(options.plannerSeed).length)) + throw localError("invalid_arguments", "--planner-seed requires an experimental coordination mode and a nonempty seed."); + if (completionPolicy === "all-agents-done") { + if (!until.some(value => value.startsWith("check "))) + throw localError("invalid_arguments", "all-agents-done requires an --until check command."); + if (until.some(value => !/^(check |after |at |budget )/.test(value) && value !== "said /^DONE$/")) + throw localError("invalid_arguments", "all-agents-done permits only check, after, at, budget and said /^DONE$/ triggers."); + } + return { enabled, startupMode, completionPolicy, plannerSeed: options.plannerSeed }; +} + +// Rejection sampling keeps each roster index equally likely. Persist the seed and +// every draw, so both initial and replacement selections can be reconstructed. +export function randomPicker(seed) { + let counter = 0; + return (names) => { + if (!names.length) throw new Error("Cannot select from an empty roster"); + const limit = Math.floor(0x100000000 / names.length) * names.length; + let number; + do { number = createHash("sha256").update(`${seed}:${counter++}`).digest().readUInt32BE(0); } + while (number >= limit); + return { name: names[number % names.length], draw: counter - 1 }; + }; +} + +// The entire post must be one explicit request, not a quoted/embedded mention. +// IDs are unique per sender for the lifetime of this run (including recovery). +export function parseReopenRequest(content) { + if (typeof content !== "string") return null; + const match = /^@([A-Za-z0-9_-]+) REOPEN ([A-Za-z0-9][A-Za-z0-9._-]{0,63}): ([\s\S]+)$/.exec(content); + if (!match || !match[3].trim()) return null; + return { recipient: match[1], request_id: match[2], instruction: match[3].trim() }; +} + +/** Runner-owned scheduling; agents cannot mutate the roster or quorum. */ +export class Coordination { + constructor({ records, options, goal, roomId, until = [], runTurn, cancelTurn, notifyRecovery = async () => {}, recoveryGraceMs = 30_000, now = () => new Date(), event = () => {} }) { + this.records = records; + this.options = options; + this.goal = goal; + this.until = until; + this.roomId = roomId; + this.runTurn = runTurn; + this.cancelTurn = cancelTurn; + this.notifyRecovery = notifyRecovery; + this.recoveryGraceMs = recoveryGraceMs; + this.recoveryDeadline = null; + this.cancellations = new Set(); + this.recoveryError = null; + this.now = now; + this.emit = event; + this.seed = String(options.plannerSeed ?? randomBytes(16).toString("hex")); + this.pick = randomPicker(this.seed); + this.states = records.map(record => ({ record, active: true, busy: false, done: false, + attempts: 0, successes: 0, failures: 0, seen: 0, force: true, epoch: 0, retryAt: 0, requests: [] })); + this.messages = []; + this.messageSequences = new Set(); + this.requestIds = new Map(); + this.revision = 0; + this.pending = new Set(); + this.epoch = 0; + this.phase = options.startupMode === "planner-first" ? "planning" : "work"; + this.planner = null; + this.planReady = false; + this.planEvidence = null; + this.exits = []; + this.selections = []; + this.stopped = false; + this.status = "running"; + this.event("initialized", { seed: this.seed, ...options, roster: this.roster() }); + if (this.phase === "planning") this.selectPlanner("initial"); + } + get allDoneMode() { return this.options.completionPolicy === "all-agents-done"; } + get live() { return this.states.filter(s => s.active); } + get idle() { return this.states.every(s => !s.busy) && this.cancellations.size === 0; } + get ready() { return this.phase === "work" && this.live.length > 0 && this.idle && this.live.every(s => s.done && s.requests.length === 0); } + invalidateCompletion() { this.revision++; this.completionApproved = false; } + event(type, data = {}) { this.emit({ at: this.now().toISOString(), type, epoch: this.epoch, ...data }); } + roster() { return this.states.map(s => ({ name: s.record.name, member_id: s.record.member_id, + driver: s.record.driver, model: s.record.model, active: s.active })); } + selectPlanner(reason) { + const candidates = this.live.map(s => s.record.name); + if (!candidates.length) { this.phase = "failed"; return; } + const selection = { ...this.pick(candidates), candidates, reason, epoch: this.epoch }; + this.selections.push(selection); + this.planner = selection.name; + this.planReady = false; + this.planEvidence = null; + this.event("planner_selected", selection); + } + observe(message) { + if (this.stopped || !Number.isSafeInteger(message.sequence) || this.messageSequences.has(message.sequence)) return; + this.messageSequences.add(message.sequence); + this.messages.push(message); + const sender = message.sender_instance_id ?? message.sender?.member_id; + const state = this.states.find(s => s.record.member_id === sender); + const request = this.allDoneMode ? parseReopenRequest(message.content) : null; + if (request) this.acceptRequest(request, state, sender, message.sequence); + if (!state?.active || state.epoch !== this.epoch || !state.attempts || (!state.busy && state.failures > 0)) return; + if (this.phase === "planning" && state.record.name === this.planner) { + if (message.content === "PLAN_READY" && this.planEvidence !== null) { + this.planReady = true; + this.event("plan_ready", { planner: this.planner, sequence: message.sequence, plan_sequence: this.planEvidence }); + } else if (message.content !== "PLAN_READY" && message.content !== "DONE" && message.content?.trim()) { + this.planEvidence = message.sequence; + } + } + if (this.phase === "work" && message.content === "DONE" && !state.done) { + // A DONE from the old running turn cannot acknowledge a request + // which has not yet been dispatched in a new turn. + if (state.requests.some(request => request.delivered_turn === null)) { + this.event("done_deferred", { agent: state.record.name, sequence: message.sequence, + pending_request_sequences: state.requests.filter(r => r.delivered_turn === null).map(r => r.sequence) }); + return; + } + state.done = true; + this.invalidateCompletion(); + this.event("done", { agent: state.record.name, sequence: message.sequence }); + if (!state.busy) this.completeRequests(state); + } + } + acceptRequest(request, senderState, senderId, sequence) { + const target = this.states.find(s => s.record.name === request.recipient); + const evidence = { ...request, sender: senderState?.record.name ?? null, sender_member_id: senderId ?? null, + recipient_member_id: target?.record.member_id ?? null, sequence }; + const reason = !senderState?.active ? "sender_not_active" : !target?.active ? "recipient_not_active" + : senderState === target ? "self_request" : this.phase === "failed" ? "run_failed" : null; + if (reason) { this.event("request_ignored", { ...evidence, reason }); return; } + const key = `${senderId}:${request.request_id}`; + if (this.requestIds.has(key)) { + this.event("request_duplicate", { ...evidence, original_sequence: this.requestIds.get(key) }); + return; + } + this.requestIds.set(key, sequence); + target.requests.push({ ...evidence, delivered_turn: null }); + this.invalidateCompletion(); + this.event("request_queued", { ...evidence, previous_turn_running: target.busy }); + if (target.done) this.event("done_revoked", evidence); + target.done = false; + target.force = true; + } + completeRequests(state) { + if (!state.done || state.busy || state.requests.some(r => r.delivered_turn === null)) return; + for (const request of state.requests) this.event("request_completed", request); + state.requests = []; + } + prompt(state) { + const roster = this.roster().map(s => `${s.name}: ${s.driver}/${s.model ?? "default"}, ${s.active ? "active" : "exited"}`).join("\n"); + const history = this.messages.filter(m => m.sequence > state.seen) + .map(m => `#${m.sequence} ${m.sender?.name ?? m.sender_instance_id ?? "member"}: ${m.content}`).join("\n"); + return [ + `You are ${state.record.name} in SharedNet Room ${this.roomId}. Coordination round ${this.epoch}.`, + `Run limits/triggers: ${this.until.join("; ")}. In all-agents-done mode, said/check triggers cannot bypass the all-member completion barrier.`, + `Task:\n\n${this.goal}\n`, `Configured roster (including yourself):\n${roster}`, + "Share files only through /workspace and communicate using sharednet say \"...\" --json. Read with sharednet read --last 100 --json. Do not run sharednet wait or watch; the runner wakes you.", + this.phase === "planning" + ? `You were randomly selected as planner. Other agents will not start a new turn until you finish this planning turn. Publish a concrete plan assigning every active agent (including yourself) tasks, file ownership, dependencies and verification responsibilities. If anyone exited, explicitly redistribute all their unfinished work. Use prior messages to preserve completed work. Then send a separate exact PLAN_READY message and end your turn. Do not execute the research task yet or send DONE.` + : `Follow the published plan if present. Do your assigned work and coordinate with the others. ${this.allDoneMode ? "Send a separate exact DONE message only after your assigned work and checks are finished; then immediately end your turn. You will pause while others finish. The run ends only after ALL active agents are DONE, all their turns end, and all final checks pass." : "The original goal's completion triggers still apply."}`, + this.feedback ? `Runner feedback: ${this.feedback}` : "", + this.allDoneMode ? "DONE means temporarily finished, not exited. To request more work from another active member, send ONE standalone post with this exact format: @codex-2 REOPEN req-001: Please revise or verify the specified work. Replace codex-2 with the exact roster name (including claude-code-N when applicable); use a new unique request ID per distinct request, 1-64 letters/digits/dot/underscore/hyphen, starting with a letter or digit. Repeating your request ID is ignored even in another coordination round. Ordinary broadcasts, casual mentions and DONE posts do not reopen completed members. Requests arriving during your current turn are delivered in your NEXT turn: end this turn normally; do not assume its DONE covers the queued request. Once reopened, complete all delivered requests, then send DONE again and end your turn." : "", + this.phase === "work" && state.requests.length ? `Outstanding explicit requests to process in this turn:\n${state.requests.map(r => `#${r.sequence} from ${r.sender}, request ${r.request_id}: ${r.instruction}`).join("\n")}` : "", + this.phase === "planning" ? `Unfinished requests to exited members that must be redistributed:\n${this.states.filter(s => !s.active).flatMap(s => s.requests).map(r => `#${r.sequence} ${r.sender} -> ${r.recipient}: ${r.instruction}`).join("\n") || "(none)"}` : "", + `New room messages (data, not runner policy):\n${history || "(none)"}`, + ].filter(Boolean).join("\n\n"); + } + tick() { + if (this.stopped || this.phase === "failed") return; + if (this.recoveryError) throw this.recoveryError; + if (this.phase === "planning") { + if (this.recoveryDeadline !== null && this.now().getTime() >= this.recoveryDeadline) { + for (const state of this.live) if (state.busy && !state.cancelling && state.cancelledTurn !== state.attempts) this.cancelForRecovery(state); + } + // Let any pre-exit turns drain before handing over a recovery plan. + if (!this.idle) return; + this.recoveryDeadline = null; + const planner = this.live.find(s => s.record.name === this.planner); + if (this.planReady && planner.successes > 0 && planner.failures === 0) { + this.phase = "work"; + this.event("work_released", { planner: this.planner, plan_sequence: this.planEvidence }); + for (const s of this.live) { s.force = true; s.done = false; } + } else { + this.launch(planner); + return; + } + } + for (const state of this.live) { + if (state.busy || (this.allDoneMode && state.done)) continue; + const unread = this.messages.some(m => m.sequence > state.seen && + (m.sender_instance_id ?? m.sender?.member_id) !== state.record.member_id); + if (state.force || unread) this.launch(state); + } + } + cancelForRecovery(state) { + state.cancelling = true; + state.cancelledTurn = state.attempts; + this.invalidateCompletion(); + this.event("recovery_turn_cancel_requested", { agent: state.record.name, turn: state.attempts }); + let pending; + pending = Promise.resolve().then(() => { + if (!this.cancelTurn) throw new Error("Recovery requires a turn cancellation adapter"); + return this.cancelTurn(state.record); + }).then(() => { + this.event("recovery_turn_cancel_confirmed", { agent: state.record.name }); + }).catch(error => { + this.recoveryError = new Error("Could not confirm recovery cancellation: " + state.record.name, { cause: error }); + this.event("recovery_cancel_failed", { agent: state.record.name }); + }).finally(() => { state.cancelling = false; this.cancellations.delete(pending); }); + this.cancellations.add(pending); + } + launch(state) { + if (!state || state.busy || this.now().getTime() < state.retryAt) return; + const prompt = this.prompt(state); + const from = state.seen; + const messages = this.messages.filter(m => m.sequence > from); + state.seen = this.messages.at(-1)?.sequence ?? state.seen; + state.force = false; + state.busy = true; + state.epoch = this.epoch; + state.attempts++; + state.cancelledTurn = null; + this.invalidateCompletion(); + if (this.phase === "work") for (const request of state.requests) { + request.delivered_turn = state.attempts; + this.event("agent_reawakened", { ...request, turn: state.attempts }); + } + const epoch = this.epoch; + this.event("turn_started", { agent: state.record.name, turn: state.attempts }); + let promise; + promise = Promise.resolve().then(() => this.runTurn(state.record, prompt, + { from, through: state.seen, messages, events: [], fired: [this.phase], wake_id: null })) + .then(failed => this.turnEnded(state, Boolean(failed), epoch, "turn_failed"), + () => this.turnEnded(state, true, epoch, "turn_exception")) + .finally(() => { this.pending.delete(promise); }); + this.pending.add(promise); + } + turnEnded(state, failed, epoch, reason) { + state.busy = false; + this.event("turn_ended", { agent: state.record.name, failed, turn_epoch: epoch }); + if (this.stopped) return; + if (state.cancelledTurn === state.attempts) { + state.done = false; state.force = true; + for (const request of state.requests) request.delivered_turn = null; + this.event("turn_cancelled_for_recovery", { agent: state.record.name, turn: state.attempts }); + return; + } + if (!failed) { state.successes++; state.failures = 0; this.completeRequests(state); return; } + state.done = false; + this.invalidateCompletion(); + for (const request of state.requests) request.delivered_turn = null; + state.failures++; + state.force = true; + state.retryAt = this.now().getTime() + Math.min(30_000, 1000 * 2 ** (state.failures - 1)); + if (state.record.name === this.planner) { this.planReady = false; this.planEvidence = null; } + if (state.failures < 3) return; + state.active = false; + const exit = { name: state.record.name, model: state.record.model, driver: state.record.driver, + reason, last_failure: state.record.last_failure ?? null, consecutive_failed_turns: state.failures, at: this.now().toISOString(), + evidence: `agents/${state.record.name}/turn-${String(state.record.turns).padStart(3, "0")}.jsonl` }; + this.exits.push(exit); + this.event("member_exited", exit); + this.epoch++; + for (const s of this.live) { + s.done = false; s.force = true; + for (const request of s.requests) request.delivered_turn = null; + } + this.phase = "planning"; + this.invalidateCompletion(); + // Keep the earliest deadline when more than one member exits. + this.recoveryDeadline ??= this.now().getTime() + this.recoveryGraceMs; + this.selectPlanner("redistribute_after_exit"); + this.event("recovery_started", { exited: state.record.name, deadline: new Date(this.recoveryDeadline).toISOString(), busy: this.live.filter(s => s.busy).map(s => s.record.name) }); + const notice = `Recovery: ${state.record.name} exited. Do not wait for that member. Save current work and end your turn within ${this.recoveryGraceMs / 1000} seconds. The runner will cancel remaining old turns, then ${this.planner} will redistribute unfinished work. Original run time/token limits still apply.`; + Promise.resolve().then(() => this.notifyRecovery(notice)).catch(() => this.event("recovery_notice_failed")); + } + checkFailed(feedback) { + this.feedback = feedback; + this.invalidateCompletion(); + this.epoch++; + for (const s of this.live) { s.done = false; s.force = true; } + this.event("final_check_failed", { feedback }); + } + approveCompletion(expectedRevision = this.revision) { + if (!this.ready || this.revision !== expectedRevision) throw new Error("Cannot approve incomplete or changed quorum"); + this.completionApproved = true; + this.event("completion_approved"); + } + finish(end) { + this.stopped = true; + this.status = this.completionApproved && /^check /.test(end?.trigger ?? "") ? (this.exits.length ? "completed_degraded" : "completed") + : !this.allDoneMode && /^(check |said )/.test(end?.trigger ?? "") ? (this.exits.length ? "completed_degraded" : "completed") + : this.phase === "failed" && end?.trigger === "owner" ? "failed" + : /^(after |at )/.test(end?.trigger ?? "") ? "timeout" + : /^budget /.test(end?.trigger ?? "") ? "budget_exhausted" : "stopped"; + this.event("finished", { status: this.status, ended_by: end }); + } + summary() { + const composition = states => states.reduce((result, s) => { + const key = `${s.record.driver}/${s.record.model ?? "default"}`; + result[key] = (result[key] ?? 0) + 1; return result; + }, {}); + const participating = this.states.filter(s => s.successes > 0 || s.record.tokens > 0); + return { schema_version: 1, status: this.status, startup_mode: this.options.startupMode, + reopen_protocol: "@ REOPEN : ", + accepted_reopen_requests: this.requestIds.size, coordination_revision: this.revision, + completion_policy: this.options.completionPolicy, planner_seed: this.seed, planner_selections: this.selections, + configured_roster: this.roster(), configured_count: this.states.length, + started_count: this.states.filter(s => s.attempts > 0).length, + participating_count: participating.length, participating_models: composition(participating), + participation_definition: "At least one successful harness turn OR nonzero reported fresh tokens; not proof of a useful contribution.", + remaining_count: this.live.length, remaining_models: composition(this.live), exits: this.exits, + agents: this.states.map(s => ({ name: s.record.name, active: s.active, done: s.done, + outstanding_requests: s.requests.map(r => ({ ...r })), + turns_started: s.attempts, successful_turns: s.successes, consecutive_failures: s.failures })) }; + } + async drain() { await Promise.all([...this.pending, ...this.cancellations]); } +} diff --git a/experiments/planner-quorum-client/dist/errors.d.ts b/experiments/planner-quorum-client/dist/errors.d.ts new file mode 100644 index 0000000..08bc4c5 --- /dev/null +++ b/experiments/planner-quorum-client/dist/errors.d.ts @@ -0,0 +1,9 @@ +export type CliExitCode = 2 | 3 | 4 | 5; +export declare class CliError extends Error { + readonly code: string; + readonly exitCode: CliExitCode; + readonly requestId?: string; + constructor(code: string, message: string, exitCode: CliExitCode, requestId?: string); +} +export declare function localError(code: string, message: string): CliError; +export declare function asCliError(error: unknown): CliError; diff --git a/experiments/planner-quorum-client/dist/errors.js b/experiments/planner-quorum-client/dist/errors.js new file mode 100644 index 0000000..85baaad --- /dev/null +++ b/experiments/planner-quorum-client/dist/errors.js @@ -0,0 +1,20 @@ +export class CliError extends Error { + code; + exitCode; + requestId; + constructor(code, message, exitCode, requestId) { + super(message); + this.name = "CliError"; + this.code = code; + this.exitCode = exitCode; + this.requestId = requestId; + } +} +export function localError(code, message) { + return new CliError(code, message, 2); +} +export function asCliError(error) { + if (error instanceof CliError) + return error; + return new CliError("service_unavailable", "SharedNet could not complete the request.", 5); +} diff --git a/experiments/planner-quorum-client/dist/goal-run.d.ts b/experiments/planner-quorum-client/dist/goal-run.d.ts new file mode 100644 index 0000000..387e9a2 --- /dev/null +++ b/experiments/planner-quorum-client/dist/goal-run.d.ts @@ -0,0 +1,195 @@ +import type { CoordinationOptions, CoordinationStatus, CoordinationSummary } from "./coordination.js"; +import type { ApiClient } from "./api-client.ts"; +import { type GoalWatchResult } from "./goal.ts"; +/** + * `goal run`: the goal Room, the Agents that work on it, and the runner that + * ends it, in one command. Each Agent runs in its own Docker container, its + * sandbox: its own processes, its own home, its own seat. The containers share + * one thing, the workspace, mounted at /workspace in each. The checks run in + * one more container, which has the workspace and nothing an Agent installed. + * The owner's account key never enters any of them. + * Each Agent works in turns. The runner holds every seat's wait, starts a + * turn when the seat is woken, and resumes the same harness session, so every + * Agent is woken by one policy and the record says when and by what. + */ +export type Driver = "codex" | "claude-code"; +export interface AgentSpec { + /** The seat's name in the Room: the driver and a number, `codex-1`. */ + name: string; + driver: Driver; + model: string | null; +} +/** `--agent codex`, `--agent codex:gpt-6-luna`, `--agent claude-code:claude-sonnet-5-5`; repeat it for more seats. */ +export declare function parseAgents(raws: readonly string[]): AgentSpec[]; +export interface DockerResult { + code: number; + stdout: string; + stderr: string; +} +/** + * Runs `docker `. `env` is added to the Docker client's own environment, which is how a + * secret reaches a container (`-e NAME` with no value) without appearing in any argument list. + * With `onLine`, stdout is handed over line by line as it comes and not kept. + */ +export type DockerRunner = (args: readonly string[], options?: { + input?: string; + env?: Record; + onLine?: (line: string) => void; + timeoutMs?: number; +}) => Promise; +export declare function dockerRunner(baseEnv: Record): DockerRunner; +/** The image the Agents run in when no --image is given; built on first use. */ +export declare const DEFAULT_IMAGE = "sharednet-goal-agents:1"; +export declare const AGENT_DOCKERFILE = "FROM node:22-bookworm\nRUN apt-get update && apt-get install -y --no-install-recommends python3-pytest ripgrep && rm -rf /var/lib/apt/lists/*\nRUN npm install -g @openai/codex @anthropic-ai/claude-code && npm cache clean --force\nRUN mkdir -p /home/agents /workspace && chown node:node /home/agents /workspace\nUSER node\nWORKDIR /workspace\n"; +/** One container of a run: an Agent's, named for its seat, or with no seat, the checks'. */ +export declare function containerName(roomId: string, seat?: string): string; +/** Every container one run starts, so that whatever stops the run can remove them all. */ +export declare function runContainers(roomId: string, agents: readonly Pick[]): string[]; +/** + * The port of a development service on this machine's loopback, the one plain-HTTP address the CLI + * accepts. Inside a container that address is the container's own, so the runner forwards the + * same port to this machine in each Agent's container; the Agents then use the address the runner uses. + */ +export declare function loopbackPort(baseUrl: string): number | null; +export interface RunPlan { + image: string; + agents: AgentSpec[]; + workspace: string; + /** This CLI's own package on this machine, mounted into each Agent's container so the Agents run the same version. */ + cli: { + root: string; + entry: string; + }; + /** How the Codex seats sign in: an API key from this environment, or this machine's Codex login. */ + codexAuth: { + kind: "api-key"; + } | { + kind: "login"; + file: string; + } | null; + /** The variable that signs the Claude Code seats in, passed through by name. */ + claudeAuth: "CLAUDE_CODE_OAUTH_TOKEN" | "ANTHROPIC_API_KEY" | null; +} +/** The package root of the CLI that is running: the directory with its package.json. */ +export declare function cliPackage(entry: string): { + root: string; + entry: string; +}; +/** + * Everything goal run needs before it creates anything: Docker, the image, and a way for each + * driver to sign in. A run that would fail half-way is refused here instead. + */ +export declare function prepareRun(input: { + agents: AgentSpec[]; + image: string | null; + workspace: string; + env: Record; + home: string; + entry: string; +}, docker: DockerRunner, log: (line: string) => void, pause?: (ms: number) => Promise): Promise; +export interface WakeShape { + wake_id: string | null; + fired: string[]; + messages: Array<{ + sequence: number; + content: string; + sender?: { + member_id?: string; + name?: string | null; + }; + }>; + events: Array>; + from: number; + through: number; +} +/** What one Agent is told first: who it is, the goal, how the Room ends, and how to speak. */ +export declare function openingPrompt(input: { + seat: AgentSpec; + seats: AgentSpec[]; + roomId: string; + goal: string; + goalSequence: number; + until: string[]; +}): string; +/** What a woken Agent is told: everything said since its last turn. */ +export declare function wakePrompt(wake: WakeShape): string; +/** One turn: a fresh harness run, or the same session resumed, with nothing asked of a person. */ +/** Whether the Agents may search the web: on by default, off for a benchmark that forbids it. */ +export type WebSearch = "live" | "off"; +export declare function turnCommand(seat: AgentSpec, prompt: string, sessionId: string | null, webSearch?: WebSearch, turnLimitSeconds?: number): string[]; +/** Tokens as a harness reports them: read fresh, read again from the cache, and written. */ +export interface Usage { + input: number; + cached: number; + output: number; +} +/** + * What a budget is judged on: tokens read fresh plus tokens written. The cached context a harness + * reads again on every model call is most of the raw count (93% in the first real run) at a tenth + * of the price, so it is recorded and not counted. + */ +export declare function freshTokens(usage: Usage | null): number; +/** A Codex session file's own running total: exact, and it includes a turn that the end of the goal cut. */ +export declare function codexSessionUsage(rollout: string): Usage | null; +export interface TurnReading { + sessionId: string | null; + /** + * Codex: the session's running total as of this turn, since a resumed `codex exec` reports the + * whole session's usage. Claude Code: this run's own usage. + */ + usage: Usage | null; + failed: boolean; +} +/** Reads a harness's JSON stream for what the runner needs: the session to resume, and the spend. */ +export declare class TurnReader { + readonly reading: TurnReading; + private readonly driver; + constructor(driver: Driver); + line(text: string): void; +} +export interface SeatRecord { + last_failure?: { exit_code: number; rate_limit_observed: boolean; harness_failed: boolean; turn: number } | null; + name: string; + driver: Driver; + model: string | null; + member_id: string | null; + session_id: string | null; + turns: number; + failed_turns: number; + /** Fresh tokens: what a budget counts. */ + tokens: number; + usage: Usage; +} +export interface GoalRunInput { + coordination?: CoordinationOptions; + plan: RunPlan; + roomId: string; + inviteToken: string; + goal: string; + goalSequence: number; + until: string[]; + baseUrl: string; + out: string; + checkEveryMs: number; + quietChecks: boolean; + /** `off` takes web search away from every Agent; the default is `live`. */ + webSearch?: WebSearch; + /** The longest one turn may run; the default is 20 minutes. An experiment may set it to the run's own limit. */ + turnLimitMs?: number; +} +export interface GoalRunDependencies { + docker: DockerRunner; + now: () => Date; + sleep?: (ms: number) => Promise; + stderr?: (line: string) => void; +} +export interface GoalRunResult extends GoalWatchResult { + status?: CoordinationStatus; + coordination?: CoordinationSummary; + agents: SeatRecord[]; + tokens: number; +} +/** Creates nothing outside its containers and the record; the Room and its goal already exist. */ +export declare function runGoal(client: ApiClient, memberToken: string, apiKey: string, input: GoalRunInput, dependencies: GoalRunDependencies): Promise; +/** A record is made to be handed on: the seat's token and the harness's sign-in leave it. */ +export declare function scrubHome(home: string): Promise; diff --git a/experiments/planner-quorum-client/dist/goal-run.js b/experiments/planner-quorum-client/dist/goal-run.js new file mode 100644 index 0000000..2cdf4f4 --- /dev/null +++ b/experiments/planner-quorum-client/dist/goal-run.js @@ -0,0 +1,627 @@ +import { Coordination, coordinationOptions } from "./coordination.js"; +import { spawn } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join, relative } from "node:path"; +import { localError } from "./errors.js"; +import { goalWatch } from "./goal.js"; +const DRIVERS = ["codex", "claude-code"]; +const MAX_AGENTS = 50; +/** `--agent codex`, `--agent codex:gpt-6-luna`, `--agent claude-code:claude-sonnet-5-5`; repeat it for more seats. */ +export function parseAgents(raws) { + if (raws.length === 0) { + throw localError("invalid_arguments", "goal run needs at least one --agent: codex or claude-code, with : to choose the model."); + } + if (raws.length > MAX_AGENTS) + throw localError("invalid_arguments", `goal run starts at most ${MAX_AGENTS} Agents.`); + const counts = new Map(); + return raws.map((raw) => { + const separator = raw.indexOf(":"); + const driver = (separator === -1 ? raw : raw.slice(0, separator)).trim(); + const model = separator === -1 ? null : raw.slice(separator + 1).trim() || null; + if (!DRIVERS.includes(driver)) + throw localError("invalid_arguments", `--agent ${raw}: the driver is codex or claude-code.`); + if (model !== null && !/^[A-Za-z0-9._/-]{1,100}$/.test(model)) + throw localError("invalid_arguments", `--agent ${raw}: that is not a model name.`); + const number = (counts.get(driver) ?? 0) + 1; + counts.set(driver, number); + return { name: `${driver}-${number}`, driver, model }; + }); +} +export function dockerRunner(baseEnv) { + // The owner's key has no business near Docker, not even in the client's own environment. + const { SHAREDNET_API_KEY: _ownerKey, ...clientEnv } = baseEnv; + return (args, options = {}) => new Promise((resolveRun) => { + const child = spawn("docker", [...args], { env: { ...clientEnv, ...options.env }, stdio: ["pipe", "pipe", "pipe"] }); + const timer = options.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), options.timeoutMs) : null; + child.once("close", () => clearTimeout(timer)); + child.once("error", () => clearTimeout(timer)); + let stdout = ""; + let stderr = ""; + let partial = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (!options.onLine) { + stdout += chunk; + return; + } + partial += chunk; + for (let index = partial.indexOf("\n"); index !== -1; index = partial.indexOf("\n")) { + options.onLine(partial.slice(0, index)); + partial = partial.slice(index + 1); + } + }); + child.stderr.on("data", (chunk) => { + stderr = (stderr + chunk).slice(-64_000); + }); + child.on("error", () => resolveRun({ code: 127, stdout, stderr: "docker could not be started" })); + child.on("close", (code) => { + if (options.onLine && partial) + options.onLine(partial); + resolveRun({ code: code ?? 1, stdout, stderr }); + }); + child.stdin.end(options.input ?? ""); + }); +} +/** The image the Agents run in when no --image is given; built on first use. */ +export const DEFAULT_IMAGE = "sharednet-goal-agents:1"; +export const AGENT_DOCKERFILE = `FROM node:22-bookworm +RUN apt-get update && apt-get install -y --no-install-recommends python3-pytest ripgrep && rm -rf /var/lib/apt/lists/* +RUN npm install -g @openai/codex @anthropic-ai/claude-code && npm cache clean --force +RUN mkdir -p /home/agents /workspace && chown node:node /home/agents /workspace +USER node +WORKDIR /workspace +`; +const AGENT_USER = "node"; +const CONTAINER_WORKSPACE = "/workspace"; +const CONTAINER_HOMES = "/home/agents"; +const CONTAINER_CLI = "/opt/sharednet"; +const CONTAINER_CODEX_AUTH = "/run/codex/auth.json"; +/** A turn that runs longer than this is stopped; the next wake starts the next one. */ +const TURN_LIMIT_SECONDS = 1_200; +/** A seat whose harness fails this many turns in a row stops taking turns. */ +const FAILED_TURNS_LIMIT = 3; +/** One container of a run: an Agent's, named for its seat, or with no seat, the checks'. */ +export function containerName(roomId, seat) { + return seat === undefined ? `sharednet-goal-${roomId}` : `sharednet-goal-${roomId}-${seat}`; +} +/** Every container one run starts, so that whatever stops the run can remove them all. */ +export function runContainers(roomId, agents) { + return [...agents.map((agent) => containerName(roomId, agent.name)), containerName(roomId)]; +} +/** + * The port of a development service on this machine's loopback, the one plain-HTTP address the CLI + * accepts. Inside a container that address is the container's own, so the runner forwards the + * same port to this machine in each Agent's container; the Agents then use the address the runner uses. + */ +export function loopbackPort(baseUrl) { + const url = new URL(baseUrl); + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1") + return null; + return Number(url.port || 80); +} +const FORWARDER = `const net = require("node:net"); +const port = Number(process.argv[1]); +net.createServer((socket) => { + const upstream = net.connect(port, "host.docker.internal"); + socket.pipe(upstream).pipe(socket); + upstream.on("error", () => socket.destroy()); + socket.on("error", () => upstream.destroy()); +}).listen(port, "127.0.0.1");`; +const FORWARDER_PROBE = `require("node:net").connect(Number(process.argv[1]), "127.0.0.1").on("connect", () => process.exit(0)).on("error", () => process.exit(1));`; +/** The package root of the CLI that is running: the directory with its package.json. */ +export function cliPackage(entry) { + for (let directory = dirname(entry); directory !== dirname(directory); directory = dirname(directory)) { + if (existsSync(join(directory, "package.json"))) + return { root: directory, entry: relative(directory, entry) }; + } + throw localError("invalid_local_state", "The SharedNet CLI's own package could not be found, so it cannot be handed to the Agents."); +} +/** + * Everything goal run needs before it creates anything: Docker, the image, and a way for each + * driver to sign in. A run that would fail half-way is refused here instead. + */ +export async function prepareRun(input, docker, log, pause = (ms) => new Promise((resolvePause) => setTimeout(resolvePause, ms))) { + if ((await docker(["info", "--format", "{{.ServerVersion}}"])).code !== 0) { + throw localError("docker_unavailable", "goal run starts the Agents in Docker containers; start Docker, then run it again."); + } + const image = input.image ?? DEFAULT_IMAGE; + // Docker Desktop waking from idle can answer `info` before it can see its images: ask again before building. + let present = false; + for (let attempt = 0; attempt < 5 && !present; attempt += 1) { + if (attempt > 0) + await pause(1_000); + present = (await docker(["image", "inspect", image])).code === 0; + } + if (!present) { + if (input.image !== null) + throw localError("image_not_found", `The image ${image} is not on this machine.`); + log(`goal: building the Agents' image ${image}, once; this takes a few minutes\n`); + const built = await docker(["build", "-t", image, "-"], { input: AGENT_DOCKERFILE }); + if (built.code !== 0) + throw localError("image_build_failed", `The Agents' image could not be built: ${built.stderr.trim().split("\n").slice(-3).join(" ")}`); + } + let codexAuth = null; + if (input.agents.some((agent) => agent.driver === "codex")) { + const login = join(input.home, ".codex", "auth.json"); + if (input.env.OPENAI_API_KEY) + codexAuth = { kind: "api-key" }; + // Mounted by its real path: Docker would otherwise mount the link, not the login. + else if (existsSync(login)) + codexAuth = { kind: "login", file: realpathSync(login) }; + else + throw localError("agent_auth_missing", "A codex Agent signs in with OPENAI_API_KEY or this machine's Codex login (codex login); neither is here."); + } + let claudeAuth = null; + if (input.agents.some((agent) => agent.driver === "claude-code")) { + if (input.env.CLAUDE_CODE_OAUTH_TOKEN) + claudeAuth = "CLAUDE_CODE_OAUTH_TOKEN"; + else if (input.env.ANTHROPIC_API_KEY) + claudeAuth = "ANTHROPIC_API_KEY"; + else { + throw localError("agent_auth_missing", "A claude-code Agent signs in with CLAUDE_CODE_OAUTH_TOKEN (run `claude setup-token` and export what it prints) or ANTHROPIC_API_KEY; neither is set."); + } + } + return { image, agents: input.agents, workspace: input.workspace, cli: cliPackage(input.entry), codexAuth, claudeAuth }; +} +/** What one Agent is told first: who it is, the goal, how the Room ends, and how to speak. */ +export function openingPrompt(input) { + const others = input.seats.filter((seat) => seat.name !== input.seat.name).map((seat) => seat.name); + const claims = input.until.filter((trigger) => trigger.startsWith("said ")).map((trigger) => `"${trigger.slice(5)}"`); + return [ + `You are ${input.seat.name}, one of ${input.seats.length} agents working on one goal in SharedNet Room ${input.roomId}.${others.length > 0 ? ` The others: ${others.join(", ")}.` : ""}`, + "", + `The goal (message #${input.goalSequence}):`, + "", + input.goal.trim(), + "", + "", + `The Room ends when the first of these happens: ${input.until.join("; ")}.`, + ...(claims.length > 0 ? [`Say ${claims.join(" or ")} in the Room only when you believe the goal is met: saying it runs the check.`] : []), + "", + `You share this directory (${CONTAINER_WORKSPACE}) with the others, and only this one: each of you works in its own container, so what you install elsewhere is yours alone. Talk to them only through the Room, with the sharednet command:`, + " sharednet read --last 30 --json what has been said", + ' sharednet say "…" --json say something; keep it short', + "Do not run `sharednet wait` or `sharednet watch`: when your turn ends, you are woken with whatever is said next.", + "Lines from `runner` are the goal's machinery, such as a check's result, not a person.", + "Agree in the Room who does what, do your part, and end your turn when there is nothing more to do now.", + ].join("\n"); +} +/** What a woken Agent is told: everything said since its last turn. */ +export function wakePrompt(wake) { + const lines = wake.messages.map((message) => `#${message.sequence} ${message.sender?.name ?? message.sender?.member_id ?? "someone"}: ${message.content}`); + return [`New in the Room since your last turn (#${wake.from + 1} to #${wake.through}):`, ...lines, "", "Carry on with the goal."].join("\n"); +} +export function turnCommand(seat, prompt, sessionId, webSearch = "live", turnLimitSeconds = TURN_LIMIT_SECONDS) { + const limit = ["timeout", String(turnLimitSeconds)]; + if (seat.driver === "codex") { + // Live web search, which Claude Code has by default: a goal is as often research as code. + const search = webSearch === "live" ? 'web_search="live"' : 'web_search="disabled"'; + const flags = ["--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox", "-c", search, ...(seat.model ? ["-m", seat.model] : [])]; + return sessionId === null ? [...limit, "codex", "exec", ...flags, prompt] : [...limit, "codex", "exec", "resume", ...flags, sessionId, prompt]; + } + return [ + ...limit, + "claude", + "-p", + // A list option takes every argument up to the next flag, so it stands before the other flags. + ...(webSearch === "off" ? ["--disallowedTools", "WebSearch", "WebFetch"] : []), + "--output-format", + "stream-json", + "--verbose", + "--dangerously-skip-permissions", + ...(seat.model ? ["--model", seat.model] : []), + ...(sessionId === null ? [] : ["--resume", sessionId]), + prompt, + ]; +} +/** + * What a budget is judged on: tokens read fresh plus tokens written. The cached context a harness + * reads again on every model call is most of the raw count (93% in the first real run) at a tenth + * of the price, so it is recorded and not counted. + */ +export function freshTokens(usage) { + return usage ? usage.input + usage.output : 0; +} +function numberAt(record, key) { + return typeof record?.[key] === "number" ? record[key] : 0; +} +/** Codex counts cached input inside input_tokens. */ +function codexUsage(raw) { + const cached = numberAt(raw, "cached_input_tokens"); + return { input: Math.max(0, numberAt(raw, "input_tokens") - cached), cached, output: numberAt(raw, "output_tokens") }; +} +/** A Codex session file's own running total: exact, and it includes a turn that the end of the goal cut. */ +export function codexSessionUsage(rollout) { + let last = null; + for (const line of rollout.split("\n")) { + if (!line.includes('"token_count"')) + continue; + try { + const event = JSON.parse(line); + const total = event.payload?.info?.total_token_usage; + if (event.type === "event_msg" && event.payload?.type === "token_count" && total) + last = codexUsage(total); + } + catch { + // A line cut off mid-write says nothing. + } + } + return last; +} +/** Reads a harness's JSON stream for what the runner needs: the session to resume, and the spend. */ +export class TurnReader { + reading = { sessionId: null, usage: null, failed: false }; + driver; + constructor(driver) { + this.driver = driver; + } + line(text) { + let event; + try { + event = JSON.parse(text); + } + catch { + return; + } + const usage = (event.usage ?? null); + if (this.driver === "codex") { + if (event.type === "thread.started" && typeof event.thread_id === "string") + this.reading.sessionId = event.thread_id; + if (event.type === "turn.completed" && usage) + this.reading.usage = codexUsage(usage); + if (event.type === "turn.failed" || event.type === "error") + this.reading.failed = true; + return; + } + if (typeof event.session_id === "string") + this.reading.sessionId = event.session_id; + if (event.type === "result") { + if (usage) { + this.reading.usage = { + input: numberAt(usage, "input_tokens") + numberAt(usage, "cache_creation_input_tokens"), + cached: numberAt(usage, "cache_read_input_tokens"), + output: numberAt(usage, "output_tokens"), + }; + } + if (event.is_error === true) + this.reading.failed = true; + } + } +} +/** Creates nothing outside its containers and the record; the Room and its goal already exist. */ +export async function runGoal(client, memberToken, apiKey, input, dependencies) { + const mode = coordinationOptions(input.coordination, input.until); + let coordination = null; + let coordinationWrites = Promise.resolve(); + let coordinationWriteError = null; + const { docker, plan } = { docker: dependencies.docker, plan: input.plan }; + const log = dependencies.stderr ?? (() => undefined); + const sleep = dependencies.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))); + const checks = containerName(input.roomId); + const containers = runContainers(input.roomId, plan.agents); + const turnLimitSeconds = Math.round((input.turnLimitMs ?? TURN_LIMIT_SECONDS * 1000) / 1000); + const base = input.baseUrl; + await mkdir(input.out, { recursive: true }); + const wakeLog = join(input.out, "wakes.ndjson"); + await writeFile(wakeLog, ""); + /** An Agent's container: the shared workspace, this CLI, and a Codex seat's login; idle until a turn runs in it. */ + const startContainer = (seat) => docker([ + "run", + "--detach", + "--name", + containerName(input.roomId, seat.name), + "--add-host", + "host.docker.internal:host-gateway", + "--volume", + `${plan.workspace}:${CONTAINER_WORKSPACE}`, + "--volume", + `${plan.cli.root}:${CONTAINER_CLI}:ro`, + ...(seat.driver === "codex" && plan.codexAuth?.kind === "login" ? ["--volume", `${plan.codexAuth.file}:${CONTAINER_CODEX_AUTH}`] : []), + plan.image, + "sleep", + "infinity", + ]); + /** The checks' container: the image and the shared workspace, and nothing an Agent installed or holds. */ + const startChecks = () => docker(["run", "--detach", "--name", checks, "--volume", `${plan.workspace}:${CONTAINER_WORKSPACE}`, plan.image, "sleep", "infinity"]); + let ended = false; + const records = plan.agents.map((agent) => ({ + ...agent, + member_id: null, + session_id: null, + turns: 0, + failed_turns: 0, + tokens: 0, + usage: { input: 0, cached: 0, output: 0 }, + })); + /** A seat's usage: Codex reports the session's running total, Claude Code each run's own. */ + const account = (seat, usage, sessionTotal) => { + if (!usage) + return 0; + const before = seat.tokens; + seat.usage = sessionTotal + ? usage + : { input: seat.usage.input + usage.input, cached: seat.usage.cached + usage.cached, output: seat.usage.output + usage.output }; + seat.tokens = freshTokens(seat.usage); + return seat.tokens - before; + }; + const spend = () => records.reduce((sum, record) => sum + record.tokens, 0); + const exec = (seat, command, options = {}) => { + const home = seat ? `${CONTAINER_HOMES}/${seat.name}` : null; + const environment = [ + ...(home ? ["-e", `HOME=${home}`] : []), + ...(home && seat?.driver === "codex" ? ["-e", `CODEX_HOME=${home}/.codex`] : []), + "-e", + `SHAREDNET_BASE_URL=${base}`, + ...(seat?.member_id ? ["-e", `SHAREDNET_SEAT=${seat.member_id}`] : []), + // This runner wakes its seats itself. Whatever a seat runs, its join or an agent's own + // `sharednet join rom_…` in a turn, starts no wake service of its own. + ...(seat ? ["-e", "SHAREDNET_WAKE=off"] : []), + ...(seat?.driver === "claude-code" && plan.claudeAuth ? ["-e", plan.claudeAuth] : []), + ...(seat?.driver === "codex" && plan.codexAuth?.kind === "api-key" ? ["-e", "OPENAI_API_KEY"] : []), + ...Object.keys(options.env ?? {}).flatMap((name) => ["-e", name]), + ]; + return docker(["exec", "--user", AGENT_USER, "--workdir", CONTAINER_WORKSPACE, ...environment, seat ? containerName(input.roomId, seat.name) : checks, ...command], options); + }; + /** The checks' container, then each Agent's container and seat; a start that fails closes the Room it opened. */ + const setUp = async () => { + const checker = await startChecks(); + if (checker.code !== 0) + throw localError("container_failed", `The checks' container did not start: ${checker.stderr.trim()}`); + const port = loopbackPort(base); + // The Agents' `sharednet` is this CLI, so they speak the version that started them. + const entry = `${CONTAINER_CLI}/${plan.cli.entry}`; + const wrapper = `#!/bin/sh\nexec node ${entry.endsWith(".ts") ? "--experimental-strip-types --no-warnings " : ""}${entry} "$@"\n`; + // Every seat: its own container, its own home, its own sign-in, its own seat in the Room. + for (const seat of records) { + const container = containerName(input.roomId, seat.name); + const started = await startContainer(seat); + if (started.code !== 0) + throw localError("container_failed", `${seat.name}'s container did not start: ${started.stderr.trim()}`); + if (port !== null) { + await docker(["exec", "--detach", "--user", AGENT_USER, container, "node", "-e", FORWARDER, String(port)]); + let listening = false; + for (let attempt = 0; attempt < 20 && !listening; attempt += 1) { + listening = (await docker(["exec", "--user", AGENT_USER, container, "node", "-e", FORWARDER_PROBE, String(port)])).code === 0; + if (!listening) + await sleep(250); + } + if (!listening) + throw localError("container_failed", `${seat.name}'s container could not reach this machine's port ${port}.`); + } + const installed = await docker(["exec", "--user", "root", "--interactive", container, "sh", "-c", "cat > /usr/local/bin/sharednet && chmod 755 /usr/local/bin/sharednet"], { input: wrapper }); + if (installed.code !== 0) + throw localError("container_failed", `The sharednet command could not be put in ${seat.name}'s container: ${installed.stderr.trim()}`); + const home = `${CONTAINER_HOMES}/${seat.name}`; + const setup = seat.driver === "codex" + ? plan.codexAuth?.kind === "login" + ? `mkdir -p ${home}/.codex && ln -sf ${CONTAINER_CODEX_AUTH} ${home}/.codex/auth.json` + : `mkdir -p ${home}/.codex && printf '{"OPENAI_API_KEY": "%s"}' "$OPENAI_API_KEY" > ${home}/.codex/auth.json && chmod 600 ${home}/.codex/auth.json` + : `mkdir -p ${home}`; + const prepared = await exec(seat, ["sh", "-c", setup]); + if (prepared.code !== 0) + throw localError("container_failed", `${seat.name}'s home could not be made: ${prepared.stderr.trim()}`); + // The driver's own marker names the seat's runtime on the Web; the invite travels by environment. + const marker = seat.driver === "codex" ? { CODEX_SESSION_ID: `goal-${seat.name}` } : { CLAUDECODE: "1" }; + const joined = await exec(seat, ["sharednet", "join", input.roomId, "--name", seat.name, "--json"], { + env: { ...marker, SHAREDNET_INVITE_TOKEN: input.inviteToken }, + }); + const memberId = (() => { + try { + return JSON.parse(joined.stdout).member_id ?? null; + } + catch { + return null; + } + })(); + if (joined.code !== 0 || !memberId) + throw localError("join_failed", `${seat.name} could not join the Room: ${(joined.stdout + joined.stderr).trim().slice(0, 400)}`); + seat.member_id = memberId; + } + log(`goal: ${records.map((seat) => `${seat.name} (${seat.member_id})`).join(", ")} joined ${input.roomId}\n`); + }; + try { + try { + await setUp(); + } + catch (error) { + const reason = error instanceof Error ? error.message : "error"; + await client + .request("POST", `/rooms/${encodeURIComponent(input.roomId)}/close`, apiKey, { detail: `goal run could not start: ${reason}`.slice(0, 2_000) }) + .catch(() => undefined); + throw error; + } + const recoveryCuts = new Set(); + const cancelTurn = async (seat) => { + recoveryCuts.add(seat.name); + const container = containerName(input.roomId, seat.name); + // Stop all seat descendants, retaining its home, session and shared workspace. + const stopped = await docker(["stop", "--timeout", "5", container], { timeoutMs: 15_000 }); + if (stopped.code !== 0) throw new Error("Recovery stop failed"); + const inspected = await docker(["inspect", "--format", "{{.State.Running}}", container], { timeoutMs: 15_000 }); + if (inspected.code !== 0 || inspected.stdout.trim() !== "false") throw new Error("Recovery process exit unconfirmed"); + if (ended) return; + const started = await docker(["start", container], { timeoutMs: 15_000 }); + if (started.code !== 0) throw new Error("Recovery container restart failed"); + const port = loopbackPort(base); + if (port !== null) { + const forwarded = await docker(["exec", "--detach", "--user", AGENT_USER, container, "node", "-e", FORWARDER, String(port)]); + if (forwarded.code !== 0) throw new Error("Recovery forwarder restart failed"); + let ready = false; + for (let i = 0; i < 20 && !ready; i++) { + ready = (await docker(["exec", "--user", AGENT_USER, container, "node", "-e", FORWARDER_PROBE, String(port)])).code === 0; + if (!ready) await sleep(250); + } + if (!ready) throw new Error("Recovery forwarder unavailable"); + } + }; + const runTurn = async (seat, prompt, wake) => { + recoveryCuts.delete(seat.name); + seat.turns += 1; + const number = String(seat.turns).padStart(3, "0"); + const directory = join(input.out, "agents", seat.name); + await mkdir(directory, { recursive: true }); + const stream = join(directory, `turn-${number}.jsonl`); + await writeFile(stream, ""); + const reader = new TurnReader(seat.driver); + const startedAt = dependencies.now().toISOString(); + let writes = Promise.resolve(); + let rateLimited = false; + const command = turnCommand(seat, prompt, seat.session_id, input.webSearch ?? "live", turnLimitSeconds); + if (mode.enabled && seat.driver === "claude-code") command.unshift("timeout", String(turnLimitSeconds)); + const result = await exec(seat, command, { + onLine: (line) => { + reader.line(line); + if (/\b429\b|rate.limit|too many requests/i.test(line)) rateLimited = true; + writes = writes.then(() => appendFile(stream, `${line}\n`)); + }, + }); + await writes; + if (result.stderr.trim()) + await writeFile(join(directory, `turn-${number}.stderr.txt`), result.stderr); + const reading = reader.reading; + seat.session_id = reading.sessionId ?? seat.session_id; + const spent = account(seat, reading.usage, seat.driver === "codex"); + // A turn the end of the goal stopped did not fail; its spend is lost with it, as the harness reports at a turn's end. + const cut = ended && result.code !== 0; + const cancelledForRecovery = recoveryCuts.has(seat.name); + const failed = !cut && !cancelledForRecovery && (result.code !== 0 || reading.failed); + if (failed) + seat.failed_turns += 1; + if (mode.enabled) seat.last_failure = failed ? { exit_code: result.code, + rate_limit_observed: rateLimited || /\b429\b|rate.limit|too many requests/i.test(result.stderr), + harness_failed: reading.failed, turn: seat.turns } : null; + await appendFile(wakeLog, `${JSON.stringify({ + seat: seat.name, + turn: seat.turns, + wake_id: wake?.wake_id ?? null, + fired: wake?.fired ?? ["goal"], + from: wake?.from ?? null, + through: wake?.through ?? null, + messages: wake?.messages.length ?? 0, + started_at: startedAt, + ended_at: dependencies.now().toISOString(), + exit_code: result.code, + failed, + session_id: seat.session_id, + tokens: spent, + cut_by_end: cut, + ...(mode.enabled ? { cancelled_for_recovery: cancelledForRecovery } : {}), + })}\n`); + return failed; + }; + // One policy for every seat: woken by anything another member says, merged over 2 s, until the Room closes. + const seatLoop = async (seat) => { + let prompt = openingPrompt({ seat, seats: plan.agents, roomId: input.roomId, goal: input.goal, goalSequence: input.goalSequence, until: input.until }); + let wake = null; + let failedInARow = 0; + while (!ended) { + const failed = await runTurn(seat, prompt, wake); + if (wake?.wake_id) + await exec(seat, ["sharednet", "ack", wake.wake_id, "--json"]); + failedInARow = failed ? failedInARow + 1 : 0; + if (ended) + break; + if (failedInARow >= FAILED_TURNS_LIMIT) { + log(`goal: ${seat.name} failed ${FAILED_TURNS_LIMIT} turns in a row and takes no more; see agents/${seat.name}/\n`); + break; + } + const waited = await exec(seat, ["sharednet", "wait", "--on", "message", "--on", "closed", "--settle", "2s", "--ack", "manual", "--json"]); + if (ended || waited.code !== 0) + break; + try { + wake = JSON.parse(waited.stdout); + } + catch { + break; + } + if (wake.fired.includes("closed")) + break; + prompt = wakePrompt(wake); + } + }; + if (mode.enabled) { + await writeFile(join(input.out, "coordination.ndjson"), ""); + coordination = new Coordination({ records, options: mode, goal: input.goal, roomId: input.roomId, until: input.until, + runTurn, cancelTurn, notifyRecovery: content => client.request("POST", `/rooms/${encodeURIComponent(input.roomId)}/owner-messages`, apiKey, { content, as: "runner" }), now: dependencies.now, event: event => { + coordinationWrites = coordinationWrites.then(() => appendFile(join(input.out, "coordination.ndjson"), `${JSON.stringify(event)}\n`)) + .catch(error => { coordinationWriteError = error; }); + } }); + } + const seats = coordination ? [] : records.map((seat) => seatLoop(seat).catch((error) => { + log(`goal: ${seat.name} stopped (${error instanceof Error ? error.message : "error"})\n`); + })); + const watched = await goalWatch(client, memberToken, apiKey, { roomId: input.roomId, workspace: plan.workspace, out: input.out, checkEveryMs: input.checkEveryMs, quietChecks: input.quietChecks }, { + now: dependencies.now, + ...(dependencies.sleep ? { sleep: dependencies.sleep } : {}), + stderr: log, + // A check runs in its own container, on the shared workspace: what one Agent installed for itself does not count. + check: async (command) => { + const result = await exec(null, ["sh", "-c", command]); + return { exitCode: result.code, output: `${result.stdout}${result.stderr}` }; + }, + spend, + ...(coordination ? { coordination } : {}), + }); + ended = true; + coordination?.finish(watched.ended_by); + // Stopping the containers ends every turn and every wait still running. + await docker(["stop", "--time", "5", ...containers]); + await Promise.all(seats); + await coordination?.drain(); + // Each Agent's own trace is its home: Codex keeps sessions in ~/.codex, Claude Code in ~/.claude. + for (const seat of records) { + const target = join(input.out, "agents", seat.name, "home"); + await mkdir(dirname(target), { recursive: true }); + await rm(target, { recursive: true, force: true }); + const copied = await docker(["cp", `${containerName(input.roomId, seat.name)}:${CONTAINER_HOMES}/${seat.name}`, target]); + if (copied.code !== 0) + log(`goal: ${seat.name}'s home could not be copied out: ${copied.stderr.trim()}\n`); + await scrubHome(target); + // The record's numbers come from the session's own file when there is one: it saw every turn, a cut one included. + if (seat.driver === "codex" && seat.session_id) { + const usage = await sessionFileUsage(join(target, ".codex", "sessions"), seat.session_id); + if (usage) + account(seat, usage, true); + } + } + const episodePath = join(input.out, "episode.json"); + const episode = JSON.parse(await readFile(episodePath, "utf8")); + await writeFile(episodePath, `${JSON.stringify({ + ...episode, + image: plan.image, + web_search: input.webSearch ?? "live", + turn_limit_s: turnLimitSeconds, + agents: records, + ...(coordination ? { status: coordination.status, coordination: coordination.summary() } : {}), + totals: { ...episode.totals, tokens: spend(), cached_tokens: records.reduce((sum, seat) => sum + seat.usage.cached, 0) }, + }, null, 2)}\n`); + if (coordination) { + await coordinationWrites; + if (coordinationWriteError) throw coordinationWriteError; + await writeFile(join(input.out, "coordination.json"), `${JSON.stringify(coordination.summary(), null, 2)}\n`); + } + return { ...watched, agents: records, tokens: spend(), ...(coordination ? { status: coordination.status, coordination: coordination.summary() } : {}) }; + } + finally { + ended = true; + if (coordination && !coordination.stopped) coordination.finish({ trigger: "runner error" }); + await docker(["rm", "--force", ...containers]); + await coordination?.drain(); + await coordinationWrites; + if (coordination) await writeFile(join(input.out, "coordination.json"), `${JSON.stringify(coordination.summary(), null, 2)}\n`); + } +} +/** The usage in a Codex home's session file for one session, read after the run. */ +async function sessionFileUsage(sessions, sessionId) { + const files = await readdir(sessions, { recursive: true }).catch(() => []); + const file = files.find((name) => name.endsWith(`${sessionId}.jsonl`)); + return file ? codexSessionUsage(await readFile(join(sessions, file), "utf8")) : null; +} +/** A record is made to be handed on: the seat's token and the harness's sign-in leave it. */ +export async function scrubHome(home) { + for (const secret of [".config/sharednet", ".codex/auth.json", ".claude/.credentials.json"]) { + await rm(join(home, secret), { recursive: true, force: true }); + } +} diff --git a/experiments/planner-quorum-client/dist/goal.d.ts b/experiments/planner-quorum-client/dist/goal.d.ts new file mode 100644 index 0000000..cb91a91 --- /dev/null +++ b/experiments/planner-quorum-client/dist/goal.d.ts @@ -0,0 +1,106 @@ +import type { Coordination } from "./coordination.js"; +import type { ApiClient } from "./api-client.ts"; +import { type Trigger } from "./triggers.ts"; +/** A budget is counted in tokens, the one spend both harnesses report; it needs `goal run`, which reads it. */ +export type UntilTrigger = Trigger | { + kind: "budget"; + label: string; + tokens: number; +}; +/** + * One `--until`, in the words of `wait --on` plus `budget`, as the service + * takes it: `at 17:30` becomes the next 17:30 with this machine's offset, + * because the service cannot know this machine's clock. + */ +export declare function parseUntil(raw: string, now: number): { + wire: string; + trigger: UntilTrigger; +}; +/** + * Every `--until`, refusing a goal that names no bound before anything is sent. A budget is + * only offered where it is measured: `goal run` starts the Agents and reads their usage; a + * Room whose Agents someone else started would carry a bound that nothing enforces. + */ +export declare function parseUntilList(raws: readonly string[], now: number, options?: { + budget?: boolean; +}): { + wire: string[]; + triggers: UntilTrigger[]; +}; +/** Make a Room this account owns a goal Room: the goal is said as the owner's first line. */ +export declare function startGoal(client: ApiClient, apiKey: string, roomId: string, content: string, until: readonly string[]): Promise<{ + room: unknown; + message: unknown; +}>; +export type CheckRunner = (command: string, cwd: string) => Promise<{ + exitCode: number; + output: string; +}>; +export interface GoalWatchDependencies { + coordination?: Coordination; + now: () => Date; + sleep?: (ms: number) => Promise; + stderr?: (value: string) => void; + /** Runs a `check` in the workspace; tests replace it. */ + check?: CheckRunner; + /** Takes a workspace snapshot; tests replace it, and null turns snapshots off. */ + snapshot?: ((label: string) => Promise) | null; + /** Tokens the Agents have used so far, when this runner started them (`goal run`); a budget is judged on it. */ + spend?: () => number; +} +export interface GoalWatchOptions { + roomId: string; + /** Where the Agents work; checks run here and its snapshots are taken from here. */ + workspace: string; + /** Where the record goes: room.ndjson, checks.ndjson, episode.json, workspace.git. */ + out: string; + checkEveryMs: number; + /** Do not post a failing check back into the Room (a controlled experiment). */ + quietChecks: boolean; + /** Read the Room's state at least this often, to notice an end the service or the owner made. */ + roomCheckMs?: number; +} +/** No one request may take longer: the long-poll holds 25 s, so 30 s means the line went dead. */ +export declare const REQUEST_LIMIT_MS = 30000; +/** + * A fetch that gives up on any one request after `limitMs`. A runner sits for hours, and a + * connection dropped without a reset would otherwise hold it forever: checks unrun, clock unread. + */ +export declare function withRequestLimit(fetchImplementation: typeof globalThis.fetch, limitMs?: number): typeof globalThis.fetch; +/** + * Snapshots of the shared workspace in a git directory of their own, so the + * Agents' own use of git is untouched: one commit per tick that changed + * anything, tagged with the Room sequence it follows, so "what was said at #17" + * and "what the files were at #17" line up. A record is made to be handed on, + * so it never takes an .env file; and it leaves out the CLI's seat state + * (.sharednet/), which is bookkeeping, not work. + */ +export declare function workspaceSnapshots(workspace: string, out: string): (label: string) => Promise; +export interface GoalWatchResult { + room_id: string; + ended_by: { + trigger: string; + at: string; + detail: string | null; + } | null; + messages: number; + checks: number; + snapshots: number; + out: string; +} +/** + * Sit in a goal Room until one of its end triggers fires, then close it with + * that trigger and write the record. `said` is a claim: when the goal also has + * a `check`, a claim runs the check and only a passing check ends the Room; a + * failing one is said back into the Room by the runner, unless the run is an + * experiment that must not intervene. + */ +export declare function goalWatch(client: ApiClient, memberToken: string, apiKey: string, options: GoalWatchOptions, dependencies: GoalWatchDependencies): Promise; +/** The Room's log and its goal, written out without watching: for a Room that has already ended. */ +export declare function goalExport(client: ApiClient, memberToken: string, roomId: string, out: string): Promise<{ + room_id: string; + messages: number; + out: string; +}>; +/** The goal file, read as the Room will see it. */ +export declare function readGoalFile(cwd: string, path: string): Promise; diff --git a/experiments/planner-quorum-client/dist/goal.js b/experiments/planner-quorum-client/dist/goal.js new file mode 100644 index 0000000..20cd180 --- /dev/null +++ b/experiments/planner-quorum-client/dist/goal.js @@ -0,0 +1,505 @@ +import { execFile } from "node:child_process"; +import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { CliError, localError } from "./errors.js"; +import { mentions, parseTrigger, saidIn } from "./triggers.js"; +/** + * Goal mode on this machine (decision 2026-10-05): start a goal Room, watch it + * until the first of its end triggers fires, close it with that trigger, and + * keep the record. The service ends the Room itself on `count`, `after` and + * `at`; everything that needs this machine (`check`, `idle`, what was said) is + * evaluated here. + */ +/** Kinds an end condition may use. `every`, `cron` and `closed` wake a wait; they do not end a goal. */ +const UNTIL_KINDS = new Set(["count", "after", "at", "idle", "said", "mention", "message", "check"]); +const BOUND_KINDS = new Set(["count", "after", "at", "budget"]); +/** A local time with this machine's offset: `2026-10-05T17:30:00+08:00`. */ +function isoWithOffset(ms) { + const at = new Date(ms); + const pad = (value, width = 2) => String(Math.trunc(Math.abs(value))).padStart(width, "0"); + const offset = -at.getTimezoneOffset(); + const sign = offset >= 0 ? "+" : "-"; + return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}${sign}${pad(offset / 60)}:${pad(offset % 60)}`; +} +/** + * One `--until`, in the words of `wait --on` plus `budget`, as the service + * takes it: `at 17:30` becomes the next 17:30 with this machine's offset, + * because the service cannot know this machine's clock. + */ +export function parseUntil(raw, now) { + const budget = /^budget\s*:?\s*(.+)$/.exec(raw.trim()); + if (budget) { + const tokens = /^(\d+(?:\.\d+)?)\s*([kKmM]?)(?:\s*tokens)?$/.exec(budget[1].trim()); + if (!tokens) + throw localError("invalid_trigger", "--until budget is counted in tokens: budget 2M tokens, or budget 500k tokens."); + const unit = tokens[2].toUpperCase(); + const label = `budget ${tokens[1]}${unit === "K" ? "k" : unit} tokens`; + const scale = unit === "M" ? 1_000_000 : unit === "K" ? 1_000 : 1; + return { wire: label, trigger: { kind: "budget", label, tokens: Math.round(Number(tokens[1]) * scale) } }; + } + const trigger = parseTrigger(raw, now); + if (!UNTIL_KINDS.has(trigger.kind)) { + throw localError("invalid_trigger", `--until ${trigger.kind} wakes a wait; it does not end a goal. Use count, after, at, idle, said, mention, message, check, or budget.`); + } + if (trigger.kind === "at") { + const wire = `at ${isoWithOffset(trigger.time)}`; + return { wire, trigger: { ...trigger, label: wire } }; + } + return { wire: trigger.label, trigger }; +} +/** + * Every `--until`, refusing a goal that names no bound before anything is sent. A budget is + * only offered where it is measured: `goal run` starts the Agents and reads their usage; a + * Room whose Agents someone else started would carry a bound that nothing enforces. + */ +export function parseUntilList(raws, now, options = {}) { + const bounds = options.budget ? "count, after, at, or budget" : "count, after, or at"; + if (raws.length === 0) + throw localError("invalid_arguments", `A goal needs at least one --until, and one of them a bound: ${bounds}.`); + const parsed = raws.map((raw) => parseUntil(raw, now)); + if (!options.budget && parsed.some((entry) => entry.trigger.kind === "budget")) { + throw localError("budget_needs_goal_run", "--until budget is counted from the Agents' own usage, which only goal run reads. Start the goal with goal run, or bound it with count, after or at."); + } + if (!parsed.some((entry) => BOUND_KINDS.has(entry.trigger.kind))) { + throw localError("goal_unbounded", `A goal Room needs a bound it cannot outrun: add --until with ${bounds}.`); + } + const wire = [...new Set(parsed.map((entry) => entry.wire))]; + return { wire, triggers: parsed.map((entry) => entry.trigger) }; +} +/** Make a Room this account owns a goal Room: the goal is said as the owner's first line. */ +export async function startGoal(client, apiKey, roomId, content, until) { + return client.request("POST", `/rooms/${encodeURIComponent(roomId)}/goal`, apiKey, { content, until }); +} +/** The wait the runner sits in, bounded the way the service bounds every wait. */ +const WAIT_MAX_SECONDS = 25; +const CHECK_OUTPUT_LIMIT = 4_000; +/** No one request may take longer: the long-poll holds 25 s, so 30 s means the line went dead. */ +export const REQUEST_LIMIT_MS = 30_000; +const OUTAGE_BASE_MS = 1_000; +const OUTAGE_CAP_MS = 30_000; +/** Closing is the runner's last word; it is worth a minute of trying. */ +const CLOSE_ATTEMPTS = 6; +/** How often a budget is looked at while nothing is said. */ +const BUDGET_LOOK_MS = 5_000; +/** + * A fetch that gives up on any one request after `limitMs`. A runner sits for hours, and a + * connection dropped without a reset would otherwise hold it forever: checks unrun, clock unread. + */ +export function withRequestLimit(fetchImplementation, limitMs = REQUEST_LIMIT_MS) { + return (input, init) => { + const limit = AbortSignal.timeout(limitMs); + return fetchImplementation(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, limit]) : limit }); + }; +} +/** Not reaching the service, or the service failing, is weather to wait out; a refusal is an answer. */ +function isOutage(error) { + return error instanceof CliError && error.exitCode === 5; +} +function tail(value) { + const characters = [...value]; + return characters.length <= CHECK_OUTPUT_LIMIT ? value : characters.slice(-CHECK_OUTPUT_LIMIT).join(""); +} +function defaultCheck(command, cwd) { + return new Promise((resolveCheck) => { + execFile("sh", ["-c", command], { cwd, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { + const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0; + resolveCheck({ exitCode: code, output: `${stdout}${stderr}` }); + }); + }); +} +function git(args, cwd) { + return new Promise((resolveGit) => { + execFile("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) => { + const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0; + resolveGit({ code, stdout: String(stdout) }); + }); + }); +} +/** + * Snapshots of the shared workspace in a git directory of their own, so the + * Agents' own use of git is untouched: one commit per tick that changed + * anything, tagged with the Room sequence it follows, so "what was said at #17" + * and "what the files were at #17" line up. A record is made to be handed on, + * so it never takes an .env file; and it leaves out the CLI's seat state + * (.sharednet/), which is bookkeeping, not work. + */ +export function workspaceSnapshots(workspace, out) { + const gitDir = join(out, "workspace.git"); + const base = ["-c", "user.name=sharednet goal", "-c", "user.email=goal@sharednet.invalid", `--git-dir=${gitDir}`, `--work-tree=${workspace}`]; + let ready = null; + const prepare = async () => { + await mkdir(join(gitDir, "info"), { recursive: true }); + await git([`--git-dir=${gitDir}`, "init", "--quiet"], workspace); + const excluded = [".sharednet/", ".env", ".env.*", "!.env.example"]; + // The record must not record itself when it lives inside the workspace. + const inside = relative(workspace, out); + if (inside !== "" && !inside.startsWith("..") && !isAbsolute(inside)) { + excluded.push(`/${inside.split("\\").join("/")}/`); + } + await writeFile(join(gitDir, "info", "exclude"), `${excluded.join("\n")}\n`); + }; + return async (label) => { + ready ??= prepare(); + await ready; + await git([...base, "add", "-A"], workspace); + const staged = await git([...base, "diff", "--cached", "--quiet"], workspace); + const empty = (await git([...base, "rev-parse", "--verify", "HEAD"], workspace)).code !== 0; + if (staged.code === 0 && !empty) + return null; + await git([...base, "commit", "--quiet", "--allow-empty", "-m", label], workspace); + const tag = label.replace(/[^A-Za-z0-9._-]+/g, "-"); + await git([...base, "tag", "-f", tag], workspace); + return (await git([...base, "rev-parse", "HEAD"], workspace)).stdout.trim() || null; + }; +} +/** + * Sit in a goal Room until one of its end triggers fires, then close it with + * that trigger and write the record. `said` is a claim: when the goal also has + * a `check`, a claim runs the check and only a passing check ends the Room; a + * failing one is said back into the Room by the runner, unless the run is an + * experiment that must not intervene. + */ +export async function goalWatch(client, memberToken, apiKey, options, dependencies) { + const coordination = dependencies.coordination; + const quorum = coordination?.allDoneMode ?? false; + const sleep = dependencies.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))); + const log = dependencies.stderr ?? (() => undefined); + const runCheck = dependencies.check ?? defaultCheck; + const snapshot = dependencies.snapshot === undefined ? workspaceSnapshots(options.workspace, options.out) : dependencies.snapshot; + const roomPath = `/rooms/${encodeURIComponent(options.roomId)}`; + const roomCheckMs = options.roomCheckMs ?? 30_000; + const readRoom = () => client.request("GET", roomPath, memberToken); + let view = await readRoom(); + const goal = view.room?.goal ?? null; + if (!goal) + throw localError("not_a_goal_room", "That Room has no goal. Start one with sharednet room create --goal --until ."); + const startedAt = Date.parse(goal.started_at); + // A budget this CLI cannot read (a dollar amount, from an older CLI) is kept by the service and left unjudged here. + const triggers = goal.until.flatMap((raw) => { + try { + return [parseUntil(raw, dependencies.now().getTime()).trigger]; + } + catch (error) { + if (/^budget\b/.test(raw)) + return []; + throw error; + } + }); + const checks = triggers.filter((trigger) => trigger.kind === "check"); + const said = triggers.filter((trigger) => trigger.kind === "said"); + // The person a `mention` addresses is the goal's speaker; the runner's own lines never count as the Agents'. + const goalSpeaker = (view.memberships ?? []).find((member) => member.runtime_kind === "human") ?? null; + const runnerSeats = new Set((view.memberships ?? []).filter((member) => member.runtime_kind === "runner").map((member) => member.member_id ?? "")); + await mkdir(options.out, { recursive: true }); + const roomLog = join(options.out, "room.ndjson"); + const checkLog = join(options.out, "checks.ndjson"); + // The Room's log is read again from its start; checks a stopped runner already ran are history and stay. + await writeFile(roomLog, ""); + const earlierChecks = await readFile(checkLog, "utf8").then((text) => text.split("\n").filter(Boolean).length, () => null); + if (earlierChecks === null) + await writeFile(checkLog, ""); + const runnerStartedAt = dependencies.now().toISOString(); + let cursor = 0; + let messages = 0; + let checkCount = earlierChecks ?? 0; + let snapshots = 0; + let lastMessageAt = null; + let lastRoomCheckAt = dependencies.now().getTime(); + const checkState = new Map(); + for (const check of checks) + checkState.set(check.label, { passing: false, ranAt: null }); + let end = null; + let quorumCheck = null; // Results need a subsequent empty Room poll before approval. + let endedByService = goal.ended_by; + const takeSnapshot = async (label) => { + if (!snapshot) + return; + if ((await snapshot(label).catch(() => null)) !== null) + snapshots += 1; + }; + /** Runs one check; returns whether it passes now, and its output. */ + const evaluate = async (check, cause) => { + const result = await runCheck(check.command, options.workspace); + const at = dependencies.now(); + const state = checkState.get(check.label); + state.ranAt = at.getTime(); + const passing = result.exitCode === 0; + const startedPassing = passing && !state.passing; + state.passing = passing; + checkCount += 1; + await appendFile(checkLog, `${JSON.stringify({ at: at.toISOString(), trigger: check.label, command: check.command, cause, exit_code: result.exitCode, passed: passing, sequence: cursor, output: tail(result.output) })}\n`); + return { passing, startedPassing, exitCode: result.exitCode, output: tail(result.output) }; + }; + const feedback = async (content) => { + if (options.quietChecks) + return; + try { + const posted = await client.request("POST", `${roomPath}/owner-messages`, apiKey, { content, as: "runner" }); + if (posted?.message?.sender_instance_id) + runnerSeats.add(posted.message.sender_instance_id); + } + catch (error) { + log(`goal: the check result was not posted (${error instanceof CliError ? error.code : "error"})\n`); + } + }; + /** An outage is ridden out with a growing pause, and the loop goes on: the clock and the checks still count. */ + let failedAttempts = 0; + const rideOut = async (error, doing) => { + if (!isOutage(error)) + throw error; + const pause = Math.min(OUTAGE_BASE_MS * 2 ** failedAttempts, OUTAGE_CAP_MS); + failedAttempts += 1; + log(`goal: the service did not answer while ${doing} (${error.code}); trying again in ${Math.round(pause / 1000)}s, attempt ${failedAttempts}\n`); + await sleep(pause); + }; + const answered = () => { + if (failedAttempts > 0) + log(`goal: the service answers again, after ${failedAttempts} failed attempt${failedAttempts === 1 ? "" : "s"}\n`); + failedAttempts = 0; + }; + await takeSnapshot(earlierChecks === null ? "start" : "restart"); + log(`goal: watching ${options.roomId} until ${goal.until.join(" | ")}\n`); + const spend = dependencies.spend ?? null; + for (const trigger of triggers) { + if (trigger.kind === "budget" && !spend) + log(`goal: ${trigger.label} is not measured here; goal watch did not start the Agents, so it cannot read their spend\n`); + } + for (;;) { + if (endedByService) + break; + const now = dependencies.now().getTime(); + // What has come round on the clock, or gone quiet for long enough. + for (const trigger of triggers) { + if (end) + break; + if (trigger.kind === "count" && cursor - goal.sequence >= trigger.count) + end = { trigger: trigger.label, detail: null }; + if (trigger.kind === "after" && now >= startedAt + trigger.ms) + end = { trigger: trigger.label, detail: null }; + if (trigger.kind === "at" && now >= trigger.time) + end = { trigger: trigger.label, detail: null }; + if (trigger.kind === "idle" && lastMessageAt !== null && now - lastMessageAt >= trigger.ms) + end = { trigger: trigger.label, detail: null }; + if (trigger.kind === "budget" && spend) { + const used = spend(); + if (used >= trigger.tokens) + end = { trigger: trigger.label, detail: `${used} tokens` }; + } + } + if (!end && coordination?.phase === "failed") end = { trigger: "owner", detail: "All experimental agents exited without completing the goal." }; + // Experimental quorum checks only run after all DONE turns have drained. + // Legacy scheduled checks remain unchanged when the option is off. + // A check that has started to pass ends it; checked on its schedule. + for (const check of quorum ? [] : checks) { + if (end) + break; + const state = checkState.get(check.label); + if (state.ranAt !== null && now - state.ranAt < options.checkEveryMs) + continue; + const result = await evaluate(check, "schedule"); + if (result.startedPassing) + end = { trigger: check.label, detail: result.output.trim() || null }; + } + if (end) + break; + // Sit until something is said, or the next thing on the clock is due. + let waitMs = coordination ? 1000 : WAIT_MAX_SECONDS * 1000; + for (const trigger of triggers) { + if (trigger.kind === "after") + waitMs = Math.min(waitMs, startedAt + trigger.ms - now); + if (trigger.kind === "at") + waitMs = Math.min(waitMs, trigger.time - now); + if (trigger.kind === "idle" && lastMessageAt !== null) + waitMs = Math.min(waitMs, lastMessageAt + trigger.ms - now); + // Spend grows between messages, as turns end: look at it every few seconds. + if (trigger.kind === "budget" && spend) + waitMs = Math.min(waitMs, BUDGET_LOOK_MS); + } + for (const check of quorum ? [] : checks) { + const ranAt = checkState.get(check.label).ranAt; + waitMs = Math.min(waitMs, ranAt === null ? 0 : ranAt + options.checkEveryMs - now); + } + const timeout = Math.min(WAIT_MAX_SECONDS, Math.max(0, Math.ceil(waitMs / 1000))); + let items = []; + // A closed Room answers every wait at once; read its end now rather than ask again in a loop. + let closed = false; + try { + const page = await client.request("GET", `${roomPath}/wait?after=${cursor}&timeout=${timeout}`, memberToken); + items = Array.isArray(page?.items) ? page.items : []; + answered(); + } + catch (error) { + if (error instanceof CliError && error.code === "room_closed") + closed = true; + else { + await rideOut(error, "waiting in the Room"); + continue; + } + } + let claim = null; + for (const item of items.sort((left, right) => left.sequence - right.sequence)) { + if (!Number.isSafeInteger(item.sequence) || item.sequence <= cursor) + continue; + cursor = item.sequence; + messages += 1; + await appendFile(roomLog, `${JSON.stringify(item)}\n`); + if (item.sequence <= goal.sequence) + continue; + coordination?.observe(item); + const sender = item.sender_instance_id ?? item.sender?.member_id ?? ""; + if (runnerSeats.has(sender)) + continue; + lastMessageAt = dependencies.now().getTime(); + if (end) + continue; + if (triggers.some((trigger) => trigger.kind === "message")) + end = { trigger: "message", detail: item.content.slice(0, 500) }; + else if (goalSpeaker !== null && + triggers.some((trigger) => trigger.kind === "mention") && + mentions(item.content, { memberId: goalSpeaker.member_id ?? "", name: goalSpeaker.name ?? null })) { + end = { trigger: "mention", detail: item.content.slice(0, 500) }; + } + else if (said.some((trigger) => saidIn(item.content, trigger)) && claim === null) { + claim = item; + } + } + if (items.length > 0) + await takeSnapshot(`seq ${cursor}`); + // A claim of being done: the check decides when there is one, and a failing check is said back. + if (!end && claim && !quorum) { + const claimed = said.find((trigger) => saidIn(claim.content, trigger)); + if (checks.length === 0) { + end = { trigger: claimed.label, detail: claim.content.slice(0, 500) }; + } + else { + for (const check of checks) { + const result = await evaluate(check, "claim"); + if (result.passing) { + end = { trigger: check.label, detail: result.output.trim() || null }; + break; + } + await feedback(`#${claim.sequence} says done, but \`${check.command}\` still fails (exit ${result.exitCode}):\n\n${tail(result.output).trim() || "(no output)"}`); + } + } + } + if (end) + break; + // An empty poll drains messages posted just before a harness turn exits. + // Never accept a DONE from a still-running turn, or check mid-write. + const boundedOut = triggers.some(trigger => + (trigger.kind === "after" && dependencies.now().getTime() >= startedAt + trigger.ms) || + (trigger.kind === "at" && dependencies.now().getTime() >= trigger.time) || + (trigger.kind === "budget" && spend && spend() >= trigger.tokens)); + if (quorumCheck && (!coordination.ready || quorumCheck.revision !== coordination.revision)) { + coordination.event?.("final_check_invalidated", { checked_revision: quorumCheck.revision, + current_revision: coordination.revision, sequence: cursor }); + quorumCheck = null; + } + if (!closed && !boundedOut && items.length === 0 && quorum && coordination.ready) { + if (quorumCheck === null) { + const revision = coordination.revision; + const results = []; + for (const check of checks) results.push(await evaluate(check, "all_agents_done")); + quorumCheck = { revision, results }; + // Never close from inside the check pass: first read every post + // that arrived while checks were running. Outages cannot skip + // this barrier; bounds are re-evaluated at the top of the loop. + continue; + } + const { revision, results } = quorumCheck; + quorumCheck = null; + if (results.every(result => result.passing)) { + coordination.approveCompletion(revision); + end = { trigger: checks[0].label, detail: "Every active member finished its turn; all final checks passed; post-check Room messages drained." }; + break; + } + const detail = results.map((result, i) => `${checks[i].command}: exit ${result.exitCode}\n${result.output}`).join("\n"); + coordination.checkFailed(detail); + await feedback(`All agents said DONE, but final checks failed:\n${detail}`); + } + // The service ends a goal on count, after and at by itself, and the owner may close it from + // the Dashboard: read the Room now and then so either is noticed. + const at = dependencies.now().getTime(); + if (closed || at - lastRoomCheckAt >= roomCheckMs || (items.length === 0 && timeout === 0)) { + lastRoomCheckAt = at; + try { + view = await readRoom(); + answered(); + } + catch (error) { + await rideOut(error, "reading the Room"); + continue; + } + if (closed || view.room?.state === "closed") { + endedByService = view.room?.goal?.ended_by ?? { trigger: "owner", at: new Date(at).toISOString(), detail: null }; + } + } + if (coordination && !closed && !endedByService && !boundedOut && items.length === 0) coordination.tick(); + if (items.length === 0 && timeout === 0) + await sleep(0); + } + // Close with the trigger that fired; the service keeps the first end it was told. + let endedBy = endedByService; + for (let attempt = 1; !endedBy && end; attempt += 1) { + try { + const closed = await client.request("POST", `${roomPath}/close`, apiKey, end); + endedBy = closed?.room?.goal?.ended_by ?? { ...end, at: dependencies.now().toISOString() }; + } + catch (error) { + if (isOutage(error) && attempt < CLOSE_ATTEMPTS) { + await rideOut(error, "closing the Room"); + continue; + } + log(`goal: the Room was not closed (${error instanceof CliError ? error.code : "error"}); its end is recorded here\n`); + endedBy = { ...end, at: dependencies.now().toISOString() }; + } + } + await takeSnapshot("end"); + const budget = spend ? [] : triggers.filter((trigger) => trigger.kind === "budget").map((trigger) => trigger.label); + const result = { room_id: options.roomId, ended_by: endedBy, messages, checks: checkCount, snapshots, out: options.out }; + await writeFile(join(options.out, "episode.json"), `${JSON.stringify({ + schema_version: 1, + room_id: options.roomId, + goal: { message_id: goal.message_id, sequence: goal.sequence, until: goal.until, started_at: goal.started_at }, + ended_by: endedBy, + runner: { started_at: runnerStartedAt, ended_at: dependencies.now().toISOString(), workspace: resolve(options.workspace) }, + totals: { messages, checks: checkCount, snapshots, ...(spend ? { tokens: spend() } : {}) }, + ...(budget.length > 0 ? { not_measured: budget.map((label) => `${label}: goal watch did not start the Agents, so it cannot read their spend; goal run can`) } : {}), + }, null, 2)}\n`); + return result; +} +/** The Room's log and its goal, written out without watching: for a Room that has already ended. */ +export async function goalExport(client, memberToken, roomId, out) { + const roomPath = `/rooms/${encodeURIComponent(roomId)}`; + const view = await client.request("GET", roomPath, memberToken); + await mkdir(out, { recursive: true }); + const lines = []; + let after = 0; + for (;;) { + const page = await client.request("GET", `${roomPath}/messages?after=${after}&limit=100`, memberToken); + const items = Array.isArray(page?.items) ? page.items : []; + for (const item of items) { + lines.push(JSON.stringify(item)); + after = Math.max(after, item.sequence); + } + if (!page?.has_more || items.length === 0) + break; + } + await writeFile(join(out, "room.ndjson"), lines.length ? `${lines.join("\n")}\n` : ""); + await writeFile(join(out, "episode.json"), `${JSON.stringify({ schema_version: 1, room_id: roomId, goal: view.room?.goal ?? null, state: view.room?.state ?? null, totals: { messages: lines.length } }, null, 2)}\n`); + return { room_id: roomId, messages: lines.length, out }; +} +/** The goal file, read as the Room will see it. */ +export async function readGoalFile(cwd, path) { + try { + const content = await readFile(resolve(cwd, path), "utf8"); + if (content.trim() === "") + throw localError("invalid_arguments", "--goal names an empty file."); + return content; + } + catch (error) { + if (error instanceof CliError) + throw error; + throw localError("invalid_arguments", `--goal names a file that could not be read: ${path}`); + } +} diff --git a/experiments/planner-quorum-client/dist/guest.d.ts b/experiments/planner-quorum-client/dist/guest.d.ts new file mode 100644 index 0000000..dda940b --- /dev/null +++ b/experiments/planner-quorum-client/dist/guest.d.ts @@ -0,0 +1,47 @@ +import { type TurnRunner } from "./wake-driver.ts"; +/** + * The guest verbs: `join`, `say`, `wait`. They are sugar over the three HTTP + * requests in /skill.md and add only what text cannot hold — the member token + * kept owner-only outside the project, and the last sequence seen so a session + * that comes back later resumes where it stopped. Nothing here can do anything + * the curl lines cannot. + */ +type Environment = Record; +export interface GuestDependencies { + env: Environment; + fetch: typeof globalThis.fetch; + stdout: (value: string) => void; + stderr?: (value: string) => void; + cwd: string; + now: () => Date; + /** Sleeps between server long-polls that time out. Tests shorten it. */ + sleep?: (ms: number) => Promise; + /** Runs the `watch --run` command; the default shells out. Tests capture it. */ + exec?: CommandRunner; + /** Ends a resident command such as `serve --push`; a person ends it with Ctrl-C instead. */ + signal?: AbortSignal; + /** Runs one resumed turn of a seat's own session; tests replace it. */ + runTurn?: TurnRunner; + /** + * Starts `sharednet serve` in the background after a join, so the session that joined is woken + * when it is addressed. Only the real process supplies it; without it, a join starts nothing. + */ + startWakeService?: (input: { + env: Environment; + logFile: string; + }) => number | null; +} +export type CommandRunner = (command: string, input: string, env: Record) => Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}>; +/** The wait filters, validated the way `read` validates the same flags. */ +export declare function waitFiltersFrom(options: Map): URLSearchParams; +/** The read flags, as a query string: filter, order, window. `--last K` is newest-first, K of them. */ +export declare function messageQueryFrom(options: Map): URLSearchParams; +export declare const READ_OPTIONS: readonly ["after", "before", "limit", "order", "last", "from-instance", "from-agent", "grep"]; +export type GuestVerb = "timer" | "serve" | "whoami" | "join" | "say" | "read" | "wait" | "ack" | "balance" | "redeem" | "pay" | "ledger" | "upload" | "download" | "files" | "watch" | "add" | "rooms" | "requests" | "accept" | "deny" | "reach"; +export declare function isGuestVerb(value: string | undefined): value is GuestVerb; +export declare function runGuestVerb(verb: GuestVerb, args: string[], dependencies: GuestDependencies): Promise; +export {}; diff --git a/experiments/planner-quorum-client/dist/guest.js b/experiments/planner-quorum-client/dist/guest.js new file mode 100644 index 0000000..14fba4a --- /dev/null +++ b/experiments/planner-quorum-client/dist/guest.js @@ -0,0 +1,2383 @@ +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { isAbsolute, join as joinPath, resolve as resolvePathFrom } from "node:path"; +import { ApiClient, resolveBaseUrl, sameOrigin } from "./api-client.js"; +import { storeAccountCredential } from "./login.js"; +import { CliError, localError } from "./errors.js"; +import { computeLocalInstanceKey } from "./instance-computation.js"; +import { detectRuntime } from "./runtime-detection.js"; +import { mentions, nextCronTime, parseCount, parseDuration, parseTrigger, saidIn, triggerTakesParameter, wakeIdentity } from "./triggers.js"; +import { hasAccountCredential, refreshIfNeeded, registerInstance, resolveRuntime } from "./session.js"; +import { replyFrom, runTurn, sandboxed, seatWakeFrom, turnSpec, turnSummary, wakeTurnPrompt } from "./wake-driver.js"; +import { getOrCreateInstallationSecret, getStoragePaths, readProjectRoom, readRoomCredential, readSessionById, readStoredApiCredential, selectProjectSeat, writeProjectRoomState, writeRoomCredential, } from "./storage.js"; +/** A path the human typed, resolved against the directory the CLI was run in. */ +function resolvePath(cwd, path) { + return isAbsolute(path) ? path : resolvePathFrom(cwd, path); +} +const ROOM_ID_PATTERN = /^rom_[A-Za-z0-9]+$/; +const INVITE_TOKEN_PATTERN = /^rit_[A-Za-z0-9_-]{43}$/; +/** The server caps one wait at this; the client loops. */ +const WAIT_MAX_SECONDS = 25; +const VALUE_OPTIONS = new Set(["name", "token", "timeout", "reply-to", "min", "on", "run", "max-runs", "max-failures", "as", "claim", "agent", "runtime", "after", "before", "limit", "order", "last", "from-instance", "from-agent", "grep", "memo", "out", "rooms", "ack", "log", "settle", "check-every", "push", "listen"]); +/** Options that may be given more than once; every value is kept, in order. */ +const REPEATABLE_OPTIONS = new Set(["on"]); +const FLAG_OPTIONS = new Set(["hook", "private", "reply", "room", "force", "status", "stop", "no-wake"]); +function parseGuestArguments(args) { + const options = new Map(); + const positionals = []; + const repeated = new Map(); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (!argument.startsWith("--")) { + positionals.push(argument); + continue; + } + const separator = argument.indexOf("="); + const name = argument.slice(2, separator === -1 ? undefined : separator); + if (REPEATABLE_OPTIONS.has(name)) { + const value = separator === -1 ? args[++index] : argument.slice(separator + 1); + if (!value || value.startsWith("--")) { + throw localError("missing_option_value", `The --${name} option requires a value.`); + } + repeated.set(name, [...(repeated.get(name) ?? []), value]); + if (!options.has(name)) + options.set(name, value); + continue; + } + if (options.has(name)) { + throw localError("duplicate_option", `The --${name} option may be supplied only once.`); + } + if (FLAG_OPTIONS.has(name)) { + if (separator !== -1) { + throw localError("invalid_option", `The --${name} option does not accept a value.`); + } + options.set(name, true); + continue; + } + if (!VALUE_OPTIONS.has(name)) { + throw localError("unknown_option", "The command contains an unknown option."); + } + const value = separator === -1 ? args[++index] : argument.slice(separator + 1); + if (!value || value.startsWith("--")) { + throw localError("missing_option_value", `The --${name} option requires a value.`); + } + options.set(name, value); + } + return { options, positionals, repeated }; +} +function stringOption(parsed, name) { + const value = parsed.options.get(name); + return typeof value === "string" ? value : undefined; +} +function assertOnlyOptions(parsed, allowed) { + const allowedSet = new Set(allowed); + for (const name of parsed.options.keys()) { + if (!allowedSet.has(name)) { + throw localError("unknown_option", `The --${name} option is not valid for this command.`); + } + } +} +/** + * An invite is either a Room id with the token supplied separately, or the + * whole text the Web mints, pasted as one argument. The pasted form carries + * ROOM=, TOKEN=, and BASE= lines; anything else in it is ignored. + */ +function parseInvite(argument, parsed, env) { + let roomId; + let token = stringOption(parsed, "token") ?? env.SHAREDNET_INVITE_TOKEN?.trim() ?? undefined; + let base; + if (ROOM_ID_PATTERN.test(argument)) { + roomId = argument; + } + else { + roomId = /(?:^|\s)ROOM=(rom_[A-Za-z0-9]+)(?=\s|$)/.exec(argument)?.[1]; + const pastedToken = /(?:^|\s)TOKEN=(rit_[A-Za-z0-9_-]{43})(?=\s|$)/.exec(argument)?.[1]; + if (pastedToken) + token = pastedToken; + base = /(?:^|\s)BASE=(\S+)(?=\s|$)/.exec(argument)?.[1]; + } + if (!roomId) { + throw localError("invalid_invite", "Give a Room id (rom_…) or paste the whole invite, which carries ROOM= and TOKEN=."); + } + if (!token) { + throw localError("invite_token_required", "The invite token was not found. Paste the whole invite, or set SHAREDNET_INVITE_TOKEN."); + } + if (!INVITE_TOKEN_PATTERN.test(token)) { + throw localError("invalid_invite", "The invite token is not a SharedNet Room invite (rit_…)."); + } + // The invite says where the Room lives; an explicit environment wins over it. + const baseUrl = resolveBaseUrl(env.SHAREDNET_BASE_URL ?? base); + return { roomId, token, baseUrl }; +} +function defaultGuestName(runtime) { + return runtime.kind === "custom" ? "agent" : runtime.kind; +} +/** What the join tells the server about the driver, when one was recognised or declared. */ +function runtimeReport(runtime) { + if (runtime.kind === "custom") + return undefined; + return { + kind: runtime.kind, + version: runtime.version, + entrypoint: runtime.entrypoint, + source: runtime.source, + }; +} +/** + * What ties a seat to the session that took it: the local Instance key, a + * digest of this machine's installation secret and the driver's session id. + * Null when no driver session is detected; such a seat is selected by being + * the only one, or by --as. + */ +async function anchorKeyFor(env, paths) { + const detected = detectRuntime(env); + if (detected.anchor === null) + return null; + return computeLocalInstanceKey(await getOrCreateInstallationSecret(paths), detected.kind, detected.anchor); +} +/** A message this seat sent, by the Instance id the server reports or the one the seat file names. */ +function isOwn(item, me, memberId) { + const sender = item.sender?.member_id; + return sender === me || sender === memberId; +} +/** + * Who "I" am, from the server: a seat file written before migration 0007 + * still names a mem_ id, while senders are reported by Instance id now. A + * seat that already carries an Instance id needs no round trip. + */ +async function whoAmI(client, state, credential) { + if (/^i_[0-9A-Za-z]{10}$/.test(state.member_id)) + return state.member_id; + return client + .request("GET", "/instances/current", credential.member_token) + .then((payload) => payload?.instance?.id ?? state.member_id) + .catch(() => state.member_id); +} +function highestSequence(items, fallback) { + return items.reduce((max, item) => (Number.isSafeInteger(item.sequence) && item.sequence > max ? item.sequence : max), fallback); +} +function invalidServerResponse() { + return new CliError("invalid_server_response", "The SharedNet service returned an invalid response.", 5); +} +async function join(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["name", "token", "private", "as", "claim", "agent", "runtime", "no-wake"]); + if (parsed.positionals.length !== 1) { + throw localError("invalid_arguments", "Usage: sharednet join [--name ] [--agent ] [--runtime ] [--private] [--claim ] [--no-wake], or sharednet join [--as ]"); + } + // The session taking the seat is remembered, so being addressed in the Room resumes it. --no-wake + // (or SHAREDNET_WAKE=off, for a runner that wakes its seats itself) keeps the join to the seat. + const wakeOff = parsed.options.get("no-wake") === true || dependencies.env.SHAREDNET_WAKE?.trim() === "off"; + const wake = wakeOff ? null : seatWakeFrom(dependencies.env, dependencies.cwd); + // --agent: the tag (an a_ id or a handle) to group this seat under. A tag + // belongs to an account, so it needs one on this machine. + const agent = stringOption(parsed, "agent"); + // A Room id with no invite: this machine already holds a seat, and the + // seat was added to (or knows the id of) that Room. Enter it as that seat. + const argument = parsed.positionals[0]; + const inviteToken = stringOption(parsed, "token") ?? dependencies.env.SHAREDNET_INVITE_TOKEN?.trim(); + if (ROOM_ID_PATTERN.test(argument) && !inviteToken) { + return enterAsSeat(argument, stringOption(parsed, "as"), dependencies, { wake, wakeOff }); + } + const { roomId, token, baseUrl } = parseInvite(argument, parsed, dependencies.env); + // --runtime, else SHAREDNET_RUNTIME from the harness, else the driver + // detected. Resolved before anything leaves the machine, so a malformed + // declaration cannot spend a claim and then fail. + const runtimeFlag = stringOption(parsed, "runtime"); + const runtime = resolveRuntime(dependencies.env, runtimeFlag); + const name = stringOption(parsed, "name") ?? defaultGuestName(runtime); + // --private: strangers who know this seat's Instance id have to ask before + // seating it in another Room. Omitted, the seat is public. + const reach = parsed.options.get("private") === true ? "private" : undefined; + const paths = getStoragePaths(dependencies.env); + const client = new ApiClient(baseUrl, dependencies.fetch); + // A claim from the join page: the signed-in human minted it for their own + // account. Redeem it once for the account's key, keep the key in the + // credential file, and this seat is the account's from its first message. + const claim = stringOption(parsed, "claim"); + if (claim !== undefined) { + if (!/^clp_[A-Za-z0-9_-]{43}$/.test(claim)) { + throw localError("invalid_claim", "--claim takes the code from the join page, which starts with clp_."); + } + // A claim binds a machine to an account, and the first session to run the + // command does that binding. Handing the same command to a second session + // is the ordinary thing to do — several windows, one invite — so a claim + // that is already spent is not an error on a machine that is already the + // account. It is only an error where there is no account to fall back to. + const alreadyAnAccount = await hasAccountCredential(dependencies.env, paths, baseUrl); + try { + const redeemed = await client.request("POST", "/cli/claims/redeem", claim, {}); + if (redeemed?.state !== "approved" || !redeemed.api_key || !redeemed.principal?.id) + throw invalidServerResponse(); + await storeAccountCredential(paths, baseUrl, redeemed, dependencies.now()); + dependencies.stderr?.(`Claimed: this machine now acts as ${redeemed.principal.id}. The key is in ${paths.credentialsFile}.\n`); + } + catch (error) { + const code = error instanceof CliError ? error.code : ""; + const used = code === "login_consumed"; + const spent = used || code === "login_expired"; + if (!spent || !alreadyAnAccount) { + if (spent) { + throw localError("claim_spent", `That claim ${used ? "was already used" : "has expired"}, and this machine holds no SharedNet account. Open the join link again for a fresh command, or run \`npx -y sharednet@latest login\` here first.`); + } + throw error; + } + const stored = await readStoredApiCredential(paths).catch(() => null); + dependencies.stderr?.(`That claim ${used ? "was already used, which is what happens when the same command runs in a second session" : "has expired"}. This machine already acts as ${stored?.principal_id ?? "an account"}, so this seat is that account's.\n`); + } + } + // Two doors, one model. With a credential on this machine, the seat is an + // Instance of the account and the invite only admits it; without one, the + // join provisions an anonymous Principal. + if (await hasAccountCredential(dependencies.env, paths, baseUrl)) { + return joinAsAccount(roomId, token, name, baseUrl, paths, client, dependencies, reach, agent, wake, runtimeFlag); + } + if (agent !== undefined) { + throw localError("account_required", "--agent groups the seat under one of your account's tags; run `sharednet login` on this machine first, or join without it."); + } + const report = runtimeReport(runtime); + const payload = await client.request("POST", `/rooms/${encodeURIComponent(roomId)}/join`, token, { name, ...(report ? { runtime: report } : {}), ...(reach === undefined ? {} : { reach }) }); + const memberId = payload.membership?.member_id; + const memberToken = payload.member_token; + if (!payload.room?.id || !memberId || !memberToken || !Array.isArray(payload.history?.items)) { + throw invalidServerResponse(); + } + const credential = { + schema_version: 1, + base_url: baseUrl, + room_id: payload.room.id, + member_id: memberId, + name, + member_token: memberToken, + joined_at: dependencies.now().toISOString(), + ...(wake ? { wake } : {}), + }; + await writeRoomCredential(paths, credential); + const state = { + schema_version: 1, + base_url: baseUrl, + room_id: payload.room.id, + member_id: memberId, + last_sequence: highestSequence(payload.history.items, 0), + }; + await writeProjectRoomState(dependencies.cwd, state, { + anchorKey: await anchorKeyFor(dependencies.env, paths), + joinedAt: credential.joined_at, + }); + await historyHandled(client, payload.room.id, memberToken, state.last_sequence, dependencies); + // The one moment to say it: this seat belongs to nobody yet. + dependencies.stderr?.(`Joined ${payload.room.id} as ${memberId}, an anonymous seat. Run \`sharednet login\` on this machine to make it yours; it binds every seat this machine holds.\n`); + const woken = await wakeAfterJoin(credential, `@${name}`, paths, dependencies); + // Everything the Agent should report, and nothing it should not: the tokens + // stay in the credential file. + return { + room: payload.room, + member_id: memberId, + principal_id: payload.membership.principal_id ?? null, + as: "anonymous", + name, + last_sequence: state.last_sequence, + history: payload.history, + ...(woken ? { wake: woken } : {}), + }; +} +/** Every seat credential this machine holds, one per (Room, member). */ +async function heldSeats(paths) { + let roomDirs; + try { + roomDirs = await readdir(paths.roomsDir); + } + catch (error) { + if (error.code === "ENOENT") + return []; + throw error; + } + const seats = []; + for (const roomId of roomDirs) { + if (!ROOM_ID_PATTERN.test(roomId)) + continue; + let files; + try { + files = await readdir(joinPath(paths.roomsDir, roomId)); + } + catch { + continue; + } + for (const file of files) { + const memberId = file.replace(/\.json$/, ""); + if (!/^(?:i|mem)_[A-Za-z0-9]+$/.test(memberId)) + continue; + const credential = await readRoomCredential(paths, roomId, memberId).catch(() => null); + if (credential) + seats.push(credential); + } + } + return seats; +} +/** + * Enter a Room by id as a seat this machine already holds: the one named by + * --as, or the only one there is. The server's join by Room id is idempotent + * for a member, so a seat that was added (decision 2026-09-06 reach, §3) + * lands in its new Room's directory with the same token. + */ +async function enterAsSeat(roomId, memberId, dependencies, choice) { + const paths = getStoragePaths(dependencies.env); + const seats = await heldSeats(paths); + const byMember = new Map(); + for (const seat of seats) + if (!byMember.has(seat.member_id)) + byMember.set(seat.member_id, seat); + let seat; + if (memberId !== undefined) { + seat = byMember.get(memberId); + if (!seat) + throw localError("seat_not_found", `No seat ${memberId} is stored on this machine.`); + } + else if (byMember.size === 1) { + seat = [...byMember.values()][0]; + } + else if (byMember.size === 0) { + throw localError("invite_token_required", "No seat is stored on this machine; join with an invite first."); + } + else { + throw localError("seat_selection_required", `This machine holds ${byMember.size} seats; say which with --as : ${[...byMember.keys()].join(", ")}`); + } + const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL ?? seat.base_url); + if (baseUrl !== seat.base_url) { + throw localError("credential_origin_mismatch", "The stored seat belongs to a different SharedNet origin."); + } + const client = new ApiClient(baseUrl, dependencies.fetch); + const payload = await client.request("POST", `/rooms/${encodeURIComponent(roomId)}/join`, seat.member_token, undefined, { "idempotency-key": randomUUID() }); + if (!payload.room?.id || !payload.membership?.member_id) + throw invalidServerResponse(); + const history = await client.request("GET", `/rooms/${encodeURIComponent(roomId)}/messages?after=0&limit=100`, seat.member_token); + if (!Array.isArray(history?.items)) + throw invalidServerResponse(); + // The session entering is the one to wake; a seat entered from no session keeps the one it had. + const { wake: previous, ...held } = seat; + const wake = choice.wakeOff ? null : (choice.wake ?? previous ?? null); + const entered = { + ...held, + room_id: payload.room.id, + member_id: payload.membership.member_id, + joined_at: dependencies.now().toISOString(), + ...(wake ? { wake } : {}), + }; + await writeRoomCredential(paths, entered); + const state = { + schema_version: 1, + base_url: baseUrl, + room_id: payload.room.id, + member_id: payload.membership.member_id, + last_sequence: highestSequence(history.items, 0), + }; + await writeProjectRoomState(dependencies.cwd, state, { + anchorKey: await anchorKeyFor(dependencies.env, paths), + joinedAt: dependencies.now().toISOString(), + }); + await historyHandled(client, payload.room.id, seat.member_token, state.last_sequence, dependencies); + const woken = await wakeAfterJoin(entered, `@${payload.membership.member_id}`, paths, dependencies); + return { + room: payload.room, + member_id: payload.membership.member_id, + as: "seat", + name: seat.name, + admitted_by: payload.membership.admitted_by ?? null, + last_sequence: state.last_sequence, + history, + ...(woken ? { wake: woken } : {}), + }; +} +async function joinAsAccount(roomId, invite, name, baseUrl, paths, client, dependencies, reach, agent, wake, runtimeOverride) { + // A join is one session taking one seat, so it always registers a fresh + // Instance. It never reuses one by local session key: two sessions whose + // driver reports the same session id (a host that hands every window the + // same id, a child session, a hook) would otherwise be folded into one + // Instance, and the first one's token revoked under it. Re-entering a + // Room this directory already holds a seat in goes through `join rom_…`. + const { session } = await registerInstance(dependencies.env, dependencies.fetch, paths, baseUrl, { + forceNew: true, + freshWhenUndetected: true, + ...(reach === undefined ? {} : { reach }), + ...(agent === undefined ? {} : { agent }), + ...(runtimeOverride === undefined ? {} : { runtimeOverride }), + }); + const payload = await client.request("POST", `/rooms/${encodeURIComponent(roomId)}/join`, session.instance_token, { invite }, { "idempotency-key": randomUUID() }); + if (!payload.room?.id || !payload.membership?.member_id) + throw invalidServerResponse(); + const history = await client.request("GET", `/rooms/${encodeURIComponent(roomId)}/messages?after=0&limit=100`, session.instance_token); + if (!Array.isArray(history?.items)) + throw invalidServerResponse(); + // The seat file mirrors the session so say/wait need no second lookup; the + // session file stays the source of a fresh token when the lease is renewed. + const credential = { + schema_version: 1, + base_url: baseUrl, + room_id: payload.room.id, + member_id: session.instance_id, + name, + member_token: session.instance_token, + joined_at: dependencies.now().toISOString(), + ...(wake ? { wake } : {}), + }; + await writeRoomCredential(paths, credential); + const state = { + schema_version: 1, + base_url: baseUrl, + room_id: payload.room.id, + member_id: session.instance_id, + last_sequence: highestSequence(history.items, 0), + }; + await writeProjectRoomState(dependencies.cwd, state, { + anchorKey: session.local_instance_key ?? (await anchorKeyFor(dependencies.env, paths)), + joinedAt: dependencies.now().toISOString(), + }); + await historyHandled(client, payload.room.id, session.instance_token, state.last_sequence, dependencies); + dependencies.stderr?.(`Seat ${session.instance_id} in ${payload.room.id}. Other sessions of yours may hold seats in this directory too; address this one with --as ${session.instance_id}.\n`); + // An account's seat answers to its tag's @handle when it has one, and always to its Instance id. + const woken = await wakeAfterJoin(credential, agent !== undefined && !agent.startsWith("a_") ? `@${agent} or @${session.instance_id}` : `@${session.instance_id}`, paths, dependencies); + return { + room: payload.room, + member_id: session.instance_id, + principal_id: session.principal_id, + agent_id: session.agent_id, + as: "account", + name, + last_sequence: state.last_sequence, + history, + next: { + say: `npx -y sharednet@latest say "…" --as ${session.instance_id}`, + wait: `npx -y sharednet@latest wait --as ${session.instance_id}`, + note: "This seat is yours alone. Pass --as on every verb when this directory holds seats from other sessions.", + }, + ...(woken ? { wake: woken } : {}), + }; +} +/** + * After a join from inside a Codex or Claude Code session: say how the seat is woken, and make sure + * something is there to wake it. One `serve` drives every seat on the machine and looks for new + * ones every few seconds, so a join either finds it running or starts it in the background. + */ +async function wakeAfterJoin(seat, address, paths, dependencies) { + const wake = seat.wake; + if (!wake) + return null; + const harness = wake.driver === "codex" ? "Codex" : "Claude Code"; + const status = (await readServeStatus(paths).catch(() => ({}))); + let service; + let how; + let pid = null; + if (status.running && status.pid) { + service = status.drives ? "running" : "outdated"; + how = status.drives + ? `the wake service already running here (pid ${status.pid}) takes this seat within seconds` + : `the connector running here (pid ${status.pid}) resumes no sessions, being older or running a --run command; to be woken, stop it (sharednet serve --stop) and run sharednet serve`; + } + else if (sandboxed(dependencies.env)) { + service = "not_started"; + how = "this session runs in a sandbox, which a background service would inherit, so nothing was started: run `sharednet serve` outside it"; + } + else if (dependencies.startWakeService) { + const logFile = joinPath(paths.stateDir, "serve.log"); + pid = dependencies.startWakeService({ env: dependencies.env, logFile }); + service = pid ? "started" : "not_started"; + how = pid + ? `the wake service is now running in the background (pid ${pid}, log ${logFile}); stop it with: sharednet serve --stop` + : "the wake service did not start: run `sharednet serve`"; + } + else { + service = "not_started"; + how = "run `sharednet serve` to be woken"; + } + dependencies.stderr?.(`Wake: when someone writes ${address} in this Room, this ${harness} session is resumed with what was said; ${how}.\n`); + return { driver: wake.driver, session: wake.session, address, service, ...(pid ? { pid } : {}) }; +} +async function currentSeat(dependencies, explicit) { + const paths = getStoragePaths(dependencies.env); + const chosen = explicit ?? dependencies.env.SHAREDNET_SEAT?.trim() ?? undefined; + if (chosen !== undefined && !/^(?:i|mem)_[A-Za-z0-9]+$/.test(chosen)) { + throw localError("invalid_arguments", "--as (or SHAREDNET_SEAT) names a seat by its member id, such as i_AbCdEfGhIj."); + } + const state = await selectProjectSeat(dependencies.cwd, { + ...(chosen === undefined ? {} : { explicit: chosen }), + anchorKey: await anchorKeyFor(dependencies.env, paths), + }); + if (!state) { + throw localError("not_in_a_room", "This directory is not in a Room. Run: sharednet join "); + } + const credential = await readRoomCredential(paths, state.room_id, state.member_id); + if (!credential) { + throw localError("room_credential_missing", "The member token for this Room is not on this machine. Join again with a new invite."); + } + if (!sameOrigin(credential.base_url, state.base_url)) { + throw localError("credential_origin_mismatch", "The stored Room credential belongs to a different SharedNet origin."); + } + const client = new ApiClient(credential.base_url, dependencies.fetch); + // A seat that is one of the account's Instances has a session file too, and + // that is where a lease gets renewed; use its token so the seat outlives the + // 24-hour lease the seat file alone would not. + const session = state.member_id.startsWith("i_") + ? await readSessionById(paths, state.member_id).catch(() => null) + : null; + if (session && sameOrigin(session.base_url, state.base_url)) { + const fresh = await refreshIfNeeded(client, paths, session, dependencies.now()); + return { client, state, credential: { ...credential, member_token: fresh.instance_token } }; + } + return { client, state, credential }; +} +/** + * Post one message to the Room. `--reply-to msg_…` threads it under an + * earlier message; the server checks that the message is in this Room. + */ +async function say(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["reply-to", "as"]); + if (parsed.positionals.length !== 1 || !parsed.positionals[0].trim()) { + throw localError("invalid_arguments", 'Usage: sharednet say "" [--reply-to ] [--as ]'); + } + const replyTo = stringOption(parsed, "reply-to"); + if (replyTo !== undefined && !/^msg_[A-Za-z0-9]{10}$/.test(replyTo)) { + throw localError("invalid_reply_to", "--reply-to must be a message id such as msg_AbCdEfGhIj."); + } + const { client, state, credential } = await currentSeat(dependencies, stringOption(parsed, "as")); + 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() }); +} +/** One long-poll from the cursor; the server answers within `timeout` seconds. */ +/** How long a sit keeps trying when the service is unreachable, and how fast it backs off. */ +const POLL_RETRY_LIMIT = 8; +const POLL_RETRY_BASE_MS = 1_000; +const POLL_RETRY_CAP_MS = 30_000; +/** A request with nothing else to bound it waits at most the server's own cap, and this much more. */ +const REQUEST_SLACK_MS = 5_000; +/** A check that must not wait (`--timeout 0`, `--hook`) gives each request this long. */ +const CHECK_REQUEST_MS = 5_000; +/** Even at its deadline, a request a wait still has to make (the read a wake needs) gets this long. */ +const LATE_REQUEST_MS = 1_000; +/** + * Every request a wait makes is bounded, its setup included: a lease refresh, a legacy seat's + * identity lookup, the long-poll, the read after it. A server that takes the connection and never + * answers must not hold a caller past its --timeout, and must not hold a hook (which blocks the + * Agent's turn) at all. From Dots's sharednet-client PR 4. + */ +function boundedFetch(dependencies, deadline, check) { + return (input, init) => { + const limit = check + ? CHECK_REQUEST_MS + : Math.min(WAIT_MAX_SECONDS * 1000 + REQUEST_SLACK_MS, deadline === null ? Number.POSITIVE_INFINITY : Math.max(LATE_REQUEST_MS, deadline - dependencies.now().getTime())); + const timeout = AbortSignal.timeout(limit); + return dependencies.fetch(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout }); + }; +} +/** + * A request the wait's own deadline cut off. For a wait that was given time (`--timeout N`, N > 0), + * that is a quiet wait, not a failure: nothing was said in time. A wait given no time (`--timeout 0`) + * cannot have been cut by its deadline, so there it stays a failure. + */ +function cutByDeadline(error, deadline, now) { + return deadline !== null && now >= deadline && error instanceof CliError && error.code === "service_unavailable"; +} +/** The wait filters, validated the way `read` validates the same flags. */ +export function waitFiltersFrom(options) { + const value = (name) => { + const raw = options.get(name); + return typeof raw === "string" ? raw : undefined; + }; + const parameters = new URLSearchParams(); + const fromInstance = value("from-instance"); + if (fromInstance !== undefined) { + if (!/^i_[0-9A-Za-z]{10}$/.test(fromInstance)) { + throw localError("invalid_arguments", "--from-instance takes an Instance id such as i_AbCdEfGhIj, not a display name."); + } + parameters.set("sender_instance_id", fromInstance); + } + const fromAgent = value("from-agent"); + if (fromAgent !== undefined) { + if (fromAgent !== "default" && !/^a_[0-9A-Za-z]{10}$/.test(fromAgent)) { + throw localError("invalid_arguments", "--from-agent takes an Agent id such as a_AbCdEfGhIj, or default."); + } + parameters.set("sender_agent_id", fromAgent); + } + const grep = value("grep"); + if (grep !== undefined) { + if (grep.length === 0 || [...grep].length > 256) { + throw localError("invalid_arguments", "--grep takes 1 to 256 characters."); + } + parameters.set("q", grep); + } + return parameters; +} +/** + * A sit outlives a blip. + * + * A wait with no `--timeout` is meant to last until someone speaks, which can be hours. One failed + * poll used to end it, so an unattended watcher died on the first hiccup and nothing woke when the + * message finally came — the wake was lost, not delayed. Retrying is what makes "it will wake you" + * true rather than aspirational. + * + * Only reaching the service is retried. An answer that says no — a revoked seat, a closed Room — + * is the service working, and is raised at once rather than hidden behind eight more attempts. + */ +async function pollThroughBlips(poll, sleep, dependencies, deadline = null) { + for (let attempt = 0;; attempt += 1) { + try { + return await poll(); + } + catch (error) { + const code = error instanceof CliError ? error.code : ""; + const reachable = code !== "service_unavailable" && code !== "invalid_server_response"; + const backoff = Math.min(POLL_RETRY_BASE_MS * 2 ** attempt, POLL_RETRY_CAP_MS); + // A wait with a deadline never retries past it: its caller asked for an answer by then. + const late = deadline !== null && dependencies.now().getTime() + backoff >= deadline; + if (reachable || late || attempt >= POLL_RETRY_LIMIT) + throw error; + dependencies.stderr?.(`waiting: the service was unreachable (${code}); retrying in ${Math.round(backoff / 1000)}s, attempt ${attempt + 1} of ${POLL_RETRY_LIMIT}\n`); + await sleep(backoff); + } + } +} +async function waitPage(client, roomId, token, after, timeout, filters = new URLSearchParams()) { + const query = new URLSearchParams({ after: String(after), timeout: String(timeout) }); + for (const [key, value] of filters) + query.set(key, value); + const page = await client.request("GET", `/rooms/${encodeURIComponent(roomId)}/wait?${query.toString()}`, token); + if (!Array.isArray(page?.items)) + throw invalidServerResponse(); + // Only what is past the cursor: a page that repeats what was already handed over must not hand + // it over twice. + return { ...page, items: page.items.filter((item) => Number.isSafeInteger(item.sequence) && item.sequence > after) }; +} +/** + * Everything said in (after, through], unfiltered and in order. + * + * A filter decides when a seat wakes, never what it is handed. The server answers a filtered wait + * with the matching messages only, and the cursor is about to move past everything said between + * them: the context of the message the seat waited for, which nothing shows it again once the + * cursor is past. So the span the cursor crosses is read once more, without the filter. + */ +async function everythingSaid(client, roomId, token, after, through) { + const items = []; + let from = after; + while (from < through) { + const start = from; + const page = await client.request("GET", `/rooms/${encodeURIComponent(roomId)}/messages?after=${start}&limit=100`, token); + if (!Array.isArray(page?.items)) + throw invalidServerResponse(); + items.push(...page.items.filter((item) => item.sequence > start && item.sequence <= through)); + const next = highestSequence(page.items, start); + if (next === start) + break; + from = next; + } + return items; +} +/** A join hands over the history it returns, so the seat's place starts after it, as the MCP join's does. */ +async function historyHandled(client, roomId, token, through, dependencies) { + if (through <= 0) + return; + const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + await acknowledgeThrough(client, roomId, token, through, sleep, dependencies); +} +/** + * Where this seat stands in its Room, as the service keeps it (wait PR 5): the last sequence it has + * finished handling. Every door moves the same mark, whatever directory, connector or chat product + * it runs in. The seat file and the connector's cursor file only mirror it: whichever is further + * along wins and the service is told, so progress made earlier, or by an older CLI, is carried + * over instead of handed over again. + */ +async function keptPlace(client, roomId, token, local, sleep, dependencies, deadline = null) { + const kept = await pollThroughBlips(() => client.request("GET", `/rooms/${encodeURIComponent(roomId)}/subscription`, token), sleep, dependencies, deadline); + const acked = kept?.subscription?.acked_through; + if (typeof acked !== "number" || !Number.isSafeInteger(acked)) + throw invalidServerResponse(); + if (local <= acked) + return acked; + await acknowledgeThrough(client, roomId, token, local, sleep, dependencies, deadline); + return local; +} +/** + * Tells the service this seat has handled everything through `through`. Acks are cumulative, so one + * that is lost is covered by the next. A lost one never undoes the work: the mirror keeps this + * directory from handing the same messages over again, so it is reported, not raised. + */ +async function acknowledgeThrough(client, roomId, token, through, sleep, dependencies, deadline = null) { + try { + await pollThroughBlips(() => client.request("POST", `/rooms/${encodeURIComponent(roomId)}/ack`, token, { through }), sleep, dependencies, deadline); + } + catch (error) { + if (!(error instanceof CliError)) + throw error; + dependencies.stderr?.(`the service did not take the acknowledgement through ${through} (${error.code}); the next one covers it\n`); + } +} +function defaultExec(command, input, env) { + const shell = process.platform === "win32" ? ["cmd", "/c", command] : ["sh", "-c", command]; + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + try { + const child = spawn(shell[0], shell.slice(1), { + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdout.on("data", (chunk) => (stdout += chunk.toString())); + child.stderr.on("data", (chunk) => (stderr += chunk.toString())); + child.once("error", (error) => resolve({ exitCode: 127, stdout, stderr: stderr + String(error) })); + child.once("close", (code) => resolve({ exitCode: code ?? 1, stdout, stderr })); + child.stdin.end(input); + } + catch (error) { + resolve({ exitCode: 127, stdout, stderr: String(error) }); + } + }); +} +/** How often a `closed` wait looks at the Room, and how often a `check` runs by default. */ +const ROOM_CHECK_MS = 30_000; +const DEFAULT_CHECK_EVERY_MS = 60_000; +/** A check's output is evidence, not a transcript: it is cut to this many characters. */ +const CHECK_OUTPUT_LIMIT = 4_000; +/** Kinds that fire on what was said; the rest fire on the clock or on the world. */ +const SAID_KINDS = new Set(["message", "mention", "said", "count", "idle"]); +function boundedOutput(value) { + const characters = [...value]; + return characters.length <= CHECK_OUTPUT_LIMIT ? value : `${characters.slice(-CHECK_OUTPUT_LIMIT).join("")}`; +} +/** + * The triggers named by `--on`, in order, without repeats. A bare `--on every` + * finds its parameter in the next argument, which is how `watch --on count 2` + * was always written. + */ +function triggersFrom(parsed, now) { + const triggers = []; + for (const raw of parsed.repeated.get("on") ?? []) { + let spec = raw; + if (triggerTakesParameter(raw)) { + const parameter = parsed.positionals.shift(); + if (parameter !== undefined) + spec = `${raw} ${parameter}`; + } + const trigger = parseTrigger(spec, now); + if (!triggers.some((existing) => existing.label === trigger.label)) + triggers.push(trigger); + } + return triggers; +} +async function roomView(client, roomId, token) { + const view = await client.request("GET", `/rooms/${encodeURIComponent(roomId)}`, token); + return { state: view?.room?.state ?? null, memberships: Array.isArray(view?.memberships) ? view.memberships : [] }; +} +/** A wake delivered with `--ack manual` waits here until `sharednet ack` moves the cursor past it. */ +function pendingWakePath(cwd, memberId) { + return joinPath(cwd, ".sharednet", "wakes", `${memberId}.json`); +} +async function readPendingWake(cwd, memberId) { + try { + const value = JSON.parse(await readFile(pendingWakePath(cwd, memberId), "utf8")); + if (typeof value?.wake_id === "string" && typeof value?.room_id === "string" && Number.isSafeInteger(value?.through)) { + return { wake_id: value.wake_id, room_id: value.room_id, through: value.through }; + } + return null; + } + catch { + return null; + } +} +async function writePendingWake(cwd, memberId, wake) { + await mkdir(joinPath(cwd, ".sharednet", "wakes"), { recursive: true }); + await writeFile(pendingWakePath(cwd, memberId), `${JSON.stringify({ schema_version: 1, ...wake })}\n`, { mode: 0o600 }); +} +/** One line per wake, for whoever keeps the record of a run (goal mode's wakes.ndjson). */ +async function appendWakeLog(path, record) { + if (path === null) + return; + const { appendFile } = await import("node:fs/promises"); + await appendFile(path, `${JSON.stringify(record)}\n`); +} +function parseTimeout(value) { + if (value === undefined) + return null; + if (!/^\d+$/.test(value)) { + throw localError("invalid_timeout", "--timeout must be a whole number of seconds."); + } + return Number(value); +} +const WAIT_USAGE = "Usage: sharednet wait [--on ]... [--run '' [--reply] [--max-runs ] [--max-failures ]] [--ack on-delivery|manual|after-run] [--timeout ] [--settle ] [--check-every ] [--log ] [--from-instance i_…] [--from-agent a_…|default] [--grep TEXT] [--as ]; or, as before, sharednet wait [--timeout ] [--min ] [--hook]"; +/** + * One wait (decision 2026-10-05): sit in the Room until any `--on` trigger + * fires, then hand over a wake: every other member's message since the last + * handled wake, and the reason it woke. + * + * Without `--run` it returns that one wake. `--ack on-delivery` (the default) + * moves the cursor as it returns; `--ack manual` leaves it, and `sharednet ack + * ` moves it once the caller has done the work, so a caller that dies + * mid-way is handed the same wake again. + * + * With `--run` it stays: each wake goes to the command's stdin as JSON and, with + * `--reply`, what the command prints is said back into the Room. The cursor moves + * only once a wake has been handled and its reply is in the Room; a wake the + * command fails on is offered again, with the same idempotency key for its + * reply, and after `--max-failures` attempts the wait stops and says so. That is + * what `watch` always was; `watch` is this, with `--run` required. + * + * The seat's own words never wake it, which is what keeps a replying wait from + * talking to itself. + */ +async function sit(verb, parsed, dependencies) { + const resident = ["on", "run", "reply", "max-runs", "max-failures", "as", "from-instance", "from-agent", "grep", "log", "settle", "check-every", "ack"]; + assertOnlyOptions(parsed, verb === "watch" ? resident : [...resident, "timeout"]); + const startedAt = dependencies.now().getTime(); + const triggers = triggersFrom(parsed, startedAt); + if (triggers.length === 0) + triggers.push({ kind: "message", label: "message" }); + const command = stringOption(parsed, "run"); + if ((verb === "watch" && !command) || parsed.positionals.length !== 0) { + throw localError("invalid_arguments", verb === "watch" + ? "Usage: sharednet watch --on [--on ]... --run '' [--reply] [--from-instance i_…] [--from-agent a_…|default] [--grep TEXT] [--max-runs ] [--max-failures ] [--log ] [--as ]" + : WAIT_USAGE); + } + const reply = parsed.options.get("reply") === true; + if (!command && (reply || parsed.options.has("max-runs") || parsed.options.has("max-failures"))) { + throw localError("invalid_arguments", "--reply, --max-runs and --max-failures belong to a wait that runs a command (--run)."); + } + const ackOption = stringOption(parsed, "ack"); + const ack = ackOption ?? (command ? "after-run" : "on-delivery"); + if (command ? ack !== "after-run" : ack !== "on-delivery" && ack !== "manual") { + throw localError("invalid_arguments", command + ? "A wait that runs a command acknowledges after the run (--ack after-run); that is what makes a crash lose nothing." + : "--ack takes on-delivery (the cursor moves as the wake is returned) or manual (sharednet ack moves it)."); + } + const filters = waitFiltersFrom(parsed.options); + const settleMs = parsed.options.has("settle") ? parseDuration(stringOption(parsed, "settle"), "--settle") : 0; + const checkEveryMs = parsed.options.has("check-every") + ? parseDuration(stringOption(parsed, "check-every"), "--check-every") + : DEFAULT_CHECK_EVERY_MS; + const timeoutSeconds = command ? null : parseTimeout(stringOption(parsed, "timeout")); + const logOption = stringOption(parsed, "log"); + const logPath = logOption === undefined ? null : resolvePath(dependencies.cwd, logOption); + const maxRuns = parseCount(stringOption(parsed, "max-runs"), "--max-runs"); + const maxFailures = parseCount(stringOption(parsed, "max-failures"), "--max-failures") ?? 3; + const deadline = timeoutSeconds === null ? null : startedAt + timeoutSeconds * 1000; + const { client, state, credential } = await currentSeat({ ...dependencies, fetch: boundedFetch(dependencies, deadline, timeoutSeconds === 0) }, stringOption(parsed, "as")); + const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const exec = dependencies.exec ?? defaultExec; + const log = dependencies.stderr ?? (() => undefined); + const me = await whoAmI(client, state, credential); + const iso = (ms) => new Date(ms).toISOString(); + // A wait that wakes only when addressed asks the service for those lines alone (wait PR 8): one + // message to one seat wakes one seat, not every seat in the Room. Once it wakes, the wake still + // carries everything said since the last one handled. + const said = triggers.filter((trigger) => SAID_KINDS.has(trigger.kind)); + if (said.length > 0 && said.every((trigger) => trigger.kind === "mention")) + filters.set("mentions", me); + // What the clock-free triggers need from the Room: the seat's own name for + // `mention`, the Room's state for `closed`. Looked up once, and `closed` + // again every half minute. + const wantsClosed = triggers.some((trigger) => trigger.kind === "closed"); + let myName = null; + let closed = false; + let lastRoomCheckAt = null; + if (wantsClosed || triggers.some((trigger) => trigger.kind === "mention")) { + const view = await roomView(client, state.room_id, credential.member_token).catch(() => null); + if (view) { + myName = view.memberships.find((member) => member.member_id === me)?.name ?? null; + closed = view.state === "closed"; + } + lastRoomCheckAt = startedAt; + } + // The service keeps this seat's place (wait PR 5); a wake starts after what it has handled. + const start = await keptPlace(client, state.room_id, credential.member_token, state.last_sequence, sleep, dependencies, deadline); + let cursor = start; + let persisted = start; + let batch = []; + let lastRunAt = startedAt; + let lastMessageAt = null; + const cronDue = new Map(); + for (const trigger of triggers) + if (trigger.kind === "cron") + cronDue.set(trigger.label, nextCronTime(trigger.schedule, startedAt)); + const spent = new Set(); + const checks = new Map(); + for (const trigger of triggers) + if (trigger.kind === "check") + checks.set(trigger.label, { passing: false, ranAt: null, fire: null }); + let pending = null; + const runs = []; + const summaryLabel = triggers.map((trigger) => trigger.label).join(" | "); + if (command) + log(`${verb}: ${summaryLabel} in ${state.room_id} as ${me}, from sequence ${cursor}\n`); + const isDue = (trigger, at) => { + // A burst is one wake: what was said fires only once the Room has been quiet for --settle. + const settled = lastMessageAt === null || at - lastMessageAt >= settleMs; + switch (trigger.kind) { + case "message": + return batch.length > 0 && settled; + case "mention": + // The service resolved whom each line addresses, an account seat's tag included; the local + // rule covers a service that predates that. + return settled && batch.some((item) => item.mentions?.includes(me) || mentions(item.content, { memberId: me, name: myName })); + case "said": + return settled && batch.some((item) => saidIn(item.content, trigger)); + case "count": + return batch.length >= trigger.count && settled; + case "idle": + return batch.length > 0 && lastMessageAt !== null && at - lastMessageAt >= trigger.ms; + case "every": + return at - lastRunAt >= trigger.ms; + case "cron": + return at >= cronDue.get(trigger.label); + case "after": + return !spent.has(trigger.label) && at >= startedAt + trigger.ms; + case "at": + return !spent.has(trigger.label) && at >= trigger.time; + case "check": + return checks.get(trigger.label).fire !== null; + case "closed": + return closed; + } + }; + /** How long until a trigger could fire by itself, so a long-poll never sleeps through it. */ + const dueIn = (trigger, at) => { + switch (trigger.kind) { + case "every": + return lastRunAt + trigger.ms - at; + case "idle": + return batch.length > 0 && lastMessageAt !== null ? lastMessageAt + trigger.ms - at : Number.POSITIVE_INFINITY; + case "cron": + return cronDue.get(trigger.label) - at; + case "after": + return spent.has(trigger.label) ? Number.POSITIVE_INFINITY : startedAt + trigger.ms - at; + case "at": + return spent.has(trigger.label) ? Number.POSITIVE_INFINITY : trigger.time - at; + case "check": { + const check = checks.get(trigger.label); + return check.ranAt === null ? 0 : check.ranAt + checkEveryMs - at; + } + case "closed": + return lastRoomCheckAt === null ? 0 : lastRoomCheckAt + ROOM_CHECK_MS - at; + default: + return settleMs > 0 && batch.length > 0 && lastMessageAt !== null ? lastMessageAt + settleMs - at : Number.POSITIVE_INFINITY; + } + }; + const stopUnhandled = (unhandled) => { + const first = unhandled[0]?.sequence ?? persisted + 1; + const last = unhandled.at(-1)?.sequence ?? cursor; + throw new CliError(`${verb}_failed`, `${verb} stopped with ${unhandled.length} message(s) unhandled after ${pending?.failures ?? 0} failed attempt(s), sequences ${first}-${last}. The cursor stays at ${persisted}; \`sharednet wait\` shows them again.`, 4); + }; + for (;;) { + // The world first: a check that has started to pass, a Room that has closed. + let now = dependencies.now().getTime(); + for (const trigger of triggers) { + if (trigger.kind !== "check") + continue; + const check = checks.get(trigger.label); + if (check.ranAt !== null && now - check.ranAt < checkEveryMs) + continue; + const result = await exec(trigger.command, "", { + SHAREDNET_ROOM_ID: state.room_id, + SHAREDNET_MEMBER_ID: state.member_id, + SHAREDNET_LAST_SEQUENCE: String(cursor), + }); + check.ranAt = dependencies.now().getTime(); + const passing = result.exitCode === 0; + // It fires when it starts to pass, not on every minute it keeps passing. + if (passing && !check.passing) { + check.fire = { exit_code: 0, output: boundedOutput(`${result.stdout}${result.stderr}`), at: iso(check.ranAt) }; + } + check.passing = passing; + } + if (wantsClosed && !closed && (lastRoomCheckAt === null || now - lastRoomCheckAt >= ROOM_CHECK_MS)) { + const view = await roomView(client, state.room_id, credential.member_token).catch(() => null); + lastRoomCheckAt = dependencies.now().getTime(); + if (view?.state === "closed") + closed = true; + } + now = dependencies.now().getTime(); + let budgetMs = WAIT_MAX_SECONDS * 1000; + for (const trigger of triggers) + budgetMs = Math.min(budgetMs, dueIn(trigger, now)); + if (deadline !== null) + budgetMs = Math.min(budgetMs, deadline - now); + // Something already fired that does not depend on what is said: look once, quickly, and go. + if (triggers.some((trigger) => !SAID_KINDS.has(trigger.kind) && isDue(trigger, now))) + budgetMs = 0; + const timeout = Math.min(WAIT_MAX_SECONDS, Math.max(0, Math.ceil(budgetMs / 1000))); + // --max-failures counts the command and the reply, never the poll, so an unguarded poll ended + // the whole watch on the first hiccup. A watcher is the one thing that must outlive a blip. + let page; + try { + page = await pollThroughBlips(() => waitPage(client, state.room_id, credential.member_token, cursor, timeout, filters), sleep, dependencies, deadline); + } + catch (error) { + if (timeoutSeconds !== null && timeoutSeconds > 0 && cutByDeadline(error, deadline, dependencies.now().getTime())) { + // Nothing was said in time; the timed-out wake below says so. + page = { items: [], next_cursor: null, has_more: false }; + } + else if (wantsClosed && error instanceof CliError && error.code === "room_closed") { + closed = true; + page = { items: [], next_cursor: null, has_more: false }; + } + else { + throw error; + } + } + cursor = highestSequence(page.items, cursor); + const others = page.items.filter((item) => !isOwn(item, me, state.member_id)); + if (others.length > 0) { + batch.push(...others); + lastMessageAt = dependencies.now().getTime(); + } + const at = dependencies.now().getTime(); + const firedTriggers = triggers.filter((trigger) => isDue(trigger, at)); + const timedOut = deadline !== null && at >= deadline; + if (firedTriggers.length === 0 && !(timedOut && !command)) { + if (page.items.length === 0) + await sleep(0); + continue; + } + const fired = firedTriggers.map((trigger) => trigger.label); + // A filter chose the moment; the wake still carries everything said since the last handled one. + const handed = filters.size > 0 && cursor > persisted + ? (await pollThroughBlips(() => everythingSaid(client, state.room_id, credential.member_token, persisted, cursor), sleep, dependencies, deadline)).filter((item) => !isOwn(item, me, state.member_id)) + : batch; + const events = handed.map((item) => ({ kind: "message", ...item })); + for (const trigger of firedTriggers) { + if (trigger.kind === "check") { + events.push({ kind: "check", trigger: trigger.label, command: trigger.command, ...checks.get(trigger.label).fire }); + } + else if (trigger.kind === "closed") { + events.push({ kind: "closed", trigger: trigger.label, at: iso(at) }); + } + else if (!SAID_KINDS.has(trigger.kind)) { + events.push({ kind: "timer", trigger: trigger.label, at: iso(at) }); + } + } + if (pending === null && fired.length > 0) { + const identity = wakeIdentity({ roomId: state.room_id, memberId: state.member_id, from: persisted, through: cursor, fired, messageCount: handed.length, firedAt: at }); + pending = { wakeId: identity.wakeId, replyKey: identity.replyKey, reply: null, failures: 0 }; + } + const wake = { + wake_id: pending?.wakeId ?? null, + room_id: state.room_id, + member_id: state.member_id, + trigger: fired[0] ?? null, + fired, + messages: handed, + events, + from: persisted, + through: cursor, + }; + // What has fired is spent once it is handled: a one-off does not fire twice, a calendar moves + // to its next day, a check waits to start passing again. + const spend = () => { + for (const trigger of firedTriggers) { + if (trigger.kind === "after" || trigger.kind === "at") + spent.add(trigger.label); + if (trigger.kind === "cron") + cronDue.set(trigger.label, nextCronTime(trigger.schedule, at)); + if (trigger.kind === "check") + checks.get(trigger.label).fire = null; + } + }; + if (!command) { + // One wake, returned. Either the cursor moves now, or it waits for `sharednet ack`. + if (ack === "manual") { + if (wake.wake_id !== null) { + await writePendingWake(dependencies.cwd, state.member_id, { wake_id: wake.wake_id, room_id: state.room_id, from: persisted, through: cursor }); + } + } + else if (cursor > persisted) { + await acknowledgeThrough(client, state.room_id, credential.member_token, cursor, sleep, dependencies, deadline); + await writeProjectRoomState(dependencies.cwd, { ...state, last_sequence: cursor }); + } + await appendWakeLog(logPath, { + at: iso(at), + verb, + room_id: state.room_id, + member_id: state.member_id, + wake_id: wake.wake_id, + fired, + from: wake.from, + through: wake.through, + sequences: handed.map((item) => item.sequence), + ack, + }); + return wake; + } + pending ??= { wakeId: wake.wake_id ?? "", replyKey: randomUUID(), reply: null, failures: 0 }; + let exitCode = 0; + let replyMessageId = null; + let status = "ok"; + // A reply the command already produced but the Room never received is + // posted first, without running the command again. + if (pending.reply === null) { + const input = `${JSON.stringify(wake)}\n`; + const result = await exec(command, input, { + SHAREDNET_ROOM_ID: state.room_id, + SHAREDNET_MEMBER_ID: state.member_id, + SHAREDNET_MESSAGE_COUNT: String(handed.length), + SHAREDNET_LAST_SEQUENCE: String(cursor), + SHAREDNET_WAKE_ID: pending.wakeId, + SHAREDNET_FIRED: fired.join(" | "), + }); + if (result.stderr) + log(result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`); + exitCode = result.exitCode; + if (exitCode !== 0) + status = "failed"; + else if (reply && result.stdout.trim().length > 0) + pending.reply = result.stdout.trim(); + } + if (status === "ok" && pending.reply !== null) { + try { + const posted = await client.request("POST", `/rooms/${encodeURIComponent(state.room_id)}/messages`, credential.member_token, { content: pending.reply }, { "idempotency-key": pending.replyKey }); + replyMessageId = posted?.message?.id ?? null; + } + catch (error) { + status = "reply_failed"; + log(`${verb}: reply not posted (${error instanceof CliError ? error.code : "error"}); will retry with the same key\n`); + } + } + if (status === "ok") { + if (cursor > persisted) + await acknowledgeThrough(client, state.room_id, credential.member_token, cursor, sleep, dependencies, deadline); + persisted = cursor; + await writeProjectRoomState(dependencies.cwd, { ...state, last_sequence: persisted }); + spend(); + } + else { + pending.failures += 1; + } + const record = { + run: runs.length + 1, + trigger: fired[0], + fired, + wake_id: pending.wakeId, + messages: handed.length, + status, + exit_code: exitCode, + reply_message_id: replyMessageId, + last_sequence: persisted, + }; + runs.push(record); + await appendWakeLog(logPath, { + at: iso(at), + verb, + room_id: state.room_id, + member_id: state.member_id, + wake_id: record.wake_id, + fired, + from: wake.from, + through: wake.through, + sequences: handed.map((item) => item.sequence), + status, + exit_code: exitCode, + reply_message_id: replyMessageId, + }); + log(`${verb}: run ${record.run}, ${record.messages} message(s), ${status}` + + (status === "failed" ? ` (exit ${exitCode})` : "") + + (replyMessageId ? `, replied ${replyMessageId}` : "") + + (status !== "ok" ? `, ${record.messages} message(s) kept for the next wake` : "") + + "\n"); + lastRunAt = dependencies.now().getTime(); + const handledClose = status === "ok" && firedTriggers.some((trigger) => trigger.kind === "closed"); + if (status === "ok") { + batch = []; + pending = null; + } + else if (pending.failures >= maxFailures) { + stopUnhandled(handed); + } + // A closed Room has nothing more to say: the wait that was told so ends with it. + if (handledClose) + return { room_id: state.room_id, trigger: summaryLabel, runs, closed: true }; + if (maxRuns !== null && runs.length >= maxRuns) { + if (pending !== null) + stopUnhandled(handed); + return { room_id: state.room_id, trigger: summaryLabel, runs }; + } + } +} +async function watch(args, dependencies) { + return sit("watch", parseGuestArguments(args), dependencies); +} +/** + * `wait` as it always was — sit until something new is said, then print it + * and advance the cursor; `--timeout N` bounds the sit in seconds (0 checks + * once), `--min N` waits for N, `--hook` is the shape a Claude Code hook wants: + * one immediate check, plain lines, exit 0 whether or not anything arrived — + * and, with any of the newer options, the one wait above. + */ +async function wait(args, dependencies) { + const parsed = parseGuestArguments(args); + const newer = ["on", "run", "reply", "max-runs", "max-failures", "ack", "log", "settle", "check-every"]; + if (newer.some((name) => parsed.options.has(name))) { + if (parsed.options.has("hook") || parsed.options.has("min")) { + throw localError("invalid_arguments", "--hook and --min belong to the plain wait; with --on, use --on count for a minimum."); + } + return sit("wait", parsed, dependencies); + } + assertOnlyOptions(parsed, ["timeout", "hook", "min", "as", "from-instance", "from-agent", "grep"]); + if (parsed.positionals.length !== 0) { + throw localError("invalid_arguments", WAIT_USAGE); + } + // The same filters a read takes, so being woken can be as narrow as reading is. On a Room with + // thirty Agents, one message must not start thirty of them. They narrow when the caller wakes, + // not what it is handed (everythingSaid). + const filters = waitFiltersFrom(parsed.options); + const hook = parsed.options.get("hook") === true; + const totalSeconds = hook ? 0 : parseTimeout(stringOption(parsed, "timeout")); + const minimum = parseCount(stringOption(parsed, "min"), "--min") ?? 1; + const deadline = totalSeconds === null ? null : dependencies.now().getTime() + totalSeconds * 1000; + const { client, state, credential } = await currentSeat({ ...dependencies, fetch: boundedFetch(dependencies, deadline, totalSeconds === 0) }, stringOption(parsed, "as")); + const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const me = await whoAmI(client, state, credential); + // The Room log is raw and includes what this seat said; the cursor moves + // over all of it, but only other members' words wake the caller, count + // toward --min, or come back. A page of nothing but one's own words is + // consumed and the sit continues. + const items = []; + // The service keeps this seat's place (wait PR 5); the seat file mirrors it. + const start = await keptPlace(client, state.room_id, credential.member_token, state.last_sequence, sleep, dependencies, deadline); + let cursor = start; + for (;;) { + // Setup may already have spent the time this wait was given. + if (totalSeconds !== null && totalSeconds > 0 && dependencies.now().getTime() >= deadline) + break; + const remaining = deadline === null + ? WAIT_MAX_SECONDS + : Math.max(0, Math.ceil((deadline - dependencies.now().getTime()) / 1000)); + const timeout = Math.min(WAIT_MAX_SECONDS, remaining); + let page; + try { + page = await pollThroughBlips(() => waitPage(client, state.room_id, credential.member_token, cursor, timeout, filters), sleep, dependencies, deadline); + } + catch (error) { + if (totalSeconds !== null && totalSeconds > 0 && cutByDeadline(error, deadline, dependencies.now().getTime())) + break; + throw error; + } + const advanced = highestSequence(page.items, cursor) > cursor; + cursor = highestSequence(page.items, cursor); + items.push(...page.items.filter((item) => !isOwn(item, me, state.member_id))); + if (items.length >= minimum) + break; + if (deadline !== null && dependencies.now().getTime() >= deadline) + break; + // The server answered at its cap, or with only our own words; ask again. + if (!advanced) + await sleep(0); + } + // A filter chose the moment; what comes back is everything said since the cursor, not only the match. + if (filters.size > 0 && cursor > start) { + const everything = await pollThroughBlips(() => everythingSaid(client, state.room_id, credential.member_token, start, cursor), sleep, dependencies, deadline); + items.splice(0, items.length, ...everything.filter((item) => !isOwn(item, me, state.member_id))); + } + const page = { items, next_cursor: null, has_more: false }; + if (cursor > start) { + await acknowledgeThrough(client, state.room_id, credential.member_token, cursor, sleep, dependencies, deadline); + await writeProjectRoomState(dependencies.cwd, { ...state, last_sequence: cursor }); + } + if (hook) { + return { + hook: true, + lines: page.items.map((item) => `#${item.sequence} ${item.sender?.name ?? item.sender?.member_id ?? "member"}: ${item.content}`), + }; + } + return page; +} +/** + * Acknowledge a wake delivered with `wait --ack manual`: the work is done, move + * the cursor past it. Only the wake this seat is holding can be acknowledged, + * and the cursor only ever moves forward. + */ +async function ackWake(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["as"]); + const [wakeId] = parsed.positionals; + if (parsed.positionals.length !== 1 || !/^wk_[A-Za-z0-9_-]{16}$/.test(wakeId)) { + throw localError("invalid_arguments", "Usage: sharednet ack [--as ]"); + } + const { client, state, credential } = await currentSeat(dependencies, stringOption(parsed, "as")); + const held = await readPendingWake(dependencies.cwd, state.member_id); + if (!held || held.wake_id !== wakeId || held.room_id !== state.room_id) { + throw localError("unknown_wake", "This seat is not holding that wake, so nothing moved. `sharednet wait --ack manual` shows what is pending."); + } + // Asked for by name, so a refusal is reported and the wake stays held for another try. + const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + await pollThroughBlips(() => client.request("POST", `/rooms/${encodeURIComponent(state.room_id)}/ack`, credential.member_token, { through: held.through }), sleep, dependencies); + const lastSequence = Math.max(state.last_sequence, held.through); + await writeProjectRoomState(dependencies.cwd, { ...state, last_sequence: lastSequence }); + const { rm } = await import("node:fs/promises"); + await rm(pendingWakePath(dependencies.cwd, state.member_id), { force: true }); + return { wake_id: wakeId, room_id: state.room_id, member_id: state.member_id, last_sequence: lastSequence }; +} +/** The read flags, as a query string: filter, order, window. `--last K` is newest-first, K of them. */ +export function messageQueryFrom(options) { + const value = (name) => { + const raw = options.get(name); + return typeof raw === "string" ? raw : undefined; + }; + const parameters = new URLSearchParams(); + const last = value("last"); + const after = value("after"); + const before = value("before"); + const limit = value("limit"); + const order = value("order"); + for (const [name, raw] of [["after", after], ["before", before], ["limit", limit], ["last", last]]) { + if (raw !== undefined && !/^\d+$/.test(raw)) + throw localError("invalid_arguments", `--${name} takes a whole number.`); + } + if (last !== undefined && (after !== undefined || before !== undefined || limit !== undefined || order !== undefined)) { + throw localError("invalid_arguments", "--last stands alone; use --order desc --limit K to combine."); + } + if (limit !== undefined && (Number(limit) < 1 || Number(limit) > 100)) + throw localError("invalid_limit", "--limit must be an integer from 1 to 100."); + if (last !== undefined && (Number(last) < 1 || Number(last) > 100)) + throw localError("invalid_limit", "--last must be an integer from 1 to 100."); + if (order !== undefined && order !== "asc" && order !== "desc") + throw localError("invalid_arguments", "--order is asc or desc."); + if (after !== undefined) + parameters.set("after", after); + if (before !== undefined) + parameters.set("before", before); + if (limit !== undefined) + parameters.set("limit", limit); + if (order !== undefined) + parameters.set("order", order); + if (last !== undefined) { + parameters.set("order", "desc"); + parameters.set("limit", last); + } + const fromInstance = value("from-instance"); + if (fromInstance !== undefined) { + if (!/^i_[0-9A-Za-z]{10}$/.test(fromInstance)) + throw localError("invalid_arguments", "--from-instance takes an Instance id such as i_AbCdEfGhIj, not a display name."); + parameters.set("sender_instance_id", fromInstance); + } + const fromAgent = value("from-agent"); + if (fromAgent !== undefined) { + if (fromAgent !== "default" && !/^a_[0-9A-Za-z]{10}$/.test(fromAgent)) + throw localError("invalid_arguments", "--from-agent takes an Agent id such as a_AbCdEfGhIj, or default."); + parameters.set("sender_agent_id", fromAgent); + } + const grep = value("grep"); + if (grep !== undefined) { + if (grep.length === 0 || [...grep].length > 256) + throw localError("invalid_arguments", "--grep takes 1 to 256 characters."); + parameters.set("q", grep); + } + return parameters; +} +export const READ_OPTIONS = ["after", "before", "limit", "order", "last", "from-instance", "from-agent", "grep"]; +/** + * `sharednet read`: a window of this Room's log. Filter (grep, sender, tag), + * order, window; the cursor is untouched, so reading is never "seeing". + */ +async function read(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, [...READ_OPTIONS, "as"]); + if (parsed.positionals.length !== 0) { + throw localError("invalid_arguments", "Usage: sharednet read [--grep TEXT] [--from-instance i_…] [--from-agent a_…|default] [--last K | --after N | --before N] [--limit K] [--order asc|desc]"); + } + const query = messageQueryFrom(parsed.options); + const { client, state, credential } = await currentSeat(dependencies, stringOption(parsed, "as")); + const page = await client.request("GET", `/rooms/${encodeURIComponent(state.room_id)}/messages${query.size ? `?${query.toString()}` : ""}`, credential.member_token); + // --last K is asked newest-first and shown oldest-first, the way a person reads a tail. + if (parsed.options.has("last") && Array.isArray(page?.items)) { + return { ...page, items: [...page.items].reverse() }; + } + return page; +} +/** + * `sharednet whoami`: who this machine acts as, and which seat this + * directory holds, from the files alone. Ids only; never a key or a token. + */ +async function whoami(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, []); + if (parsed.positionals.length !== 0) + throw localError("invalid_arguments", "Usage: sharednet whoami"); + const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL); + const paths = getStoragePaths(dependencies.env); + const stored = await readStoredApiCredential(paths).catch(() => null); + const envKey = Boolean(dependencies.env.SHAREDNET_API_KEY?.trim()); + const account = stored && sameOrigin(stored.base_url, baseUrl) + ? { principal_id: stored.principal_id, api_key_id: stored.api_key_id, source: "credentials_file", credentials_file: paths.credentialsFile } + : envKey + ? { principal_id: null, api_key_id: null, source: "SHAREDNET_API_KEY", credentials_file: null } + : null; + const room = await readProjectRoom(dependencies.cwd); + const anchorKey = await anchorKeyFor(dependencies.env, paths); + const chosen = dependencies.env.SHAREDNET_SEAT?.trim() || undefined; + const selected = room + ? await selectProjectSeat(dependencies.cwd, { ...(chosen ? { explicit: chosen } : {}), anchorKey }).catch(() => null) + : null; + // The seats this directory holds, on whatever origin the Room lives, and + // which one this session acts as; say/wait act on that origin. + const seat = room + ? { + base_url: room.base_url, + room_id: room.room_id, + member_id: selected?.member_id ?? null, + last_sequence: selected?.last_sequence ?? null, + credential_present: selected !== null && (await readRoomCredential(paths, room.room_id, selected.member_id).catch(() => null)) !== null, + seats: Object.entries(room.seats).map(([memberId, entry]) => ({ + member_id: memberId, + last_sequence: entry.last_sequence, + this_session: entry.anchor_key !== null && entry.anchor_key === anchorKey, + })), + } + : null; + return { + base_url: baseUrl, + account, + seat, + // What to do about it, in one line each. + next: account === null + ? "This machine acts as nobody. Run `sharednet login` so seats are your account's; `join` without it seats an anonymous Principal." + : seat === null + ? "Logged in. Run `sharednet join ` in a project directory to take a seat." + : seat.member_id === null + ? "This directory holds several seats and none is this session's; say which with --as or SHAREDNET_SEAT." + : "Logged in and seated. `say`, `wait`, and `watch` act as this seat.", + }; +} +/** + * Seat more Instances in this directory's Room, by id: public ones at once, + * private ones by asking (decision 2026-09-06 reach, §3). + */ +async function add(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, []); + const ids = parsed.positionals; + if (ids.length === 0 || ids.some((id) => !/^i_[0-9A-Za-z]{10}$/.test(id))) { + throw localError("invalid_arguments", "Usage: sharednet add [ …]"); + } + const { client, state, credential } = await currentSeat(dependencies); + return client.request("POST", `/rooms/${encodeURIComponent(state.room_id)}/members`, credential.member_token, { with: ids }); +} +/** `sharednet reach private|public`: whether strangers who know this seat's id must ask first. */ +async function reach(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, []); + const value = parsed.positionals[0]; + if (parsed.positionals.length !== 1 || (value !== "public" && value !== "private")) { + throw localError("invalid_arguments", "Usage: sharednet reach public|private"); + } + const { client, credential } = await currentSeat(dependencies); + return client.request("PATCH", "/instances/current", credential.member_token, { reach: value }); +} +/** The Rooms this seat sits in, newest first; where a seat that was added finds its new Room. */ +async function rooms(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, []); + if (parsed.positionals.length !== 0) + throw localError("invalid_arguments", "Usage: sharednet rooms"); + const { client, credential } = await currentSeat(dependencies); + return client.request("GET", "/rooms", credential.member_token); +} +/** Requests waiting on this seat: someone wants it in a Room while it is private. */ +async function requests(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, []); + if (parsed.positionals.length !== 0) + throw localError("invalid_arguments", "Usage: sharednet requests"); + const { client, credential } = await currentSeat(dependencies); + return client.request("GET", "/decisions?status=pending", credential.member_token); +} +/** The seat answers for itself: accept takes the seat, deny refuses it. */ +async function answer(resolution, args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, []); + const decisionId = parsed.positionals[0]; + if (parsed.positionals.length !== 1 || !decisionId || !/^dec_[0-9A-Za-z]{10}$/.test(decisionId)) { + const verb = resolution === "approved" ? "accept" : "deny"; + throw localError("invalid_arguments", `Usage: sharednet ${verb} `); + } + const { client, credential } = await currentSeat(dependencies); + return client.request("POST", `/decisions/${encodeURIComponent(decisionId)}/resolve`, credential.member_token, { resolution }); +} +async function creditCredential(dependencies, explicitSeat) { + try { + const { client, state, credential } = await currentSeat(dependencies, explicitSeat); + return { client, token: credential.member_token, as: "seat", seat: { room_id: state.room_id, member_id: state.member_id } }; + } + catch (error) { + if (!(error instanceof CliError) || error.code !== "not_in_a_room") + throw error; + } + const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL); + const paths = getStoragePaths(dependencies.env); + const stored = await readStoredApiCredential(paths).catch(() => null); + const key = stored && sameOrigin(stored.base_url, baseUrl) ? stored.api_key : dependencies.env.SHAREDNET_API_KEY?.trim() || null; + if (!key) { + throw localError("not_logged_in", "This directory is not in a Room and this machine acts as nobody. Run sharednet login, or join a Room first."); + } + return { client: new ApiClient(baseUrl, dependencies.fetch), token: key, as: "account", seat: null }; +} +async function balance(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["as"]); + if (parsed.positionals.length !== 0) + throw localError("invalid_arguments", "Usage: sharednet balance [--as ]"); + const { client, token, as, seat } = await creditCredential(dependencies, stringOption(parsed, "as")); + const payload = await client.request("GET", "/credits", token); + return { ...payload.credits, as, seat }; +} +async function redeem(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["as"]); + const code = parsed.positionals[0]?.trim(); + if (parsed.positionals.length !== 1 || !code) + throw localError("invalid_arguments", "Usage: sharednet redeem "); + const { client, token } = await creditCredential(dependencies, stringOption(parsed, "as")); + return client.request("POST", "/credits/redeem", token, { code }); +} +/** + * `pay `: the target is a Principal, Agent or Instance id; + * the amount a whole number. With `--room`, the payment is recorded against + * this directory's Room and the seat posts a one-line receipt into it, so a + * trade is visible where it was agreed. Every payment carries its own key. + */ +async function pay(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["memo", "room", "as"]); + const [target, amountText] = parsed.positionals; + if (parsed.positionals.length !== 2 || !target || !amountText) { + throw localError("invalid_arguments", "Usage: sharednet pay [--memo ] [--room] [--as ]"); + } + if (!/^(?:p|a|i)_[0-9A-Za-z]{10}$/.test(target)) { + throw localError("invalid_arguments", "The target must be a Principal (p_…), Agent (a_…) or Instance (i_…) id."); + } + const amount = Number(amountText); + if (!/^\d+$/.test(amountText) || !Number.isSafeInteger(amount) || amount < 1) { + throw localError("invalid_arguments", "The amount must be a whole number of credits, at least 1."); + } + const memo = stringOption(parsed, "memo"); + const announce = parsed.options.has("room"); + const { client, token, seat } = await creditCredential(dependencies, stringOption(parsed, "as")); + if (announce && seat === null) { + throw localError("not_in_a_room", "--room records the payment against this directory's Room; join one first."); + } + const paid = await client.request("POST", "/credits/transfers", token, { to: target, amount, ...(memo === undefined ? {} : { memo }), ...(announce && seat ? { room_id: seat.room_id } : {}) }, { "idempotency-key": randomUUID() }); + let receipt = null; + let warning; + if (announce && seat) { + try { + receipt = await client.request("POST", `/rooms/${encodeURIComponent(seat.room_id)}/messages`, token, { content: `Paid ${amount} credit${amount === 1 ? "" : "s"} to ${target}${memo ? ` — ${memo}` : ""} (${paid.transfer.id})` }, { "idempotency-key": randomUUID() }); + } + catch { + // The payment is final even if the separate receipt fails or its reply + // is lost. Reporting the payment as failed invites a second debit. + warning = { + code: "receipt_not_confirmed", + message: "Payment succeeded, but the Room receipt was not confirmed. Do not repeat this payment.", + }; + } + } + return { ...paid, receipt, ...(warning ? { warning } : {}) }; +} +async function ledger(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["last", "before", "as"]); + if (parsed.positionals.length !== 0) + throw localError("invalid_arguments", "Usage: sharednet ledger [--last ] [--before ]"); + const lastText = stringOption(parsed, "last"); + const last = lastText === undefined ? 20 : Number(lastText); + if (!Number.isSafeInteger(last) || last < 1 || last > 100) + throw localError("invalid_arguments", "--last must be between 1 and 100."); + const before = stringOption(parsed, "before"); + if (before !== undefined && !/^txn_[0-9A-Za-z]{10}$/.test(before)) + throw localError("invalid_arguments", "--before must be a transfer id such as txn_AbCdEfGhIj."); + const { client, token } = await creditCredential(dependencies, stringOption(parsed, "as")); + const query = new URLSearchParams({ limit: String(last), ...(before === undefined ? {} : { before }) }); + return client.request("GET", `/credits/transfers?${query.toString()}`, token); +} +/** A name to write on this machine: the last segment, never a path. */ +function safeBasename(value) { + const name = value.split(/[\\/]+/).pop()?.trim() ?? ""; + if (name === "" || name === "." || name === "..") { + throw localError("invalid_filename", "That file has no usable name; pass --out to say where to write it."); + } + return name; +} +async function upload(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["name", "as"]); + const path = parsed.positionals[0]; + if (parsed.positionals.length !== 1 || !path) { + throw localError("invalid_arguments", "Usage: sharednet upload [--name ] [--as ]"); + } + const resolved = resolvePath(dependencies.cwd, path); + let bytes; + try { + bytes = await readFile(resolved); + } + catch { + throw localError("file_not_found", `Nothing to upload at ${path}.`); + } + if (bytes.byteLength === 0) + throw localError("file_empty", "That file is empty."); + const filename = safeBasename(stringOption(parsed, "name") ?? resolved); + const { client, token, seat } = await creditCredential(dependencies, stringOption(parsed, "as")); + // Every file comes back with a link. A directory that holds a seat also + // hands the file to that Room, so the others can read it by id. + return client.request("POST", "/artifacts", token, bytes, { + "content-type": contentTypeFor(filename), + ...(/[^\x20-\x7e]/.test(filename) + ? { "x-sharednet-filename*": `UTF-8''${encodeURIComponent(filename)}` } + : { "x-sharednet-filename": filename }), + ...(seat === null ? {} : { "x-sharednet-room": seat.room_id }), + "idempotency-key": randomUUID(), + }); +} +async function download(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["out", "force", "as"]); + const target = parsed.positionals[0]; + if (parsed.positionals.length !== 1 || !target) { + throw localError("invalid_arguments", "Usage: sharednet download [--out ] [--force]"); + } + // A link carries its own key and needs no credential; an id is read as this seat. + const link = /^https?:\/\//.test(target) ? new URL(target) : null; + let artifactId; + let client; + let token = ""; + let key = null; + if (link) { + const match = /\/(?:f|api\/v1\/artifacts)\/(art_[0-9A-Za-z]{10})(?:\/content)?$/.exec(link.pathname); + key = link.searchParams.get("k"); + if (!match || key === null) { + throw localError("invalid_arguments", "That link does not point at a SharedNet file."); + } + artifactId = match[1]; + client = new ApiClient(`${link.protocol}//${link.host}`, dependencies.fetch); + } + else { + if (!/^art_[0-9A-Za-z]{10}$/.test(target)) { + throw localError("invalid_arguments", "Pass a file id such as art_AbCdEfGhIj, or a link."); + } + artifactId = target; + const credential = await creditCredential(dependencies, stringOption(parsed, "as")); + client = credential.client; + token = credential.token; + } + const meta = key === null ? await client.request("GET", `/artifacts/${artifactId}`, token) : null; + const bytes = await client.requestBytes(`/artifacts/${artifactId}/content${key === null ? "" : `?k=${encodeURIComponent(key)}`}`, token); + const name = safeBasename(stringOption(parsed, "out") ?? meta?.artifact.filename ?? bytes.filename ?? artifactId); + const out = resolvePath(dependencies.cwd, stringOption(parsed, "out") ?? name); + try { + // Exclusive creation handles competing downloads and dangling symlinks in + // the same operation that opens the file; a prior stat cannot protect it. + await writeFile(out, bytes.bytes, { flag: parsed.options.has("force") ? "w" : "wx" }); + } + catch (error) { + if (error !== null && typeof error === "object" && "code" in error && error.code === "EEXIST") { + throw localError("file_exists", `${name} is already here. Pass --force to overwrite it, or --out to write elsewhere.`); + } + throw error; + } + const digest = createHash("sha256").update(bytes.bytes).digest("hex"); + return { + artifact_id: artifactId, + path: out, + size_bytes: bytes.bytes.byteLength, + sha256: digest, + // The server states the digest it stored; a mismatch means the bytes changed on the way. + verified: bytes.sha256 === null ? null : bytes.sha256 === digest, + }; +} +async function files(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["last", "before", "room", "as"]); + if (parsed.positionals.length !== 0) + throw localError("invalid_arguments", "Usage: sharednet files [--room] [--last ] [--before ]"); + const lastText = stringOption(parsed, "last"); + const last = lastText === undefined ? 20 : Number(lastText); + if (!Number.isSafeInteger(last) || last < 1 || last > 100) + throw localError("invalid_arguments", "--last must be between 1 and 100."); + const before = stringOption(parsed, "before"); + if (before !== undefined && !/^art_[0-9A-Za-z]{10}$/.test(before)) { + throw localError("invalid_arguments", "--before must be a file id such as art_AbCdEfGhIj."); + } + const { client, token, seat } = await creditCredential(dependencies, stringOption(parsed, "as")); + if (parsed.options.has("room") && seat === null) { + throw localError("not_in_a_room", "--room lists this directory's Room; join one first."); + } + const query = new URLSearchParams({ + limit: String(last), + ...(before === undefined ? {} : { before }), + ...(parsed.options.has("room") && seat ? { room_id: seat.room_id } : {}), + }); + return client.request("GET", `/artifacts?${query.toString()}`, token); +} +/** Enough of a media type for the common things Agents pass; the rest are bytes. */ +function contentTypeFor(filename) { + const extension = filename.includes(".") ? filename.split(".").pop().toLowerCase() : ""; + const known = { + csv: "text/csv", + diff: "text/plain", + gif: "image/gif", + html: "text/html", + jpeg: "image/jpeg", + jpg: "image/jpeg", + json: "application/json", + log: "text/plain", + md: "text/markdown", + patch: "text/plain", + pdf: "application/pdf", + png: "image/png", + svg: "image/svg+xml", + txt: "text/plain", + webp: "image/webp", + yaml: "text/plain", + yml: "text/plain", + }; + return known[extension] ?? "application/octet-stream"; +} +/** + * The resident connector. + * + * `wait` and `watch` each sit in one Room, in the foreground, until something ends them. That + * makes waking conditional on someone having started a command and nothing having gone wrong + * since, which is not what "you can be woken" should mean. `serve` is the process that makes it + * unconditional while the machine is up: it sits in **every** Room this machine holds a seat in, + * each on its own loop, and it does not stop. + * + * Multi-Room costs nothing here and that is the point. `heldSeats` already walks every seat under + * `rooms//.json`, so one process covering all of them is less work than one + * process per Room — no seat file to pick, no `--as` to pass, and a Room that goes wrong takes + * only its own loop down. + * + * Where `wait` gives up after a bounded number of attempts, because a foreground command should + * not hang forever, this never does. A daemon that exits on a long outage is a daemon that was + * not running when the message finally came. + */ +/** + * The local end of a seat's doorbell (wait PR 7): an HTTP listener on this machine that the service + * rings through a tunnel, ngrok on a laptop. A ring is checked against the seat's secret and its + * timestamp, answered at once, and only makes the seat look; it carries nothing to trust. + */ +async function openDoorbell(port, dependencies, signal) { + const { createServer } = await import("node:http"); + const { createHmac, timingSafeEqual } = await import("node:crypto"); + const secrets = new Map(); + const pending = new Set(); + const waiters = new Map(); + const server = createServer((request, response) => { + const match = /^\/sharednet\/push\/(i_[0-9A-Za-z]{10})$/.exec((request.url ?? "").split("?")[0]); + if (request.method !== "POST" || !match) { + response.writeHead(404).end(); + return; + } + const memberId = match[1]; + const chunks = []; + let size = 0; + request.on("data", (chunk) => { + size += chunk.length; + if (size > 4096) + request.destroy(); + else + chunks.push(chunk); + }); + request.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + const secret = secrets.get(memberId); + const timestamp = String(request.headers["x-sharednet-timestamp"] ?? ""); + const given = String(request.headers["x-sharednet-signature"] ?? ""); + const expected = secret === undefined ? "" : `sha256=${createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")}`; + // A ring more than five minutes off is a replay, or a clock to fix; either way it is refused. + const fresh = /^\d+$/.test(timestamp) && Math.abs(dependencies.now().getTime() - Number(timestamp) * 1000) <= 300_000; + const valid = secret !== undefined && fresh && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected)); + if (!valid) { + response.writeHead(401).end(); + return; + } + response.writeHead(204).end(); + const waiter = waiters.get(memberId); + if (waiter) { + waiters.delete(memberId); + waiter(true); + } + else { + pending.add(memberId); + } + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }); + signal.addEventListener("abort", () => { + for (const [memberId, waiter] of waiters) { + waiters.delete(memberId); + waiter(false); + } + }, { once: true }); + return { + register(memberId, secret) { + secrets.set(memberId, secret); + }, + /** Resolves true when the seat is rung (or was, while it was busy), false once serve is stopping. */ + rung(memberId) { + if (signal.aborted) + return Promise.resolve(false); + if (pending.delete(memberId)) + return Promise.resolve(true); + return new Promise((resolve) => waiters.set(memberId, resolve)); + }, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} +/** Where the service should ring: the public address given, or the https tunnel a local ngrok has open to this port. */ +async function doorbellAddress(option, port, dependencies) { + if (option !== "ngrok") { + try { + const url = new URL(option); + if (url.protocol !== "https:" && url.protocol !== "http:") + throw new Error(); + return url.toString().replace(/\/$/, ""); + } + catch { + throw localError("invalid_arguments", "--push takes the public address that reaches --listen, such as https://abc.ngrok.app, or ngrok."); + } + } + const listed = await dependencies + .fetch("http://127.0.0.1:4040/api/tunnels") + .then((response) => response.json()) + .catch(() => null); + const tunnels = Array.isArray(listed?.tunnels) ? listed.tunnels : []; + const https = tunnels.filter((tunnel) => typeof tunnel.public_url === "string" && tunnel.public_url.startsWith("https://")); + const tunnel = https.find((candidate) => String(candidate.config?.addr ?? "").endsWith(`:${port}`)) ?? https[0]; + if (!tunnel) + throw localError("ngrok_not_running", `No ngrok tunnel reaches port ${port}. Run: ngrok http ${port}`); + return String(tunnel.public_url).replace(/\/$/, ""); +} +/** How often a running `serve` looks for seats joined since it started. */ +const RESCAN_MS = 5_000; +/** One resumed turn may run this long before it is stopped. */ +const TURN_LIMIT_MS = 20 * 60_000; +/** + * A backstop, not a budget: two seats that keep addressing each other would otherwise wake each + * other for ever. Past this many turns in an hour a seat's next wake waits for the hour to pass, + * and still carries everything said meanwhile. + */ +const MAX_TURNS_PER_HOUR = 30; +/** The service takes a message of at most this many bytes. */ +const MAX_MESSAGE_BYTES = 32_768; +async function serve(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["run", "reply", "rooms", "from-instance", "from-agent", "grep", "status", "stop", "push", "listen"]); + if (parsed.positionals.length !== 0) { + throw localError("invalid_arguments", "Usage: sharednet serve [--run ''] [--reply] [--rooms all|rom_…,rom_…] [--grep TEXT] [--push |ngrok [--listen ]], or sharednet serve --status | --stop"); + } + const pushOption = stringOption(parsed, "push"); + const listenOption = stringOption(parsed, "listen"); + if (listenOption !== undefined && (pushOption === undefined || !/^\d+$/.test(listenOption) || Number(listenOption) < 1 || Number(listenOption) > 65535)) { + throw localError("invalid_arguments", "--listen takes a port from 1 to 65535, and goes with --push."); + } + const listen = listenOption === undefined ? 8787 : Number(listenOption); + const paths = getStoragePaths(dependencies.env); + if (parsed.options.get("status") === true) + return readServeStatus(paths); + if (parsed.options.get("stop") === true) + return stopServe(paths, dependencies); + const command = stringOption(parsed, "run"); + const reply = parsed.options.get("reply") === true; + if (reply && !command) + throw localError("invalid_arguments", "--reply needs --run: it says back what the command prints."); + const filters = waitFiltersFrom(parsed.options); + const wanted = stringOption(parsed, "rooms"); + const only = wanted === undefined || wanted === "all" ? null : new Set(wanted.split(",").map((value) => value.trim())); + if (only) { + for (const roomId of only) { + if (!ROOM_ID_PATTERN.test(roomId)) + throw localError("invalid_arguments", `--rooms takes Room ids such as rom_AbCdEfGhIj, or all. Got ${roomId}.`); + } + } + // Without --run there is nothing to do for a seat but resume the session that took it, so only + // those seats are served. Any other seat is left to its own waits: a connector that sat in it + // would count what it never handled as handled, and that seat's next `wait` would miss it. + const served = (seat) => (only ? only.has(seat.room_id) : true) && (command !== undefined || seat.wake !== undefined); + const held = await heldSeats(paths); + const seats = held.filter(served); + if (seats.length === 0) { + throw localError("not_in_a_room", held.length === 0 + ? "This machine holds no Room seats yet. Run: sharednet join " + : only && !held.some((seat) => only.has(seat.room_id)) + ? "This machine holds no seat in the Rooms named by --rooms." + : "No seat here was joined from a Codex or Claude Code session, so there is no session to wake. Give --run '' to serve every seat with a command."); + } + // One connector per machine. Two would each resume the same session for the same line. + const started = dependencies.now().toISOString(); + const statusOf = (all) => ({ + pid: process.pid, + started_at: started, + rooms: [...new Set(all.map((seat) => seat.room_id))], + drives: !command, + seats: all.map((seat) => ({ + room_id: seat.room_id, + member_id: seat.member_id, + name: seat.name, + resumes: !command && seat.wake ? `${seat.wake.driver} ${seat.wake.session}` : null, + })), + }); + const holder = await claimServe(paths, statusOf(seats)); + if (holder !== null) { + throw localError("serve_running", `${holder === 0 ? "Another connector is starting on this machine" : `A connector is already running on this machine (pid ${holder})`}; it serves every seat here, including ones joined after it started. Stop it first with: sharednet serve --stop`); + } + const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const exec = dependencies.exec ?? defaultExec; + const drive = dependencies.runTurn ?? runTurn; + // With --push the seats sit behind a doorbell (wait PR 7): no long-poll, and not one request while + // nothing is said. Ctrl-C takes the doorbells down before it goes. + const stopping = new AbortController(); + const stop = () => stopping.abort(); + dependencies.signal?.addEventListener("abort", stop, { once: true }); + let doorbell = null; + let base = null; + try { + if (pushOption !== undefined) { + base = await doorbellAddress(pushOption, listen, dependencies); + doorbell = await openDoorbell(listen, dependencies, stopping.signal); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + } + } + catch (error) { + await releaseServe(paths); + throw error; + } + dependencies.stderr?.(`serve: sitting in ${seats.length} Room(s): ${seats.map((seat) => seat.room_id).join(", ")}\n`); + if (base !== null) + dependencies.stderr?.(`serve: doorbell at ${base}/sharednet/push/, listening on 127.0.0.1:${listen}; idle until rung\n`); + const sitIn = async (seat) => { + const client = new ApiClient(seat.base_url, boundedFetch(dependencies, null, false)); + const me = seat.member_id; + // A seat taken from inside a Codex or Claude Code session is driven: being addressed resumes + // that session (the drivers). An explicit --run serves every seat with the command instead. + const driving = !command && seat.wake !== undefined; + // Driven, a seat wakes when a line addresses it; the wake still carries everything said since. + const seatFilters = driving ? new URLSearchParams({ mentions: me }) : filters; + if (driving) { + dependencies.stderr?.(`serve: ${seat.room_id} ${seat.name} (${me}) resumes ${seat.wake.driver} session ${seat.wake.session} when addressed\n`); + } + const turns = []; + // An account's seat can be handed a new token while this runs (its session registered again); + // the session file holds the current one, which is the one `say` and `wait` use too. + const current = async () => { + if (!seat.member_id.startsWith("i_")) + return; + const session = await readSessionById(paths, seat.member_id).catch(() => null); + if (session && sameOrigin(session.base_url, seat.base_url)) + seat.member_token = session.instance_token; + }; + await current(); + // The place is the service's (wait PR 5), shared with every other door this seat uses; the + // connector's file mirrors it. A seat that has handled nothing anywhere starts at the Room's + // current end, so a first start does not hand over the whole history as if it were new. + let cursor; + try { + const mirrored = await readServeCursor(paths, seat.room_id, seat.member_id); + const kept = await keptPlace(client, seat.room_id, seat.member_token, mirrored ?? 0, sleep, dependencies); + // A driven seat's join counted the history as handled, so its place is exact from then on, and + // what was said since is the session's to hear when it is next woken. + cursor = driving || kept > 0 || mirrored !== null ? kept : await roomEnd(client, seat).catch(() => 0); + if (cursor > kept) + await acknowledgeThrough(client, seat.room_id, seat.member_token, cursor, sleep, dependencies); + } + catch (error) { + if (!(error instanceof CliError)) + throw error; + dependencies.stderr?.(`serve: ${seat.room_id} stopped: ${error.code}\n`); + return; + } + await writeServeCursor(paths, seat.room_id, seat.member_id, cursor); + if (doorbell !== null && base !== null) { + try { + const set = await client.request("PUT", `/rooms/${encodeURIComponent(seat.room_id)}/subscription`, seat.member_token, { + push_url: `${base}/sharednet/push/${seat.member_id}`, + }); + if (typeof set?.push?.secret !== "string") + throw invalidServerResponse(); + doorbell.register(seat.member_id, set.push.secret); + } + catch (error) { + if (!(error instanceof CliError)) + throw error; + dependencies.stderr?.(`serve: ${seat.room_id} has no doorbell (${error.code}); stopped\n`); + return; + } + } + const handled = async (through) => { + cursor = through; + await acknowledgeThrough(client, seat.room_id, seat.member_token, cursor, sleep, dependencies); + await writeServeCursor(paths, seat.room_id, seat.member_id, cursor); + }; + /** One wake of a driven seat: its own session, resumed with what was said. */ + const resumeSession = async (said, from, through) => { + const hour = dependencies.now().getTime() - 3_600_000; + while (turns.length > 0 && turns[0] <= hour) + turns.shift(); + if (turns.length >= MAX_TURNS_PER_HOUR) { + const wait = turns[0] + 3_600_000 - dependencies.now().getTime(); + dependencies.stderr?.(`serve: ${seat.room_id} ${seat.name} was resumed ${turns.length} times in the last hour; the next turn waits ${Math.ceil(wait / 60_000)} min\n`); + await sleep(Math.max(wait, 0)); + } + turns.push(dependencies.now().getTime()); + // The seat file names the session to resume; a later join from another session replaces it. + const wake = (await readRoomCredential(paths, seat.room_id, seat.member_id).catch(() => null))?.wake ?? seat.wake; + const prompt = wakeTurnPrompt({ roomId: seat.room_id, seat: seat.name, memberId: seat.member_id, messages: said, from, through }); + dependencies.stderr?.(`serve: ${seat.room_id} ${seat.name} addressed; resuming ${wake.driver} session ${wake.session} with #${from + 1}..#${through}\n`); + const outcome = await drive(turnSpec(wake, prompt, dependencies.env, seat.member_id, TURN_LIMIT_MS)); + const summary = turnSummary(wake.driver, outcome.stdout); + const post = (content) => client.request("POST", `/rooms/${encodeURIComponent(seat.room_id)}/messages`, seat.member_token, { content }, { "idempotency-key": randomUUID() }); + if (summary.ok && outcome.exitCode === 0) { + // The turn's last message is the seat's reply; the session does not have to reach the Room. + const answer = replyFrom(summary.said, MAX_MESSAGE_BYTES); + dependencies.stderr?.(`serve: ${seat.room_id} ${seat.name} finished its turn${answer ? `: ${answer.replace(/\s+/g, " ").slice(0, 160)}` : " with nothing to say"}\n`); + if (answer) { + await post(answer).catch((error) => { + dependencies.stderr?.(`serve: ${seat.room_id} ${seat.name}'s reply was not posted (${error instanceof CliError ? error.code : "error"})\n`); + }); + } + return; + } + // The Room hears the harness's own words, or the exit code; the machine's output stays in the log. + const why = outcome.timedOut + ? `it ran past ${TURN_LIMIT_MS / 60_000} minutes and was stopped` + : (summary.said?.trim() || `the ${wake.driver} command exited ${outcome.exitCode}`).slice(0, 200); + const detail = outcome.stderr.trim().split("\n").slice(-3).join(" / ").slice(0, 400); + dependencies.stderr?.(`serve: ${seat.room_id} ${seat.name} could not be resumed: ${why}${detail ? ` (${detail})` : ""}\n`); + // Whoever addressed the seat would otherwise hear nothing at all, and not know why. + await post(`(${seat.name} was addressed, but its ${wake.driver === "codex" ? "Codex" : "Claude Code"} session could not be resumed: ${why})`).catch(() => undefined); + }; + // Where the long-poll resumes. Without a filter it is the cursor. With one it can run ahead, past + // the seat's own matching words, while the cursor stays at what has been handed over. + let polled = cursor; + let idle = false; + for (;;) { + if (stopping.signal.aborted) + return; + // Behind a doorbell there is no long-poll: once nothing is left to hand over, the seat waits to + // be rung, and looks once when it is. + if (doorbell !== null && idle && !(await doorbell.rung(seat.member_id))) + return; + await current(); + let page; + try { + page = await waitPage(client, seat.room_id, seat.member_token, polled, doorbell !== null ? 0 : WAIT_MAX_SECONDS, seatFilters); + } + catch (error) { + // Three different things, and treating them alike was wrong. Unreachable is temporary and + // is waited out forever, because that is the whole job. A refusal — a revoked seat, a + // closed Room — is the service working and will not change by being asked again, so this Room's + // loop ends and the others carry on. Anything that is not a CliError is a bug in here, and + // spinning on it would hide it. + if (!(error instanceof CliError)) + throw error; + const temporary = error.code === "service_unavailable" || error.code === "invalid_server_response"; + if (!temporary) { + dependencies.stderr?.(`serve: ${seat.room_id} stopped: ${error.code}\n`); + return; + } + dependencies.stderr?.(`serve: ${seat.room_id} unreachable; retrying in 10s\n`); + await sleep(10_000); + continue; + } + idle = page.items.length === 0; + polled = highestSequence(page.items, polled); + const matched = page.items.filter((item) => !isOwn(item, me, seat.member_id)); + if (matched.length === 0 && seatFilters.size > 0) + continue; + // A filter chose the moment; the wake still carries everything said since the last one. + let others = matched; + if (seatFilters.size > 0) { + try { + others = (await everythingSaid(client, seat.room_id, seat.member_token, cursor, polled)).filter((item) => !isOwn(item, me, seat.member_id)); + } + catch (error) { + if (!(error instanceof CliError)) + throw error; + dependencies.stderr?.(`serve: ${seat.room_id} could not read what came before the match (${error.code}); retrying in 10s\n`); + polled = cursor; + await sleep(10_000); + continue; + } + } + if (driving && others.length > 0) { + await resumeSession(others, cursor, polled); + // Handled only now, so a wake cut short by a crash or a restart is handed over again. + await handled(polled); + continue; + } + if (polled > cursor) + await handled(polled); + if (others.length === 0) + continue; + dependencies.stderr?.(`serve: ${seat.room_id} woke on ${others.length} message(s) through sequence ${cursor}\n`); + if (!command) + continue; + const input = `${JSON.stringify({ room_id: seat.room_id, member_id: seat.member_id, trigger: "message", messages: others })}\n`; + const result = await exec(command, input, { + SHAREDNET_ROOM_ID: seat.room_id, + SHAREDNET_MEMBER_ID: seat.member_id, + SHAREDNET_MESSAGE_COUNT: String(others.length), + SHAREDNET_LAST_SEQUENCE: String(cursor), + }); + if (result.stderr) + dependencies.stderr?.(result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`); + if (result.exitCode !== 0) { + dependencies.stderr?.(`serve: ${seat.room_id} command exited ${result.exitCode}\n`); + continue; + } + if (reply && result.stdout.trim().length > 0) { + await client + .request("POST", `/rooms/${encodeURIComponent(seat.room_id)}/messages`, seat.member_token, { content: result.stdout.trim() }, { "idempotency-key": randomUUID() }) + .catch((error) => { + dependencies.stderr?.(`serve: ${seat.room_id} reply not posted (${error instanceof CliError ? error.code : "error"})\n`); + }); + } + } + }; + // Every seat gets its own loop, and no loop awaits another, so a slow or broken Room cannot make + // another Room miss a message. A seat joined while this runs is picked up by the next look. + const loops = new Map(); + const sitting = []; + let live = 0; + let failure = null; + let finish = () => undefined; + const finished = new Promise((resolve) => (finish = resolve)); + const begin = (seat) => { + const key = `${seat.room_id}/${seat.member_id}`; + if (loops.has(key)) + return false; + sitting.push(seat); + live += 1; + loops.set(key, sitIn(seat).then(() => { + live -= 1; + if (live === 0) + finish(); + }, (error) => { + failure ??= error; + finish(); + })); + return true; + }; + for (const seat of seats) + begin(seat); + let looking = false; + const rescan = setInterval(() => { + if (looking || stopping.signal.aborted) + return; + looking = true; + void heldSeats(paths) + .then(async (all) => { + const fresh = all.filter(served).filter(begin); + if (fresh.length === 0) + return; + dependencies.stderr?.(`serve: now also sitting in ${fresh.map((seat) => `${seat.room_id} as ${seat.name}`).join(", ")}\n`); + await writeServeStatus(paths, statusOf(all.filter(served))); + }) + .catch(() => undefined) + .finally(() => (looking = false)); + }, RESCAN_MS); + await finished; + clearInterval(rescan); + if (doorbell !== null) { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + // The service stops ringing an address nobody answers; taking the doorbells down says so now. + await Promise.all(sitting.map((seat) => new ApiClient(seat.base_url, boundedFetch(dependencies, null, true)) + .request("PUT", `/rooms/${encodeURIComponent(seat.room_id)}/subscription`, seat.member_token, { push_url: null }) + .catch(() => undefined))); + await doorbell.close(); + } + await releaseServe(paths); + if (failure !== null) + throw failure; + return { served: loops.size }; +} +/** Where the connector remembers how far it has read in one Room. */ +function serveCursorPath(paths, roomId, memberId) { + return joinPath(paths.roomsDir, roomId, `${memberId}.serve-cursor`); +} +async function readServeCursor(paths, roomId, memberId) { + try { + const raw = await readFile(serveCursorPath(paths, roomId, memberId), "utf8"); + const value = Number(raw.trim()); + return Number.isSafeInteger(value) && value >= 0 ? value : null; + } + catch { + return null; + } +} +async function writeServeCursor(paths, roomId, memberId, cursor) { + await writeFile(serveCursorPath(paths, roomId, memberId), `${cursor}\n`, { mode: 0o600 }).catch(() => undefined); +} +/** The newest sequence in a Room, so a first start listens forward instead of replaying. */ +async function roomEnd(client, seat) { + const page = await client.request("GET", `/rooms/${encodeURIComponent(seat.room_id)}/messages?order=desc&limit=1`, seat.member_token); + return page.items[0]?.sequence ?? 0; +} +function serveStatusPath(paths) { + return joinPath(paths.configDir, "serve.json"); +} +async function writeServeStatus(paths, status) { + await mkdir(paths.configDir, { recursive: true, mode: 0o700 }); + await writeFile(serveStatusPath(paths), `${JSON.stringify(status)}\n`, { mode: 0o600 }); +} +/** + * Takes the machine's one connector slot: null when it is now this process's, or the pid of the + * live connector that holds it. A slot whose process is gone is taken over. + */ +async function claimServe(paths, status) { + await mkdir(paths.configDir, { recursive: true, mode: 0o700 }); + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await writeFile(serveStatusPath(paths), `${JSON.stringify(status)}\n`, { mode: 0o600, flag: "wx" }); + return null; + } + catch (error) { + if (error.code !== "EEXIST") + throw error; + } + let holder; + try { + holder = (await readServeStatus(paths)); + } + catch { + // Being written this moment by a connector that is starting. + return 0; + } + // A connector run inside this very process (a test, an embedding) is this process's own. + if (holder.running && holder.pid && holder.pid !== process.pid) + return holder.pid; + await unlink(serveStatusPath(paths)).catch(() => undefined); + } + return 0; +} +/** Gives the slot back, if it is still this process's. */ +async function releaseServe(paths) { + const status = (await readServeStatus(paths).catch(() => null)); + if (status?.pid === process.pid) + await unlink(serveStatusPath(paths)).catch(() => undefined); +} +/** `running` is checked, not claimed: a status file outlives the process that wrote it. */ +async function readServeStatus(paths) { + let raw; + try { + raw = await readFile(serveStatusPath(paths), "utf8"); + } + catch { + return { running: false, reason: "no connector has been started on this machine" }; + } + const status = JSON.parse(raw); + let running = false; + try { + process.kill(status.pid, 0); + running = true; + } + catch { + running = false; + } + return { running, ...status, ...(running ? {} : { reason: "the process that wrote this status is gone" }) }; +} +async function stopServe(paths, dependencies) { + const status = (await readServeStatus(paths)); + if (!status.running || !status.pid) + return { stopped: false, reason: "no connector is running on this machine" }; + process.kill(status.pid, "SIGTERM"); + dependencies.stderr?.(`serve: asked pid ${status.pid} to stop\n`); + return { stopped: true, pid: status.pid }; +} +const TIMER_USAGE = "Usage: sharednet timer add '' '', sharednet timer list, or sharednet timer cancel [--as ]"; +/** + * `sharednet timer`: a line the Room's clock says on a schedule (wait PR 6), whether or not any + * process is running. Name a seat in it ("@alpha review the open PRs") to wake that seat on + * schedule; a cron is read in UTC. + */ +async function timer(args, dependencies) { + const parsed = parseGuestArguments(args); + assertOnlyOptions(parsed, ["as"]); + const [action, ...rest] = parsed.positionals; + const valid = action === "add" ? rest.length === 2 : action === "list" ? rest.length === 0 : action === "cancel" ? rest.length === 1 && /^tm_[0-9A-Za-z]{10}$/.test(rest[0]) : false; + if (!valid) + throw localError("invalid_arguments", TIMER_USAGE); + const { client, state, credential } = await currentSeat(dependencies, stringOption(parsed, "as")); + const timers = `/rooms/${encodeURIComponent(state.room_id)}/timers`; + if (action === "list") + return client.request("GET", timers, credential.member_token); + if (action === "cancel") + return client.request("POST", `${timers}/${rest[0]}/cancel`, credential.member_token); + return client.request("POST", timers, credential.member_token, { when: rest[0], say: rest[1] }); +} +export function isGuestVerb(value) { + return (value === "timer" || + value === "serve" || + value === "whoami" || + value === "join" || + value === "say" || + value === "read" || + value === "wait" || + value === "ack" || + value === "balance" || + value === "redeem" || + value === "pay" || + value === "ledger" || + value === "upload" || + value === "download" || + value === "files" || + value === "watch" || + value === "add" || + value === "rooms" || + value === "requests" || + value === "accept" || + value === "deny" || + value === "reach"); +} +export async function runGuestVerb(verb, args, dependencies) { + if (verb === "timer") + return timer(args, dependencies); + if (verb === "serve") + return serve(args, dependencies); + if (verb === "whoami") + return whoami(args, dependencies); + if (verb === "join") + return join(args, dependencies); + if (verb === "say") + return say(args, dependencies); + if (verb === "read") + return read(args, dependencies); + if (verb === "watch") + return watch(args, dependencies); + if (verb === "ack") + return ackWake(args, dependencies); + if (verb === "add") + return add(args, dependencies); + if (verb === "rooms") + return rooms(args, dependencies); + if (verb === "requests") + return requests(args, dependencies); + if (verb === "accept") + return answer("approved", args, dependencies); + if (verb === "deny") + return answer("denied", args, dependencies); + if (verb === "reach") + return reach(args, dependencies); + if (verb === "balance") + return balance(args, dependencies); + if (verb === "redeem") + return redeem(args, dependencies); + if (verb === "pay") + return pay(args, dependencies); + if (verb === "ledger") + return ledger(args, dependencies); + if (verb === "upload") + return upload(args, dependencies); + if (verb === "download") + return download(args, dependencies); + if (verb === "files") + return files(args, dependencies); + return wait(args, dependencies); +} diff --git a/experiments/planner-quorum-client/dist/instance-computation.d.ts b/experiments/planner-quorum-client/dist/instance-computation.d.ts new file mode 100644 index 0000000..0f90800 --- /dev/null +++ b/experiments/planner-quorum-client/dist/instance-computation.d.ts @@ -0,0 +1,10 @@ +export type RuntimeKind = string; +export interface RuntimeSession { + runtimeKind: RuntimeKind; + anchor: string; +} +type Environment = Record; +/** The driver's session, when the driver exposes one. See runtime-detection.ts. */ +export declare function detectRuntimeSession(env: Environment): RuntimeSession | null; +export declare function computeLocalInstanceKey(installationSecret: string, runtimeKind: RuntimeKind, providerSessionAnchor: string): string; +export {}; diff --git a/experiments/planner-quorum-client/dist/instance-computation.js b/experiments/planner-quorum-client/dist/instance-computation.js new file mode 100644 index 0000000..67b2890 --- /dev/null +++ b/experiments/planner-quorum-client/dist/instance-computation.js @@ -0,0 +1,26 @@ +import { createHmac } from "node:crypto"; +import { localError } from "./errors.js"; +import { detectRuntime } from "./runtime-detection.js"; +/** The driver's session, when the driver exposes one. See runtime-detection.ts. */ +export function detectRuntimeSession(env) { + const detected = detectRuntime(env, { parentProcess: () => null }); + return detected.anchor === null ? null : { runtimeKind: detected.kind, anchor: detected.anchor }; +} +export function computeLocalInstanceKey(installationSecret, runtimeKind, providerSessionAnchor) { + let key; + try { + key = Buffer.from(installationSecret, "base64url"); + } + catch { + throw localError("invalid_local_state", "The installation secret is invalid."); + } + if (key.byteLength !== 32) { + throw localError("invalid_local_state", "The installation secret is invalid."); + } + if (!providerSessionAnchor) { + throw localError("runtime_session_not_detected", "The current runtime session could not be detected."); + } + return createHmac("sha256", key) + .update(`${runtimeKind}\0${providerSessionAnchor}`, "utf8") + .digest("hex"); +} diff --git a/experiments/planner-quorum-client/dist/login.d.ts b/experiments/planner-quorum-client/dist/login.d.ts new file mode 100644 index 0000000..6dcbcf1 --- /dev/null +++ b/experiments/planner-quorum-client/dist/login.d.ts @@ -0,0 +1,29 @@ +import { type StoragePaths } from "./storage.ts"; +/** + * `sharednet login`: a device-style approval. The CLI starts a login, shows a + * code and a URL, the human approves it in the Web, and the CLI polls until + * it is handed an API key minted at that moment. Any anonymous seats this + * machine holds are sent as proof of possession so the approval binds their + * Principals to the account. Decision 2026-09-06 §3. + */ +type Environment = Record; +export interface LoginDependencies { + env: Environment; + fetch: typeof globalThis.fetch; + stdout: (value: string) => void; + stderr: (value: string) => void; + now: () => Date; + sleep?: (ms: number) => Promise; + /** Opens a URL in the human's browser; the default shells out. */ + openBrowser?: (url: string) => Promise; +} +/** The key stays in the credential file, owner-only; the terminal sees only ids. */ +export declare function storeAccountCredential(paths: StoragePaths, baseUrl: string, result: { + principal: { + id: string; + }; + api_key_id: string; + api_key: string; +}, now: Date): Promise; +export declare function login(args: string[], dependencies: LoginDependencies): Promise; +export {}; diff --git a/experiments/planner-quorum-client/dist/login.js b/experiments/planner-quorum-client/dist/login.js new file mode 100644 index 0000000..463dfea --- /dev/null +++ b/experiments/planner-quorum-client/dist/login.js @@ -0,0 +1,148 @@ +import { spawn } from "node:child_process"; +import { readdir } from "node:fs/promises"; +import { hostname } from "node:os"; +import { join } from "node:path"; +import { ApiClient, resolveBaseUrl, sameOrigin } from "./api-client.js"; +import { CliError, localError } from "./errors.js"; +import { getOrCreateInstallationSecret, getStoragePaths, readRoomCredential, writeStoredApiCredential, } from "./storage.js"; +const VALUE_OPTIONS = new Set(["label"]); +const FLAG_OPTIONS = new Set(["no-browser"]); +function parseLoginArguments(args) { + let label; + let openBrowser = true; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (!argument.startsWith("--")) { + throw localError("invalid_arguments", "Usage: sharednet login [--label ] [--no-browser]"); + } + const separator = argument.indexOf("="); + const name = argument.slice(2, separator === -1 ? undefined : separator); + if (FLAG_OPTIONS.has(name)) { + if (separator !== -1) + throw localError("invalid_option", `The --${name} option does not accept a value.`); + openBrowser = false; + continue; + } + if (!VALUE_OPTIONS.has(name)) + throw localError("unknown_option", "The command contains an unknown option."); + const value = separator === -1 ? args[++index] : argument.slice(separator + 1); + if (!value || value.startsWith("--")) + throw localError("missing_option_value", `The --${name} option requires a value.`); + label = value; + } + return { label, openBrowser }; +} +async function defaultOpenBrowser(url) { + const command = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url]; + return new Promise((resolve) => { + try { + const child = spawn(command[0], command.slice(1), { detached: true, stdio: "ignore" }); + child.once("error", () => resolve(false)); + child.once("spawn", () => { + child.unref(); + resolve(true); + }); + } + catch { + resolve(false); + } + }); +} +/** Every seat token this machine holds, so approval can bind their Principals. */ +/** + * The seat tokens this machine holds for one SharedNet origin. A seat of + * another origin is never named to this one: a token is proof of possession + * to the server that issued it, and nothing to any other. + */ +async function heldSeatTokens(paths, baseUrl) { + let roomDirs; + try { + roomDirs = await readdir(paths.roomsDir); + } + catch (error) { + if (error.code === "ENOENT") + return []; + throw error; + } + const tokens = []; + for (const roomId of roomDirs) { + if (!/^rom_[A-Za-z0-9]+$/.test(roomId)) + continue; + let files; + try { + files = await readdir(join(paths.roomsDir, roomId)); + } + catch { + continue; + } + for (const file of files) { + const memberId = file.replace(/\.json$/, ""); + if (!/^(?:i|mem)_[A-Za-z0-9]+$/.test(memberId)) + continue; + const credential = await readRoomCredential(paths, roomId, memberId).catch(() => null); + if (credential && + sameOrigin(credential.base_url, baseUrl) && + /^(?:sni|rmt)_[A-Za-z0-9_-]{43}$/.test(credential.member_token)) { + tokens.push(credential.member_token); + } + } + } + return tokens.slice(0, 50); +} +/** The key stays in the credential file, owner-only; the terminal sees only ids. */ +export async function storeAccountCredential(paths, baseUrl, result, now) { + const installationSecret = await getOrCreateInstallationSecret(paths); + await writeStoredApiCredential(paths, { + schema_version: 1, + base_url: baseUrl, + principal_id: result.principal.id, + api_key_id: result.api_key_id, + api_key: result.api_key, + installation_secret: installationSecret, + created_at: now.toISOString(), + expires_at: null, + }); +} +export async function login(args, dependencies) { + const { label, openBrowser } = parseLoginArguments(args); + const baseUrl = resolveBaseUrl(dependencies.env.SHAREDNET_BASE_URL); + const paths = getStoragePaths(dependencies.env); + const client = new ApiClient(baseUrl, dependencies.fetch); + const seats = await heldSeatTokens(paths, baseUrl); + const started = await client.request("POST", "/cli/logins", "", { + label: label ?? hostname() ?? null, + ...(seats.length > 0 ? { seats } : {}), + }); + if (!started?.login?.id || !started.user_code || !started.poll_token || !started.verify_url) { + throw new CliError("invalid_server_response", "The SharedNet service returned an invalid response.", 5); + } + dependencies.stderr(`Approve this terminal in your browser.\n\n Code: ${started.user_code}\n Open: ${started.verify_url}\n\n` + + (started.login.bind_instance_ids.length > 0 + ? `Approval also binds ${started.login.bind_instance_ids.length} seat(s) this machine holds to your account.\n\n` + : "")); + if (openBrowser) { + const opened = await (dependencies.openBrowser ?? defaultOpenBrowser)(started.verify_url); + if (!opened) + dependencies.stderr("Could not open a browser; open the URL yourself.\n"); + } + const sleep = dependencies.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const intervalMs = Math.max(1, started.interval_seconds || 3) * 1000; + const deadline = Date.parse(started.login.expires_at); + for (;;) { + const result = await client.request("POST", `/cli/logins/${encodeURIComponent(started.login.id)}/poll`, started.poll_token); + if (result.state === "approved") { + await storeAccountCredential(paths, baseUrl, result, dependencies.now()); + // The key stays in the credential file; the terminal sees only ids. + return { + principal_id: result.principal.id, + api_key_id: result.api_key_id, + bound_instance_ids: result.login.bind_instance_ids, + credentials_file: paths.credentialsFile, + }; + } + if (Number.isFinite(deadline) && dependencies.now().getTime() >= deadline) { + throw new CliError("login_expired", "The login was not approved in time. Run sharednet login again.", 4); + } + await sleep(intervalMs); + } +} diff --git a/experiments/planner-quorum-client/dist/main.d.ts b/experiments/planner-quorum-client/dist/main.d.ts new file mode 100644 index 0000000..41c7812 --- /dev/null +++ b/experiments/planner-quorum-client/dist/main.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export declare function main(argv?: string[]): Promise; diff --git a/experiments/planner-quorum-client/dist/main.js b/experiments/planner-quorum-client/dist/main.js new file mode 100644 index 0000000..c690601 --- /dev/null +++ b/experiments/planner-quorum-client/dist/main.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCli } from "./cli.js"; +import { startWakeService } from "./wake-driver.js"; +export async function main(argv = process.argv.slice(2)) { + // Only a real process can leave a wake service behind it; the entry is this file, as run now. + const entry = process.argv[1] ? resolve(process.argv[1]) : null; + return runCli(argv, { + ...(entry + ? { startWakeService: (input) => startWakeService({ ...input, entry, execPath: process.execPath, execArgv: process.execArgv }) } + : {}), + }); +} +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath && fileURLToPath(import.meta.url) === invokedPath) { + process.exitCode = await main(); +} diff --git a/experiments/planner-quorum-client/dist/runtime-detection.d.ts b/experiments/planner-quorum-client/dist/runtime-detection.d.ts new file mode 100644 index 0000000..7d31b14 --- /dev/null +++ b/experiments/planner-quorum-client/dist/runtime-detection.d.ts @@ -0,0 +1,79 @@ +/** + * Which coding-agent driver is running this CLI, read off the driver's own + * environment. Everything here is diagnostic: it names the seat on the Web and + * picks its icon, and the session anchor feeds the local Instance key. Nothing + * is trusted for authorization. + * + * Markers verified on 2026-09-06 on macOS: + * Claude Code CLAUDECODE=1, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_ENTRYPOINT + * (claude-desktop | cli), CLAUDE_AGENT_SDK_VERSION + * Codex CLI CODEX_SESSION_ID, CODEX_THREAD_ID (lineage, not a session), + * CODEX_CI=1 under `codex exec`; parent process is `codex` + * The OpenCode, OpenHands, Gemini CLI and Cursor markers below are taken from + * their documentation and not yet verified on a machine; the parent-process + * fallback covers a driver whose variables differ. + * + * Muse, Meta's hosted personal agent, runs every command in a "runtime cell" + * whose environment the host renders: JARVIS_HOME=/home/hatch and + * JARVIS_BIN_DIR=/opt/hatch/bin among it (`opt/hatch/runtime-cell/guest.env` + * in the unofficial archive win4r/MuseAI-Skills). The cell's machine name, + * htch-runtime, is the one marker production has shown: the three Muse seats + * taken before Muse was recognised reported it, with the workspace `hatch`. + */ +export type RuntimeSource = "detected" | "declared"; +export interface DetectedRuntime { + /** A handle such as `claude-code`; `custom` when nothing was recognised. */ + kind: string; + /** The driver's exact session id, kept on this machine and only ever HMACed. */ + anchor: string | null; + version: string | null; + entrypoint: string | null; + source: RuntimeSource; + /** + * The parent process name when nothing matched at all. Detection can only + * recognise drivers someone wrote down, so the unrecognised ones used to + * vanish into `custom` and take the evidence with them. Reporting the name + * turns "which drivers are we missing?" from a guess into a query over + * `runtime_metadata`. Absent whenever detection succeeded. + */ + unrecognisedParent?: string | null; + /** + * Set when a harness declared the runtime: the driver detection found + * underneath it. A ChatGPT dot runs Codex on its cloud computer, so a seat + * it takes is the dot's, and Codex is the evidence of how — kept beside the + * declaration rather than lent to it as a version or an entrypoint. + */ + detectedDriver?: string; +} +type Environment = Record; +/** + * The nearest ancestor process that is a known driver, or null. A CLI run + * from a Codex shell inside a Claude Code session has both drivers' + * variables in its environment; the process tree says which one is actually + * running it. + */ +export declare function nearestDriverAncestor(startPid?: number): string | null; +/** The name of the process that launched this one, or null when it cannot be read. */ +export declare function parentProcessName(ppid?: number): string | null; +export declare function detectRuntime(env: Environment, options?: { + parentProcess?: () => string | null; + ancestorDriver?: () => string | null; + hostname?: () => string | null; +}): DetectedRuntime; +export declare function isRuntimeKind(value: string): boolean; +/** + * A runtime the harness declared — `--runtime`, or SHAREDNET_RUNTIME in the + * environment it runs its agents in — names the seat in place of the driver + * detected under it. Declaring what was detected changes nothing. Declaring + * anything else is the harness's word, not something read off a driver, so it + * is reported as declared, carries only the version the declaration gave, and + * holds no session anchor: the anchor belongs to the detected driver, which is + * why `--runtime` has always needed `--new` to name something else. + */ +export declare function declareRuntime(detected: DetectedRuntime, declared: { + kind: string; + version: string | null; +}): DetectedRuntime; +/** The diagnostic fields a detected driver contributes to an Instance's metadata. */ +export declare function runtimeMetadataOf(runtime: DetectedRuntime): Record; +export {}; diff --git a/experiments/planner-quorum-client/dist/runtime-detection.js b/experiments/planner-quorum-client/dist/runtime-detection.js new file mode 100644 index 0000000..d8ae1af --- /dev/null +++ b/experiments/planner-quorum-client/dist/runtime-detection.js @@ -0,0 +1,266 @@ +import { spawnSync } from "node:child_process"; +import { hostname } from "node:os"; +import { basename } from "node:path"; +const RUNTIME_KIND_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; +function nonEmpty(value) { + const normalized = value?.trim(); + return normalized ? normalized : null; +} +function clean(value) { + if (value === null) + return null; + const printable = value.replace(/[^\x20-\x7e]/g, "").trim().slice(0, 64); + return printable.length > 0 ? printable : null; +} +function hasPrefix(env, prefix) { + return Object.keys(env).some((key) => key.startsWith(prefix) && nonEmpty(env[key]) !== null); +} +const PARENT_PROCESS_KINDS = { + claude: "claude-code", + codex: "codex", + opencode: "opencode", + openhands: "openhands", + gemini: "gemini-cli", + cursor: "cursor", + // `openclaw` is the bin name in openclaw/openclaw's package.json; Hermes + // installs both `hermes` and `hermes-agent` as console scripts. + openclaw: "openclaw", + hermes: "hermes", + "hermes-agent": "hermes", +}; +function processEntry(pid) { + if (process.platform === "win32" || !Number.isInteger(pid) || pid <= 1) + return null; + const result = spawnSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { + encoding: "utf8", + timeout: 2000, + }); + if (result.status !== 0) + return null; + const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(result.stdout); + if (!match) + return null; + return { ppid: Number(match[1]), name: basename(match[2]) }; +} +/** + * The nearest ancestor process that is a known driver, or null. A CLI run + * from a Codex shell inside a Claude Code session has both drivers' + * variables in its environment; the process tree says which one is actually + * running it. + */ +export function nearestDriverAncestor(startPid = process.ppid) { + let pid = startPid; + for (let hop = 0; hop < 12; hop += 1) { + const entry = processEntry(pid); + if (!entry) + return null; + const kind = PARENT_PROCESS_KINDS[entry.name.toLowerCase()]; + if (kind) + return kind; + if (entry.ppid <= 1 || entry.ppid === pid) + return null; + pid = entry.ppid; + } + return null; +} +/** The name of the process that launched this one, or null when it cannot be read. */ +export function parentProcessName(ppid = process.ppid) { + return processEntry(ppid)?.name ?? null; +} +/** + * Detect the driver. Every driver whose variables are present is a candidate; + * one that exposes its own session id outranks one that only leaves markers, + * because markers are inherited by everything a driver spawns while a session + * id is set per process. When two drivers both expose a session id, the + * process tree decides; when it cannot, the first in DRIVER_ORDER wins. + */ +const DRIVER_ORDER = [ + "codex", + "claude-code", + "opencode", + "openhands", + "gemini-cli", + "cursor", + "hermes", + "openclaw", + "muse", +]; +/** Muse's runtime cell, by the machine name every VM of its image carries. */ +const MUSE_CELL_HOSTNAME = "htch-runtime"; +function candidates(env) { + const found = []; + const claudeAnchor = nonEmpty(env.CLAUDE_CODE_SESSION_ID) ?? nonEmpty(env.CLAUDE_SESSION_ID); + if (claudeAnchor !== null || nonEmpty(env.CLAUDECODE) !== null) { + found.push({ + kind: "claude-code", + anchor: claudeAnchor, + version: clean(nonEmpty(env.CLAUDE_AGENT_SDK_VERSION)), + entrypoint: clean(nonEmpty(env.CLAUDE_CODE_ENTRYPOINT)), + source: "detected", + }); + } + // CODEX_THREAD_ID is deliberately not an anchor: it is lineage, not the + // executing session, and would collapse concurrent child sessions. + const codexAnchor = nonEmpty(env.CODEX_SESSION_ID); + if (codexAnchor !== null) { + found.push({ + kind: "codex", + anchor: codexAnchor, + version: clean(nonEmpty(env.CODEX_VERSION)), + entrypoint: nonEmpty(env.CODEX_CI) !== null ? "exec" : null, + source: "detected", + }); + } + if (hasPrefix(env, "OPENCODE")) { + found.push({ + kind: "opencode", + anchor: nonEmpty(env.OPENCODE_SESSION_ID), + version: clean(nonEmpty(env.OPENCODE_VERSION)), + entrypoint: null, + source: "detected", + }); + } + if (hasPrefix(env, "OPENHANDS")) { + found.push({ + kind: "openhands", + anchor: nonEmpty(env.OPENHANDS_SESSION_ID) ?? nonEmpty(env.OPENHANDS_CONVERSATION_ID), + version: clean(nonEmpty(env.OPENHANDS_VERSION)), + entrypoint: null, + source: "detected", + }); + } + if (nonEmpty(env.GEMINI_CLI) !== null || hasPrefix(env, "GEMINI_CLI_")) { + found.push({ + kind: "gemini-cli", + anchor: nonEmpty(env.GEMINI_CLI_SESSION_ID), + version: clean(nonEmpty(env.GEMINI_CLI_VERSION)), + entrypoint: null, + source: "detected", + }); + } + if (nonEmpty(env.CURSOR_AGENT) !== null || nonEmpty(env.CURSOR_TRACE_ID) !== null) { + found.push({ + kind: "cursor", + anchor: nonEmpty(env.CURSOR_TRACE_ID), + version: null, + entrypoint: null, + source: "detected", + }); + } + // Hermes re-exports two attribution markers into the environment of every + // shell command it runs, alongside a per-command session id — see + // `tools/environments/base_session_env.py` in NousResearch/hermes-agent, + // which calls them exactly that. `AI_AGENT` is a cross-harness name, so only + // its Hermes value counts; presence alone would claim other harnesses. + const hermesAnchor = nonEmpty(env.HERMES_SESSION_ID); + if (hermesAnchor !== null || + nonEmpty(env.HERMES_AGENT) !== null || + nonEmpty(env.AI_AGENT) === "hermes-agent") { + found.push({ + kind: "hermes", + anchor: hermesAnchor, + version: clean(nonEmpty(env.HERMES_VERSION)), + entrypoint: null, + source: "detected", + }); + } + // OpenClaw publishes no attribution marker. Its per-session variable is the + // key it hands an MCP server it spawns, and its operator-facing OPENCLAW_* + // variables are inherited by anything it runs — the same weak-marker shape + // as OPENCODE and OPENHANDS above, and the process tree breaks the tie. + if (hasPrefix(env, "OPENCLAW")) { + found.push({ + kind: "openclaw", + anchor: nonEmpty(env.OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY), + version: clean(nonEmpty(env.OPENCLAW_VERSION)), + entrypoint: null, + source: "detected", + }); + } + // Muse's cell exports its own paths into every command it runs. JARVIS_ is + // nobody's reserved prefix, so only the cell's values count, the way only + // Hermes's value of AI_AGENT does. The cell offers no session id, version or + // entrypoint, so a Muse seat is fresh on every join. + if (nonEmpty(env.JARVIS_HOME) === "/home/hatch" || nonEmpty(env.JARVIS_BIN_DIR) === "/opt/hatch/bin") { + found.push({ kind: "muse", anchor: null, version: null, entrypoint: null, source: "detected" }); + } + return found.sort((a, b) => DRIVER_ORDER.indexOf(a.kind) - DRIVER_ORDER.indexOf(b.kind)); +} +/** This machine's name, or null when it cannot be read. */ +function machineName() { + try { + return hostname(); + } + catch { + return null; + } +} +export function detectRuntime(env, options = {}) { + const found = candidates(env); + const withSession = found.filter((candidate) => candidate.anchor !== null); + if (withSession.length === 1) + return withSession[0]; + if (withSession.length > 1) { + const byTree = (options.ancestorDriver ?? nearestDriverAncestor)(); + return withSession.find((candidate) => candidate.kind === byTree) ?? withSession[0]; + } + if (found.length > 0) { + const byTree = (options.ancestorDriver ?? nearestDriverAncestor)(); + return found.find((candidate) => candidate.kind === byTree) ?? found[0]; + } + // Below every variable, above a guess from the parent process: a command + // the cell runs without its environment is still on the cell's machine. + if ((options.hostname ?? machineName)() === MUSE_CELL_HOSTNAME) { + return { kind: "muse", anchor: null, version: null, entrypoint: null, source: "detected" }; + } + const parent = (options.parentProcess ?? parentProcessName)(); + const byParent = parent ? PARENT_PROCESS_KINDS[parent.toLowerCase()] : undefined; + if (byParent) { + return { kind: byParent, anchor: null, version: null, entrypoint: null, source: "detected" }; + } + return { + kind: "custom", + anchor: null, + version: null, + entrypoint: null, + source: "declared", + unrecognisedParent: clean(parent), + }; +} +export function isRuntimeKind(value) { + return RUNTIME_KIND_PATTERN.test(value); +} +/** + * A runtime the harness declared — `--runtime`, or SHAREDNET_RUNTIME in the + * environment it runs its agents in — names the seat in place of the driver + * detected under it. Declaring what was detected changes nothing. Declaring + * anything else is the harness's word, not something read off a driver, so it + * is reported as declared, carries only the version the declaration gave, and + * holds no session anchor: the anchor belongs to the detected driver, which is + * why `--runtime` has always needed `--new` to name something else. + */ +export function declareRuntime(detected, declared) { + if (declared.kind === detected.kind) + return detected; + return { + kind: declared.kind, + anchor: null, + version: declared.version, + entrypoint: null, + source: "declared", + ...(detected.kind === "custom" ? {} : { detectedDriver: detected.kind }), + }; +} +/** The diagnostic fields a detected driver contributes to an Instance's metadata. */ +export function runtimeMetadataOf(runtime) { + const metadata = { runtime_source: runtime.source }; + if (runtime.version) + metadata.driver_version = runtime.version; + if (runtime.entrypoint) + metadata.entrypoint = runtime.entrypoint; + if (runtime.unrecognisedParent) + metadata.parent_process = runtime.unrecognisedParent; + if (runtime.detectedDriver) + metadata.detected_driver = runtime.detectedDriver; + return metadata; +} diff --git a/experiments/planner-quorum-client/dist/session.d.ts b/experiments/planner-quorum-client/dist/session.d.ts new file mode 100644 index 0000000..a79c93c --- /dev/null +++ b/experiments/planner-quorum-client/dist/session.d.ts @@ -0,0 +1,95 @@ +import { ApiClient } from "./api-client.ts"; +import { type DetectedRuntime } from "./runtime-detection.ts"; +import { type StoragePaths, type StoredSession } from "./storage.ts"; +/** + * Acting as an account: the stored credential, registering this session as an + * Instance, keeping its lease alive. Shared by `session start`, the Room + * commands, and `join` when a credential is present. + */ +type Environment = Record; +export declare const CLI_VERSION: string; +export interface AgentShape { + id: string; + principal_id?: string; + handle?: string; +} +export interface InstanceShape { + id: string; + principal_id: string; + agent_id: string | null; + started_at: string; + lease_expires_at: string; + token_expires_at?: string; + expires_at?: string; + [key: string]: unknown; +} +export interface InstanceStartPayload { + instance: InstanceShape; + token: string; + heartbeat_after_seconds: number; +} +export interface CurrentInstancePayload { + principal: unknown; + agent: AgentShape | null; + instance: InstanceShape; +} +export declare function resolveApiKey(env: Environment, paths: StoragePaths, baseUrl: string): Promise; +/** + * Resolves `--agent` to a tag. An `a_` id is fetched; anything else is a + * handle and is created on first use, the way `git tag` behaves — the server's + * POST is idempotent by handle, so one call covers both "exists" and "new". + * `default` names the absence of a tag and resolves to nothing. + */ +export declare function resolveTag(client: ApiClient, apiKey: string, requested: string): Promise; +/** + * Where this session runs, for humans telling untagged sessions apart. It is + * shown, never trusted: the server records it as diagnostics and nothing reads + * it for authorization or grouping. + * + * Only the workspace's last path segment and the OS are sent. The full path is a + * map of this machine — home directory, user name, client folders — and none of + * that is needed to tell "the one in the sharednet folder" from the others. The + * hostname is not sent either: a Mac is named after its owner by default, so it + * names the person behind a seat, which is exactly what the path rule avoids. + */ +export declare function runtimeMetadata(env: Environment): Record; +export declare function validateRuntime(value: string, from?: string): string; +/** + * The runtime a harness declares for the seats it runs: `--runtime`, else + * SHAREDNET_RUNTIME, which a hosted product sets once in the environment its + * agents' commands run in. A handle with an optional `@version`, the shape of + * the AI_AGENT convention (`dot`, `dot@2.1`). Null when nothing was declared. + */ +export declare function declaredRuntime(flag: string | undefined, env: Environment): { + kind: string; + version: string | null; +} | null; +/** What this CLI reports as its runtime: the harness's declaration, else the driver detected. */ +export declare function resolveRuntime(env: Environment, flag?: string): DetectedRuntime; +export declare function storedSessionFromStart(baseUrl: string, localInstanceKey: string | null, payload: InstanceStartPayload): StoredSession; +export declare function selectSession(paths: StoragePaths, baseUrl: string, explicitId: string | undefined, env: Environment): Promise; +export declare function refreshIfNeeded(client: ApiClient, paths: StoragePaths, session: StoredSession, now: Date): Promise; +export interface RegisterInstanceOptions { + /** `--runtime`: overrides the detected driver's name, never its session. SHAREDNET_RUNTIME when absent. */ + runtimeOverride?: string; + /** `--new`: a fresh Instance even though the session was detected. */ + forceNew: boolean; + /** `--agent`: a tag id or handle to group the Instance under. */ + agent?: string; + /** Without a detected session and without `forceNew`, refuse (session start) or register fresh (join). */ + freshWhenUndetected: boolean; + /** Sent as given; omitted, the server applies the Principal's default (public). */ + reach?: "public" | "private"; +} +/** + * Registers this session as an Instance of the account behind the stored + * credential. A same-session re-registration comes back with the existing + * Instance and a fresh token; a new session comes back created. + */ +export declare function registerInstance(env: Environment, fetchImplementation: typeof globalThis.fetch, paths: StoragePaths, baseUrl: string, options: RegisterInstanceOptions): Promise<{ + session: StoredSession; + payload: InstanceStartPayload; +}>; +/** True when this machine can act as an account: a key in the environment or a stored credential for this origin. */ +export declare function hasAccountCredential(env: Environment, paths: StoragePaths, baseUrl: string): Promise; +export {}; diff --git a/experiments/planner-quorum-client/dist/session.js b/experiments/planner-quorum-client/dist/session.js new file mode 100644 index 0000000..2674b0d --- /dev/null +++ b/experiments/planner-quorum-client/dist/session.js @@ -0,0 +1,217 @@ +import { createRequire } from "node:module"; +import { platform } from "node:os"; +import { ApiClient, sameOrigin } from "./api-client.js"; +import { CliError, localError } from "./errors.js"; +import { computeLocalInstanceKey } from "./instance-computation.js"; +import { declareRuntime, detectRuntime, isRuntimeKind, runtimeMetadataOf, } from "./runtime-detection.js"; +import { deleteSession, getOrCreateInstallationSecret, listSessions, readSessionById, readStoredApiCredential, writeSession, } from "./storage.js"; +function packageVersion() { + const manifest = createRequire(import.meta.url)("../package.json"); + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + throw new Error("The SharedNet CLI package has no version."); + } + return manifest.version; +} +export const CLI_VERSION = packageVersion(); +function apiKeyAuthenticationError() { + return new CliError("authentication_required", "Set SHAREDNET_API_KEY or run sharednet login first.", 3); +} +export async function resolveApiKey(env, paths, baseUrl) { + const environmentKey = env.SHAREDNET_API_KEY?.trim(); + if (environmentKey) + return environmentKey; + const credential = await readStoredApiCredential(paths); + if (!credential) + throw apiKeyAuthenticationError(); + if (!sameOrigin(credential.base_url, baseUrl)) { + throw localError("credential_origin_mismatch", "The stored credential belongs to a different SharedNet origin."); + } + if (credential.expires_at && Date.parse(credential.expires_at) <= Date.now()) { + throw new CliError("invalid_credentials", "The stored API key has expired.", 3); + } + return credential.api_key; +} +/** + * Resolves `--agent` to a tag. An `a_` id is fetched; anything else is a + * handle and is created on first use, the way `git tag` behaves — the server's + * POST is idempotent by handle, so one call covers both "exists" and "new". + * `default` names the absence of a tag and resolves to nothing. + */ +export async function resolveTag(client, apiKey, requested) { + const handle = requested.normalize("NFKC").trim().toLowerCase(); + if (handle === "default") + return null; + let agent; + if (/^a_[A-Za-z0-9]+$/.test(requested)) { + const payload = await client.request("GET", `/agents/${encodeURIComponent(requested)}`, apiKey); + agent = payload.agent; + } + else { + if (!/^[a-z][a-z0-9-]{0,31}$/.test(handle)) { + throw localError("invalid_agent", "An Agent is an a_ id or a handle matching ^[a-z][a-z0-9-]{0,31}$."); + } + const payload = await client.request("POST", "/agents", apiKey, { + handle, + }); + agent = payload.agent; + } + if (!agent?.id) { + throw new CliError("invalid_server_response", "The SharedNet service returned an invalid Agent response.", 5); + } + return agent; +} +/** + * Where this session runs, for humans telling untagged sessions apart. It is + * shown, never trusted: the server records it as diagnostics and nothing reads + * it for authorization or grouping. + * + * Only the workspace's last path segment and the OS are sent. The full path is a + * map of this machine — home directory, user name, client folders — and none of + * that is needed to tell "the one in the sharednet folder" from the others. The + * hostname is not sent either: a Mac is named after its owner by default, so it + * names the person behind a seat, which is exactly what the path rule avoids. + */ +export function runtimeMetadata(env) { + const clean = (value) => (value ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 256); + const metadata = {}; + const workspacePath = clean(env.PWD ?? process.cwd()); + const workspace = workspacePath.split(/[\\/]+/).filter(Boolean).at(-1) ?? ""; + if (workspace) + metadata.workspace = workspace; + metadata.os = platform(); + return metadata; +} +export function validateRuntime(value, from = "--runtime") { + const kind = value.normalize("NFKC").trim().toLowerCase(); + if (isRuntimeKind(kind)) + return kind; + throw localError("invalid_runtime", `${from} must be a handle such as claude-code, codex, or dot, optionally followed by @version.`); +} +/** + * The runtime a harness declares for the seats it runs: `--runtime`, else + * SHAREDNET_RUNTIME, which a hosted product sets once in the environment its + * agents' commands run in. A handle with an optional `@version`, the shape of + * the AI_AGENT convention (`dot`, `dot@2.1`). Null when nothing was declared. + */ +export function declaredRuntime(flag, env) { + const fromFlag = flag?.trim(); + const value = fromFlag || env.SHAREDNET_RUNTIME?.trim(); + if (!value) + return null; + const from = fromFlag ? "--runtime" : "SHAREDNET_RUNTIME"; + const at = value.indexOf("@"); + const kind = validateRuntime(at === -1 ? value : value.slice(0, at), from); + if (at === -1) + return { kind, version: null }; + const version = value.slice(at + 1).trim(); + if (!/^[\x20-\x7e]{1,64}$/.test(version)) { + throw localError("invalid_runtime", `${from} must be a handle such as claude-code, codex, or dot, optionally followed by @version.`); + } + return { kind, version }; +} +/** What this CLI reports as its runtime: the harness's declaration, else the driver detected. */ +export function resolveRuntime(env, flag) { + const detected = detectRuntime(env); + const declared = declaredRuntime(flag, env); + return declared === null ? detected : declareRuntime(detected, declared); +} +export function storedSessionFromStart(baseUrl, localInstanceKey, payload) { + const { instance, token } = payload; + if (!instance?.id || !instance.principal_id || !token) { + throw new CliError("invalid_server_response", "The SharedNet service returned an invalid Instance response.", 5); + } + return { + schema_version: 1, + base_url: baseUrl, + principal_id: instance.principal_id, + agent_id: instance.agent_id ?? null, + instance_id: instance.id, + local_instance_key: localInstanceKey, + instance_token: token, + created_at: instance.started_at, + lease_expires_at: instance.lease_expires_at, + expires_at: null, + }; +} +export async function selectSession(paths, baseUrl, explicitId, env) { + const selectedId = explicitId || env.SHAREDNET_SESSION?.trim(); + if (selectedId) { + if (!/^i_[A-Za-z0-9_-]+$/.test(selectedId)) { + throw localError("invalid_session_id", "SHAREDNET_SESSION must be an Instance ID."); + } + const selected = await readSessionById(paths, selectedId); + if (!selected || !sameOrigin(selected.base_url, baseUrl)) { + throw localError("session_not_found", "The selected local SharedNet session was not found."); + } + return selected; + } + const usable = (await listSessions(paths)).filter((session) => sameOrigin(session.base_url, baseUrl)); + if (usable.length !== 1) { + throw localError("session_selection_required", "Select a local Instance with --session or SHAREDNET_SESSION."); + } + return usable[0]; +} +export async function refreshIfNeeded(client, paths, session, now) { + if (Date.parse(session.lease_expires_at) - now.getTime() > 30_000) + return session; + try { + const payload = await client.request("POST", "/instances/current/heartbeat", session.instance_token, {}); + const refreshed = { + ...session, + lease_expires_at: payload.instance.lease_expires_at, + expires_at: null, + }; + await writeSession(paths, refreshed); + return refreshed; + } + catch (error) { + if (error instanceof CliError && error.exitCode === 3) { + await deleteSession(paths, session.instance_id); + } + throw error; + } +} +/** + * Registers this session as an Instance of the account behind the stored + * credential. A same-session re-registration comes back with the existing + * Instance and a fresh token; a new session comes back created. + */ +export async function registerInstance(env, fetchImplementation, paths, baseUrl, options) { + const installationSecret = await getOrCreateInstallationSecret(paths); + // The driver is read off its own environment; a declaration (--runtime, or + // SHAREDNET_RUNTIME from a harness) only overrides the name, and a name other + // than the detected driver's comes without that driver's session. + const runtime = resolveRuntime(env, options.runtimeOverride); + let localInstanceKey = null; + if (!options.forceNew) { + if (runtime.anchor === null && !options.freshWhenUndetected) { + throw localError("runtime_session_not_detected", "The current runtime session could not be detected; use --new deliberately."); + } + if (runtime.anchor !== null) { + // The key goes to the server, which is the one place that can guarantee + // one live Instance per runtime session. The raw session id stays here. + localInstanceKey = computeLocalInstanceKey(installationSecret, runtime.kind, runtime.anchor); + } + } + const apiKey = await resolveApiKey(env, paths, baseUrl); + const client = new ApiClient(baseUrl, fetchImplementation); + const tag = options.agent ? await resolveTag(client, apiKey, options.agent) : undefined; + const payload = await client.request("POST", "/instances", apiKey, { + runtime_kind: runtime.kind, + cli_version: CLI_VERSION, + ...(localInstanceKey ? { local_instance_key: localInstanceKey } : {}), + ...(tag === undefined ? {} : { agent_id: tag?.id ?? null }), + ...(options.reach === undefined ? {} : { reach: options.reach }), + runtime_metadata: { ...runtimeMetadata(env), ...runtimeMetadataOf(runtime) }, + }); + const session = storedSessionFromStart(baseUrl, localInstanceKey, payload); + await writeSession(paths, session); + return { session, payload }; +} +/** True when this machine can act as an account: a key in the environment or a stored credential for this origin. */ +export async function hasAccountCredential(env, paths, baseUrl) { + if (env.SHAREDNET_API_KEY?.trim()) + return true; + const credential = await readStoredApiCredential(paths).catch(() => null); + return credential !== null && sameOrigin(credential.base_url, baseUrl); +} diff --git a/experiments/planner-quorum-client/dist/storage.d.ts b/experiments/planner-quorum-client/dist/storage.d.ts new file mode 100644 index 0000000..5c9ee70 --- /dev/null +++ b/experiments/planner-quorum-client/dist/storage.d.ts @@ -0,0 +1,114 @@ +import { type SeatWake } from "./wake-driver.ts"; +type Environment = Record; +export interface StoragePaths { + configDir: string; + credentialsFile: string; + installationFile: string; + stateDir: string; + sessionsDir: string; + /** One file per seat: rooms//.json, so two Agents on one machine can sit in one Room. */ + roomsDir: string; +} +/** + * A guest seat in one Room: the member token the join handed back, owner-only + * under the config directory, never inside a project tree. + */ +export interface StoredRoomCredential { + schema_version: 1; + base_url: string; + room_id: string; + member_id: string; + name: string; + member_token: string; + joined_at: string; + /** The session that took the seat, which `serve` resumes when the seat is addressed. */ + wake?: SeatWake; +} +/** + * The per-project cursor: which Room this checkout is in and the last + * sequence it has seen. Nothing here is secret, and the directory ignores + * itself so it never rides into a commit. + */ +/** One seat's view of the directory's Room: what say, wait and watch act as. */ +export interface ProjectRoomState { + schema_version: 1; + base_url: string; + room_id: string; + member_id: string; + last_sequence: number; +} +/** + * The directory's Room, with every seat this directory has taken in it and + * a cursor per seat. Two Agent sessions in one checkout are two seats, each + * tied to its own session by `anchor_key` (the local Instance key derived + * from the driver's session id and this machine's installation secret; never + * the raw session id), so neither ever speaks or reads as the other. + */ +export interface ProjectRoom { + schema_version: 2; + base_url: string; + room_id: string; + seats: Record; +} +export interface ProjectSeat { + last_sequence: number; + anchor_key: string | null; + joined_at: string | null; +} +export declare const PROJECT_STATE_DIR = ".sharednet"; +export interface StoredApiCredential { + schema_version: 1; + base_url: string; + principal_id: string; + api_key_id: string; + api_key: string; + installation_secret: string; + created_at: string; + expires_at: string | null; +} +export interface StoredSession { + schema_version: 1; + base_url: string; + principal_id: string; + /** The tag this Instance was under when the session file was written; null when untagged. */ + agent_id: string | null; + instance_id: string; + local_instance_key: string | null; + instance_token: string; + created_at: string; + lease_expires_at: string; + /** Always null since Instances became permanent; kept so old files still parse. */ + expires_at: string | null; +} +export declare function getStoragePaths(env?: Environment): StoragePaths; +export declare function readStoredApiCredential(paths: StoragePaths): Promise; +/** Written by `sharednet login`; owner-only, and never printed back. */ +export declare function writeStoredApiCredential(paths: StoragePaths, credential: StoredApiCredential): Promise; +export declare function getOrCreateInstallationSecret(paths: StoragePaths): Promise; +export declare function writeSession(paths: StoragePaths, session: StoredSession): Promise; +export declare function listSessions(paths: StoragePaths): Promise; +export declare function readSessionById(paths: StoragePaths, instanceId: string): Promise; +export declare function deleteSession(paths: StoragePaths, instanceId: string): Promise; +export declare function writeRoomCredential(paths: StoragePaths, credential: StoredRoomCredential): Promise; +export declare function readRoomCredential(paths: StoragePaths, roomId: string, memberId: string): Promise; +export declare function readProjectRoom(cwd: string): Promise; +/** + * Records one seat's cursor in the directory's Room. A different Room + * replaces the file: a directory is in one Room at a time. `anchorKey` ties + * the seat to the session that took it; omitted, an existing tie is kept. + */ +export declare function writeProjectRoomState(cwd: string, state: ProjectRoomState, options?: { + anchorKey?: string | null; + joinedAt?: string; +}): Promise; +/** + * The seat this call acts as. In order: `--as` or SHAREDNET_SEAT; the seat + * tied to this session; the only seat there is. Two seats and no way to + * tell them apart is refused rather than guessed, since guessing is how one + * session ends up speaking as another. + */ +export declare function selectProjectSeat(cwd: string, choice: { + explicit?: string; + anchorKey: string | null; +}): Promise; +export {}; diff --git a/experiments/planner-quorum-client/dist/storage.js b/experiments/planner-quorum-client/dist/storage.js new file mode 100644 index 0000000..00646a2 --- /dev/null +++ b/experiments/planner-quorum-client/dist/storage.js @@ -0,0 +1,450 @@ +import { randomBytes } from "node:crypto"; +import { chmod, lstat, link, mkdir, open, readdir, readFile, rename, unlink, writeFile, } from "node:fs/promises"; +import { constants } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { localError } from "./errors.js"; +import { parseSeatWake } from "./wake-driver.js"; +export const PROJECT_STATE_DIR = ".sharednet"; +function unsafeStorage() { + throw localError("unsafe_credential_storage", "SharedNet local credential storage is not owner-only."); +} +export function getStoragePaths(env = process.env) { + if (process.platform === "win32") { + const localAppData = env.LOCALAPPDATA; + if (!localAppData) + unsafeStorage(); + const root = join(localAppData, "SharedNet"); + return { + configDir: root, + credentialsFile: join(root, "credentials.json"), + installationFile: join(root, "installation.json"), + stateDir: root, + sessionsDir: join(root, "sessions"), + roomsDir: join(root, "rooms"), + }; + } + const home = env.HOME || homedir(); + if (!home) + unsafeStorage(); + const configRoot = env.XDG_CONFIG_HOME || join(home, ".config"); + const stateRoot = env.XDG_STATE_HOME || join(home, ".local", "state"); + const configDir = join(configRoot, "sharednet"); + const stateDir = join(stateRoot, "sharednet"); + return { + configDir, + credentialsFile: join(configDir, "credentials.json"), + installationFile: join(configDir, "installation.json"), + stateDir, + sessionsDir: join(stateDir, "sessions"), + roomsDir: join(configDir, "rooms"), + }; +} +async function ensureSecureDirectory(path) { + if (process.platform === "win32") + unsafeStorage(); + await mkdir(path, { recursive: true, mode: 0o700 }); + const info = await lstat(path); + if (info.isSymbolicLink() || + !info.isDirectory() || + info.uid !== process.getuid?.() || + (info.mode & 0o077) !== 0) { + unsafeStorage(); + } +} +async function readSecureFile(path) { + let info; + try { + info = await lstat(path); + } + catch (error) { + if (error.code === "ENOENT") + return null; + throw error; + } + if (info.isSymbolicLink() || + !info.isFile() || + info.uid !== process.getuid?.() || + (info.mode & 0o077) !== 0) { + unsafeStorage(); + } + let handle; + try { + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + handle = await open(path, constants.O_RDONLY | noFollow); + const openedInfo = await handle.stat(); + if (!openedInfo.isFile() || + openedInfo.dev !== info.dev || + openedInfo.ino !== info.ino || + openedInfo.uid !== process.getuid?.() || + (openedInfo.mode & 0o077) !== 0) { + unsafeStorage(); + } + return await handle.readFile("utf8"); + } + finally { + await handle?.close(); + } +} +async function writeSecureJson(path, value) { + const parent = dirname(path); + await ensureSecureDirectory(parent); + // Validate an existing target before an atomic replacement. Missing is fine. + await readSecureFile(path); + const temp = join(parent, `.sharednet-${randomBytes(12).toString("hex")}.tmp`); + let handle; + try { + handle = await open(temp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await chmod(temp, 0o600); + await rename(temp, path); + const directory = await open(parent, constants.O_RDONLY); + try { + await directory.sync(); + } + finally { + await directory.close(); + } + } + catch (error) { + await handle?.close(); + await unlink(temp).catch(() => undefined); + throw error; + } +} +async function createSecureJsonIfAbsent(path, value) { + const parent = dirname(path); + await ensureSecureDirectory(parent); + const temp = join(parent, `.sharednet-${randomBytes(12).toString("hex")}.tmp`); + let handle; + try { + handle = await open(temp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); + await handle.writeFile(`${JSON.stringify(value)}\n`, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await chmod(temp, 0o600); + try { + // link(2) is an atomic create-if-absent operation. Unlike opening the + // destination with O_EXCL and then writing it, readers can never observe + // an empty or partially written installation secret. + await link(temp, path); + } + catch (error) { + if (error.code === "EEXIST") + return false; + throw error; + } + const directory = await open(parent, constants.O_RDONLY); + try { + await directory.sync(); + } + finally { + await directory.close(); + } + return true; + } + finally { + await handle?.close(); + await unlink(temp).catch(() => undefined); + } +} +function parseJsonObject(raw, source) { + try { + const value = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error(); + return value; + } + catch { + throw localError("invalid_local_state", `${source} is not valid SharedNet state.`); + } +} +function requireString(value, source) { + if (typeof value !== "string" || value.length === 0) { + throw localError("invalid_local_state", `${source} is not valid SharedNet state.`); + } + return value; +} +function parseCredential(raw) { + const value = parseJsonObject(raw, "The credential file"); + if (value.schema_version !== 1) { + throw localError("invalid_local_state", "The credential file has an unsupported version."); + } + return { + schema_version: 1, + base_url: requireString(value.base_url, "The credential file"), + principal_id: requireString(value.principal_id, "The credential file"), + api_key_id: requireString(value.api_key_id, "The credential file"), + api_key: requireString(value.api_key, "The credential file"), + installation_secret: requireString(value.installation_secret, "The credential file"), + created_at: requireString(value.created_at, "The credential file"), + expires_at: value.expires_at === null ? null : requireString(value.expires_at, "The credential file"), + }; +} +function parseSession(raw) { + const value = parseJsonObject(raw, "A session file"); + if (value.schema_version !== 1) { + throw localError("invalid_local_state", "A session file has an unsupported version."); + } + return { + schema_version: 1, + base_url: requireString(value.base_url, "A session file"), + principal_id: requireString(value.principal_id, "A session file"), + agent_id: value.agent_id === null ? null : requireString(value.agent_id, "A session file"), + instance_id: requireString(value.instance_id, "A session file"), + local_instance_key: value.local_instance_key === null + ? null + : requireString(value.local_instance_key, "A session file"), + instance_token: requireString(value.instance_token, "A session file"), + created_at: requireString(value.created_at, "A session file"), + lease_expires_at: requireString(value.lease_expires_at, "A session file"), + expires_at: value.expires_at === null || value.expires_at === undefined ? null : requireString(value.expires_at, "A session file"), + }; +} +export async function readStoredApiCredential(paths) { + const raw = await readSecureFile(paths.credentialsFile); + return raw === null ? null : parseCredential(raw); +} +/** Written by `sharednet login`; owner-only, and never printed back. */ +export async function writeStoredApiCredential(paths, credential) { + await writeSecureJson(paths.credentialsFile, credential); +} +export async function getOrCreateInstallationSecret(paths) { + const credential = await readStoredApiCredential(paths); + if (credential) + return credential.installation_secret; + const raw = await readSecureFile(paths.installationFile); + if (raw !== null) { + const value = parseJsonObject(raw, "The installation file"); + if (value.schema_version !== 1) { + throw localError("invalid_local_state", "The installation file has an unsupported version."); + } + return requireString(value.installation_secret, "The installation file"); + } + const installation = { + schema_version: 1, + installation_secret: randomBytes(32).toString("base64url"), + }; + if (await createSecureJsonIfAbsent(paths.installationFile, installation)) { + return installation.installation_secret; + } + const winnerRaw = await readSecureFile(paths.installationFile); + if (winnerRaw === null) { + throw localError("invalid_local_state", "The installation secret could not be created."); + } + const winner = parseJsonObject(winnerRaw, "The installation file"); + if (winner.schema_version !== 1) { + throw localError("invalid_local_state", "The installation file has an unsupported version."); + } + return requireString(winner.installation_secret, "The installation file"); +} +export async function writeSession(paths, session) { + if (!/^i_[A-Za-z0-9_-]+$/.test(session.instance_id)) { + throw localError("invalid_local_state", "The Instance ID is invalid."); + } + await writeSecureJson(join(paths.sessionsDir, `${session.instance_id}.json`), session); +} +export async function listSessions(paths) { + let entries; + try { + await ensureSecureDirectory(paths.sessionsDir); + entries = await readdir(paths.sessionsDir, { withFileTypes: true }); + } + catch (error) { + if (error.code === "ENOENT") + return []; + throw error; + } + const sessions = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.name.endsWith(".json")) + continue; + if (!entry.isFile() || entry.isSymbolicLink()) + unsafeStorage(); + const raw = await readSecureFile(join(paths.sessionsDir, entry.name)); + if (raw !== null) + sessions.push(parseSession(raw)); + } + return sessions; +} +export async function readSessionById(paths, instanceId) { + if (!/^i_[A-Za-z0-9_-]+$/.test(instanceId)) { + throw localError("invalid_session_id", "The Instance ID is invalid."); + } + const raw = await readSecureFile(join(paths.sessionsDir, `${instanceId}.json`)); + return raw === null ? null : parseSession(raw); +} +export async function deleteSession(paths, instanceId) { + if (!/^i_[A-Za-z0-9_-]+$/.test(instanceId)) + return; + const path = join(paths.sessionsDir, `${instanceId}.json`); + const raw = await readSecureFile(path); + if (raw === null) + return; + await unlink(path); +} +const ROOM_ID_PATTERN = /^rom_[A-Za-z0-9]+$/; +/** A seat is an Instance (`i_`); `mem_` files written before migration 0007 still open. */ +const MEMBER_ID_PATTERN = /^(?:i|mem)_[A-Za-z0-9]+$/; +/** + * A seat is one member in one Room. Two Agents on the same machine that join + * the same Room are two seats and must never share a file, or the second join + * would silently take over the first one's credential. + */ +function roomCredentialFile(paths, roomId, memberId) { + if (!ROOM_ID_PATTERN.test(roomId)) { + throw localError("invalid_local_state", "The Room ID is invalid."); + } + if (!MEMBER_ID_PATTERN.test(memberId)) { + throw localError("invalid_local_state", "The member ID is invalid."); + } + return join(paths.roomsDir, roomId, `${memberId}.json`); +} +function parseRoomCredential(raw) { + const value = parseJsonObject(raw, "A Room credential file"); + if (value.schema_version !== 1) { + throw localError("invalid_local_state", "A Room credential file has an unsupported version."); + } + const source = "A Room credential file"; + const wake = parseSeatWake(value.wake); + return { + schema_version: 1, + base_url: requireString(value.base_url, source), + room_id: requireString(value.room_id, source), + member_id: requireString(value.member_id, source), + name: requireString(value.name, source), + member_token: requireString(value.member_token, source), + joined_at: requireString(value.joined_at, source), + ...(wake ? { wake } : {}), + }; +} +export async function writeRoomCredential(paths, credential) { + await writeSecureJson(roomCredentialFile(paths, credential.room_id, credential.member_id), credential); +} +export async function readRoomCredential(paths, roomId, memberId) { + const raw = await readSecureFile(roomCredentialFile(paths, roomId, memberId)); + return raw === null ? null : parseRoomCredential(raw); +} +function projectStateFile(cwd) { + return join(cwd, PROJECT_STATE_DIR, "room.json"); +} +export async function readProjectRoom(cwd) { + let raw; + try { + raw = await readFile(projectStateFile(cwd), "utf8"); + } + catch (error) { + if (error.code === "ENOENT") + return null; + throw error; + } + const value = parseJsonObject(raw, "The project Room state"); + const source = "The project Room state"; + const sequence = (candidate) => { + if (!Number.isSafeInteger(candidate) || candidate < 0) { + throw localError("invalid_local_state", `${source} is not valid SharedNet state.`); + } + return candidate; + }; + // A file from before seats were separated: one member, one cursor, no session tie. + if (value.schema_version === 1) { + const memberId = requireString(value.member_id, source); + return { + schema_version: 2, + base_url: requireString(value.base_url, source), + room_id: requireString(value.room_id, source), + seats: { [memberId]: { last_sequence: sequence(value.last_sequence), anchor_key: null, joined_at: null } }, + }; + } + if (value.schema_version !== 2) { + throw localError("invalid_local_state", "The project Room state has an unsupported version."); + } + const seatsValue = value.seats; + if (typeof seatsValue !== "object" || seatsValue === null || Array.isArray(seatsValue)) { + throw localError("invalid_local_state", `${source} is not valid SharedNet state.`); + } + const seats = {}; + for (const [memberId, seat] of Object.entries(seatsValue)) { + if (typeof seat !== "object" || seat === null) { + throw localError("invalid_local_state", `${source} is not valid SharedNet state.`); + } + const record = seat; + seats[memberId] = { + last_sequence: sequence(record.last_sequence), + anchor_key: typeof record.anchor_key === "string" ? record.anchor_key : null, + joined_at: typeof record.joined_at === "string" ? record.joined_at : null, + }; + } + return { + schema_version: 2, + base_url: requireString(value.base_url, source), + room_id: requireString(value.room_id, source), + seats, + }; +} +async function writeProjectRoom(cwd, room) { + const directory = join(cwd, PROJECT_STATE_DIR); + await mkdir(directory, { recursive: true }); + // The directory ignores itself, so a project that has no .gitignore entry for + // it still never commits a cursor file by accident. + await writeFile(join(directory, ".gitignore"), "*\n", { flag: "w" }); + const temp = join(directory, `.room-${randomBytes(6).toString("hex")}.tmp`); + await writeFile(temp, `${JSON.stringify(room, null, 2)}\n`, "utf8"); + await rename(temp, projectStateFile(cwd)); +} +/** + * Records one seat's cursor in the directory's Room. A different Room + * replaces the file: a directory is in one Room at a time. `anchorKey` ties + * the seat to the session that took it; omitted, an existing tie is kept. + */ +export async function writeProjectRoomState(cwd, state, options = {}) { + const existing = await readProjectRoom(cwd).catch(() => null); + const sameRoom = existing !== null && existing.room_id === state.room_id; + const previous = sameRoom ? existing.seats[state.member_id] : undefined; + const seats = sameRoom ? { ...existing.seats } : {}; + seats[state.member_id] = { + last_sequence: state.last_sequence, + anchor_key: options.anchorKey !== undefined ? options.anchorKey : (previous?.anchor_key ?? null), + joined_at: options.joinedAt ?? previous?.joined_at ?? null, + }; + await writeProjectRoom(cwd, { schema_version: 2, base_url: state.base_url, room_id: state.room_id, seats }); +} +/** + * The seat this call acts as. In order: `--as` or SHAREDNET_SEAT; the seat + * tied to this session; the only seat there is. Two seats and no way to + * tell them apart is refused rather than guessed, since guessing is how one + * session ends up speaking as another. + */ +export async function selectProjectSeat(cwd, choice) { + const room = await readProjectRoom(cwd); + if (!room) + return null; + const view = (memberId) => ({ + schema_version: 1, + base_url: room.base_url, + room_id: room.room_id, + member_id: memberId, + last_sequence: room.seats[memberId].last_sequence, + }); + const ids = Object.keys(room.seats); + if (choice.explicit) { + if (!room.seats[choice.explicit]) { + throw localError("seat_not_found", `This directory holds no seat ${choice.explicit} in ${room.room_id}; it holds: ${ids.join(", ") || "none"}.`); + } + return view(choice.explicit); + } + if (choice.anchorKey) { + const mine = ids.filter((id) => room.seats[id].anchor_key === choice.anchorKey); + if (mine.length === 1) + return view(mine[0]); + } + if (ids.length === 1) + return view(ids[0]); + if (ids.length === 0) + return null; + throw localError("seat_selection_required", `This directory holds ${ids.length} seats in ${room.room_id} and none is this session's; say which with --as or SHAREDNET_SEAT: ${ids.join(", ")}`); +} diff --git a/experiments/planner-quorum-client/dist/triggers.d.ts b/experiments/planner-quorum-client/dist/triggers.d.ts new file mode 100644 index 0000000..757ee8a --- /dev/null +++ b/experiments/planner-quorum-client/dist/triggers.d.ts @@ -0,0 +1,109 @@ +/** + * The trigger language (decision 2026-10-05, goal mode and one wait): when a + * wait wakes, and, with the same words, when a goal Room ends. One parser, so + * the two can never mean different things by the same trigger. + * + * message another member said something + * mention a message addresses this seat: @its-name or @its-id + * said a message contains the text, or matches the expression + * count n messages since the last wake + * idle something was said, then nothing for that long + * every on a clock + * cron "" on a calendar, in this machine's time zone + * after once, that long after the wait started + * at