diff --git a/README.md b/README.md index 31befa4..13d21fc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ There are two parts: **one inspector service**, plus **native integrations on ea The API and setup CLI require Node >=22.19. Install the version-pinned release: ```sh -npm install --global @samuelho-dev/coordinator@0.3.0-beta.9 +npm install --global @samuelho-dev/coordinator@0.3.0-beta.10 ``` For source development only, use a reviewed checkout with `pnpm install --frozen-lockfile && pnpm build` (pnpm 11.9.0). `coordinator-install --source` deliberately activates that exact local package; never source-link a native package manager's own `node_modules` directory. @@ -20,6 +20,8 @@ Client and server code stays in [`src/mcp.ts`](src/mcp.ts). The `Mcp` layer cont The grouped metadata/workspace contract introduced in `0.3.0-beta.5` is a breaking cutover from beta.4 and earlier APIs, publishers and readers. `0.3.0-beta.6` retains that contract and works with the beta.5 API. `0.3.0-beta.7` adds machine/session resource telemetry; `0.3.0-beta.8` replaces product-specific references with generic terminal name/version. Both require matching API, native publisher and reader upgrades; deploy the API first. `0.3.0-beta.9` changes only native Darwin machine scope identity; no API rollout is required. +The list-only metadata consolidation changes list output to `{ "machines": [...], "sessions": [...], "nextCursor": ... }` and replaces the `harness` list filter with `repository`. API and `coordinate` snapshots may have a non-null cursor; the CLI drains pages and emits `nextCursor: null`, while `/coordinator list` drains pages and omits the cursor. This is a breaking API/reader cutover: deploy matching API and readers together, then restart native readers/hosts. MCP protocol negotiation does not negotiate the tool-input or tool-result shape; strict boundaries reject the removed `harness` input, and old and new strict readers cannot decode each other's list output. Full connector announcements, native `ref.harness` identity and `session.inspect` are unchanged; no credential, installation-identity, or publisher migration is required for this list-only change. These local source changes are not a published release; live beta.9 is unchanged. + For Kubernetes, follow the [chart operator guide](deploy/helm/coordinator/README.md): provide an existing Secret, verified image digest and matching source SHA. The chart creates an inspector Deployment/internal Service, not native plugins, TLS routing, a database or runners. Operators own rollout and supervision. Existing broker state requires the guide's preservation/migration procedure; the plugin does not migrate or delete it. ## Configuration @@ -109,8 +111,8 @@ The installer validates prerequisites and creates launchers/config fragments. `- | Target | Activation | Presence publisher | | --- | --- | --- | -| OMP | `omp plugin install @samuelho-dev/coordinator@0.3.0-beta.9`; merge only the generated `mcp.json` coordinator entry into native MCP settings. `/coordinator list` needs no MCP registration. | In-session extension. | -| Pi | `pi install npm:@samuelho-dev/coordinator@0.3.0-beta.9`; directly registers `coordinator_session_list` / `coordinator_session_inspect`, without an MCP bridge. | In-session extension. | +| OMP | `omp plugin install @samuelho-dev/coordinator@0.3.0-beta.10`; merge only the generated `mcp.json` coordinator entry into native MCP settings. `/coordinator list` needs no MCP registration. | In-session extension. | +| Pi | `pi install npm:@samuelho-dev/coordinator@0.3.0-beta.10`; directly registers `coordinator_session_list` / `coordinator_session_inspect`, without an MCP bridge. | In-session extension. | | Claude Code | `claude --plugin-dir "$PRIVATE/claude-host"`; approve generated hooks/MCP tools; preserve existing status-line configuration. | Separate `connector.sh`. | | Codex CLI | Merge generated `hooks/hooks.json` and coordinator `mcp.toml`; trust only Coordinator's handlers in native `/hooks`. | Separate `connector.sh`. | | Hermes | Merge generated hook entries and `mcp_servers.coordinator` from `mcp.yaml` into the selected profile; no Python plugin installation. | Separate `connector.sh`. | @@ -127,9 +129,9 @@ Hook/status-line/notification capture is local and can initialize identity/cache ### Native session commands and terminal identity -Pi/OMP register `/coordinator list`. It follows every API page visible to the owner key and returns pretty JSON (`{ "sessions": [...] }`) using Effect's JSON formatter, with native JSON syntax highlighting in the terminal. Each session preserves full native session/agent IDs, machine names/installation IDs, harness and all reported metadata: model/provider, workspace, activity, connection liveness, process identity, resource usage and terminal name/version. Missing metadata is omitted; an empty listing returns `{ "sessions": [] }`. It reads published snapshots directly, with no model turn, on-demand per-session sampling or separate MCP registration. +Pi/OMP register `/coordinator list`. It follows every API page visible to the owner key and returns pretty JSON (`{ "machines": [...], "sessions": [...] }`) using Effect's JSON formatter, with native JSON syntax highlighting in the terminal. Machines are deduplicated by installation ID and resource scope; session rows retain full inspectable refs, bindings, activity state, model and workspace identity without repeating machine telemetry. Missing metadata is omitted; an empty listing returns `{ "machines": [], "sessions": [] }`. It reads published snapshots directly, with no model turn, on-demand per-session sampling or separate MCP registration. -Presence groups optional metadata under `machine`, `agent`, `model`, `workspace` and `terminal`; absent facts/groups are omitted, with no old flat or product-specific aliases: +Full connector publishing and inspection presence groups optional metadata under `machine`, `agent`, `model`, `workspace` and `terminal`; this is not the compact list representation. Absent facts/groups are omitted, with no old flat or product-specific aliases: ```json { @@ -149,6 +151,12 @@ Presence groups optional metadata under `machine`, `agent`, `model`, `workspace` Git discovery is read-only and local to the session machine, bounded to two seconds without network, hooks, remote URLs or working-tree scans. `available` reports the actual worktree root and shared absolute Git common directory (including linked worktrees); `branch: null` means detached HEAD, `head: null` an unborn branch. `not-repository` means genuinely outside Git; `unavailable` means discovery failed, was denied, timed out or Git was missing. All three outcomes include `observedAt` and are cached for 30 seconds at unchanged cwd; future timestamps force refresh. Changing cwd clears stale associations. Native startup does not await discovery. +The compact `session.list` representation separates machine records (installation `machineId` plus nullable resource `scopeId`) from session rows (`ref.machineId` and nullable `machineScopeId`). Available `workspace.git` retains `worktreeRoot`, `commonDir`, `branch` and `head` for discovery and grouping. A null scope is unknown, never a substitute installation ID or proof of a shared execution boundary. Resource percentages on a session retain their own `resources.machineScopeId`, which may differ from the row's source machine scope. Repeated machine readings in the same scope use the newest intact observed snapshot, not combined metric values; the same installation can report multiple scopes across reboots or cgroups. Each page includes its own machines, and CLI/native command aggregate across pages by the composite identity. Pages are bounded to 48 KiB including machines and cursor, but are not a transactionally frozen fleet snapshot. + +The optional `repository` list selector matches the exact published `workspace.git.commonDir` only when `git.state` is `available`, across all native harnesses and linked worktrees sharing that directory. For example, `{ "limit": 100, "machineId": "installation-uuid", "repository": "/home/operator/projects/product/main/.git" }` uses the path published by that remote machine, not the reader's local checkout. The selector is a 1–4096-character path and can be combined with `machineId`; owner visibility always applies. A common directory is machine-local, not a global cross-machine repository ID. Missing Git metadata, `not-repository` and `unavailable` observations do not match. The inspector does no filesystem/Git lookup or normalization and does not match path prefixes, cwd, worktree root, `workspace.project`, branch, directory basename or inferred remote identity. Omit `repository` for all repositories and non-Git sessions. Continuation cursors are bound to the owner and exact `machineId`/`repository` inputs, including whether each was omitted; adding, changing or removing either selector invalidates the cursor. + +Equal-timestamp copies prefer the intact snapshot with fewer stale readings, so a disconnected publisher cannot hide a live copy of the same sample. + Projects are explicit scope, independent of Git: Claude's documented status-line [`workspace.project_dir`](https://code.claude.com/docs/en/statusline) supplies its launch directory as `source: "native"`, not a guessed Git repository or application identity. The optional private configuration `project` above uses `source: "configured"` and wins only for cwd inside its root; outside that root, a valid native association can remain. Association respects directory/symlink boundaries and is recomputed independently of the Git cache. No project ID/name is inferred from a directory, repository or branch. `terminal` is generic: `name` comes directly from `TERM_PROGRAM`, and optional `version` from `TERM_PROGRAM_VERSION`. Any valid terminal name is accepted without a product whitelist, vendor environment keys, per-product session IDs, hostname guessing, CLI discovery or terminal queries. Missing, blank or malformed standard markers leave identity unknown; `TERM=xterm-256color` describes capabilities, not an application. Version alone does not identify a terminal. Hook metadata is captured at the hook source, never inferred from the connector's own terminal. @@ -170,13 +178,13 @@ This is a clean schema cutover, with no legacy aliases or compatibility shims. E ## Plugin publication -The version-pinned beta is `@samuelho-dev/coordinator@0.3.0-beta.9`. The [npm workflow](.github/workflows/npm-release.yaml) promotes the exact-source CI-accepted tarball with owner authorization; [first-publication authentication and trust setup](#first-npm-publication) are prerequisites for a new publisher. The existing [release workflow](.github/workflows/release.yaml) publishes server/chart/GitHub artifacts, not native marketplace listings. Generated per-machine configuration is not a portable marketplace package. +The version-pinned beta is `@samuelho-dev/coordinator@0.3.0-beta.10`. The [npm workflow](.github/workflows/npm-release.yaml) promotes the exact-source CI-accepted tarball with owner authorization; [first-publication authentication and trust setup](#first-npm-publication) are prerequisites for a new publisher. The existing [release workflow](.github/workflows/release.yaml) publishes server/chart/GitHub artifacts, not native marketplace listings. Generated per-machine configuration is not a portable marketplace package. For a version-pinned install, use your native package manager, then follow [configuration](#configuration) and [native host setup](#native-host-setup): ```sh -omp plugin install @samuelho-dev/coordinator@0.3.0-beta.9 -pi install npm:@samuelho-dev/coordinator@0.3.0-beta.9 +omp plugin install @samuelho-dev/coordinator@0.3.0-beta.10 +pi install npm:@samuelho-dev/coordinator@0.3.0-beta.10 ``` Stock OMP 18.12.6 does not automatically update npm plugins. Until upstream fixes its channel handling and dry-run behavior, use explicit versioned installs for updates; avoid `omp plugin upgrade` and `upgrade --dry-run` for beta releases. @@ -197,6 +205,8 @@ Stock OMP 18.12.6 does not automatically update npm plugins. Until upstream fixe `0.3.0-beta.9` canonicalizes the Darwin boot timestamp to validated numeric seconds/microseconds before hashing it with the hostname. Locale/timezone renderings of the same boot now retain one machine scope ID; a reboot or different host still gets another. Missing or malformed boot data produces no scope ID. The wire schema and runtime dependencies are unchanged; update and reload native publishers/readers without redeploying the API. +`0.3.0-beta.10` consolidates session list metadata into machine and session collections and adds repository and machine ID filters. This is a breaking list API and reader cutover; coordinate the matching inspector and native readers as described above. + | Host | Publication route | | --- | --- | | [OMP](https://github.com/can1357/oh-my-pi/blob/main/docs/marketplace.md) | Publish a Git-backed `.omp-plugin/marketplace.json` catalog and runnable plugin tree with `package.json` `omp.extensions`. Claude-compatible `.claude-plugin/marketplace.json` is a fallback. npm catalog sources are not supported; ordinary `omp plugin install PACKAGE` is a separate npm route. | @@ -224,7 +234,7 @@ Registry verification allows up to 120 checks, ten seconds apart, for npm proces ## Internal contract -- **Read API:** `/mcp` exposes only `session.list` and `session.inspect`. List requires `limit` 1–100, with cursor/machine/harness filters. Inspect takes the complete returned `SessionRef`: `machineId`, `harness`, `nativeSessionId`, optional `nativeAgentId`. +- **Read API:** `/mcp` exposes only `session.list` and `session.inspect`. List requires `limit` 1–100, with optional `cursor`, `machineId` and exact published Git-common-directory `repository` selectors. `harness` is not a list input; it remains native identity in the complete returned `SessionRef`: `machineId`, `harness`, `nativeSessionId`, optional `nativeAgentId`. Inspect takes that complete ref unchanged. - **Publishing:** native hosts initiate outbound `/connector/announce`, `/connector/poll`, `/connector/result` and `/connector/disconnect` requests. Detail queries are correlated transient read RPCs, not execution jobs. - **Startup:** generated integrations do not await the inspector during activation. Pi/OMP publish in the background; Claude/Codex/Hermes hooks capture locally; the local stdio bridge initializes and lists tools without a remote handshake. Explicit inspection/`coordinate` requests may await a bounded remote reply. Local configuration, filesystem and native loading still run during initialization. - **State/lifetime:** the owner-isolated registry is in memory; restart loses discovery until reannouncement, never native sessions. A Pi/OMP plugin owns one Effect managed runtime; presence/poll failures cancel their peer and shutdown disposes its fibers without killing the host. Closing the stdio reader's stdin ends its scope and cancels pending HTTP reads. Failed startup/terminated connectors can restart on a later native event. @@ -258,6 +268,8 @@ CPU exposes one-core consumption, visible capacity in cores, and percentage of t Each observation retains source, scope identity, timestamp, units, sample window and unavailable/stale provenance. Repeated session rows with the same `scopeId` share environment totals: never sum them. Session rows identify native agent sessions, not OS threads. Shutdown or expired observations remain explicitly stale; collection ends with the native runtime. +List-only metrics retain `status`, `value` (null and `reason` when unavailable) and true-only `estimated`, `shared`, `partial`, `stale` flags; absent flags mean false. Field names determine units: CPU `capacityCores` in cores, `usedPercent` and `cpuCapacityPercent` in percent of execution capacity, `usedBytes`/`capacityBytes`/`rssBytes` in bytes, and `rssCapacityPercent` in percent of memory capacity. Machine resources retain scope, source and observation time; `memory.accounting` carries the source's used-memory definition once per machine record (not once per metric). In particular macOS used memory includes inactive cache and must not be read as memory pressure. Session rows retain observation time and separate source machine scope and process-resource scope; shared process readings overlap and must never be summed. Verbose provenance, raw one-core CPU, `availableBytes`, detailed activity, worktree/common Git paths, OS release/architecture and terminal version remain on full announcements and `session.inspect`, not list. + ## Source | File | Responsibility | diff --git a/deploy/helm/coordinator/Chart.yaml b/deploy/helm/coordinator/Chart.yaml index 086802c..edc0237 100644 --- a/deploy/helm/coordinator/Chart.yaml +++ b/deploy/helm/coordinator/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: coordinator description: Read-only ephemeral native-session inspector with explicit legacy storage retention type: application -version: 0.3.0-beta.9 -appVersion: 0.3.0-beta.9 +version: 0.3.0-beta.10 +appVersion: 0.3.0-beta.10 diff --git a/deploy/helm/coordinator/README.md b/deploy/helm/coordinator/README.md index a131602..9c1065b 100644 --- a/deploy/helm/coordinator/README.md +++ b/deploy/helm/coordinator/README.md @@ -1,4 +1,4 @@ -# Coordinator inspector chart 0.3.0-beta.9 +# Coordinator inspector chart 0.3.0-beta.10 This chart deploys the private, ephemeral native-session inspector: one Deployment and internal ClusterIP Service named `-inspector`, plus only explicitly retained legacy PVC manifests. It creates no database, migrations, task queue, native agents, PVC mounts, ingress, namespace, NetworkPolicy or provider CRDs. See the [root README](../../../README.md) for configuration, native host setup, metrics and verification. diff --git a/package.json b/package.json index 38893ba..534cbc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@samuelho-dev/coordinator", - "version": "0.3.0-beta.9", + "version": "0.3.0-beta.10", "type": "module", "description": "Read-only native agent session inspection across self-hosted machines", "license": "MIT", diff --git a/scripts/deployment-smoke.ts b/scripts/deployment-smoke.ts index 8e71e1d..2932d67 100644 --- a/scripts/deployment-smoke.ts +++ b/scripts/deployment-smoke.ts @@ -67,7 +67,7 @@ const program = Command.make("deployment-smoke", { let found = false; for (let page = 0; page < 10; page++) { const result = yield* mcp.call(config.inspectorUrl, token, InspectionTools.list, { - machineId: ref.machineId, harness: ref.harness, limit: 100, ...(cursor === undefined ? {} : { cursor }), + machineId: ref.machineId, limit: 100, ...(cursor === undefined ? {} : { cursor }), }); if (result.sessions.some((presence) => sessionRefKey(presence.ref) === sessionRefKey(ref))) { found = true; break; } if (result.nextCursor === null) break; diff --git a/scripts/smoke.ts b/scripts/smoke.ts index dd35e87..60b9395 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -22,7 +22,7 @@ const verify = { }; const CoordinatorListMessage = Schema.Struct({ role: Schema.Literal("custom"), customType: Schema.Literal("coordinator-list"), content: Schema.String, - details: Schema.Struct({ sessions: Schema.Array(SessionPresence) }), + details: Schema.Struct({ machines: InspectionTools.list.output.fields.machines, sessions: InspectionTools.list.output.fields.sessions }), }); const NativeStartupMessage = Schema.fromJsonString(Schema.Union([ Schema.Struct({ id: Schema.Literal("coordinator-startup"), type: Schema.Literal("response"), command: Schema.Literal("get_state"), success: Schema.Boolean }), @@ -53,11 +53,13 @@ const program = Effect.gen(function*() { const token = yield* crypto.randomUUIDv4(), siblingToken = yield* crypto.randomUUIDv4(), outsiderToken = yield* crypto.randomUUIDv4(); const machineId = `smoke-${yield* crypto.randomUUIDv4()}`; const principal = (id: string, owner: string, credential: string) => crypto.digest("SHA-256", new TextEncoder().encode(credential)).pipe(Effect.map((digest) => ({ id, owner, tokenHash: Hex.encode(digest) }))); - const inspector = yield* Inspector.make({ host: "127.0.0.1", port: 0, principals: [ - yield* principal("local", "smoke-owner", token), - yield* principal("sibling", "smoke-owner", siblingToken), - yield* principal("outsider", "other-owner", outsiderToken), - ] }); + const inspector = yield* Inspector.make({ + host: "127.0.0.1", port: 0, principals: [ + yield* principal("local", "smoke-owner", token), + yield* principal("sibling", "smoke-owner", siblingToken), + yield* principal("outsider", "other-owner", outsiderToken), + ] + }); const server = Context.get(yield* Layer.build(serverLayer("127.0.0.1", 0)), HttpServer.HttpServer); let blockedStartup: { readonly machineId: string; @@ -116,8 +118,9 @@ const program = Effect.gen(function*() { }).pipe(Effect.forkScoped); const waitForSession = (harness: "pi" | "omp" | "hermes", sessionId?: string, installationId = machineId) => Effect.gen(function*() { for (let attempt = 0; attempt < 300; attempt++) { - const result = yield* mcp.call(inspectorUrl, token, InspectionTools.list, { machineId: installationId, harness, limit: 100 }); - const found = result.sessions.find((session) => sessionId === undefined ? session.ref.nativeSessionId !== presence.ref.nativeSessionId : session.ref.nativeSessionId === sessionId); + const result = yield* mcp.call(inspectorUrl, token, InspectionTools.list, { machineId: installationId, limit: 100 }); + const found = result.sessions.find((session) => session.ref.harness === harness && + (sessionId === undefined ? session.ref.nativeSessionId !== presence.ref.nativeSessionId : session.ref.nativeSessionId === sessionId)); if (found !== undefined) return found; yield* Effect.sleep(100); } @@ -293,9 +296,9 @@ const program = Effect.gen(function*() { })), Effect.forkScoped); const hermes = yield* waitForSession("hermes", hermesId, hermesMachineId); yield* verify.equal(hermes.activity?.state, "idle", "Turn end retains Hermes identity as idle"); - yield* verifyWorkspace(hermes, repositoryRoot, "main"); - yield* verifySessionResources(hermes, "shared"); const hermesUsage = yield* mcp.call(inspectorUrl, token, InspectionTools.inspect, { ref: hermes.ref }); + yield* verifyWorkspace(hermesUsage.presence, repositoryRoot, "main"); + yield* verifySessionResources(hermesUsage.presence, "shared"); yield* verify.equal(hermesUsage.presence.agent?.profile, "synthetic-profile"); yield* verify.equal(hermesUsage.presence.model?.provider, "synthetic-provider"); yield* verify.equal(hermesUsage.presence.model?.id, "synthetic-model"); @@ -320,8 +323,8 @@ const program = Effect.gen(function*() { yield* Effect.sleep(25); yield* hermesHook("on_session_end"); for (let attempt = 0; attempt < 300; attempt++) { - const current = yield* mcp.call(inspectorUrl, token, InspectionTools.list, { machineId: hermesMachineId, harness: "hermes", limit: 100 }); - const updated = current.sessions.find((session) => session.ref.nativeSessionId === hermesId); + const current = yield* mcp.call(inspectorUrl, token, InspectionTools.list, { machineId: hermesMachineId, limit: 100 }); + const updated = current.sessions.find((session) => session.ref.harness === "hermes" && session.ref.nativeSessionId === hermesId); if (updated?.lastActivityAt !== undefined && updated.lastActivityAt !== hermes.lastActivityAt) break; yield* verify.ok(attempt < 299, "Idle presence refresh deadline expired"); yield* Effect.sleep(100); @@ -344,8 +347,8 @@ const program = Effect.gen(function*() { yield* hermesHook("post_api_request", { usage: { input_tokens: 999 }, response: sensitive }); yield* hermesHook("on_session_end"); for (let attempt = 0; attempt < 300; attempt++) { - const current = yield* mcp.call(inspectorUrl, token, InspectionTools.list, { machineId: hermesMachineId, harness: "hermes", limit: 100 }); - if (!current.sessions.some((session) => session.ref.nativeSessionId === hermesId)) break; + const current = yield* mcp.call(inspectorUrl, token, InspectionTools.list, { machineId: hermesMachineId, limit: 100 }); + if (!current.sessions.some((session) => session.ref.harness === "hermes" && session.ref.nativeSessionId === hermesId)) break; yield* verify.ok(attempt < 299, "Finalized Hermes identity must disappear and late callbacks cannot resurrect it"); yield* Effect.sleep(100); } @@ -485,12 +488,23 @@ const program = Effect.gen(function*() { yield* verify.ok(result.details.sessions.some((entry) => entry.ref.machineId === hermesMachineId && entry.ref.harness === harness)); yield* verify.ok(result.details.sessions.some((entry) => entry.ref.machineId === otherMachineId)); const content = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(CoordinatorListMessage.fields.details))(result.content); - yield* verify.deepEqual(content.sessions, result.details.sessions, "Displayed JSON must preserve every listed session"); + yield* verify.deepEqual(content, result.details, "Displayed JSON must preserve compact machine and session records"); const displayedNative = content.sessions.find((entry) => entry.ref.machineId === hermesMachineId && entry.ref.harness === harness); if (displayedNative === undefined) return yield* verify.fail("Displayed native session missing"); - yield* verifyWorkspace(displayedNative); - yield* verifySessionResources(displayedNative, harness === "omp" ? "shared" : "owned"); - yield* verify.deepEqual(displayedNative.terminal, terminal, "Displayed native list must retain source terminal identity"); + const displayedMachine = content.machines.find((entry) => entry.machineId === displayedNative.ref.machineId && entry.scopeId === displayedNative.machineScopeId); + if (!displayedMachine?.resources || !displayedNative.resources) return yield* verify.fail("Native list must include machine and process summaries"); + yield* verify.equal(displayedNative.workspace?.cwd, worktreeRoot); + yield* verify.deepEqual(displayedNative.workspace?.project, project); + yield* verify.equal(displayedNative.workspace?.git?.state, "available"); + if (displayedNative.workspace?.git?.state === "available") { + yield* verify.equal(displayedNative.workspace.git.head, gitHead); + yield* verify.equal(displayedNative.workspace.git.worktreeRoot, worktreeRoot); + yield* verify.equal(displayedNative.workspace.git.commonDir, join(repositoryRoot, ".git")); + } + yield* verify.equal(displayedNative.resources.attribution, harness === "omp" ? "shared" : "owned"); + yield* verify.equal(displayedNative.resources.machineScopeId, displayedMachine.scopeId); + yield* verify.equal(displayedNative.resources.rssBytes.shared, harness === "omp" ? true : undefined); + yield* verify.deepEqual(displayedNative.terminal, terminal ? { name: terminal.name } : undefined, "Native list must retain terminal name without diagnostic version"); yield* verify.ok(!result.content.includes(token)); yield* Deferred.succeed(listVerified, undefined); return; @@ -517,11 +531,13 @@ const program = Effect.gen(function*() { yield* verify.equal(yield* Deferred.isDone(startup.finished), false, `${harness} startup waited for the inspector response`); blockedStartup = undefined; yield* Deferred.succeed(startup.resume, undefined); - const native = yield* Effect.raceFirst(waitForSession(harness, undefined, hermesMachineId), Effect.gen(function*() { + const nativeList = yield* Effect.raceFirst(waitForSession(harness, undefined, hermesMachineId), Effect.gen(function*() { const code = yield* child.exitCode; yield* Fiber.join(stderr); return yield* Effect.die(`Disposable ${harness} exited ${code} before presence; stderr (last 4096 characters): ${JSON.stringify(nativeStderr)}`); })); + const inspected = yield* mcp.call(inspectorUrl, token, InspectionTools.inspect, { ref: nativeList.ref }); + const native = inspected.presence; yield* verify.deepEqual(native.terminal, terminal, "Native presence must preserve inherited terminal identity"); yield* verify.deepEqual(native.programStatus, { state: "idle" }, "Native startup has a known idle status"); yield* verifyWorkspace(native); @@ -544,7 +560,7 @@ const program = Effect.gen(function*() { yield* Deferred.succeed(runList, undefined); yield* Effect.raceFirst(Deferred.await(listVerified), Fiber.join(stdout).pipe(Effect.andThen(verify.fail("Native RPC ended before list verification")))).pipe(Effect.timeout("30 seconds")); yield* verify.equal(detailRequests, beforeList, "The list command must not sample per-session details"); - const inspected = yield* mcp.call(inspectorUrl, token, InspectionTools.inspect, { ref: native.ref }); + yield* verify.equal(inspected.presence.ref.nativeSessionId, nativeList.ref.nativeSessionId); yield* verify.equal(inspected.presence.ref.harness, harness); yield* verifyWorkspace(inspected.presence); yield* verify.deepEqual(inspected.presence.terminal, terminal, "Inspection must preserve source terminal identity"); @@ -596,7 +612,8 @@ const program = Effect.gen(function*() { yield* guard(JSON.stringify(rawListing) === JSON.stringify(listing), "Raw native list JSON must preserve complete structured details"); yield* guard(listing.sessions.some((entry) => entry.ref.machineId === ref.machineId && entry.ref.harness === "pi" && entry.ref.nativeSessionId === ref.nativeSessionId), "Native reader must discover the independent disposable Pi CLI"); const listedNative = listing.sessions.find((entry) => entry.ref.nativeSessionId === ref.nativeSessionId && entry.ref.machineId === ref.machineId && entry.ref.harness === ref.harness); - yield* guard(listedNative?.workspace?.cwd === ${JSON.stringify(worktreeRoot)} && listedNative.workspace.git?.state === "available" && listedNative.workspace.git.worktreeRoot === ${JSON.stringify(worktreeRoot)} && listedNative.workspace.git.head === ${JSON.stringify(gitHead)}, "Native list JSON must retain real linked-worktree association"); + yield* guard(listedNative?.workspace?.cwd === ${JSON.stringify(worktreeRoot)} && listedNative.workspace.git?.state === "available" && listedNative.workspace.git.head === ${JSON.stringify(gitHead)}, "Native list JSON must retain real linked-worktree association"); + yield* guard(listedNative?.workspace?.git?.state === "available" && listedNative.workspace.git.worktreeRoot === ${JSON.stringify(worktreeRoot)} && listedNative.workspace.git.commonDir === ${JSON.stringify(join(repositoryRoot, ".git"))}, "Native list JSON must retain shared Git identity and distinct worktree root"); yield* guard(listedNative?.workspace?.project?.root === ${JSON.stringify(project.root)} && listedNative.workspace.project.id === "smoke-project", "Native list JSON must retain explicit project scope"); yield* guard(listing.sessions.some((entry) => entry.ref.machineId === otherMachineId), "Native owner-key reader must discover separate installation"); const inspected = yield* Effect.promise(() => inspect.execute("native-smoke-inspect", { ref })); @@ -637,8 +654,10 @@ const program = Effect.gen(function*() { } yield* otherConnector.kill(); yield* otherConnector.exitCode; - yield* Console.log(JSON.stringify({ scenario: "ephemeral-inspector", status: "ok", authenticatedHttpMcp: true, outboundConnector: true, - ownerIsolation: true, sharedOwnerMultipleInstallations: true, sameInstallationNativeIdentity: true, groupedMetadata: true, terminalIdentity: terminal !== undefined, localMachineTelemetry: nativeHarnesses, realGitWorktree: nativeHarnesses, explicitProjectScope: true, selfHostBootstrap: true, authenticatedClientSetup: true, onDemandOsResources: true, fixtureNativeCounters: "unavailable, not fabricated", nativeHarnesses, nativePrograms: nativeResults })); + yield* Console.log(JSON.stringify({ + scenario: "ephemeral-inspector", status: "ok", authenticatedHttpMcp: true, outboundConnector: true, + ownerIsolation: true, sharedOwnerMultipleInstallations: true, sameInstallationNativeIdentity: true, groupedMetadata: true, terminalIdentity: terminal !== undefined, localMachineTelemetry: nativeHarnesses, realGitWorktree: nativeHarnesses, explicitProjectScope: true, selfHostBootstrap: true, authenticatedClientSetup: true, onDemandOsResources: true, fixtureNativeCounters: "unavailable, not fabricated", nativeHarnesses, nativePrograms: nativeResults + })); }); NodeRuntime.runMain(program.pipe(Effect.scoped, Effect.timeout("120 seconds"), Effect.provide(Layer.mergeAll(NodeServices.layer, Host.layer, NodeHttpClient.layerUndici, Mcp.layer)))); diff --git a/src/inspection.ts b/src/inspection.ts index 8a4a9c7..3b99156 100644 --- a/src/inspection.ts +++ b/src/inspection.ts @@ -92,7 +92,8 @@ export const MachineResources = Schema.Struct({ export type MachineResources = typeof MachineResources.Type; export const staleMachineResources = (resources: MachineResources): MachineResources => { const stale = (value: M): M => ({ ...value, provenance: { ...value.provenance, stale: true } }); - return { ...resources, + return { + ...resources, cpu: { usagePercent: stale(resources.cpu.usagePercent), capacityCores: stale(resources.cpu.capacityCores), usedPercent: stale(resources.cpu.usedPercent) }, memory: { usedBytes: stale(resources.memory.usedBytes), capacityBytes: stale(resources.memory.capacityBytes), availableBytes: stale(resources.memory.availableBytes), usedPercent: stale(resources.memory.usedPercent) }, }; @@ -105,8 +106,10 @@ export const SessionResources = Schema.Struct({ export type SessionResources = typeof SessionResources.Type; export const staleSessionResources = (resources: SessionResources): SessionResources => { const stale = (value: M): M => ({ ...value, provenance: { ...value.provenance, stale: true } }); - return { ...resources, cpuPercent: stale(resources.cpuPercent), cpuCapacityPercent: stale(resources.cpuCapacityPercent), - rssBytes: stale(resources.rssBytes), rssCapacityPercent: stale(resources.rssCapacityPercent) }; + return { + ...resources, cpuPercent: stale(resources.cpuPercent), cpuCapacityPercent: stale(resources.cpuCapacityPercent), + rssBytes: stale(resources.rssBytes), rssCapacityPercent: stale(resources.rssCapacityPercent) + }; }; export const SessionMachine = Schema.Struct({ name: Schema.optional(Label), @@ -167,6 +170,149 @@ export const SessionPresence = Schema.Struct({ }); export type SessionPresence = typeof SessionPresence.Type; +// List-only projection; connector announcements and inspection retain the full observations. +const ListMetric = (value: S) => Schema.Union([ + Schema.Struct({ + status: Schema.Literal("available"), value, + estimated: Schema.optional(Schema.Literal(true)), shared: Schema.optional(Schema.Literal(true)), + partial: Schema.optional(Schema.Literal(true)), stale: Schema.optional(Schema.Literal(true)) + }), + Schema.Struct({ + status: Schema.Literal("unavailable"), value: Schema.Null, reason: Label, + estimated: Schema.optional(Schema.Literal(true)), shared: Schema.optional(Schema.Literal(true)), + partial: Schema.optional(Schema.Literal(true)), stale: Schema.optional(Schema.Literal(true)) + }), +]); +export const ListMachine = Schema.Struct({ + machineId: Id, scopeId: Schema.NullOr(Id), name: Schema.optional(Label), + os: Schema.optional(Schema.Struct({ platform: Schema.Literals(["linux", "darwin"]) })), + resources: Schema.optional(Schema.Struct({ + scope: Schema.Literals(["host", "cgroup", "unknown"]), source: Label, observedAt: Timestamp, + cpu: Schema.Struct({ capacityCores: ListMetric(Nonnegative), usedPercent: ListMetric(Nonnegative) }), + memory: Schema.Struct({ accounting: Label, usedBytes: ListMetric(Count), capacityBytes: ListMetric(Count), usedPercent: ListMetric(Nonnegative) }), + })), +}); +export type ListMachine = typeof ListMachine.Type; +const ListGit = Schema.Union([ + Schema.Struct({ + state: Schema.Literal("available"), observedAt: Timestamp, worktreeRoot: Path, commonDir: Path, + branch: Schema.NullOr(Id), head: Schema.NullOr(Schema.String.check(Schema.isPattern(/^(?:[a-f0-9]{40}|[a-f0-9]{64})(?![\s\S])/))), + }), + Schema.Struct({ state: Schema.Literal("not-repository"), observedAt: Timestamp }), + Schema.Struct({ state: Schema.Literal("unavailable"), observedAt: Timestamp }), +]); +export const ListSession = Schema.Struct({ + ref: SessionRef, observedAt: Timestamp, machineScopeId: Schema.NullOr(Id), + agent: Schema.optional(SessionAgent), model: Schema.optional(SessionModel), + workspace: Schema.optional(Schema.Struct({ cwd: Path, project: Schema.optional(SessionProject), git: Schema.optional(ListGit) })), + connection: Schema.optional(Schema.Struct({ state: Schema.Literals(["live", "stale"]), receivedAt: Timestamp, liveUntil: Timestamp })), + startedAt: Schema.optional(Schema.NullOr(Timestamp)), lastActivityAt: Schema.optional(Schema.NullOr(Timestamp)), + parent: Schema.optional(SessionRef), label: Schema.optional(Label), + terminal: Schema.optional(Schema.Struct({ name: NativeReference })), programStatus: Schema.optional(ProgramStatus), + processes: Schema.optional(bounded(ProcessIdentity, 128)), + resources: Schema.optional(Schema.Struct({ + observedAt: Timestamp, machineScopeId: Schema.NullOr(Id), attribution: Schema.Literals(["owned", "shared", "unknown"]), + cpuCapacityPercent: ListMetric(Nonnegative), rssBytes: ListMetric(Count), rssCapacityPercent: ListMetric(Nonnegative), + })), + activity: Schema.optional(Schema.Struct({ + state: Schema.Literals(["idle", "working", "waiting", "stopped", "unknown"]), + wait: Schema.optional(Schema.Literals(["api", "tool", "user", "approval", "unknown"])), + approval: Schema.optional(Schema.Literals(["pending", "approved", "denied", "unknown"])), + currentToolName: Schema.optional(Id), + })), +}); +export type ListSession = typeof ListSession.Type; +const listMetric = (value: Metric) => { + const flags = { + ...(value.provenance.estimated ? { estimated: true as const } : {}), + ...(value.provenance.shared ? { shared: true as const } : {}), + ...(value.provenance.partial ? { partial: true as const } : {}), + ...(value.provenance.stale ? { stale: true as const } : {}), + }; + return value.status === "unavailable" + ? { status: "unavailable" as const, value: null, reason: value.reason, ...flags } + : { status: "available" as const, value: value.value, ...flags }; +}; +export const projectListMachine = (presence: SessionPresence): ListMachine => { + const machine = presence.machine; + const resources = machine?.resources; + return { + machineId: presence.ref.machineId, scopeId: resources?.scopeId ?? null, + ...(machine?.name ? { name: machine.name } : {}), + ...(machine?.os ? { os: { platform: machine.os.platform } } : {}), + ...(resources ? { + resources: { + scope: resources.scope, source: resources.source, observedAt: resources.observedAt, + cpu: { capacityCores: listMetric(resources.cpu.capacityCores), usedPercent: listMetric(resources.cpu.usedPercent) }, + memory: { + accounting: resources.memory.usedBytes.provenance.scopeDescription, + usedBytes: listMetric(resources.memory.usedBytes), capacityBytes: listMetric(resources.memory.capacityBytes), + usedPercent: listMetric(resources.memory.usedPercent) + }, + } + } : {}), + }; +}; +export const projectListSession = (presence: SessionPresence): ListSession => ({ + ref: presence.ref, observedAt: presence.observedAt, machineScopeId: presence.machine?.resources?.scopeId ?? null, + ...(presence.agent ? { agent: presence.agent } : {}), ...(presence.model ? { model: presence.model } : {}), + ...(presence.workspace ? { + workspace: { + cwd: presence.workspace.cwd, ...(presence.workspace.project ? { project: presence.workspace.project } : {}), + ...(presence.workspace.git ? { + git: presence.workspace.git.state === "available" + ? { + state: "available" as const, observedAt: presence.workspace.git.observedAt, + worktreeRoot: presence.workspace.git.worktreeRoot, commonDir: presence.workspace.git.commonDir, + branch: presence.workspace.git.branch, head: presence.workspace.git.head, + } + : { state: presence.workspace.git.state, observedAt: presence.workspace.git.observedAt } + } : {}), + } + } : {}), + ...(presence.connection ? { connection: presence.connection } : {}), + ...(presence.startedAt !== undefined ? { startedAt: presence.startedAt } : {}), + ...(presence.lastActivityAt !== undefined ? { lastActivityAt: presence.lastActivityAt } : {}), + ...(presence.parent ? { parent: presence.parent } : {}), ...(presence.label ? { label: presence.label } : {}), + ...(presence.terminal ? { terminal: { name: presence.terminal.name } } : {}), + ...(presence.programStatus ? { programStatus: presence.programStatus } : {}), + ...(presence.processes ? { processes: presence.processes } : {}), + ...(presence.resources ? { + resources: { + observedAt: presence.resources.observedAt, machineScopeId: presence.resources.machineScopeId, attribution: presence.resources.attribution, + cpuCapacityPercent: listMetric(presence.resources.cpuCapacityPercent), rssBytes: listMetric(presence.resources.rssBytes), + rssCapacityPercent: listMetric(presence.resources.rssCapacityPercent), + } + } : {}), + ...(presence.activity ? { + activity: { + state: presence.activity.state, ...(presence.activity.wait ? { wait: presence.activity.wait } : {}), + ...(presence.activity.approval ? { approval: presence.activity.approval } : {}), + ...(presence.activity.currentToolName ? { currentToolName: presence.activity.currentToolName } : {}), + } + } : {}), +}); +export const listMachineKey = (machine: ListMachine): string => JSON.stringify([machine.machineId, machine.scopeId]); +const staleListMetricCount = (resources: NonNullable): number => + Number(resources.cpu.capacityCores.stale === true) + Number(resources.cpu.usedPercent.stale === true) + + Number(resources.memory.usedBytes.stale === true) + Number(resources.memory.capacityBytes.stale === true) + + Number(resources.memory.usedPercent.stale === true); +export const preferListMachine = (candidate: ListMachine, current: ListMachine | undefined): boolean => { + if (!current) return true; + const next = candidate.resources, previous = current.resources; + if (!next) return false; + if (!previous) return true; + return next.observedAt !== previous.observedAt + ? next.observedAt > previous.observedAt + : staleListMetricCount(next) < staleListMetricCount(previous); +}; +export const mergeListMachines = (target: Map, machines: ReadonlyArray): void => { + for (const machine of machines) { + const id = listMachineKey(machine); + if (preferListMachine(machine, target.get(id))) target.set(id, machine); + } +}; + const TokenMetric = metricSchema("tokens", Count); const CountMetric = metricSchema("count", Count); const TimeMetric = metricSchema("milliseconds", Nonnegative); @@ -266,7 +412,7 @@ export const ConnectorConfig = Schema.Struct({ export type ConnectorConfig = typeof ConnectorConfig.Type; export class HttpError extends Schema.TaggedError()("HttpError", { status: Schema.Int.check(Schema.isBetween({ minimum: 400, maximum: 599 })), message: Label, -}) {} +}) { } export const ObservationError = Schema.Struct({ code: Schema.Literals(["unavailable", "not-found", "timeout", "disconnected", "invalid-observation"]), message: Schema.Literal("Native session observation unavailable"), @@ -291,10 +437,10 @@ export const ConnectorDisconnect = Schema.Struct(ConnectorIdentity); export type ConnectorDisconnect = typeof ConnectorDisconnect.Type; export const SessionListInput = Schema.Struct({ cursor: Schema.optional(Id), limit: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 })), - machineId: Schema.optional(Id), harness: Schema.optional(Harness), + machineId: Schema.optional(Id), repository: Schema.optional(Path), }); export type SessionListInput = typeof SessionListInput.Type; -export const SessionListOutput = Schema.Struct({ sessions: bounded(SessionPresence, 100), nextCursor: Schema.NullOr(Id) }); +export const SessionListOutput = Schema.Struct({ machines: bounded(ListMachine, 100), sessions: bounded(ListSession, 100), nextCursor: Schema.NullOr(Id) }); export type SessionListOutput = typeof SessionListOutput.Type; export const SessionInspectInput = Schema.Struct({ ref: SessionRef }); export type SessionInspectInput = typeof SessionInspectInput.Type; diff --git a/src/inspector.ts b/src/inspector.ts index 448f946..51d9e21 100644 --- a/src/inspector.ts +++ b/src/inspector.ts @@ -3,12 +3,12 @@ import { Hex } from "effect/encoding"; import { equalDigests } from "./platform.ts"; import { INSPECTION_LIMIT, HttpError, sessionRefKey, unavailableMetric, staleMachineResources, staleSessionResources, + listMachineKey, preferListMachine, projectListMachine, projectListSession, type ConnectorAccepted, type ConnectorAnnounce, type ConnectorDisconnect, type ConnectorPoll, type ConnectorPollResult, type ConnectorResult, type InspectorConfig, type InspectorPrincipal, - type ProcessIdentity, type ProcessObservation, type SessionInspection, type SessionInspectInput, type SessionListInput, + type ListMachine, type ListSession, type ProcessIdentity, type ProcessObservation, type SessionInspection, type SessionInspectInput, type SessionListInput, type SessionListOutput, type SessionPresence, type SessionRef, type Metric, } from "./inspection.ts"; - const LIVE_MS = 30_000; const EXPIRE_MS = 120_000; const REQUEST_MS = 10_000; @@ -43,7 +43,7 @@ type Pending = { requestId: string; sessionKey: string; ref: SessionRef; connector: Connector; incarnation: string; deadline: number; delivered: boolean; reply: Deferred.Deferred; }; -type Cursor = { owner: string; machineId: string | undefined; harness: string | undefined; after: string; expires: number }; +type Cursor = { owner: string; machineId: string | undefined; repository: string | undefined; after: string; expires: number }; const make = (config: InspectorConfig): Effect.Effect => Effect.gen(function*() { const crypto = yield* Crypto.Crypto; @@ -113,9 +113,11 @@ const make = (config: InspectorConfig): Effect.Effect process.ownership === "shared" || sharedProcess(session, process, shared)))) { const markShared = (value: M): M => ({ ...value, provenance: { ...value.provenance, shared: true } }); - resources = { ...resources, attribution: "shared", cpuPercent: markShared(resources.cpuPercent), + resources = { + ...resources, attribution: "shared", cpuPercent: markShared(resources.cpuPercent), cpuCapacityPercent: markShared(resources.cpuCapacityPercent), rssBytes: markShared(resources.rssBytes), - rssCapacityPercent: markShared(resources.rssCapacityPercent) }; + rssCapacityPercent: markShared(resources.rssCapacityPercent) + }; } if (resources && (!live(session.connector) || wall() - resources.observedAt >= LIVE_MS)) resources = staleSessionResources(resources); return { @@ -182,35 +184,45 @@ const make = (config: InspectorConfig): Effect.Effect session.connector.owner === principal.owner && (cursor === undefined || id > cursor.after) && (input.machineId === undefined || session.presence.ref.machineId === input.machineId) && - (input.harness === undefined || session.presence.ref.harness === input.harness), + (input.repository === undefined || (session.presence.workspace?.git?.state === "available" && + session.presence.workspace.git.commonDir === input.repository)), ).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); - const page: Array = []; + const page: ListSession[] = []; + const machines = new Map(); const shared = sharedProcesses(principal.owner); let after: string | undefined; for (const [id, session] of entries) { if (page.length >= input.limit) break; const presence = normalizedPresence(session, shared); - page.push(presence); - if (bytes({ sessions: page, nextCursor: next }) > INSPECTION_LIMIT) { + const row = projectListSession(presence); + const machine = projectListMachine(presence); + const machineKey = listMachineKey(machine); + const previous = machines.get(machineKey); + if (preferListMachine(machine, previous)) machines.set(machineKey, machine); + page.push(row); + if (bytes({ machines: [...machines.values()], sessions: page, nextCursor: next }) > INSPECTION_LIMIT) { page.pop(); + if (previous) machines.set(machineKey, previous); + else machines.delete(machineKey); if (page.length === 0) return Effect.fail(error(413, "Native session presence exceeds response limit")); break; } after = id; } + const machineRows = [...machines.values()]; if (page.length < entries.length && after !== undefined) { if (cursors.size >= MAX_CURSORS) { const oldest = cursors.keys().next().value; if (oldest !== undefined) cursors.delete(oldest); } - cursors.set(next, { owner: principal.owner, machineId: input.machineId, harness: input.harness, after, expires: now() + EXPIRE_MS }); - return Effect.succeed({ sessions: page, nextCursor: next }); + cursors.set(next, { owner: principal.owner, machineId: input.machineId, repository: input.repository, after, expires: now() + EXPIRE_MS }); + return Effect.succeed({ machines: machineRows, sessions: page, nextCursor: next }); } - return Effect.succeed({ sessions: page, nextCursor: null }); + return Effect.succeed({ machines: machineRows, sessions: page, nextCursor: null }); }))), inspect: (principal: InspectorPrincipal, input: SessionInspectInput): Effect.Effect => uuid.pipe(Effect.flatMap((requestId) => Effect.uninterruptibleMask((restore) => Effect.suspend(() => { if (!authenticated(principal)) return Effect.fail(error(401, "Unauthorized")); @@ -256,8 +268,12 @@ const make = (config: InspectorConfig): Effect.Effect MAX_SESSIONS) return Effect.fail(error(429, "Native session capacity reached")); - // UUID cursors always occupy 36 ASCII bytes; validate presence before admitting it. - for (const presence of input.sessions) if (bytes({ sessions: [{ ...presence, connection: { state: "live", receivedAt: wall(), liveUntil: wall() + LIVE_MS } }], nextCursor: "0".repeat(36) }) > INSPECTION_LIMIT) return Effect.fail(error(413, "Native session presence exceeds response limit")); + // A one-row compact list page must be admissible, including machine metadata and cursor. + // Reserve normalization growth: up to 128 shared process labels, 8 stale and 3 shared metric flags. + for (const presence of input.sessions) { + const connected = { ...presence, connection: { state: "live" as const, receivedAt: wall(), liveUntil: wall() + LIVE_MS } }; + if (bytes({ machines: [projectListMachine(connected)], sessions: [projectListSession(connected)], nextCursor: "0".repeat(36) }) + 512 > INSPECTION_LIMIT) return Effect.fail(error(413, "Native session presence exceeds response limit")); + } let connector = previous; if (!connector || connector.incarnation !== input.incarnation) { const retired = previous?.retired ?? new Set(); diff --git a/src/main.ts b/src/main.ts index 6b79d6b..f37ba9f 100755 --- a/src/main.ts +++ b/src/main.ts @@ -5,10 +5,10 @@ import { NodeRuntime, NodeServices } from "@effect/platform-node"; import { Inspector } from "./inspector.ts"; import { Mcp, serve, serveStdio } from "./mcp.ts"; import { Host, cliStdioLayer, serverLayer } from "./platform.ts"; -import { InspectorConfig, InspectionTools, SessionInspectInput } from "./inspection.ts"; +import { InspectorConfig, InspectionTools, SessionInspectInput, mergeListMachines } from "./inspection.ts"; import { NativeError, connectExternal, loadConnectorConfig, resolveNativeConfig, readConnectorToken, readPrivateFile, projectHook, projectClaudeStatus, projectCodexNotification, writeNativeCache, writeHermesHook } from "./native.ts"; import { runSetup } from "./setup.ts"; -import type { SessionPresence } from "./inspection.ts"; +import type { ListMachine, ListSession } from "./inspection.ts"; const readInput = Effect.gen(function*() { const stdio = yield* Stdio.Stdio; @@ -66,14 +66,16 @@ export const cli = (args: ReadonlyArray) => Effect.scoped(Effect.gen(fun }; if (mode === "mcp") return yield* serveStdio(reader); if (mode === "list") { - const sessions: SessionPresence[] = []; + const sessions: ListSession[] = []; + const machines = new Map(); let cursor: string | undefined; do { const page = yield* reader.list({ limit: 100, ...(cursor === undefined ? {} : { cursor }) }); sessions.push(...page.sessions); + mergeListMachines(machines, page.machines); cursor = page.nextCursor ?? undefined; } while (cursor !== undefined); - return yield* Console.log(JSON.stringify({ sessions, nextCursor: null })); + return yield* Console.log(JSON.stringify({ machines: [...machines.values()], sessions, nextCursor: null })); } if (mode === "inspect") { const input = yield* Schema.decodeUnknownEffect(SessionInspectInput)(yield* readInput); @@ -92,9 +94,11 @@ if (import.meta.main) { return Console.error(JSON.stringify({ error: "Native inspector operation unavailable", reasons: cause.reasons.map((reason) => reason._tag === "Fail" - ? { kind: reason._tag, code: reason.error._tag, + ? { + kind: reason._tag, code: reason.error._tag, ...(reason.error._tag === "HttpError" ? { status: reason.error.status } : {}), - ...(reason.error._tag === "NativeError" ? { operation: reason.error.operation } : {}) } + ...(reason.error._tag === "NativeError" ? { operation: reason.error.operation } : {}) + } : { kind: reason._tag }), })).pipe(Effect.andThen(hook ? Effect.void : Effect.failCause(cause))); })); diff --git a/src/plugin.ts b/src/plugin.ts index bec4122..8bf94d8 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2,8 +2,8 @@ import { NodeServices } from "@effect/platform-node"; import { Cause, Clock, Config, Console, Effect, Formatter, Layer, Logger, ManagedRuntime, Schema } from "effect"; import type { FileSystem, Path, Scope } from "effect"; import type { ChildProcessSpawner } from "effect/process"; -import { createInspection, HttpError, InspectionTools, metric, sessionRefKey, unavailableMetric } from "./inspection.ts"; -import type { ConnectorConfig, Harness, MetricProvenance, ProcessIdentity, ProgramStatus, SessionInspection, SessionListOutput, SessionPresence, SessionProject, SessionWorkspace } from "./inspection.ts"; +import { createInspection, HttpError, InspectionTools, mergeListMachines, metric, sessionRefKey, unavailableMetric } from "./inspection.ts"; +import type { ConnectorConfig, Harness, ListMachine, ListSession, MetricProvenance, ProcessIdentity, ProgramStatus, SessionInspection, SessionListOutput, SessionPresence, SessionProject, SessionWorkspace } from "./inspection.ts"; import { Mcp } from "./mcp.ts"; import { connectNative, loadConnectorConfig, NativeError, readConnectorToken, resolveNativeConfig, withProcessResources } from "./native.ts"; import type { NativeNotice } from "./native.ts"; @@ -108,12 +108,14 @@ export const createNativeObserver = (harness: Harness, machineId: string, proces const message = event?.message; if (message?.role === "assistant" && message.usage) { const u = message.usage; - latest = { observedAt: now, nativeUpdatedAt: message.timestamp ?? null, usage: { - input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite, totalTokens: u.totalTokens, - reasoningTokens: harness === "pi" ? u.reasoning : u.reasoningTokens, premiumRequests: u.premiumRequests, - ...(u.credits ? { credits: { cost: u.credits.cost, committedCost: u.credits.committedCost, acuCost: u.credits.acuCost } } : {}), - cost: { input: u.cost.input, output: u.cost.output, cacheRead: u.cost.cacheRead, cacheWrite: u.cost.cacheWrite, total: u.cost.total }, - } }; + latest = { + observedAt: now, nativeUpdatedAt: message.timestamp ?? null, usage: { + input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite, totalTokens: u.totalTokens, + reasoningTokens: harness === "pi" ? u.reasoning : u.reasoningTokens, premiumRequests: u.premiumRequests, + ...(u.credits ? { credits: { cost: u.credits.cost, committedCost: u.credits.committedCost, acuCost: u.credits.acuCost } } : {}), + cost: { input: u.cost.input, output: u.cost.output, cacheRead: u.cost.cacheRead, cacheWrite: u.cost.cacheWrite, total: u.cost.total }, + } + }; } }; const refreshWorkspace = (project?: SessionProject) => Effect.gen(function*() { @@ -127,17 +129,18 @@ export const createNativeObserver = (harness: Harness, machineId: string, proces const currentToolName = activeTools.values().next().value; const status = userPrompt ?? programStatus; return { - ref: { machineId, harness, nativeSessionId: current.sessionManager.getSessionId(), ...(harness === "omp" && current.agent?.id ? { nativeAgentId: current.agent.id } : {}) }, - observedAt: now, ...(lastActivityAt === undefined ? {} : { lastActivityAt }), workspace: workspace ?? { cwd: current.cwd }, processes, - machine: { name: machineName, ...(os ? { os } : {}) }, - ...(terminal ? { terminal } : {}), - ...(status ? { programStatus: status } : {}), - ...(current.model ? { model: { provider: current.model.provider, id: current.model.id } } : {}), - ...(current.agent ? { agent: { kind: current.agent.kind, ...(current.agent.name ? { name: current.agent.name } : {}) } } : {}), - activity: { state: userPrompt ? "waiting" : current.isIdle() ? "idle" : "working", streaming: !current.isIdle(), - ...(currentToolName === undefined ? {} : { currentToolName }), - ...(userPrompt ? { wait: "user" } : currentToolName === undefined ? {} : { wait: "tool" }), - }, + ref: { machineId, harness, nativeSessionId: current.sessionManager.getSessionId(), ...(harness === "omp" && current.agent?.id ? { nativeAgentId: current.agent.id } : {}) }, + observedAt: now, ...(lastActivityAt === undefined ? {} : { lastActivityAt }), workspace: workspace ?? { cwd: current.cwd }, processes, + machine: { name: machineName, ...(os ? { os } : {}) }, + ...(terminal ? { terminal } : {}), + ...(status ? { programStatus: status } : {}), + ...(current.model ? { model: { provider: current.model.provider, id: current.model.id } } : {}), + ...(current.agent ? { agent: { kind: current.agent.kind, ...(current.agent.name ? { name: current.agent.name } : {}) } } : {}), + activity: { + state: userPrompt ? "waiting" : current.isIdle() ? "idle" : "working", streaming: !current.isIdle(), + ...(currentToolName === undefined ? {} : { currentToolName }), + ...(userPrompt ? { wait: "user" } : currentToolName === undefined ? {} : { wait: "tool" }), + }, }; }; const inspect = (now = clock.currentTimeMillisUnsafe()): SessionInspection | undefined => { @@ -265,14 +268,16 @@ export default function plugin(host: Host): void { if (!activeConfig) return yield* new NativeError({ operation: "Connector configuration required" }); const token = yield* readConnectorToken(activeConfig); const mcp = yield* Mcp; - const sessions: SessionPresence[] = []; + const sessions: ListSession[] = []; + const machines = new Map(); let cursor: string | undefined; do { const page = yield* mcp.call(activeConfig.inspectorUrl, token, InspectionTools.list, { limit: 100, ...(cursor === undefined ? {} : { cursor }) }); - for (const session of page.sessions) sessions.push(session); + sessions.push(...page.sessions); + mergeListMachines(machines, page.machines); cursor = page.nextCursor ?? undefined; } while (cursor !== undefined); - return sessions; + return { machines: [...machines.values()], sessions }; })); if (stopped) return; if (exit._tag === "Failure") { @@ -285,7 +290,7 @@ export default function plugin(host: Host): void { }, { triggerTurn: false }); return; } - const result = { sessions: exit.value }; + const result = exit.value; host.sendMessage({ customType: "coordinator-list", content: Formatter.formatJson(result, { space: 2 }), display: true, details: result, diff --git a/test/inspection.test.ts b/test/inspection.test.ts index 950c209..b235da1 100644 --- a/test/inspection.test.ts +++ b/test/inspection.test.ts @@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"; import { assert, expect, it } from "@effect/vitest"; import { createInspection, decodeConnectorAnnounce, decodeConnectorConfig, decodeConnectorResult, decodeInspectorConfig, - decodeSessionInspection, decodeSessionListInput, decodeSessionListOutput, decodeSessionPresence, metric, sessionRefKey, + decodeSessionInspection, decodeSessionListInput, decodeSessionListOutput, decodeSessionPresence, metric, projectListMachine, projectListSession, sessionRefKey, type MachineResources, type MetricProvenance, type SessionGit, type SessionPresence, } from "../src/inspection.ts"; @@ -67,8 +67,9 @@ it("validates per-session capacity units, fractional quotas and nonexclusive RSS it("round trips grouped metadata through public list, inspection and connector boundaries", async () => { const copy = JSON.parse(JSON.stringify(grouped)); assert.deepStrictEqual(await run(decodeSessionPresence(copy)), grouped); - const list = { sessions: [copy], nextCursor: null }; + const list = { machines: [projectListMachine(copy)], sessions: [projectListSession(copy)], nextCursor: null }; assert.deepStrictEqual(await run(decodeSessionListOutput(list)), list); + await expect(run(decodeSessionListOutput({ ...list, sessions: [copy] }))).rejects.toThrow(); const observation = createInspection(copy, 300); assert.deepStrictEqual(await run(decodeSessionInspection(observation)), observation); const connector = { connectorId: "connector", incarnation: "incarnation" }; @@ -78,6 +79,34 @@ it("round trips grouped metadata through public list, inspection and connector b assert.deepStrictEqual(await run(decodeConnectorResult(result)), result); }); +it("projects only list fields while keeping unavailable reasons, partiality and scope distinct", async () => { + const summary: NonNullable = { + observedAt: 200, machineScopeId: "process-boundary", attribution: "shared", + cpuPercent: metric("percent-one-core", 120, machineProvenance), + cpuCapacityPercent: { status: "unavailable", value: null, unit: "percent", reason: "No matching CPU scope", provenance: { ...machineProvenance, partial: true, stale: true } }, + rssBytes: metric("bytes", 110, { ...machineProvenance, estimated: true }), + rssCapacityPercent: metric("percent", 110, machineProvenance), + }; + const value: SessionPresence = { + ...grouped, machine: { ...grouped.machine, resources: machineResources }, + resources: summary, processes: [{ pid: 123, startIdentity: "birth", ownership: "shared" }], + terminal: { name: "Terminal", version: "18" }, + activity: { state: "waiting", wait: "approval", approval: "pending", streaming: true, goals: [{ id: "goal", state: "active" }] }, + }; + const list = { machines: [projectListMachine(value)], sessions: [projectListSession(value)], nextCursor: null }; + assert.deepStrictEqual(await run(decodeSessionListOutput(list)), list); + assert.strictEqual(list.machines[0]?.resources?.memory.accounting, machineProvenance.scopeDescription); + assert.strictEqual(list.sessions[0]?.machineScopeId, "boot-boundary"); + assert.strictEqual(list.sessions[0]?.resources?.machineScopeId, "process-boundary"); + assert.deepStrictEqual(list.sessions[0]?.resources?.cpuCapacityPercent, { status: "unavailable", value: null, reason: "No matching CPU scope", shared: true, partial: true, stale: true }); + assert.deepStrictEqual(list.sessions[0]?.processes, value.processes); + assert.deepStrictEqual(list.sessions[0]?.terminal, { name: "Terminal" }); + assert.deepStrictEqual(list.sessions[0]?.activity, { state: "waiting", wait: "approval", approval: "pending" }); + assert.deepStrictEqual(list.sessions[0]?.workspace?.git, grouped.workspace?.git); + assert.strictEqual(JSON.stringify(list).includes("sampleWindow"), false); + assert.deepStrictEqual((await run(decodeSessionInspection(createInspection(value, 200)))).presence, value); +}); + it("accepts SHA-1, SHA-256, detached, unborn and explicit negative Git observations", async () => { const available = { state: "available", observedAt: 190, worktreeRoot: "/work/project", commonDir: "/work/main/.git" } satisfies Omit, "branch" | "head">; for (const git of [ @@ -88,6 +117,8 @@ it("accepts SHA-1, SHA-256, detached, unborn and explicit negative Git observati ] satisfies SessionGit[]) { const value = { ...presence, workspace: { cwd: "/work/project", git } }; assert.deepStrictEqual(await run(decodeSessionPresence(value)), value); + const list = { machines: [projectListMachine(value)], sessions: [projectListSession(value)], nextCursor: null }; + assert.deepStrictEqual((await run(decodeSessionListOutput(list))).sessions[0]?.workspace?.git, git); } for (const head of ["", "A".repeat(40), "g".repeat(40), "a".repeat(39), "a".repeat(41), "a".repeat(63), "a".repeat(65), `${"a".repeat(40)}\n`, `${"a".repeat(64)}\r\n`]) { await expect(run(decodeSessionPresence({ ...presence, workspace: { cwd: "/work/project", git: { ...available, branch: "main", head } } }))).rejects.toThrow(); @@ -184,6 +215,15 @@ it("requires bounded pagination and TLS outside loopback", async () => { } for (const limit of [0, 101, 1.5]) await expect(run(decodeSessionListInput({ limit }))).rejects.toThrow(); await expect(run(decodeSessionListInput({ limit: 1, cursor: "x".repeat(257) }))).rejects.toThrow(); + for (const repository of ["/", "/remote/work/project/.git", "x".repeat(4096)]) { + const input = { limit: 1, machineId: "remote-machine", repository }; + assert.deepStrictEqual(await run(decodeSessionListInput(input)), input); + } + for (const repository of ["", "x".repeat(4097), 12, null]) { + await expect(run(decodeSessionListInput({ limit: 1, repository }))).rejects.toThrow(); + } + await expect(run(decodeSessionListInput({ limit: 1, harness: "pi" }))).rejects.toThrow(); + await expect(run(decodeSessionListInput({ limit: 1, repository: "/remote/work/project/.git", harness: "pi" }))).rejects.toThrow(); }); it("requires canonical private token paths without restricting native metadata", async () => { diff --git a/test/inspector.test.ts b/test/inspector.test.ts index ccd31e3..5ab537e 100644 --- a/test/inspector.test.ts +++ b/test/inspector.test.ts @@ -6,7 +6,7 @@ import { TestClock } from "effect/testing"; import { Inspector } from "../src/inspector.ts"; import { createInspection, metric, type InspectorConfig, type InspectorPrincipal, - type HttpError, type MetricProvenance, type SessionInspection, type SessionListOutput, type SessionPresence, type SessionRef, + type HttpError, type MetricProvenance, type SessionInspection, type SessionListOutput, type SessionPresence, type SessionRef, mergeListMachines, INSPECTION_LIMIT, } from "../src/inspection.ts"; const { publisher, reader, outsider, config } = await Effect.runPromise(Effect.gen(function*() { @@ -52,20 +52,83 @@ it.effect("marks disconnected and aged machine readings stale without rewriting }; const source = { ...presence, machine: { ...presence.machine, resources } }; yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: [source] }); - assert.strictEqual((yield* inspector.list(reader, { limit: 10 })).sessions[0]?.machine?.resources?.cpu.usagePercent.provenance.stale, false); + assert.strictEqual((yield* inspector.list(reader, { limit: 10 })).machines[0]?.resources?.cpu.usedPercent.stale, undefined); yield* TestClock.adjust("31 seconds"); yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: [source] }); - const aged = (yield* inspector.list(reader, { limit: 10 })).sessions[0]?.machine?.resources; - assert.strictEqual(aged?.cpu.usagePercent.provenance.stale, true); - assert.strictEqual(aged?.memory.availableBytes.provenance.stale, true); + const aged = (yield* inspector.list(reader, { limit: 10 })).machines[0]?.resources; + assert.strictEqual(aged?.cpu.usedPercent.stale, true); + assert.strictEqual(aged?.memory.usedBytes.stale, true); assert.strictEqual(aged?.observedAt, at); - assert.strictEqual(aged?.cpu.usagePercent.provenance.observedAt, at); - assert.deepStrictEqual(aged?.cpu.usagePercent.provenance.sampleWindow, p.sampleWindow); - assert.deepStrictEqual((yield* inspector.list(outsider, { limit: 10 })).sessions, []); + assert.strictEqual(aged?.memory.accounting, p.scopeDescription); + assert.deepStrictEqual((yield* inspector.list(outsider, { limit: 10 })).machines, []); yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: [{ ...source, machine: { ...source.machine, resources: { ...resources, observedAt: yield* Clock.currentTimeMillis } } }] }); - assert.strictEqual((yield* inspector.list(reader, { limit: 10 })).sessions[0]?.machine?.resources?.cpu.capacityCores.provenance.stale, false); + assert.strictEqual((yield* inspector.list(reader, { limit: 10 })).machines[0]?.resources?.cpu.capacityCores.stale, undefined); yield* inspector.disconnect(publisher, identity); - assert.strictEqual((yield* inspector.list(reader, { limit: 10 })).sessions[0]?.machine?.resources?.cpu.capacityCores.provenance.stale, true); + assert.strictEqual((yield* inspector.list(reader, { limit: 10 })).machines[0]?.resources?.cpu.capacityCores.stale, true); +}))); + +it.effect("deduplicates only the same installation and scope, keeping the newest whole snapshot", () => Effect.scoped(Effect.gen(function*() { + const inspector = yield* Inspector.make(config).pipe(Effect.provide(NodeServices.layer)); + const at = yield* Clock.currentTimeMillis; + const marked = { ...provenance, scope: "cgroup" as const, estimated: true, partial: true, observedAt: at }; + const snapshot = (scopeId: string | null, observedAt: number, value: number): NonNullable => ({ + name: "same hostname", os: { platform: "darwin", architecture: "arm64" }, + resources: { + scope: "cgroup", scopeId, source: "native", observedAt, + cpu: { usagePercent: metric("percent-one-core", 50, marked), capacityCores: metric("cores", 2, marked), usedPercent: metric("percent", value, marked) }, + memory: { + usedBytes: metric("bytes", value, { ...marked, scopeDescription: "Includes inactive cache" }), capacityBytes: metric("bytes", 100, marked), + availableBytes: metric("bytes", 100 - value, marked), usedPercent: metric("percent", value, marked) + }, + }, + }); + const entries: SessionPresence[] = [ + { ...presence, machine: snapshot("boot-a", at + 1, 10) }, + { ...presence, ref: { ...presence.ref, nativeSessionId: "second" }, machine: snapshot("boot-a", at, 50) }, + { ...presence, ref: { ...presence.ref, nativeSessionId: "third" }, machine: snapshot("boot-b", at, 30) }, + { ...presence, ref: { ...presence.ref, nativeSessionId: "fourth" }, machine: snapshot(null, at, 40) }, + ]; + yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: entries }); + const page = yield* inspector.list(reader, { limit: 10 }); + assert.strictEqual(page.machines.length, 3); + assert.deepStrictEqual(Object.fromEntries(page.sessions.map((row) => [row.ref.nativeSessionId, row.machineScopeId])), { + "actual-native-session": "boot-a", second: "boot-a", third: "boot-b", fourth: null, + }); + const newest = page.machines.find((machine) => machine.scopeId === "boot-a"); + assert.strictEqual(newest?.resources?.cpu.usedPercent.value, 10); + assert.strictEqual(newest?.resources?.memory.usedBytes.value, 10); + assert.strictEqual(newest?.resources?.memory.accounting, "Includes inactive cache"); + assert.strictEqual(newest?.resources?.cpu.usedPercent.estimated, true); + assert.strictEqual(newest?.resources?.cpu.usedPercent.partial, true); + assert.strictEqual(page.machines.find((machine) => machine.scopeId === null)?.resources?.memory.usedBytes.value, 40); + assert.ok(page.machines.every((machine) => machine.os && !("architecture" in machine.os))); +}))); + +it.effect("prefers a live copy of the same machine sample over a disconnected copy across pages", () => Effect.scoped(Effect.gen(function*() { + const inspector = yield* Inspector.make(config).pipe(Effect.provide(NodeServices.layer)); + const at = yield* Clock.currentTimeMillis; + const p = { ...provenance, scope: "host" as const, observedAt: at }; + const resources = { + scope: "host" as const, scopeId: "boot", source: "native", observedAt: at, + cpu: { usagePercent: metric("percent-one-core", 0, p), capacityCores: metric("cores", 2, p), usedPercent: metric("percent", 0, p) }, + memory: { usedBytes: metric("bytes", 20, p), capacityBytes: metric("bytes", 100, p), availableBytes: metric("bytes", 80, p), usedPercent: metric("percent", 20, p) }, + }; + const first: SessionPresence = { ...presence, ref: { ...presence.ref, nativeSessionId: "a-stale" }, machine: { ...presence.machine, resources } }; + const second: SessionPresence = { ...first, ref: { ...first.ref, nativeSessionId: "z-live" } }; + yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: [first] }); + yield* inspector.announce(publisher, { connectorId: "live-connector", incarnation: "first", machineId: "mac", sessions: [second] }); + yield* inspector.disconnect(publisher, identity); + const full = yield* inspector.list(reader, { limit: 10 }); + assert.strictEqual(full.machines[0]?.resources?.cpu.usedPercent.stale, undefined); + assert.strictEqual(full.machines[0]?.resources?.cpu.usedPercent.value, 0); + const page = yield* inspector.list(reader, { limit: 1 }); + assert.ok(page.nextCursor); + const next = yield* inspector.list(reader, { limit: 1, cursor: page.nextCursor }); + const machines = new Map(); + mergeListMachines(machines, page.machines); + mergeListMachines(machines, next.machines); + assert.strictEqual([...machines.values()][0]?.resources?.memory.usedBytes.stale, undefined); + assert.strictEqual([...machines.values()][0]?.resources?.memory.usedBytes.value, 20); }))); it.effect("preserves per-session percentages while demoting duplicate process claims and aging source readings", () => Effect.scoped(Effect.gen(function*() { @@ -82,7 +145,7 @@ it.effect("preserves per-session percentages while demoting duplicate process cl const exclusive = (yield* inspector.list(reader, { limit: 10 })).sessions[0]?.resources; assert.strictEqual(exclusive?.attribution, "owned"); assert.strictEqual(exclusive?.cpuCapacityPercent.value, 62.5); - assert.strictEqual(exclusive?.rssCapacityPercent.provenance.shared, false); + assert.strictEqual(exclusive?.rssCapacityPercent.shared, undefined); const second: SessionPresence = { ...first, ref: { ...first.ref, nativeSessionId: "second" } }; const otherIdentity = { connectorId: "second-connector", incarnation: "first" }; yield* inspector.announce(publisher, { ...otherIdentity, machineId: "mac", sessions: [second] }); @@ -92,8 +155,8 @@ it.effect("preserves per-session percentages while demoting duplicate process cl assert.strictEqual(row.resources?.cpuCapacityPercent.value, 62.5); assert.strictEqual(row.resources?.rssCapacityPercent.value, 80); assert.ok(row.resources); - for (const value of [row.resources.cpuPercent, row.resources.cpuCapacityPercent, row.resources.rssBytes, row.resources.rssCapacityPercent]) { - assert.strictEqual(value.provenance.shared, true); + for (const value of [row.resources.cpuCapacityPercent, row.resources.rssBytes, row.resources.rssCapacityPercent]) { + assert.strictEqual(value.shared, true); } } const query = yield* Effect.forkChild(inspector.inspect(reader, { ref: first.ref }), { startImmediately: true }); @@ -106,13 +169,56 @@ it.effect("preserves per-session percentages while demoting duplicate process cl yield* TestClock.adjust("31 seconds"); yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: [first] }); const aged = (yield* inspector.list(reader, { limit: 10 })).sessions.find((row) => row.ref.nativeSessionId === first.ref.nativeSessionId)?.resources; - assert.strictEqual(aged?.cpuCapacityPercent.provenance.stale, true); - assert.strictEqual(aged?.rssCapacityPercent.provenance.stale, true); + assert.strictEqual(aged?.cpuCapacityPercent.stale, true); + assert.strictEqual(aged?.rssCapacityPercent.stale, true); assert.strictEqual(aged?.observedAt, at); assert.strictEqual(aged?.cpuCapacityPercent.value, 62.5); yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: [{ ...first, resources: { ...resources, observedAt: yield* Clock.currentTimeMillis } }] }); yield* inspector.disconnect(publisher, identity); - assert.strictEqual((yield* inspector.list(reader, { limit: 10 })).sessions.find((row) => row.ref.nativeSessionId === first.ref.nativeSessionId)?.resources?.rssCapacityPercent.provenance.stale, true); + assert.strictEqual((yield* inspector.list(reader, { limit: 10 })).sessions.find((row) => row.ref.nativeSessionId === first.ref.nativeSessionId)?.resources?.rssCapacityPercent.stale, true); +}))); + +it.effect("matches published repositories across worktrees and harnesses without widening owner or machine scope", () => Effect.scoped(Effect.gen(function*() { + const inspector = yield* Inspector.make(config).pipe(Effect.provide(NodeServices.layer)); + const git = presence.workspace?.git; + if (git?.state !== "available") return yield* Effect.die("Missing fixture repository"); + const repository = git.commonDir; + const matching = (["pi", "omp", "hermes", "codex", "claude"] as const).map((harness): SessionPresence => ({ + ...presence, ref: { ...presence.ref, harness }, + workspace: { ...presence.workspace, cwd: `/work/${harness}/src`, git: { ...git, worktreeRoot: `/work/${harness}`, branch: harness } }, + })); + const excluded: SessionPresence[] = [ + { ref: { ...presence.ref, nativeSessionId: "missing-workspace" }, observedAt: presence.observedAt }, + ...([ + { cwd: repository, git: { ...git, commonDir: `${repository}-sibling` } }, + { cwd: repository, project: { root: repository, source: "configured", id: repository, name: repository }, git: { ...git, worktreeRoot: repository, branch: repository, commonDir: "/other/repository/.git" } }, + { cwd: repository, git: { state: "unavailable", observedAt: 5 } }, + { cwd: repository, git: { state: "not-repository", observedAt: 5 } }, + { cwd: repository }, + ] satisfies NonNullable[]).map((workspace, index): SessionPresence => ({ + ...presence, ref: { ...presence.ref, nativeSessionId: `excluded-${index}` }, workspace, + })), + ]; + yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: [...matching, ...excluded] }); + const remote: SessionPresence = { ...presence, ref: { ...presence.ref, machineId: "linux" } }; + yield* inspector.announce(publisher, { connectorId: "remote", incarnation: "first", machineId: "linux", sessions: [remote] }); + const privateSession: SessionPresence = { ...presence, ref: { ...presence.ref, nativeSessionId: "other-owner" } }; + yield* inspector.announce(outsider, { ...identity, machineId: "mac", sessions: [privateSession] }); + const listed = yield* inspector.list(reader, { limit: 100, repository }); + assert.strictEqual(listed.sessions.length, matching.length + 1); + assert.deepStrictEqual(new Set(listed.sessions.map((session) => JSON.stringify(session.ref))), new Set([...matching, remote].map((session) => JSON.stringify(session.ref)))); + assert.deepStrictEqual(new Set(listed.machines.map((machine) => machine.machineId)), new Set(["mac", "linux"])); + const local = yield* inspector.list(reader, { limit: 100, machineId: "mac", repository }); + assert.deepStrictEqual(new Set(local.sessions.map((session) => JSON.stringify(session.ref))), new Set(matching.map((session) => JSON.stringify(session.ref)))); + assert.deepStrictEqual(local.machines.map((machine) => machine.machineId), ["mac"]); + assert.deepStrictEqual((yield* inspector.list(reader, { limit: 100, machineId: "linux", repository })).sessions.map((session) => session.ref), [remote.ref]); + assert.deepStrictEqual((yield* inspector.list(reader, { limit: 100, machineId: "missing", repository })).sessions, []); + for (const other of [repository.slice(0, -1), `${repository}/`]) { + assert.deepStrictEqual((yield* inspector.list(reader, { limit: 100, repository: other })).sessions, []); + } + assert.strictEqual((yield* inspector.list(reader, { limit: 100 })).sessions.length, matching.length + excluded.length + 1); + assert.deepStrictEqual((yield* inspector.list(outsider, { limit: 100, repository })).sessions.map((session) => session.ref), [privateSession.ref]); + assert.deepStrictEqual((yield* inspector.list(outsider, { limit: 100, machineId: "linux", repository })).sessions, []); }))); it.effect("authenticates owner keys across machines while isolating owners and connector principals", () => Effect.scoped(Effect.gen(function*() { @@ -126,6 +232,7 @@ it.effect("authenticates owner keys across machines while isolating owners and c const linux: SessionPresence = { ...presence, ref: { ...presence.ref, machineId: "linux" } }; yield* inspector.announce(publisher, { ...linuxIdentity, machineId: "linux", sessions: [linux] }); assert.strictEqual((yield* inspector.list(reader, { limit: 10 })).sessions.length, 2); + assert.deepStrictEqual(new Set((yield* inspector.list(reader, { limit: 10 })).machines.map((machine) => machine.machineId)), new Set(["mac", "linux"])); assert.strictEqual((yield* inspector.list(reader, { limit: 10, machineId: "linux" })).sessions.at(0)?.ref.machineId, "linux"); assert.deepStrictEqual((yield* inspector.list(outsider, { limit: 10 })).sessions, []); assert.deepStrictEqual((yield* inspector.list(outsider, { limit: 10, machineId: "linux" })).sessions, []); @@ -276,10 +383,12 @@ it.effect("marks a process shared across native sessions without charging either const empty = createInspection(native, 0); yield* inspector.complete(publisher, { ...identity, requestId: request.requestId, - observation: { ...empty, resources: { - processes: [{ process: { ...process, ownership: "owned" }, cpuPercent: metric("percent-one-core", 100, provenance), cpuTimeMs: metric("milliseconds", 50, provenance), rssBytes: metric("bytes", 1024, provenance) }], - ownedProcessCount: metric("count", 1, provenance), ownedCpuPercent: metric("percent-one-core", 100, provenance), ownedRssBytes: metric("bytes", 1024, provenance), - } }, + observation: { + ...empty, resources: { + processes: [{ process: { ...process, ownership: "owned" }, cpuPercent: metric("percent-one-core", 100, provenance), cpuTimeMs: metric("milliseconds", 50, provenance), rssBytes: metric("bytes", 1024, provenance) }], + ownedProcessCount: metric("count", 1, provenance), ownedCpuPercent: metric("percent-one-core", 100, provenance), ownedRssBytes: metric("bytes", 1024, provenance), + } + }, }); const actual = yield* Fiber.join(query); assert.strictEqual(actual.resources.processes.at(0)?.process.ownership, "shared"); @@ -294,23 +403,52 @@ it.effect("marks a process shared across native sessions without charging either if (!((yield* inspector.list(reader, { limit: 10 })).sessions.every((session) => session.processes?.at(0)?.ownership === "owned"))) throw new Error("Expected truthy value"); }))); -it.effect("paginates by actual response bytes using opaque owner-bound cursors", () => Effect.scoped(Effect.gen(function*() { +it.effect("paginates repository matches by actual response bytes using owner- and filter-bound cursors", () => Effect.scoped(Effect.gen(function*() { const inspector = yield* Inspector.make(config).pipe(Effect.provide(NodeServices.layer)); - const large = Array.from({ length: 5 }, (_, index): SessionPresence => ({ - ...presence, ref: { ...presence.ref, nativeSessionId: `session-${index}` }, + const at = yield* Clock.currentTimeMillis; + const p = { ...provenance, scope: "host" as const, observedAt: at }; + const resources = { + scope: "host" as const, scopeId: "boot", source: "host", observedAt: at, + cpu: { usagePercent: metric("percent-one-core", 50, p), capacityCores: metric("cores", 2, p), usedPercent: metric("percent", 25, p) }, + memory: { usedBytes: metric("bytes", 20, p), capacityBytes: metric("bytes", 100, p), availableBytes: metric("bytes", 80, p), usedPercent: metric("percent", 20, p) }, + }; + const large = Array.from({ length: 30 }, (_, index): SessionPresence => ({ + ...presence, ref: { ...presence.ref, nativeSessionId: `session-${String(index).padStart(2, "0")}` }, + machine: { ...presence.machine, resources: { ...resources, observedAt: at + index, memory: { ...resources.memory, usedBytes: metric("bytes", 20 + index, { ...p, scopeDescription: `sample-${index} ${"x".repeat(index * 15)}` }) } } }, + workspace: { ...presence.workspace, cwd: `/${"x".repeat(3000)}` }, activity: { state: "idle", goals: Array.from({ length: 100 }, (_, goal): { id: string; state: "pending" } => ({ id: `${goal}`.padEnd(256, "x"), state: "pending" })) }, })); yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: large }); - const first = yield* inspector.list(reader, { limit: 100 }); - if (!(new TextEncoder().encode(JSON.stringify(first)).byteLength <= 48 * 1024)) throw new Error("Expected truthy value"); + const filter = { machineId: "mac", repository: "/work/main/.git" }; + const first = yield* inspector.list(reader, { limit: 100, ...filter }); + assert.ok(new TextEncoder().encode(JSON.stringify(first)).byteLength <= INSPECTION_LIMIT); + const checkSnapshot = (page: SessionListOutput) => { + assert.ok(page.sessions.every((session) => session.workspace?.git?.state === "available" && session.workspace.git.commonDir === filter.repository)); + const last = page.sessions.at(-1); + assert.ok(last); + const index = Number(last.ref.nativeSessionId.slice(-2)); + assert.strictEqual(page.machines[0]?.resources?.memory.usedBytes.value, 20 + index); + assert.strictEqual(page.machines[0]?.resources?.memory.accounting, `sample-${index} ${"x".repeat(index * 15)}`); + }; + checkSnapshot(first); if (!(first.nextCursor)) throw new Error("Expected truthy value"); - assert.strictEqual((yield* Effect.flip(inspector.list(outsider, { limit: 100, cursor: first.nextCursor }))).status, 400); - assert.strictEqual((yield* Effect.flip(inspector.list(reader, { limit: 100, cursor: first.nextCursor, machineId: "other" }))).status, 400); + assert.strictEqual((yield* Effect.flip(inspector.list(outsider, { limit: 100, ...filter, cursor: first.nextCursor }))).status, 400); + assert.strictEqual((yield* Effect.flip(inspector.list(reader, { limit: 100, ...filter, cursor: first.nextCursor, machineId: "other" }))).status, 400); + assert.strictEqual((yield* Effect.flip(inspector.list(reader, { limit: 100, ...filter, cursor: first.nextCursor, repository: `${filter.repository}-sibling` }))).status, 400); + assert.strictEqual((yield* Effect.flip(inspector.list(reader, { limit: 100, machineId: filter.machineId, cursor: first.nextCursor }))).status, 400); + assert.strictEqual((yield* Effect.flip(inspector.list(reader, { limit: 100, repository: filter.repository, cursor: first.nextCursor }))).status, 400); + const unfiltered = yield* inspector.list(reader, { limit: 1, machineId: filter.machineId }); + if (!unfiltered.nextCursor) throw new Error("Expected unfiltered continuation"); + assert.strictEqual((yield* Effect.flip(inspector.list(reader, { limit: 100, ...filter, cursor: unfiltered.nextCursor }))).status, 400); const found = new Set(first.sessions.map((session) => session.ref.nativeSessionId)); + const machines = new Map(); + mergeListMachines(machines, first.machines); let cursor: string | null = first.nextCursor; while (cursor !== null) { - const page: SessionListOutput = yield* inspector.list(reader, { limit: 100, cursor }); - if (!(new TextEncoder().encode(JSON.stringify(page)).byteLength <= 48 * 1024)) throw new Error("Expected truthy value"); + const page: SessionListOutput = yield* inspector.list(reader, { limit: 100, ...filter, cursor }); + assert.ok(new TextEncoder().encode(JSON.stringify(page)).byteLength <= INSPECTION_LIMIT); + checkSnapshot(page); + mergeListMachines(machines, page.machines); for (const session of page.sessions) { assert.strictEqual(found.has(session.ref.nativeSessionId), false); found.add(session.ref.nativeSessionId); @@ -318,6 +456,62 @@ it.effect("paginates by actual response bytes using opaque owner-bound cursors", cursor = page.nextCursor; } assert.strictEqual(found.size, large.length); + assert.strictEqual(machines.size, 1); + assert.strictEqual([...machines.values()][0]?.resources?.memory.usedBytes.value, 49); +}))); + +it.effect("keeps a maximally admitted row listable after shared and stale normalization", () => Effect.scoped(Effect.gen(function*() { + const inspector = yield* Inspector.make(config).pipe(Effect.provide(NodeServices.layer)); + const at = yield* Clock.currentTimeMillis; + const p = { ...provenance, observedAt: at }; + const source: SessionPresence = { + ...presence, observedAt: at, workspace: { cwd: "/" }, + processes: Array.from({ length: 128 }, (_, index) => ({ + pid: index + 1, startIdentity: (index < 10 ? "界" : "x").repeat(256), ownership: "owned" as const, + })), + machine: { + resources: { + scope: "host", scopeId: "boot", source: "native", observedAt: at, + cpu: { usagePercent: metric("percent-one-core", 50, p), capacityCores: metric("cores", 2, p), usedPercent: metric("percent", 25, p) }, + memory: { usedBytes: metric("bytes", 20, p), capacityBytes: metric("bytes", 100, p), availableBytes: metric("bytes", 80, p), usedPercent: metric("percent", 20, p) }, + } + }, + resources: { + observedAt: at, machineScopeId: "boot", attribution: "owned", + cpuPercent: metric("percent-one-core", 50, p), cpuCapacityPercent: metric("percent", 25, p), + rssBytes: metric("bytes", 20, p), rssCapacityPercent: metric("percent", 20, p), + }, + }; + yield* TestClock.adjust("31 seconds"); + let accepted = 0, rejected = 4096; + while (accepted + 1 < rejected) { + const padding = Math.floor((accepted + rejected) / 2); + const fits = yield* inspector.announce(publisher, { + ...identity, machineId: "mac", sessions: [{ ...source, workspace: { cwd: `/${"x".repeat(padding)}` } }], + }).pipe(Effect.match({ + onSuccess: () => true, + onFailure: (failure) => { assert.strictEqual(failure.status, 413); return false; }, + })); + if (fits) accepted = padding; + else rejected = padding; + } + assert.ok(accepted > 0 && rejected < 4096, "Fixture must straddle the actual admission boundary"); + const large = { ...source, workspace: { cwd: `/${"x".repeat(accepted)}` } }; + yield* inspector.announce(publisher, { ...identity, machineId: "mac", sessions: [large] }); + yield* inspector.announce(publisher, { + connectorId: "shared-boundary", incarnation: "first", machineId: "mac", + sessions: [{ ...source, ref: { ...source.ref, nativeSessionId: "second" } }], + }); + const page = yield* inspector.list(reader, { limit: 100 }); + assert.ok(new TextEncoder().encode(JSON.stringify(page)).byteLength <= INSPECTION_LIMIT); + assert.ok(page.nextCursor); + assert.strictEqual(page.sessions[0]?.resources?.attribution, "shared"); + assert.strictEqual(page.sessions[0]?.resources?.cpuCapacityPercent.shared, true); + assert.strictEqual(page.sessions[0]?.resources?.cpuCapacityPercent.stale, true); + assert.ok(page.sessions[0]?.processes?.every((process) => process.ownership === "shared")); + const next = yield* inspector.list(reader, { limit: 100, cursor: page.nextCursor }); + assert.ok(new TextEncoder().encode(JSON.stringify(next)).byteLength <= INSPECTION_LIMIT); + assert.strictEqual(next.sessions[0]?.ref.nativeSessionId, "second"); }))); it.effect("closes pending readers with the registry scope and rejects late results", () => Effect.gen(function*() { diff --git a/test/mcp.test.ts b/test/mcp.test.ts index c04507a..a0b0aad 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -26,15 +26,17 @@ class Endpoint extends Context.Service()("test/InspectorHttp", { make: Effect.gen(function*() { const mcp = yield* Mcp; const crypto = yield* Crypto.Crypto; - const inspector = yield* Inspector.make({ host: "127.0.0.1", port: 0, principals: [ - { id: "reader", owner: "owner", tokenHash: Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(token))) }, - { id: "outsider", owner: "other-owner", tokenHash: Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(outsiderToken))) }, - ] }); + const inspector = yield* Inspector.make({ + host: "127.0.0.1", port: 0, principals: [ + { id: "reader", owner: "owner", tokenHash: Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(token))) }, + { id: "outsider", owner: "other-owner", tokenHash: Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(outsiderToken))) }, + ] + }); const server = yield* HttpServer.HttpServer; yield* serve(server, inspector); return { mcp, url: HttpServer.formatAddress(server.address) }; }), -}) {} +}) { } const runtime = () => ManagedRuntime.make(Layer.effect(Endpoint, Endpoint.make).pipe(Layer.provideMerge(Layer.mergeAll(Mcp.layer, NodeServices.layer, NodeHttpClient.layerUndici, serverLayer("127.0.0.1", 0))))); const connector = { connectorId: "native-connector", incarnation: "process-incarnation" }; @@ -54,16 +56,18 @@ it("native MCP exposes only read-only tools and escapes or rejects private param assert.strictEqual(tool.annotations?.openWorldHint, false); assert.ok(tool.outputSchema); } - const listing = mcp.call(url, token, InspectionTools.list, { limit: 10 }); + const listing = mcp.call(url, token, InspectionTools.list, { limit: 10, machineId: "machine-a", repository: "/work/main/.git" }); expectTypeOf(listing).toEqualTypeOf>(); const listed = await app.runPromise(listing); assert.strictEqual(listed.sessions.length, 1); assert.deepStrictEqual(listed.sessions[0]?.ref, presence.ref); assert.strictEqual(listed.sessions[0]?.label, presence.label); - assert.deepStrictEqual(listed.sessions[0]?.machine, presence.machine); + assert.deepStrictEqual(listed.machines, [{ machineId: presence.ref.machineId, scopeId: null, name: presence.machine?.name, os: { platform: "linux" } }]); + assert.strictEqual(listed.sessions[0]?.machineScopeId, null); assert.deepStrictEqual(listed.sessions[0]?.agent, presence.agent); assert.deepStrictEqual(listed.sessions[0]?.model, presence.model); assert.deepStrictEqual(listed.sessions[0]?.workspace, presence.workspace); + assert.deepStrictEqual((await app.runPromise(mcp.call(url, token, InspectionTools.list, { limit: 10, repository: "/work/linked" }))).sessions, []); const inspection = mcp.call(url, token, InspectionTools.inspect, { ref: presence.ref }); expectTypeOf(inspection).toEqualTypeOf>(); const inspected = await app.runPromise(Effect.gen(function*() { @@ -90,6 +94,7 @@ it("native MCP exposes only read-only tools and escapes or rejects private param const rejected = await client.callTool({ name: "session.list", arguments: { limit: 10, transcript: "private-model-input" } }); assert.strictEqual(rejected.isError, true); assert.strictEqual(JSON.stringify(rejected).includes("private-model-input"), false); + assert.strictEqual((await client.callTool({ name: "session.list", arguments: { limit: 10, harness: "pi" } })).isError, true); } finally { await client.close(); await app.dispose(); } }, 15000); @@ -115,9 +120,10 @@ it("one owner key discovers multiple installations while connector fences and ow const second: SessionPresence = { ...presence, ref: { ...presence.ref, machineId: "machine-b" } }; await app.runPromise(mcp.post(url, token, "/connector/announce", ConnectorAnnounce, ConnectorAccepted, { ...connector, machineId: "machine-a", sessions: [presence] })); await app.runPromise(mcp.post(url, token, "/connector/announce", ConnectorAnnounce, ConnectorAccepted, { connectorId: "second-installation", incarnation: "second-process", machineId: "machine-b", sessions: [second] })); - const listed = await app.runPromise(mcp.call(url, token, InspectionTools.list, { limit: 10 })); + const listed = await app.runPromise(mcp.call(url, token, InspectionTools.list, { limit: 10, repository: "/work/main/.git" })); assert.deepStrictEqual(listed.sessions.map((session) => session.ref.machineId).sort(), ["machine-a", "machine-b"]); - assert.strictEqual((await app.runPromise(mcp.call(url, outsiderToken, InspectionTools.list, { limit: 10 }))).sessions.length, 0); + assert.deepStrictEqual((await app.runPromise(mcp.call(url, token, InspectionTools.list, { limit: 10, machineId: "machine-b", repository: "/work/main/.git" }))).sessions.map((session) => session.ref), [second.ref]); + assert.strictEqual((await app.runPromise(mcp.call(url, outsiderToken, InspectionTools.list, { limit: 10, repository: "/work/main/.git" }))).sessions.length, 0); for (const ref of [presence.ref, second.ref]) { await app.runPromise(mcp.call(url, outsiderToken, InspectionTools.inspect, { ref }).pipe(Effect.match({ onSuccess: () => assert.fail("Cross-owner detail accepted"), @@ -205,18 +211,20 @@ for (const variant of ["401", "503", "oversized-json", "oversized-sse", "timeout const status = variant === "redirect" ? 307 : variant === "401" || variant === "503" ? Number(variant) : 200; const first = variant === "401" || variant === "503" ? "private-rejection".repeat(LIMIT) : variant === "oversized-json" ? "x".repeat(512 * 1024 + 1) - : variant === "oversized-sse" ? `data: ${"x".repeat(MCP_RESPONSE_LIMIT)}` : ": waiting\n\n"; + : variant === "oversized-sse" ? `data: ${"x".repeat(MCP_RESPONSE_LIMIT)}` : ": waiting\n\n"; const body = (variant === "interrupted" ? Stream.fail(new Error("Interrupted fixture response")) : variant === "redirect" ? Stream.empty - : Stream.concat(Stream.make(new TextEncoder().encode(first)), Stream.never)).pipe( - Stream.ensuring(Effect.sync(disconnected.resolve)), - ); - return HttpServerResponse.stream(body, { status, headers: variant === "redirect" ? { location: "http://localhost:1/steal" } - : { "content-type": variant === "oversized-json" ? "application/json" : "text/event-stream" } }); + : Stream.concat(Stream.make(new TextEncoder().encode(first)), Stream.never)).pipe( + Stream.ensuring(Effect.sync(disconnected.resolve)), + ); + return HttpServerResponse.stream(body, { + status, headers: variant === "redirect" ? { location: "http://localhost:1/steal" } + : { "content-type": variant === "oversized-json" ? "application/json" : "text/event-stream" } + }); })); return HttpServer.formatAddress(server.address); }), - }) {} + }) { } const app = ManagedRuntime.make(Layer.merge(Mcp.layer, Layer.effect(Fixture, Fixture.make).pipe(Layer.provide(serverLayer("127.0.0.1", 0))))); try { const url = await app.runPromise(Fixture); diff --git a/test/native.test.ts b/test/native.test.ts index ca358d5..aa61d4e 100644 --- a/test/native.test.ts +++ b/test/native.test.ts @@ -55,45 +55,45 @@ it.live("merges environment over private config, keeps file selection explicit a const settings: Record = {}; const directory = (yield* fs.makeTempDirectoryScoped({ prefix: "coordinator-config-" })); - settings.XDG_CONFIG_HOME = directory; - settings.XDG_STATE_HOME = path.join(directory, "untouched"); - settings.COORDINATOR_CONNECTOR_CONFIG = undefined; - settings.COORDINATOR_API_URL = undefined; - settings.COORDINATOR_API_TOKEN = undefined; - assert.strictEqual((yield* (loadConnectorConfig().pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))), undefined); - settings.COORDINATOR_API_URL = "https://env.example"; - settings.COORDINATOR_API_TOKEN = "environment-credential"; - const environment: ConnectorConfig = { inspectorUrl: "https://env.example", apiToken: "environment-credential" }; - assert.deepStrictEqual((yield* (loadConnectorConfig().pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))), environment); - const tokenFile = path.join(directory, "token"); - (yield* fs.writeFileString(tokenFile, "file-credential-first\n", { mode: 0o600 })); - const file = path.join(directory, "client.json"); - const project = { root: directory, id: "explicit-project", name: "Explicit project" }; - (yield* fs.writeFileString(file, JSON.stringify({ inspectorUrl: "https://file.example", apiToken: "configured-credential", tokenFile, project }), { mode: 0o600 })); - const other = path.join(directory, "other.json"); - (yield* fs.writeFileString(other, JSON.stringify({ inspectorUrl: "https://other.example", tokenFile }), { mode: 0o600 })); - settings.COORDINATOR_CONNECTOR_CONFIG = other; - assert.deepStrictEqual((yield* (loadConnectorConfig(file).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))), { ...environment, tokenFile, project }); - settings.COORDINATOR_API_URL = undefined; - settings.COORDINATOR_API_TOKEN = undefined; - assert.deepStrictEqual((yield* (loadConnectorConfig().pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))), { inspectorUrl: "https://other.example", tokenFile }); - const configured = (yield* (loadConnectorConfig(file).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))); - if (!configured) return yield* Effect.die("Missing expected fixture result"); - assert.strictEqual((yield* (readConnectorToken(configured))), "configured-credential"); - assert.deepStrictEqual(configured.project, project); - const rotating: ConnectorConfig = { inspectorUrl: configured.inspectorUrl, tokenFile }; - assert.strictEqual((yield* (readConnectorToken(rotating))), "file-credential-first"); - const replacement = path.join(directory, "replacement"); - (yield* fs.writeFileString(replacement, "file-credential-second", { mode: 0o600 })); - (yield* fs.rename(replacement, tokenFile)); - assert.strictEqual((yield* (readConnectorToken(rotating))), "file-credential-second"); - (yield* expectFailure((readConnectorToken({ inspectorUrl: configured.inspectorUrl })))); - (yield* expectFailure((loadConnectorConfig(path.join(directory, "missing")).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); - (yield* fs.writeFileString(file, JSON.stringify({ inspectorUrl: "https://file.example", machineId: "obsolete" }), { mode: 0o600 })); - (yield* expectFailure((loadConnectorConfig(file).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); - (yield* fs.writeFileString(file, JSON.stringify({ inspectorUrl: "https://file.example", project: { root: "../relative" } }), { mode: 0o600 })); - (yield* expectFailure(loadConnectorConfig(file).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))); - (yield* expectFailure(fs.stat(path.join(directory, "untouched")))); + settings.XDG_CONFIG_HOME = directory; + settings.XDG_STATE_HOME = path.join(directory, "untouched"); + settings.COORDINATOR_CONNECTOR_CONFIG = undefined; + settings.COORDINATOR_API_URL = undefined; + settings.COORDINATOR_API_TOKEN = undefined; + assert.strictEqual((yield* (loadConnectorConfig().pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))), undefined); + settings.COORDINATOR_API_URL = "https://env.example"; + settings.COORDINATOR_API_TOKEN = "environment-credential"; + const environment: ConnectorConfig = { inspectorUrl: "https://env.example", apiToken: "environment-credential" }; + assert.deepStrictEqual((yield* (loadConnectorConfig().pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))), environment); + const tokenFile = path.join(directory, "token"); + (yield* fs.writeFileString(tokenFile, "file-credential-first\n", { mode: 0o600 })); + const file = path.join(directory, "client.json"); + const project = { root: directory, id: "explicit-project", name: "Explicit project" }; + (yield* fs.writeFileString(file, JSON.stringify({ inspectorUrl: "https://file.example", apiToken: "configured-credential", tokenFile, project }), { mode: 0o600 })); + const other = path.join(directory, "other.json"); + (yield* fs.writeFileString(other, JSON.stringify({ inspectorUrl: "https://other.example", tokenFile }), { mode: 0o600 })); + settings.COORDINATOR_CONNECTOR_CONFIG = other; + assert.deepStrictEqual((yield* (loadConnectorConfig(file).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))), { ...environment, tokenFile, project }); + settings.COORDINATOR_API_URL = undefined; + settings.COORDINATOR_API_TOKEN = undefined; + assert.deepStrictEqual((yield* (loadConnectorConfig().pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))), { inspectorUrl: "https://other.example", tokenFile }); + const configured = (yield* (loadConnectorConfig(file).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))); + if (!configured) return yield* Effect.die("Missing expected fixture result"); + assert.strictEqual((yield* (readConnectorToken(configured))), "configured-credential"); + assert.deepStrictEqual(configured.project, project); + const rotating: ConnectorConfig = { inspectorUrl: configured.inspectorUrl, tokenFile }; + assert.strictEqual((yield* (readConnectorToken(rotating))), "file-credential-first"); + const replacement = path.join(directory, "replacement"); + (yield* fs.writeFileString(replacement, "file-credential-second", { mode: 0o600 })); + (yield* fs.rename(replacement, tokenFile)); + assert.strictEqual((yield* (readConnectorToken(rotating))), "file-credential-second"); + (yield* expectFailure((readConnectorToken({ inspectorUrl: configured.inspectorUrl })))); + (yield* expectFailure((loadConnectorConfig(path.join(directory, "missing")).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); + (yield* fs.writeFileString(file, JSON.stringify({ inspectorUrl: "https://file.example", machineId: "obsolete" }), { mode: 0o600 })); + (yield* expectFailure((loadConnectorConfig(file).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); + (yield* fs.writeFileString(file, JSON.stringify({ inspectorUrl: "https://file.example", project: { root: "../relative" } }), { mode: 0o600 })); + (yield* expectFailure(loadConnectorConfig(file).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings))))); + (yield* expectFailure(fs.stat(path.join(directory, "untouched")))); }))); it.live("publishes one complete installation UUID across processes and preserves it across endpoint, key and profile changes", () => fixture(Effect.gen(function*() { @@ -104,19 +104,19 @@ it.live("publishes one complete installation UUID across processes and preserves const state = path.join(directory, "state"); const script = `import { Effect, Console, Layer } from "effect"; import { NodeServices, NodeRuntime } from "@effect/platform-node"; import { Host } from ${JSON.stringify(new URL("../src/platform.ts", import.meta.url).href)}; import { loadConnectorConfig, resolveNativeConfig } from ${JSON.stringify(new URL("../src/native.ts", import.meta.url).href)}; NodeRuntime.runMain(Effect.gen(function*() { const input = yield* loadConnectorConfig(); if (!input) return yield* Effect.die("Missing configuration"); const config = yield* resolveNativeConfig(input); yield* Console.log(config.machineId); }).pipe(Effect.provide(Layer.merge(NodeServices.layer, Host.layer))));`; const environment = { XDG_STATE_HOME: state, XDG_CONFIG_HOME: path.join(directory, "config"), COORDINATOR_CONNECTOR_CONFIG: undefined, COORDINATOR_API_URL: "https://first.example", COORDINATOR_API_TOKEN: "first-owner-credential" }; - const identities = (yield* Effect.all(Array.from({ length: 4 }, () => runNativeProcess(["--input-type=module", "-e", script], environment)), { concurrency: "unbounded" })); - const identity = identities.at(0)?.trim(); - assert.match(identity ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); - for (const value of identities) assert.strictEqual(value, identity + "\n"); - assert.strictEqual((yield* runNativeProcess(["--input-type=module", "-e", script], { ...environment, COORDINATOR_API_URL: "https://second.example", COORDINATOR_API_TOKEN: "rotated-owner-credential", HERMES_PROFILE: "another-profile" })), identity + "\n"); - assert.notStrictEqual(((yield* runNativeProcess(["--input-type=module", "-e", script], { ...environment, XDG_STATE_HOME: path.join(directory, "other") }))).trim(), identity); - const root = path.join(state, "coordinator"); - assert.strictEqual(((yield* fs.stat(root))).mode & 0o777, 0o700); - assert.strictEqual(((yield* fs.stat(path.join(root, "machine-id")))).mode & 0o777, 0o600); - assert.deepStrictEqual((yield* fs.readDirectory(root)), ["machine-id"]); - const project = { root: directory, id: "project" }; - const config = yield* resolveNativeConfig({ inspectorUrl: "https://first.example", project }).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(environment))); - assert.deepStrictEqual(config.project, project); + const identities = (yield* Effect.all(Array.from({ length: 4 }, () => runNativeProcess(["--input-type=module", "-e", script], environment)), { concurrency: "unbounded" })); + const identity = identities.at(0)?.trim(); + assert.match(identity ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + for (const value of identities) assert.strictEqual(value, identity + "\n"); + assert.strictEqual((yield* runNativeProcess(["--input-type=module", "-e", script], { ...environment, COORDINATOR_API_URL: "https://second.example", COORDINATOR_API_TOKEN: "rotated-owner-credential", HERMES_PROFILE: "another-profile" })), identity + "\n"); + assert.notStrictEqual(((yield* runNativeProcess(["--input-type=module", "-e", script], { ...environment, XDG_STATE_HOME: path.join(directory, "other") }))).trim(), identity); + const root = path.join(state, "coordinator"); + assert.strictEqual(((yield* fs.stat(root))).mode & 0o777, 0o700); + assert.strictEqual(((yield* fs.stat(path.join(root, "machine-id")))).mode & 0o777, 0o600); + assert.deepStrictEqual((yield* fs.readDirectory(root)), ["machine-id"]); + const project = { root: directory, id: "project" }; + const config = yield* resolveNativeConfig({ inspectorUrl: "https://first.example", project }).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(environment))); + assert.deepStrictEqual(config.project, project); })), 30000); it.live("cleans acquired identity temporaries on write or close failure without removing failed exclusive acquisitions", () => fixture(Effect.gen(function*() { @@ -129,19 +129,21 @@ it.live("cleans acquired identity temporaries on write or close failure without const base = path.join(directory, failure); const root = path.join(base, "coordinator"); settings.XDG_STATE_HOME = base; - const injected = FileSystem.make({ ...fs, open: (file, options) => { - if (!path.basename(file).startsWith(".machine-id-")) return fs.open(file, options); - if (failure === "exclusive") return fs.writeFileString(file, "another creator", { mode: 0o600 }).pipe(Effect.andThen(Effect.fail(PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", method: "open" })))); - return Effect.gen(function*() { - const handle = yield* fs.open(file, options); - if (failure === "close") yield* Effect.addFinalizer(() => Effect.die(PlatformError.systemError({ _tag: "BadResource", module: "FileSystem", method: "close", pathOrDescriptor: "secret-identity-path" }))); - return failure === "write" ? delegateFile(handle, { writeAll: () => Effect.fail(PlatformError.systemError({ _tag: "BadResource", module: "FileSystem", method: "writeAll" })) }) : handle; - }); - }, link: (from, to) => { - if (failure === "link") return Effect.fail(PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", method: "link" })); - if (failure === "winner") return fs.writeFileString(to, "corrupt winner\n", { mode: 0o600 }).pipe(Effect.andThen(Effect.fail(PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", method: "link" })))); - return fs.link(from, to); - } }); + const injected = FileSystem.make({ + ...fs, open: (file, options) => { + if (!path.basename(file).startsWith(".machine-id-")) return fs.open(file, options); + if (failure === "exclusive") return fs.writeFileString(file, "another creator", { mode: 0o600 }).pipe(Effect.andThen(Effect.fail(PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", method: "open" })))); + return Effect.gen(function*() { + const handle = yield* fs.open(file, options); + if (failure === "close") yield* Effect.addFinalizer(() => Effect.die(PlatformError.systemError({ _tag: "BadResource", module: "FileSystem", method: "close", pathOrDescriptor: "secret-identity-path" }))); + return failure === "write" ? delegateFile(handle, { writeAll: () => Effect.fail(PlatformError.systemError({ _tag: "BadResource", module: "FileSystem", method: "writeAll" })) }) : handle; + }); + }, link: (from, to) => { + if (failure === "link") return Effect.fail(PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", method: "link" })); + if (failure === "winner") return fs.writeFileString(to, "corrupt winner\n", { mode: 0o600 }).pipe(Effect.andThen(Effect.fail(PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", method: "link" })))); + return fs.link(from, to); + } + }); const exit = yield* Effect.exit(resolveNativeConfig({ inspectorUrl: "https://inspector.example" }).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)), Effect.provideService(FileSystem.FileSystem, injected))); if (exit._tag !== "Failure") return yield* Effect.die("Expected identity publication failure"); assert.isTrue(exit.cause.reasons.every((reason) => reason._tag === "Fail" && reason.error._tag === "NativeError" && reason.error.operation === "Resolve private native installation identity")); @@ -167,35 +169,35 @@ it.live("rejects corrupt, public or symlinked identity and unsafe state roots wi const root = path.join(directory, "coordinator"); const file = path.join(root, "machine-id"); const config: ConnectorConfig = { inspectorUrl: "https://inspector.example" }; - settings.XDG_STATE_HOME = directory; - (yield* fs.makeDirectory(root, { mode: 0o700 })); - (yield* fs.writeFileString(file, "corrupt\n", { mode: 0o600 })); - (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); - assert.strictEqual((yield* fs.readFileString(file)), "corrupt\n"); - (yield* fs.remove(file)); - const valid = "ebcbb06d-92c4-4df9-973d-f2f47399a2ed\n"; - (yield* fs.writeFileString(file, valid, { mode: 0o644 })); - (yield* fs.chmod(file, 0o644)); - (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); - assert.strictEqual((yield* fs.readFileString(file)), valid); - (yield* fs.remove(file)); - const linked = path.join(directory, "linked-id"); - (yield* fs.writeFileString(linked, valid, { mode: 0o600 })); - (yield* fs.symlink(linked, file)); - (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); - assert.strictEqual((yield* fs.readLink(file)), linked); - (yield* fs.remove(file)); - (yield* fs.chmod(root, 0o755)); - (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); - (yield* expectFailure(fs.stat(file))); - settings.XDG_STATE_HOME = directory + "/../noncanonical"; - (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); - settings.XDG_STATE_HOME = "relative-state"; - (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); - (yield* fs.chmod(root, 0o700)); - settings.XDG_STATE_HOME = path.join(directory, "alias"); - (yield* fs.symlink(directory, path.join(directory, "alias"))); - (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); + settings.XDG_STATE_HOME = directory; + (yield* fs.makeDirectory(root, { mode: 0o700 })); + (yield* fs.writeFileString(file, "corrupt\n", { mode: 0o600 })); + (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); + assert.strictEqual((yield* fs.readFileString(file)), "corrupt\n"); + (yield* fs.remove(file)); + const valid = "ebcbb06d-92c4-4df9-973d-f2f47399a2ed\n"; + (yield* fs.writeFileString(file, valid, { mode: 0o644 })); + (yield* fs.chmod(file, 0o644)); + (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); + assert.strictEqual((yield* fs.readFileString(file)), valid); + (yield* fs.remove(file)); + const linked = path.join(directory, "linked-id"); + (yield* fs.writeFileString(linked, valid, { mode: 0o600 })); + (yield* fs.symlink(linked, file)); + (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); + assert.strictEqual((yield* fs.readLink(file)), linked); + (yield* fs.remove(file)); + (yield* fs.chmod(root, 0o755)); + (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); + (yield* expectFailure(fs.stat(file))); + settings.XDG_STATE_HOME = directory + "/../noncanonical"; + (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); + settings.XDG_STATE_HOME = "relative-state"; + (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); + (yield* fs.chmod(root, 0o700)); + settings.XDG_STATE_HOME = path.join(directory, "alias"); + (yield* fs.symlink(directory, path.join(directory, "alias"))); + (yield* expectFailure((resolveNativeConfig(config).pipe(Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))))); }))); it.live("starts authenticated readers without creating or requiring writable installation state", () => fixture(Effect.gen(function*() { @@ -205,24 +207,24 @@ it.live("starts authenticated readers without creating or requiring writable ins const directory = (yield* fs.makeTempDirectoryScoped({ prefix: "coordinator-reader-" })); const blocked = path.join(directory, "not-a-directory"); - (yield* fs.writeFileString(blocked, "unchanged", { mode: 0o600 })); - settings.XDG_CONFIG_HOME = directory; - settings.XDG_STATE_HOME = blocked; - settings.COORDINATOR_CONNECTOR_CONFIG = undefined; - settings.COORDINATOR_API_URL = "https://reader.example"; - settings.COORDINATOR_API_TOKEN = "reader-owner-credential"; - let calls = 0; - const transport = (yield* (Mcp.make)); - const call: typeof transport.call = (url, token) => { - calls++; - assert.strictEqual(url, "https://reader.example"); - assert.strictEqual(token, "reader-owner-credential"); - return Effect.fail(new HttpError({ status: 503, message: "Reader reached authenticated transport" })); - }; - yield* expectFailure(cli(["list"]).pipe(Effect.provideService(Mcp, { ...transport, call }), Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))); - assert.strictEqual(calls, 1); - assert.strictEqual((yield* fs.readFileString(blocked)), "unchanged"); - assert.deepStrictEqual(((yield* fs.readDirectory(directory))).sort(), ["not-a-directory"]); + (yield* fs.writeFileString(blocked, "unchanged", { mode: 0o600 })); + settings.XDG_CONFIG_HOME = directory; + settings.XDG_STATE_HOME = blocked; + settings.COORDINATOR_CONNECTOR_CONFIG = undefined; + settings.COORDINATOR_API_URL = "https://reader.example"; + settings.COORDINATOR_API_TOKEN = "reader-owner-credential"; + let calls = 0; + const transport = (yield* (Mcp.make)); + const call: typeof transport.call = (url, token) => { + calls++; + assert.strictEqual(url, "https://reader.example"); + assert.strictEqual(token, "reader-owner-credential"); + return Effect.fail(new HttpError({ status: 503, message: "Reader reached authenticated transport" })); + }; + yield* expectFailure(cli(["list"]).pipe(Effect.provideService(Mcp, { ...transport, call }), Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(settings)))); + assert.strictEqual(calls, 1); + assert.strictEqual((yield* fs.readFileString(blocked)), "unchanged"); + assert.deepStrictEqual(((yield* fs.readDirectory(directory))).sort(), ["not-a-directory"]); }))); it.live("prints every session when real inspector byte limits require multiple CLI list pages", () => fixture(Effect.gen(function*() { @@ -250,7 +252,8 @@ it.live("prints every session when real inspector byte limits require multiple C Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord({ COORDINATOR_API_URL: HttpServer.formatAddress(server.address), COORDINATOR_API_TOKEN: token })), ); const result = JSON.parse(String(printed[0])); - assert.deepStrictEqual(result.sessions.map((entry: SessionPresence) => entry.ref), records.map((entry) => entry.ref)); + assert.deepStrictEqual(result.sessions.map((entry: { ref: SessionPresence["ref"] }) => entry.ref), records.map((entry) => entry.ref)); + assert.deepStrictEqual(result.machines, [{ machineId: "machine", scopeId: null }]); assert.strictEqual(result.nextCursor, null); }).pipe(Effect.provide(serverLayer("127.0.0.1", 0))))); @@ -427,27 +430,27 @@ it.live("keeps private cached usage aged across metadata updates and expires sto const directory = (yield* fs.makeTempDirectoryScoped({ prefix: "coordinator-native-" })); (yield* fs.chmod(directory, 0o700)); const config: NativeConfig = { inspectorUrl: "http://127.0.0.1:8080", machineId: "machine", tokenFile: path.join(directory, "token"), stateDirectory: path.join(directory, "metadata") }; - const at = (yield* Clock.currentTimeMillis); - (yield* (writeNativeCache(config, (yield* (projectClaudeStatus("machine", { session_id: "session", context_window: { total_input_tokens: 0 } }, at)))))); - (yield* (writeNativeCache(config, (yield* (projectHook("claude", "machine", { session_id: "session", hook_event_name: "UserPromptSubmit", prompt: "private" }, at + 1)))))); - const cached = (yield* (readNativeCache(config).pipe(Effect.provide(NodeServices.layer)))); - assert.strictEqual(cached.length, 1); - assert.strictEqual(cached.at(0)?.tokens.latestNativeUpdate.inputTokens.value, 0); - assert.strictEqual(cached.at(0)?.tokens.latestNativeUpdate.inputTokens.provenance.observedAt, at); - assert.strictEqual(cached.at(0)?.presence.activity?.state, "working"); - assert.strictEqual(((yield* fs.stat(config.stateDirectory))).mode & 0o077, 0); - const namespace = path.join(config.stateDirectory, "native-observations"); - for (const file of (yield* fs.readDirectory(namespace))) assert.strictEqual(((yield* fs.stat(path.join(namespace, file)))).mode & 0o077, 0); - (yield* (writeNativeCache(config, (yield* (projectHook("claude", "machine", { session_id: "session", hook_event_name: "SessionEnd" }, at + 2)))))); - assert.strictEqual(((yield* (readNativeCache(config).pipe(Effect.provide(NodeServices.layer))))).length, 0); - const configFile = path.join(directory, "inspector.json"); - (yield* fs.writeFileString(configFile, "{}", { mode: 0o600 })); - (yield* (readPrivateFile(configFile))); - (yield* fs.chmod(configFile, 0o644)); - (yield* expectFailure((readPrivateFile(configFile)))); - const link = path.join(directory, "link"); - (yield* fs.symlink(configFile, link)); - (yield* expectFailure((readPrivateFile(link)))); + const at = (yield* Clock.currentTimeMillis); + (yield* (writeNativeCache(config, (yield* (projectClaudeStatus("machine", { session_id: "session", context_window: { total_input_tokens: 0 } }, at)))))); + (yield* (writeNativeCache(config, (yield* (projectHook("claude", "machine", { session_id: "session", hook_event_name: "UserPromptSubmit", prompt: "private" }, at + 1)))))); + const cached = (yield* (readNativeCache(config).pipe(Effect.provide(NodeServices.layer)))); + assert.strictEqual(cached.length, 1); + assert.strictEqual(cached.at(0)?.tokens.latestNativeUpdate.inputTokens.value, 0); + assert.strictEqual(cached.at(0)?.tokens.latestNativeUpdate.inputTokens.provenance.observedAt, at); + assert.strictEqual(cached.at(0)?.presence.activity?.state, "working"); + assert.strictEqual(((yield* fs.stat(config.stateDirectory))).mode & 0o077, 0); + const namespace = path.join(config.stateDirectory, "native-observations"); + for (const file of (yield* fs.readDirectory(namespace))) assert.strictEqual(((yield* fs.stat(path.join(namespace, file)))).mode & 0o077, 0); + (yield* (writeNativeCache(config, (yield* (projectHook("claude", "machine", { session_id: "session", hook_event_name: "SessionEnd" }, at + 2)))))); + assert.strictEqual(((yield* (readNativeCache(config).pipe(Effect.provide(NodeServices.layer))))).length, 0); + const configFile = path.join(directory, "inspector.json"); + (yield* fs.writeFileString(configFile, "{}", { mode: 0o600 })); + (yield* (readPrivateFile(configFile))); + (yield* fs.chmod(configFile, 0o644)); + (yield* expectFailure((readPrivateFile(configFile)))); + const link = path.join(directory, "link"); + (yield* fs.symlink(configFile, link)); + (yield* expectFailure((readPrivateFile(link)))); }))); it.live("preserves the hook's terminal identity rather than attributing cached sessions to the connector's terminal", () => fixture(Effect.gen(function*() { @@ -481,13 +484,15 @@ it.live("projects Claude roster groups while reusing the previous local workspac const config: NativeConfig = { inspectorUrl: "https://unused.example", machineId: "machine", stateDirectory: directory, project: { root: directory, id: "configured" } }; const rows = [{ sessionId: "session", pid: host.pid, cwd, kind: "native-agent", status: "busy", provider: "unsupported-provider", project: "unsupported-project" }]; let gitCalls = 0; - const nativeSpawner: typeof spawner = { ...spawner, spawn: (command) => { - if (ChildProcess.isStandardCommand(command) && command.command === "claude") { - return spawner.spawn(ChildProcess.make(host.executable, ["-e", `console.log(${JSON.stringify(JSON.stringify(rows))})`], { stdin: "ignore" })); + const nativeSpawner: typeof spawner = { + ...spawner, spawn: (command) => { + if (ChildProcess.isStandardCommand(command) && command.command === "claude") { + return spawner.spawn(ChildProcess.make(host.executable, ["-e", `console.log(${JSON.stringify(JSON.stringify(rows))})`], { stdin: "ignore" })); + } + if (ChildProcess.isStandardCommand(command) && command.command === "git") gitCalls++; + return spawner.spawn(command); } - if (ChildProcess.isStandardCommand(command) && command.command === "git") gitCalls++; - return spawner.spawn(command); - } }; + }; const targetReads = new Map>(); const first = (yield* claudeRoster(config, new Map(), targetReads).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, nativeSpawner))).at(0); if (!first) return yield* Effect.die("Missing roster session"); @@ -545,9 +550,11 @@ it.live("batches cached and roster sessions sharing a host before publishing nor } return HttpServerResponse.jsonUnsafe({ accepted: true }); })); - const nativeSpawner: typeof spawner = { ...spawner, spawn: (command) => ChildProcess.isStandardCommand(command) && command.command === "claude" - ? spawner.spawn(ChildProcess.make(host.executable, ["-e", `console.log(${JSON.stringify(JSON.stringify([{ sessionId: "roster", pid: host.pid, status: "idle" }]))})`], { stdin: "ignore" })) - : spawner.spawn(command) }; + const nativeSpawner: typeof spawner = { + ...spawner, spawn: (command) => ChildProcess.isStandardCommand(command) && command.command === "claude" + ? spawner.spawn(ChildProcess.make(host.executable, ["-e", `console.log(${JSON.stringify(JSON.stringify([{ sessionId: "roster", pid: host.pid, status: "idle" }]))})`], { stdin: "ignore" })) + : spawner.spawn(command) + }; const connector = yield* connectExternal(config).pipe(Effect.scoped, Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, nativeSpawner), Effect.provide(Mcp.layer), Effect.forkChild); const sessions = yield* Queue.take(announcements); assert.strictEqual(sessions.length, 2); @@ -621,23 +628,27 @@ it.effect("refreshes idle cached publishers without hooks and recomputes project return HttpServerResponse.jsonUnsafe({ accepted: true }); })); let gitCalls = 0; - const nativeSpawner: typeof spawner = { ...spawner, spawn: (command) => { - if (ChildProcess.isStandardCommand(command) && command.command === "claude") { - return spawner.spawn(ChildProcess.make(host.executable, ["-e", "console.log('[]')"], { stdin: "ignore" })); + const nativeSpawner: typeof spawner = { + ...spawner, spawn: (command) => { + if (ChildProcess.isStandardCommand(command) && command.command === "claude") { + return spawner.spawn(ChildProcess.make(host.executable, ["-e", "console.log('[]')"], { stdin: "ignore" })); + } + if (ChildProcess.isStandardCommand(command) && command.command === "git") gitCalls++; + return spawner.spawn(command); } - if (ChildProcess.isStandardCommand(command) && command.command === "git") gitCalls++; - return spawner.spawn(command); - } }; + }; const refreshing = yield* Deferred.make(); const resume = yield* Deferred.make(); let pauseRefresh = false; - const publisherFs: typeof fs = { ...fs, realPath: (file) => Effect.gen(function*() { - if (pauseRefresh && file === directory) { - yield* Deferred.succeed(refreshing, undefined); - yield* Deferred.await(resume); - } - return yield* fs.realPath(file); - }) }; + const publisherFs: typeof fs = { + ...fs, realPath: (file) => Effect.gen(function*() { + if (pauseRefresh && file === directory) { + yield* Deferred.succeed(refreshing, undefined); + yield* Deferred.await(resume); + } + return yield* fs.realPath(file); + }) + }; const connector = yield* connectExternal(config).pipe( Effect.scoped, Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, nativeSpawner), @@ -805,25 +816,25 @@ it.live("isolates native observations from legacy journals and reclaims only exp const unsupported = "f".repeat(64) + ".json"; const legacy = "e".repeat(64) + ".json"; const original = JSON.stringify({ version: 1, journal: "user-owned-rollback" }); - (yield* fs.chmod(directory, 0o700)); - (yield* fs.writeFileString(path.join(directory, legacy), original, { mode: 0o600 })); - (yield* fs.makeDirectory(path.join(directory, "backups"), { mode: 0o700 })); - (yield* fs.writeFileString(path.join(directory, "backups", "journal"), "retained", { mode: 0o600 })); - (yield* fs.makeDirectory(namespace, { mode: 0o700 })); - (yield* fs.writeFileString(path.join(namespace, unsupported), original, { mode: 0o600 })); - const old = (yield* (projectHook("codex", "machine", { session_id: "expired", hook_event_name: "Stop" }, 1))); - (yield* Effect.forEach(Array.from({ length: 1024 }, (_, index) => index), (index) => Effect.gen(function*() { - const ref = { ...old.presence.ref, nativeSessionId: `expired-${index}` }; - const file = "v4-" + Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(sessionRefKey(ref)))) + ".json"; - (yield* fs.writeFileString(path.join(namespace, file), JSON.stringify({ version: 4, kind: "inspection", expiresAt: 2, inspection: { ...old, presence: { ...old.presence, ref } } }), { mode: 0o600 })); - }), { concurrency: "unbounded" })); - const current = (yield* (projectHook("codex", "machine", { session_id: "new", hook_event_name: "Stop" }))); - (yield* (writeNativeCache(config, current))); - assert.strictEqual(((yield* (readNativeCache(config).pipe(Effect.provide(NodeServices.layer))))).at(0)?.presence.ref.nativeSessionId, "new"); - assert.strictEqual(((yield* fs.readDirectory(namespace))).length, 2); - assert.strictEqual((yield* fs.readFileString(path.join(namespace, unsupported))), original); - assert.strictEqual((yield* fs.readFileString(path.join(directory, legacy))), original); - assert.strictEqual((yield* fs.readFileString(path.join(directory, "backups", "journal"))), "retained"); + (yield* fs.chmod(directory, 0o700)); + (yield* fs.writeFileString(path.join(directory, legacy), original, { mode: 0o600 })); + (yield* fs.makeDirectory(path.join(directory, "backups"), { mode: 0o700 })); + (yield* fs.writeFileString(path.join(directory, "backups", "journal"), "retained", { mode: 0o600 })); + (yield* fs.makeDirectory(namespace, { mode: 0o700 })); + (yield* fs.writeFileString(path.join(namespace, unsupported), original, { mode: 0o600 })); + const old = (yield* (projectHook("codex", "machine", { session_id: "expired", hook_event_name: "Stop" }, 1))); + (yield* Effect.forEach(Array.from({ length: 1024 }, (_, index) => index), (index) => Effect.gen(function*() { + const ref = { ...old.presence.ref, nativeSessionId: `expired-${index}` }; + const file = "v4-" + Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(sessionRefKey(ref)))) + ".json"; + (yield* fs.writeFileString(path.join(namespace, file), JSON.stringify({ version: 4, kind: "inspection", expiresAt: 2, inspection: { ...old, presence: { ...old.presence, ref } } }), { mode: 0o600 })); + }), { concurrency: "unbounded" })); + const current = (yield* (projectHook("codex", "machine", { session_id: "new", hook_event_name: "Stop" }))); + (yield* (writeNativeCache(config, current))); + assert.strictEqual(((yield* (readNativeCache(config).pipe(Effect.provide(NodeServices.layer))))).at(0)?.presence.ref.nativeSessionId, "new"); + assert.strictEqual(((yield* fs.readDirectory(namespace))).length, 2); + assert.strictEqual((yield* fs.readFileString(path.join(namespace, unsupported))), original); + assert.strictEqual((yield* fs.readFileString(path.join(directory, legacy))), original); + assert.strictEqual((yield* fs.readFileString(path.join(directory, "backups", "journal"))), "retained"); }))); it.live("keeps native Hermes idle presence tied to the shared host without refreshing native usage or activity", () => fixture(Effect.gen(function*() { @@ -836,59 +847,59 @@ it.live("keeps native Hermes idle presence tied to the shared host without refre const config: NativeConfig = { inspectorUrl: "http://127.0.0.1:8080", machineId: "machine", tokenFile: path.join(directory, "token"), stateDirectory: directory }; const at = (yield* Clock.currentTimeMillis) - 240000; const envelope = { session_id: "hermes-session", cwd: "/workspace", profile: "native-profile", extra: { model: "native-model", provider: "native-provider" } }; - (yield* fs.chmod(directory, 0o700)); - (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "on_session_start" }, at, host.pid))); - assert.strictEqual(((yield* (readNativeCache(config)))).at(0)?.presence.activity?.state, "unknown"); - (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request", prompt: "secret-prompt", response: "secret-response", extra: { ...envelope.extra, usage: { prompt_tokens: 100, completion_tokens: 0, cache_read_tokens: 20, cache_write_tokens: 0, reasoning_tokens: 0, total_tokens: 100, secret: "secret-usage" }, response: "secret-provider", messages: ["secret-message"], args: { secret: "secret-tool" } } }, at + 1, host.pid))); - assert.strictEqual(((yield* (readNativeCache(config)))).at(0)?.presence.activity?.state, "unknown"); - (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "on_session_end" }, at + 2, host.pid))); - const cached = (yield* (readNativeCache(config))); - assert.strictEqual(cached.length, 1); - const observation = cached.at(0); - if (!observation) return yield* Effect.die("Missing expected fixture result"); - (yield* (decodeSessionInspection(observation))); - assert.strictEqual(observation.presence.agent?.profile, "native-profile"); - assert.deepStrictEqual(observation.presence.model, { id: "native-model", provider: "native-provider" }); - assert.strictEqual(observation.presence.workspace?.cwd, "/workspace"); - assert.strictEqual(observation.presence.machine?.name, host.hostname); - assert.strictEqual(observation.presence.activity?.state, "idle"); - assert.strictEqual(observation.presence.lastActivityAt, at + 2); - assert.ok(observation.presence.observedAt > at + 120000); - assert.deepStrictEqual(observation.presence.processes?.map((binding) => [binding.pid, binding.ownership]), [[host.pid, "shared"]]); - const summary = observation.presence.resources; - const capacity = observation.presence.machine?.resources; - if (!summary || !capacity) return yield* Effect.die("Source cache omitted session capacity"); - assert.strictEqual(summary.attribution, "shared"); - assert.strictEqual(summary.machineScopeId, capacity.scopeId); - for (const value of [summary.cpuPercent, summary.cpuCapacityPercent, summary.rssBytes, summary.rssCapacityPercent]) { - assert.strictEqual(value.status, "available"); - assert.strictEqual(value.provenance.shared, true); - assert.ok(value.value !== null && value.value >= 0 && Number.isFinite(value.value)); - } - if (summary.cpuPercent.value !== null && capacity.cpu.capacityCores.value !== null) assert.strictEqual(summary.cpuCapacityPercent.value, summary.cpuPercent.value / capacity.cpu.capacityCores.value); - if (summary.rssBytes.value !== null && capacity.memory.capacityBytes.value !== null) assert.strictEqual(summary.rssCapacityPercent.value, 100 * summary.rssBytes.value / capacity.memory.capacityBytes.value); - const usage = observation.tokens.latestNativeUpdate; - assert.deepStrictEqual([usage.inputTokens.value, usage.outputTokens.value, usage.cacheReadTokens.value, usage.cacheWriteTokens.value, usage.reasoningOutputTokens.value, usage.totalTokens.value], [100, 0, 20, 0, 0, 100]); - for (const counter of Object.values(usage)) { - assert.strictEqual(counter.provenance.observedAt, at + 1); - assert.strictEqual(counter.provenance.nativeUpdatedAt, null); - } - assert.strictEqual(observation.tokens.nativeAggregate.totalTokens.status, "unavailable"); - assert.strictEqual(observation.cost.nativeAggregate.totalCostUsd.status, "unavailable"); - assert.strictEqual(observation.context.contextUsedTokens.status, "unavailable"); - const resources = (yield* (withProcessResources(observation))); - assert.strictEqual(resources.resources.processes.at(0)?.process.ownership, "shared"); - assert.strictEqual(resources.resources.ownedRssBytes.status, "unavailable"); - assert.strictEqual(resources.resources.ownedCpuPercent.status, "unavailable"); - assert.strictEqual(resources.presence.resources?.attribution, "shared"); - assert.strictEqual(resources.presence.resources?.cpuCapacityPercent.status, "available"); - assert.strictEqual(resources.presence.resources?.rssCapacityPercent.status, "available"); - assert.ok(new TextEncoder().encode(JSON.stringify(resources)).length < INSPECTION_LIMIT); - const namespace = path.join(directory, "native-observations"); - for (const file of (yield* fs.readDirectory(namespace))) assert.strictEqual(((yield* fs.readFileString(path.join(namespace, file)))).includes("secret"), false); - (yield* (writeHermesHook(config, { ...envelope, session_id: "", hook_event_name: "on_session_finalize", extra: { old_session_id: "hermes-session" } }, (yield* Clock.currentTimeMillis), host.pid))); - for (const name of ["post_api_request", "on_session_end"]) (yield* (writeHermesHook(config, { ...envelope, hook_event_name: name, extra: { usage: { total_tokens: 9 } } }, (yield* Clock.currentTimeMillis), host.pid))); - assert.strictEqual(((yield* (readNativeCache(config)))).length, 0); + (yield* fs.chmod(directory, 0o700)); + (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "on_session_start" }, at, host.pid))); + assert.strictEqual(((yield* (readNativeCache(config)))).at(0)?.presence.activity?.state, "unknown"); + (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request", prompt: "secret-prompt", response: "secret-response", extra: { ...envelope.extra, usage: { prompt_tokens: 100, completion_tokens: 0, cache_read_tokens: 20, cache_write_tokens: 0, reasoning_tokens: 0, total_tokens: 100, secret: "secret-usage" }, response: "secret-provider", messages: ["secret-message"], args: { secret: "secret-tool" } } }, at + 1, host.pid))); + assert.strictEqual(((yield* (readNativeCache(config)))).at(0)?.presence.activity?.state, "unknown"); + (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "on_session_end" }, at + 2, host.pid))); + const cached = (yield* (readNativeCache(config))); + assert.strictEqual(cached.length, 1); + const observation = cached.at(0); + if (!observation) return yield* Effect.die("Missing expected fixture result"); + (yield* (decodeSessionInspection(observation))); + assert.strictEqual(observation.presence.agent?.profile, "native-profile"); + assert.deepStrictEqual(observation.presence.model, { id: "native-model", provider: "native-provider" }); + assert.strictEqual(observation.presence.workspace?.cwd, "/workspace"); + assert.strictEqual(observation.presence.machine?.name, host.hostname); + assert.strictEqual(observation.presence.activity?.state, "idle"); + assert.strictEqual(observation.presence.lastActivityAt, at + 2); + assert.ok(observation.presence.observedAt > at + 120000); + assert.deepStrictEqual(observation.presence.processes?.map((binding) => [binding.pid, binding.ownership]), [[host.pid, "shared"]]); + const summary = observation.presence.resources; + const capacity = observation.presence.machine?.resources; + if (!summary || !capacity) return yield* Effect.die("Source cache omitted session capacity"); + assert.strictEqual(summary.attribution, "shared"); + assert.strictEqual(summary.machineScopeId, capacity.scopeId); + for (const value of [summary.cpuPercent, summary.cpuCapacityPercent, summary.rssBytes, summary.rssCapacityPercent]) { + assert.strictEqual(value.status, "available"); + assert.strictEqual(value.provenance.shared, true); + assert.ok(value.value !== null && value.value >= 0 && Number.isFinite(value.value)); + } + if (summary.cpuPercent.value !== null && capacity.cpu.capacityCores.value !== null) assert.strictEqual(summary.cpuCapacityPercent.value, summary.cpuPercent.value / capacity.cpu.capacityCores.value); + if (summary.rssBytes.value !== null && capacity.memory.capacityBytes.value !== null) assert.strictEqual(summary.rssCapacityPercent.value, 100 * summary.rssBytes.value / capacity.memory.capacityBytes.value); + const usage = observation.tokens.latestNativeUpdate; + assert.deepStrictEqual([usage.inputTokens.value, usage.outputTokens.value, usage.cacheReadTokens.value, usage.cacheWriteTokens.value, usage.reasoningOutputTokens.value, usage.totalTokens.value], [100, 0, 20, 0, 0, 100]); + for (const counter of Object.values(usage)) { + assert.strictEqual(counter.provenance.observedAt, at + 1); + assert.strictEqual(counter.provenance.nativeUpdatedAt, null); + } + assert.strictEqual(observation.tokens.nativeAggregate.totalTokens.status, "unavailable"); + assert.strictEqual(observation.cost.nativeAggregate.totalCostUsd.status, "unavailable"); + assert.strictEqual(observation.context.contextUsedTokens.status, "unavailable"); + const resources = (yield* (withProcessResources(observation))); + assert.strictEqual(resources.resources.processes.at(0)?.process.ownership, "shared"); + assert.strictEqual(resources.resources.ownedRssBytes.status, "unavailable"); + assert.strictEqual(resources.resources.ownedCpuPercent.status, "unavailable"); + assert.strictEqual(resources.presence.resources?.attribution, "shared"); + assert.strictEqual(resources.presence.resources?.cpuCapacityPercent.status, "available"); + assert.strictEqual(resources.presence.resources?.rssCapacityPercent.status, "available"); + assert.ok(new TextEncoder().encode(JSON.stringify(resources)).length < INSPECTION_LIMIT); + const namespace = path.join(directory, "native-observations"); + for (const file of (yield* fs.readDirectory(namespace))) assert.strictEqual(((yield* fs.readFileString(path.join(namespace, file)))).includes("secret"), false); + (yield* (writeHermesHook(config, { ...envelope, session_id: "", hook_event_name: "on_session_finalize", extra: { old_session_id: "hermes-session" } }, (yield* Clock.currentTimeMillis), host.pid))); + for (const name of ["post_api_request", "on_session_end"]) (yield* (writeHermesHook(config, { ...envelope, hook_event_name: name, extra: { usage: { total_tokens: 9 } } }, (yield* Clock.currentTimeMillis), host.pid))); + assert.strictEqual(((yield* (readNativeCache(config)))).length, 0); }))); it.live("validates native Hermes counters and event whitelist while retaining legacy files", () => fixture(Effect.gen(function*() { @@ -900,31 +911,31 @@ it.live("validates native Hermes counters and event whitelist while retaining le const directory = (yield* fs.makeTempDirectoryScoped({ prefix: "coordinator-hermes-boundary-" })); const config: NativeConfig = { inspectorUrl: "http://127.0.0.1:8080", machineId: "machine", tokenFile: path.join(directory, "token"), stateDirectory: directory }; const envelope = { session_id: "session", profile: "default", extra: {} }; - (yield* fs.chmod(directory, 0o700)); - const namespace = path.join(directory, "native-observations"); - (yield* fs.makeDirectory(namespace, { mode: 0o700 })); - const legacy = path.join(namespace, Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(sessionRefKey({ machineId: "machine", harness: "hermes", nativeSessionId: "session" })))) + ".json"); - const original = JSON.stringify({ version: 2, kind: "hermes", expiresAt: 1, native: { machineId: "machine", sessionId: "session", profile: "default", observedAt: 1, lastActivityAt: 1, state: "idle", usage: {} } }); - (yield* fs.writeFileString(legacy, original, { mode: 0o600 })); - (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request" }, (yield* Clock.currentTimeMillis), host.pid))); - assert.strictEqual(((yield* (readNativeCache(config)))).length, 0); - (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "on_session_start" }, (yield* Clock.currentTimeMillis), host.pid))); - (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request", extra: { usage: { input_tokens: 0, prompt_tokens: 5, output_tokens: 0, completion_tokens: 8 } } }, (yield* Clock.currentTimeMillis), host.pid))); - const usage = ((yield* (readNativeCache(config)))).at(0)?.tokens.latestNativeUpdate; - assert.strictEqual(usage?.inputTokens.value, 0); - assert.strictEqual(usage?.outputTokens.value, 0); - assert.strictEqual(usage?.totalTokens.status, "unavailable"); - assert.strictEqual(usage?.reasoningOutputTokens.status, "unavailable"); - for (const value of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "3", Number.NaN]) { - (yield* expectFailure((writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request", extra: { usage: { total_tokens: value } } }, (yield* Clock.currentTimeMillis), host.pid)))); - } - for (const name of ["pre_api_request", "pre_llm_call", "post_tool_call", "on_conversation_start"]) { - (yield* expectFailure((writeHermesHook(config, { ...envelope, hook_event_name: name }, (yield* Clock.currentTimeMillis), host.pid)))); - } - (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request", extra: { usage: null } }, (yield* Clock.currentTimeMillis), host.pid))); - assert.strictEqual(((yield* (readNativeCache(config)))).at(0)?.tokens.latestNativeUpdate.inputTokens.status, "unavailable"); - assert.strictEqual((yield* fs.readFileString(legacy)), original); - assert.strictEqual(((yield* fs.readDirectory(namespace))).length, 2); + (yield* fs.chmod(directory, 0o700)); + const namespace = path.join(directory, "native-observations"); + (yield* fs.makeDirectory(namespace, { mode: 0o700 })); + const legacy = path.join(namespace, Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(sessionRefKey({ machineId: "machine", harness: "hermes", nativeSessionId: "session" })))) + ".json"); + const original = JSON.stringify({ version: 2, kind: "hermes", expiresAt: 1, native: { machineId: "machine", sessionId: "session", profile: "default", observedAt: 1, lastActivityAt: 1, state: "idle", usage: {} } }); + (yield* fs.writeFileString(legacy, original, { mode: 0o600 })); + (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request" }, (yield* Clock.currentTimeMillis), host.pid))); + assert.strictEqual(((yield* (readNativeCache(config)))).length, 0); + (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "on_session_start" }, (yield* Clock.currentTimeMillis), host.pid))); + (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request", extra: { usage: { input_tokens: 0, prompt_tokens: 5, output_tokens: 0, completion_tokens: 8 } } }, (yield* Clock.currentTimeMillis), host.pid))); + const usage = ((yield* (readNativeCache(config)))).at(0)?.tokens.latestNativeUpdate; + assert.strictEqual(usage?.inputTokens.value, 0); + assert.strictEqual(usage?.outputTokens.value, 0); + assert.strictEqual(usage?.totalTokens.status, "unavailable"); + assert.strictEqual(usage?.reasoningOutputTokens.status, "unavailable"); + for (const value of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "3", Number.NaN]) { + (yield* expectFailure((writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request", extra: { usage: { total_tokens: value } } }, (yield* Clock.currentTimeMillis), host.pid)))); + } + for (const name of ["pre_api_request", "pre_llm_call", "post_tool_call", "on_conversation_start"]) { + (yield* expectFailure((writeHermesHook(config, { ...envelope, hook_event_name: name }, (yield* Clock.currentTimeMillis), host.pid)))); + } + (yield* (writeHermesHook(config, { ...envelope, hook_event_name: "post_api_request", extra: { usage: null } }, (yield* Clock.currentTimeMillis), host.pid))); + assert.strictEqual(((yield* (readNativeCache(config)))).at(0)?.tokens.latestNativeUpdate.inputTokens.status, "unavailable"); + assert.strictEqual((yield* fs.readFileString(legacy)), original); + assert.strictEqual(((yield* fs.readDirectory(namespace))).length, 2); }))); it.live("does not promote a dead, reused or unavailable Hermes host to live or owned presence", () => fixture(Effect.gen(function*() { @@ -937,31 +948,31 @@ it.live("does not promote a dead, reused or unavailable Hermes host to live or o const config: NativeConfig = { inspectorUrl: "http://127.0.0.1:8080", machineId: "machine", tokenFile: path.join(directory, "token"), stateDirectory: directory }; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn(ChildProcess.make(host.executable, ["--input-type=module", "-e", 'import { Effect } from "effect"; import { NodeRuntime } from "@effect/platform-node"; NodeRuntime.runMain(Effect.forever(Effect.sleep("1 hour")));'], { stdin: "ignore", stdout: "ignore", stderr: "ignore" })); - (yield* fs.chmod(directory, 0o700)); - assert.ok(child.pid); - (yield* (writeHermesHook(config, { session_id: "dead", hook_event_name: "on_session_start", extra: {} }, (yield* Clock.currentTimeMillis), child.pid))); - (yield* child.kill()); - yield* child.isRunning.pipe(Effect.repeat({ while: (running) => running, schedule: Schedule.spaced("1 millis") }), Effect.timeout("5 seconds")); - assert.strictEqual((yield* readNativeCache(config)).length, 0); - const missingAt = (yield* Clock.currentTimeMillis) - 1000; - (yield* (writeHermesHook(config, { session_id: "missing", hook_event_name: "on_session_start", extra: {} }, missingAt, child.pid))); - const missing = ((yield* (readNativeCache(config)))).at(0); - assert.strictEqual(missing?.presence.ref.nativeSessionId, "missing"); - assert.strictEqual(missing?.presence.observedAt, missingAt); - assert.strictEqual(missing?.presence.processes?.length, 0); - assert.strictEqual(missing?.resources.ownedRssBytes.status, "unavailable"); - (yield* (writeHermesHook(config, { session_id: "expired-missing", hook_event_name: "on_session_start", extra: {} }, (yield* Clock.currentTimeMillis) - 240000, child.pid))); - assert.strictEqual(((yield* (readNativeCache(config)))).some((item) => item.presence.ref.nativeSessionId === "expired-missing"), false); - const reused = (yield* (projectHook("claude", "machine", { session_id: "reused", hook_event_name: "Stop" }))); - const reusedRef = { ...reused.presence.ref, harness: "hermes" as const }; - (yield* (writeNativeCache(config, { ...reused, presence: { ...reused.presence, ref: reusedRef, workspace: { cwd: directory, project: { root: directory, source: "native" } }, agent: { profile: "original-profile" }, model: { id: "original-model", provider: "original-provider" }, processes: [{ pid: host.pid, startIdentity: "different-birth", ownership: "shared" }] } }))); - const crypto = yield* Crypto.Crypto; - const file = path.join(directory, "native-observations", "v4-hermes-" + Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(sessionRefKey(reusedRef)))) + ".json"); - const original = yield* fs.readFileString(file); - assert.strictEqual(((yield* (readNativeCache(config)))).some((item) => item.presence.ref.nativeSessionId === "reused"), false); - (yield* (writeHermesHook(config, { session_id: "reused", hook_event_name: "on_session_end", cwd: path.dirname(directory), profile: "replacement-profile", extra: { model: "replacement-model", provider: "replacement-provider" } }, (yield* Clock.currentTimeMillis), host.pid))); - assert.strictEqual(yield* fs.readFileString(file), original); - assert.strictEqual(((yield* (readNativeCache(config)))).some((item) => item.presence.ref.nativeSessionId === "reused"), false); + (yield* fs.chmod(directory, 0o700)); + assert.ok(child.pid); + (yield* (writeHermesHook(config, { session_id: "dead", hook_event_name: "on_session_start", extra: {} }, (yield* Clock.currentTimeMillis), child.pid))); + (yield* child.kill()); + yield* child.isRunning.pipe(Effect.repeat({ while: (running) => running, schedule: Schedule.spaced("1 millis") }), Effect.timeout("5 seconds")); + assert.strictEqual((yield* readNativeCache(config)).length, 0); + const missingAt = (yield* Clock.currentTimeMillis) - 1000; + (yield* (writeHermesHook(config, { session_id: "missing", hook_event_name: "on_session_start", extra: {} }, missingAt, child.pid))); + const missing = ((yield* (readNativeCache(config)))).at(0); + assert.strictEqual(missing?.presence.ref.nativeSessionId, "missing"); + assert.strictEqual(missing?.presence.observedAt, missingAt); + assert.strictEqual(missing?.presence.processes?.length, 0); + assert.strictEqual(missing?.resources.ownedRssBytes.status, "unavailable"); + (yield* (writeHermesHook(config, { session_id: "expired-missing", hook_event_name: "on_session_start", extra: {} }, (yield* Clock.currentTimeMillis) - 240000, child.pid))); + assert.strictEqual(((yield* (readNativeCache(config)))).some((item) => item.presence.ref.nativeSessionId === "expired-missing"), false); + const reused = (yield* (projectHook("claude", "machine", { session_id: "reused", hook_event_name: "Stop" }))); + const reusedRef = { ...reused.presence.ref, harness: "hermes" as const }; + (yield* (writeNativeCache(config, { ...reused, presence: { ...reused.presence, ref: reusedRef, workspace: { cwd: directory, project: { root: directory, source: "native" } }, agent: { profile: "original-profile" }, model: { id: "original-model", provider: "original-provider" }, processes: [{ pid: host.pid, startIdentity: "different-birth", ownership: "shared" }] } }))); + const crypto = yield* Crypto.Crypto; + const file = path.join(directory, "native-observations", "v4-hermes-" + Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode(sessionRefKey(reusedRef)))) + ".json"); + const original = yield* fs.readFileString(file); + assert.strictEqual(((yield* (readNativeCache(config)))).some((item) => item.presence.ref.nativeSessionId === "reused"), false); + (yield* (writeHermesHook(config, { session_id: "reused", hook_event_name: "on_session_end", cwd: path.dirname(directory), profile: "replacement-profile", extra: { model: "replacement-model", provider: "replacement-provider" } }, (yield* Clock.currentTimeMillis), host.pid))); + assert.strictEqual(yield* fs.readFileString(file), original); + assert.strictEqual(((yield* (readNativeCache(config)))).some((item) => item.presence.ref.nativeSessionId === "reused"), false); }))); it.live("bounds encoded process details without losing aggregate scope or claiming complete rows", () => fixture(Effect.gen(function*() { @@ -1116,10 +1127,12 @@ it.effect("posts detail errors without actionable notices and keeps cancellation } if (request.url === "/connector/poll") { polls++; - return HttpServerResponse.jsonUnsafe({ requests: polls === 1 - ? ["missing-one", "missing-two", "unavailable", "timeout"].map((id) => ({ - requestId: id, ref: { machineId: "machine", harness: "pi", nativeSessionId: id }, - })) : [] }); + return HttpServerResponse.jsonUnsafe({ + requests: polls === 1 + ? ["missing-one", "missing-two", "unavailable", "timeout"].map((id) => ({ + requestId: id, ref: { machineId: "machine", harness: "pi", nativeSessionId: id }, + })) : [] + }); } if (request.url === "/connector/result") { const result = yield* decodeConnectorResult(yield* request.json); @@ -1350,17 +1363,21 @@ it.live("cleans identity and cache temporaries when interrupted at exclusive acq if (writer === "identity") yield* fs.remove(path.join(config.stateDirectory, "machine-id")); const acquired = yield* Deferred.make(); const resume = yield* Deferred.make(); - const injected = FileSystem.make({ ...fs, open: (file, options) => Effect.gen(function*() { - const handle = yield* fs.open(file, options); - if (options?.flag !== "wx") return handle; - if (boundary === "open") { - yield* Deferred.succeed(acquired, undefined); - yield* Deferred.await(resume); - } - return delegateFile(handle, { writeAll: (bytes) => boundary === "write" - ? Deferred.succeed(acquired, undefined).pipe(Effect.andThen(Effect.never)) - : handle.writeAll(bytes) }); - }) }); + const injected = FileSystem.make({ + ...fs, open: (file, options) => Effect.gen(function*() { + const handle = yield* fs.open(file, options); + if (options?.flag !== "wx") return handle; + if (boundary === "open") { + yield* Deferred.succeed(acquired, undefined); + yield* Deferred.await(resume); + } + return delegateFile(handle, { + writeAll: (bytes) => boundary === "write" + ? Deferred.succeed(acquired, undefined).pipe(Effect.andThen(Effect.never)) + : handle.writeAll(bytes) + }); + }) + }); const operation = writer === "identity" ? resolveNativeConfig({ inspectorUrl: "https://inspector.example" }).pipe(Effect.provideService(ConfigProvider.ConfigProvider, settings)) : projectHook("claude", config.machineId, { session_id: "cancelled", hook_event_name: "SessionStart" }).pipe(Effect.flatMap((inspection) => writeNativeCache(config, inspection))); @@ -1391,11 +1408,13 @@ it.live("sanitizes filesystem close failures, recovers cache reads, and preserve const file = path.join(cacheDirectory, entry); const programmerDefect = new Error("programmer defect"); for (const defect of [PlatformError.systemError({ _tag: "BadResource", module: "FileSystem", method: "close", pathOrDescriptor: "secret-close-path" }), programmerDefect]) { - const injected = FileSystem.make({ ...fs, open: (target, options) => Effect.gen(function*() { - const handle = yield* fs.open(target, options); - yield* Effect.addFinalizer(() => Effect.die(defect)); - return handle; - }) }); + const injected = FileSystem.make({ + ...fs, open: (target, options) => Effect.gen(function*() { + const handle = yield* fs.open(target, options); + yield* Effect.addFinalizer(() => Effect.die(defect)); + return handle; + }) + }); const exit = yield* Effect.exit(readPrivateFile(file).pipe(Effect.provideService(FileSystem.FileSystem, injected))); if (exit._tag !== "Failure") return yield* Effect.die("Expected close failure"); if (defect === programmerDefect) { @@ -1406,11 +1425,13 @@ it.live("sanitizes filesystem close failures, recovers cache reads, and preserve } else { assert.isTrue(exit.cause.reasons.every((reason) => reason._tag === "Fail" && reason.error._tag === "NativeError" && reason.error.operation === "Read private file")); assert.deepStrictEqual(yield* readNativeCache(config).pipe(Effect.provideService(FileSystem.FileSystem, injected)), []); - const failingWriter = FileSystem.make({ ...fs, open: (target, options) => Effect.gen(function*() { - const handle = yield* fs.open(target, options); - if (options?.flag === "wx") yield* Effect.addFinalizer(() => Effect.die(defect)); - return handle; - }) }); + const failingWriter = FileSystem.make({ + ...fs, open: (target, options) => Effect.gen(function*() { + const handle = yield* fs.open(target, options); + if (options?.flag === "wx") yield* Effect.addFinalizer(() => Effect.die(defect)); + return handle; + }) + }); const writeExit = yield* Effect.exit(writeNativeCache(config, inspection).pipe(Effect.provideService(FileSystem.FileSystem, failingWriter))); if (writeExit._tag !== "Failure") return yield* Effect.die("Expected cache close failure"); assert.isTrue(writeExit.cause.reasons.every((reason) => reason._tag === "Fail" && reason.error._tag === "NativeError" && reason.error.operation === "Write private metadata cache")); diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 48151ed..ff78767 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -18,7 +18,7 @@ const context = (id = "surrounding-session"): Context => ({ model: { provider: "native-provider", id: "native-model" }, sessionManager: { getSessionId: () => id }, getContextUsage: () => ({ tokens: null, contextWindow: 1000, percent: null }), - isIdle: () => true, ui: { notify: () => {} }, + isIdle: () => true, ui: { notify: () => { } }, }); const usage = { input: 10, output: 3, cacheRead: 5, cacheWrite: 2, totalTokens: 20, cost: { input: 0.1, output: 0.2, cacheRead: 0.01, cacheWrite: 0.02, total: 0.33 } }; @@ -151,8 +151,27 @@ it("retains linked-worktree metadata through native list and inspect and clears const principal = { id: "native", owner: "owner", tokenHash: Hex.encode(yield* crypto.digest("SHA-256", new TextEncoder().encode("native-worktree-secret"))) }; const inspector = yield* Inspector.make({ host: "127.0.0.1", port: 0, principals: [principal] }); const identity = { connectorId: "native", incarnation: "first" }; - yield* inspector.announce(principal, { ...identity, machineId: "machine", sessions: [observation.presence] }); - assert.deepStrictEqual((yield* inspector.list(principal, { limit: 10 })).sessions.at(0)?.workspace, workspace); + const mainObserver = yield* createNativeObserver("pi", "machine"); + mainObserver.update({ ...context(), cwd: repository }); + yield* mainObserver.refreshWorkspace(project); + const mainPresence = mainObserver.presence(); + if (mainPresence?.workspace?.git?.state !== "available") return yield* Effect.die("Missing main worktree observation"); + assert.strictEqual(mainPresence.workspace.git.commonDir, workspace.git.commonDir); + assert.strictEqual(mainPresence.workspace.git.worktreeRoot, yield* fs.realPath(repository)); + assert.notStrictEqual(mainPresence.workspace.git.worktreeRoot, workspace.git.worktreeRoot); + yield* inspector.announce(principal, { ...identity, machineId: "machine", sessions: [observation.presence, mainPresence] }); + const listed = yield* inspector.list(principal, { limit: 10, machineId: "machine", repository: workspace.git.commonDir }); + assert.deepStrictEqual(listed.sessions.map((session) => session.ref), [observation.presence.ref, mainPresence.ref]); + assert.deepStrictEqual(listed.sessions.find((session) => session.ref.harness === "pi")?.workspace?.git, mainPresence.workspace.git); + assert.strictEqual((yield* inspector.list(principal, { limit: 10, repository: linked })).sessions.length, 0); + assert.strictEqual((yield* inspector.list(principal, { limit: 10, repository: repository })).sessions.length, 0); + const listedWorkspace = listed.sessions.find((session) => session.ref.harness === "omp")?.workspace; + assert.strictEqual(listedWorkspace?.cwd, cwd); + assert.deepStrictEqual(listedWorkspace?.project, project); + if (listedWorkspace?.git?.state !== "available") return yield* Effect.die("Missing listed Git observation"); + assert.strictEqual(listedWorkspace.git.branch, "native-worktree"); + assert.strictEqual(listedWorkspace.git.head, workspace.git.head); + assert.deepStrictEqual(listedWorkspace.git, workspace.git); const query = yield* Effect.forkChild(inspector.inspect(principal, { ref: observation.presence.ref }), { startImmediately: true }); const request = (yield* inspector.poll(principal, identity)).requests.at(0); if (!request) return yield* Effect.die("Missing native inspect request"); @@ -179,9 +198,11 @@ it("discards pending workspace discovery after cwd changes, session switches and const observer = yield* createNativeObserver("omp", "machine"); observer.update({ ...context(), cwd }); const started = yield* Deferred.make(), release = yield* Deferred.make(); - const injected = { ...fs, realPath: (file: string) => file === cwd - ? Effect.andThen(Deferred.succeed(started, undefined), Effect.andThen(Deferred.await(release), fs.realPath(file))) - : fs.realPath(file) }; + const injected = { + ...fs, realPath: (file: string) => file === cwd + ? Effect.andThen(Deferred.succeed(started, undefined), Effect.andThen(Deferred.await(release), fs.realPath(file))) + : fs.realPath(file) + }; const refresh = yield* observer.refreshWorkspace({ root: cwd, source: "configured" }).pipe( Effect.provideService(FileSystem.FileSystem, injected), Effect.forkChild, ); @@ -296,9 +317,9 @@ it("matches only a leading bounded coordinate token and preserves native input r for (const text of ["coordinate", "coordinate inspect", "coordinate\ninspect"]) assert.strictEqual(isCoordinateInput(text), true); for (const text of ["/coordinate", "please coordinate", " coordinate", "coordinates", "coordinate_task", "coordinate/file", "Coordinate"]) { assert.strictEqual(isCoordinateInput(text), false); - assert.strictEqual(transformCoordinateInput("omp", { text }, { sessions: [], nextCursor: null }), undefined); + assert.strictEqual(transformCoordinateInput("omp", { text }, { machines: [], sessions: [], nextCursor: null }), undefined); } - const snapshot: SessionListOutput = { sessions: [{ ref: { machineId: "remote", harness: "omp", nativeSessionId: "actual-fleet-session" }, observedAt: 1 }], nextCursor: null }; + const snapshot: SessionListOutput = { machines: [{ machineId: "remote", scopeId: null, name: "Remote" }], sessions: [{ ref: { machineId: "remote", harness: "omp", nativeSessionId: "actual-fleet-session" }, observedAt: 1, machineScopeId: null }], nextCursor: null }; const omp = transformCoordinateInput("omp", { text: "coordinate inspect" }, snapshot); const pi = transformCoordinateInput("pi", { text: "coordinate inspect" }, snapshot); assert.strictEqual(omp?.text.includes("actual-fleet-session"), true); @@ -356,10 +377,12 @@ it("adds the real native coordinate shortcut without enabling disabled core keyw check(JSON.stringify(magicKeywordRanges("coordinate")) === JSON.stringify([{ from: 0, to: 10 }]), "Duplicate registration changed highlight"); } }).pipe(Effect.provide(NodeServices.layer))); - `], { extendEnv: true, env: { - HOME: home, XDG_CONFIG_HOME: path.join(home, "config"), XDG_STATE_HOME: path.join(home, "state"), - COORDINATOR_CONNECTOR_CONFIG: undefined, COORDINATOR_API_URL: undefined, COORDINATOR_API_TOKEN: undefined, - } }); + `], { + extendEnv: true, env: { + HOME: home, XDG_CONFIG_HOME: path.join(home, "config"), XDG_STATE_HOME: path.join(home, "state"), + COORDINATOR_CONNECTOR_CONFIG: undefined, COORDINATOR_API_URL: undefined, COORDINATOR_API_TOKEN: undefined, + } + }); })).pipe(Effect.provide(NodeServices.layer))); }); @@ -698,11 +721,13 @@ it("authenticates the OMP stdio reader from the native environment without creat check(!JSON.stringify(result).includes("PRIVATE_REJECTION_BODY"), "Reader leaked response body"); check(!(yield* fs.exists(path.join(home, "state"))), "Reader created native state"); })).pipe(Effect.provide(NodeServices.layer))); - `], { extendEnv: true, env: { - HOME: home, XDG_CONFIG_HOME: path.join(home, "config"), XDG_STATE_HOME: path.join(home, "state"), - COORDINATOR_API_URL: `http://127.0.0.1:${server.address.port}`, COORDINATOR_API_TOKEN: token, - COORDINATOR_CONNECTOR_CONFIG: undefined, - } }).pipe(Effect.timeout("20 seconds")); + `], { + extendEnv: true, env: { + HOME: home, XDG_CONFIG_HOME: path.join(home, "config"), XDG_STATE_HOME: path.join(home, "state"), + COORDINATOR_API_URL: `http://127.0.0.1:${server.address.port}`, COORDINATOR_API_TOKEN: token, + COORDINATOR_CONNECTOR_CONFIG: undefined, + } + }).pipe(Effect.timeout("20 seconds")); assert.isAbove(requests, 0, "The actual native stdio child must receive the owner's token"); })).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, Host.layer)))); }, 25000); @@ -767,11 +792,12 @@ it("lists every authorized session across pages and machines without native capt const list = () => Effect.promise(() => commands.get("coordinator").handler("list", {})); yield* list(); const message = messages.pop(); - const sessions = JSON.parse(message.content).sessions; + const { machines, sessions } = JSON.parse(message.content); check(sessions.length === 101, "List silently stopped at the first API page"); + check(machines.length === 2 && machines.every(machine => machine.name === "same-hostname" && machine.scopeId === null), "Cross-page machines must be deduplicated by installation and scope"); check(sessions.some(session => session.ref.machineId === "local-machine"), "Local machine missing"); check(sessions.some(session => session.ref.machineId === "remote-machine"), "Remote machine missing"); - check(sessions.every(session => session.machine.name === "same-hostname" && (!session.model || session.model.id === "test-model") && session.workspace.cwd.startsWith("/workspace/")), "Grouped metadata missing"); + check(sessions.every(session => session.machineScopeId === null && (!session.model || session.model.id === "test-model") && session.workspace.cwd.startsWith("/workspace/")), "Compact metadata missing"); check(sessions.find(session => session.ref.nativeSessionId === "native-0").model === undefined, "No-model session gained a model"); check(sessions.every(session => !["machineName", "os", "kind", "provider", "profile", "cwd", "repository"].some(key => key in session)), "Flat metadata leaked"); check(!message.content.includes("hidden-session") && !message.content.includes("hidden-machine"), "List exposed another owner's sessions"); @@ -790,11 +816,13 @@ it("lists every authorized session across pages and machines without native capt })).pipe(Effect.provide(NodeServices.layer))); `; for (const harness of ["pi", "omp"]) { - yield* runScript("bun", ["--eval", script], { extendEnv: true, env: { - HOME: home, XDG_CONFIG_HOME: path.join(home, "config"), XDG_STATE_HOME: path.join(home, "state"), - COORDINATOR_CONNECTOR_CONFIG: configFile, COORDINATOR_API_URL: undefined, COORDINATOR_API_TOKEN: undefined, - FIXTURE_HARNESS: harness, - } }); + yield* runScript("bun", ["--eval", script], { + extendEnv: true, env: { + HOME: home, XDG_CONFIG_HOME: path.join(home, "config"), XDG_STATE_HOME: path.join(home, "state"), + COORDINATOR_CONNECTOR_CONFIG: configFile, COORDINATOR_API_URL: undefined, COORDINATOR_API_TOKEN: undefined, + FIXTURE_HARNESS: harness, + } + }); yield* fs.writeFileString(tokenFile, token, { mode: 0o600 }); } })).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, Host.layer, Mcp.layer))));