diff --git a/.changeset/fuzzy-pears-listen.md b/.changeset/fuzzy-pears-listen.md new file mode 100644 index 000000000..399161364 --- /dev/null +++ b/.changeset/fuzzy-pears-listen.md @@ -0,0 +1,6 @@ +--- +"@cotal-ai/connector-jcode": patch +"@cotal-ai/connector-core": patch +--- + +Deliver directed peer messages into an active Jcode turn through the recipient session's soft-interrupt queue, committing them only after the containing turn succeeds. diff --git a/bin/smoke/ci-suites.txt b/bin/smoke/ci-suites.txt index 446c12d06..5ede1ed0d 100644 --- a/bin/smoke/ci-suites.txt +++ b/bin/smoke/ci-suites.txt @@ -722,3 +722,8 @@ smoke:transport-liveness:broker # including a measured successful inbox drain. Appended so every existing shard assignment remains # unchanged. smoke:connection-status + +# A directed DM arriving during a live Jcode turn must reach the recipient session through Jcode's +# soft-interrupt queue and commit only at the clean containing-turn boundary. Real loopback broker +# plus shipped host; mutation-proofed. Appended so every existing shard assignment remains unchanged. +smoke:jcode-mid-turn-delivery diff --git a/bin/smoke/mutations/package-json-duplicate-keys.json b/bin/smoke/mutations/package-json-duplicate-keys.json index ca80f05cf..1dcf15dab 100644 --- a/bin/smoke/mutations/package-json-duplicate-keys.json +++ b/bin/smoke/mutations/package-json-duplicate-keys.json @@ -14,8 +14,8 @@ { "name": "D1 restore the duplicate JCode retry-policy script", "file": "package.json", - "find": " \"smoke:jcode-provider-disconnect\": \"tsx extensions/connector-jcode/smoke/jcode-provider-disconnect.smoke.ts\",\n \"smoke:jcode-live\": \"tsx extensions/connector-jcode/smoke/jcode-live.smoke.ts\",", - "replace": " \"smoke:jcode-provider-disconnect\": \"tsx extensions/connector-jcode/smoke/jcode-provider-disconnect.smoke.ts\",\n \"smoke:jcode-retry-policy\": \"tsx extensions/connector-jcode/smoke/retry-policy.smoke.ts\",\n \"smoke:jcode-live\": \"tsx extensions/connector-jcode/smoke/jcode-live.smoke.ts\",", + "find": " \"smoke:jcode-provider-disconnect\": \"tsx extensions/connector-jcode/smoke/jcode-provider-disconnect.smoke.ts\",\n \"smoke:jcode-mid-turn-delivery\": \"tsx extensions/connector-jcode/smoke/jcode-mid-turn-delivery.smoke.ts\",\n \"smoke:jcode-live\": \"tsx extensions/connector-jcode/smoke/jcode-live.smoke.ts\",", + "replace": " \"smoke:jcode-provider-disconnect\": \"tsx extensions/connector-jcode/smoke/jcode-provider-disconnect.smoke.ts\",\n \"smoke:jcode-mid-turn-delivery\": \"tsx extensions/connector-jcode/smoke/jcode-mid-turn-delivery.smoke.ts\",\n \"smoke:jcode-retry-policy\": \"tsx extensions/connector-jcode/smoke/retry-policy.smoke.ts\",\n \"smoke:jcode-live\": \"tsx extensions/connector-jcode/smoke/jcode-live.smoke.ts\",", "expectRed": "root package JSON has 1 duplicate object key(s)" } ] diff --git a/docs/connect-jcode.md b/docs/connect-jcode.md index 419faa48e..d975fb14e 100644 --- a/docs/connect-jcode.md +++ b/docs/connect-jcode.md @@ -105,13 +105,14 @@ connected notice. For a foreground launch, the TUI opens as soon as the session is ready, before the readiness turn, so it streams boot activity instead of leaving the terminal blank. Presence still begins only after -the readiness proof passes. An inbound peer message then wakes a Harness API turn. The host marks -presence working while the turn runs, acknowledges the delivered inbox ids only after the -SDK turn succeeds, and leaves a failed turn unacknowledged for mesh redelivery. Jcode's stable -Harness API has no measured mid-turn steer surface here, so traffic arriving during a turn waits for -the next turn rather than being silently treated as an interrupt. `cotal_inbox` pulls only buffered -quiet ambient from that host-owned queue; its shared optional `peek` argument is supported, so -`peek: true` shows those messages without clearing them. +the readiness proof passes. An inbound peer message then wakes a Harness API turn. A directed message +that arrives while this Cotal-owned turn is active enters Jcode's session-owned soft-interrupt queue, +which incorporates it at a safe provider or tool boundary. Ambient channel traffic stays buffered for +the next turn. The host marks presence working while the turn runs and acknowledges every initial or +soft-interrupted inbox id only after that containing turn succeeds. A failed turn or private Harness +replacement leaves the ids unacknowledged for mesh redelivery. `cotal_inbox` pulls only buffered quiet +ambient from that host-owned queue; its shared optional `peek` argument is supported, so `peek: true` +shows those messages without clearing them. ## Model limits diff --git a/extensions/connector-core/src/docs-bundle.generated.ts b/extensions/connector-core/src/docs-bundle.generated.ts index 9d299cc54..535df9c1b 100644 --- a/extensions/connector-core/src/docs-bundle.generated.ts +++ b/extensions/connector-core/src/docs-bundle.generated.ts @@ -110,7 +110,7 @@ export const DOCS_BUNDLE: DocsBundle = { "title": "Connect Jcode (beta)", "kind": "Guide (informative)", "summary": "Jcode joins a Cotal mesh as a lateral peer.", - "body": "# Connect Jcode (beta)\n\n> **Guide** (informative) · **For:** operators · **Prereqs:** [Quickstart](getting-started.md)\n\n[Jcode](https://github.com/1jehuang/jcode) joins a Cotal mesh as a lateral peer. The connector\ncreates one private Jcode Harness API instance per seat, one Jcode session inside it, and exposes\nthe normal `cotal_*` tool surface through Jcode's documented stdio MCP configuration.\n\n**Beta** means the supported path is deliberately narrow: a fresh private session, prompt\ninjection, presence, managed start/stop, requested reasoning effort, and an attached TUI work.\nFeatures that do not preserve that private session's mesh surface fail loud: `--resume`,\nexact-session continuation, `--share-tools`, `--events`, and connector `--opt` values are not\nsupported.\n\n## Install\n\nThe connector is seeded with the Cotal CLI. It currently supports **macOS and Linux only**:\nJcode's released Harness API bridge is a Unix-socket surface. Install Jcode 0.78.1 or later from\nits GitHub release and make the binary available as `jcode` on `PATH`:\n\n```bash\njcode version --json\ncotal spawn --agent jcode\n```\n\nIf an older Cotal installation is missing the connector, run `cotal ext seed --repair` (or\n`cotal ext add @cotal-ai/connector-jcode`). This connector intentionally uses the released\nbinary's `api-bridge` command; it does not require a Rust checkout.\n\n## Spawn it\n\n```bash\ncotal spawn --agent jcode\ncotal spawn reviewer --agent jcode -d\ncotal spawn --agent jcode --model gpt-5.6-sol --prompt \"Review the current change.\"\nCOTAL_DEFAULT_AGENT=jcode cotal spawn\n```\n\nA detached seat is managed normally: `cotal ps`, `cotal attach`, and `cotal stop` control the\nsame process the connector starts. In a terminal, Jcode opens on the managed session. With piped\noutput it stays headless; set `COTAL_JCODE_TUI=1` or `COTAL_JCODE_TUI=0` in the environment of the\nprocess building the launch to override that choice. For a detached spawn, that is the manager's\nenvironment.\n\n## How it binds\n\nJcode's stable integration surface is the **Harness API**: protocol-v1 NDJSON over a Unix socket.\nThe connector launches a **private instance** with `@1jehuang/jcode-sdk`'s `launchInstance()` and\nattaches only to that instance's own socket:\n\n- `launchInstance()` starts a private `JCODE_HOME`, runtime directory, daemon, and `api-bridge`;\n the connector holds the process handle first-hand and closes that instance with the Cotal seat.\n This gives each managed Cotal peer one owned session and prevents it from seeing or changing\n the operator's live Jcode sessions.\n- Attaching to an **operator-run** `jcode api-bridge` shares the operator's live session\n inventory. That is appropriate for a dashboard or editor integration, but not a managed Cotal\n seat: stop, prompt injection, and session selection could act on the operator's work. The\n connector never attaches to an operator bridge.\n- A managed seat **never updates its own binary**. Jcode's background updater restarts the\n process tree when it lands a release; that restart drops the seat's TUI, which is the only\n connection the Jcode server counts as a client, and nothing re-attaches, so the server's idle\n reaper takes the seat down five minutes later in the middle of a turn. The seat's version is\n whatever is on `PATH` when you spawn it, and it stays that version for the seat's life. Update\n deliberately, between seats, not under a running agent.\n\nOn a graceful stop **and** on a startup failure, the connector proves the private daemon tree is\nactually gone rather than trusting the SDK's registry-keyed stop (which is a silent no-op when the\n`servers.json` socket path does not match verbatim): it reads the PIDs the private home itself\nrecords, sends a bounded SIGTERM, escalates survivors to an exact-PID SIGKILL, and reports a\nfailed stop instead of a clean one if any recorded process survives. It never signals by name, so\nteardown can only ever reach the seat's own tree.\n\nThe private Jcode home lives under `/.cotal/jcode/`. It is unique per\nspace/name and is owner-only. Jcode's own credential inheritance is used for the private instance,\nso provider logins work without copying its transcript/config tree into the seat. The spawned\nJcode process does not inherit `COTAL_*` values or the Cotal launch-material pointer.\n\nIf a provider failure closes the private Harness API connection during a mesh-driven turn, the\nconnector leaves that turn's inbox batch unacknowledged and makes one private replacement\nconnection to the same session. The seat reports `waiting` while it reconnects, then redrives that\nunacknowledged batch only after the session attaches. A failed replacement, or a second disconnect,\nends the seat rather than silently retrying bridges without bound.\n\nJcode currently supports **stdio** MCP servers. The connector writes only its own `cotal` entry to\nthe private `JCODE_HOME/mcp.json`; it starts a stdio MCP bridge for that entry and relays its calls\nto the host's one `MeshAgent`. The Jcode/MCP child receives a per-launch relay capability, but not\nthe Cotal broker credential or its launch-material pointer. Jcode also overlays project\n`.jcode/mcp.json`, `.mcp.json`, and `.claude/mcp.json`; a managed launch **refuses** a workspace\ncontaining any of those files, because one could replace the `cotal` bridge or add tools that were\nnot explicitly shared. Operator MCP configuration is isolated in the private home and project MCP\nconfiguration is not supported yet.\n\nBefore the seat joins the mesh, the host runs a mandatory Jcode turn that calls\n`cotal_orientation`. Jcode loads MCP tools asynchronously; its first turn can use the pre-MCP tool\nsnapshot immediately before Jcode rebuilds that snapshot. The host repeats the identical proof once\nin that case. A second absence fails the launch, so a bridge that never comes up remains a loud\nfailure rather than an agent that is present but mute. A managed Jcode seat has a **three-minute\nbounded readiness window**: first boot can download model material, start the MCP bridge, and wait\nthrough the provider-backed readiness turns. If that window expires, the launch is `uncertain`, not\na failed or cleanup verdict; use `cotal attach ` or `cotal ps` to inspect it and do not stop\nit solely because the window elapsed. The host then waits for the mesh connection and presence bind\nto complete before it adds a no-reply notice that the bootstrap orientation predates the join and\nthat a new orientation is live context. During a broker outage, it stays waiting and sends no\nconnected notice.\n\nFor a foreground launch, the TUI opens as soon as the session is ready, before the readiness turn,\nso it streams boot activity instead of leaving the terminal blank. Presence still begins only after\nthe readiness proof passes. An inbound peer message then wakes a Harness API turn. The host marks\npresence working while the turn runs, acknowledges the delivered inbox ids only after the\nSDK turn succeeds, and leaves a failed turn unacknowledged for mesh redelivery. Jcode's stable\nHarness API has no measured mid-turn steer surface here, so traffic arriving during a turn waits for\nthe next turn rather than being silently treated as an interrupt. `cotal_inbox` pulls only buffered\nquiet ambient from that host-owned queue; its shared optional `peek` argument is supported, so\n`peek: true` shows those messages without clearing them.\n\n## Model limits\n\n`--model` is passed to Jcode's session-level Harness API model selector. Jcode validates the model\nagainst the active provider, then the connector reads runtime identity back and refuses startup if\nit is not the requested model; a seat is never allowed to join under a model label it did not\nreceive.\n\n`cotal models --agent jcode` reads the declared catalog from the operator Jcode home's\n`config.toml`: each provider with `model_catalog = true`, its `[[providers..models]]` ids,\nand any declared `reasoning_efforts`. This is the same config Jcode copies into a private managed\ninstance. The command fails loud when the file is unreadable, malformed, or enables a catalog\nwithout model entries.\n\nThe listed effort tiers are declarations, not provider-verified capabilities. `cotal models` prints\nthat caveat inline as `variants (declared, not provider-verified)` beside each configured tier list,\nso it cannot be missed by reading only the model rows. Providers can reject a tier the file names,\nso launch remains the authority: Jcode applies the requested value and a provider rejection ends the\nlaunch. `--refresh` does not turn this local declaration into a live probe.\n\nThe Harness API can set a requested effort but cannot read an effective effort back. Its runtime\nidentity reports provider, model, and routes only; no reply or event carries the applied tier. Cotal\ntherefore records the accepted request and does not relabel it as an observed effect.\n\n`--variant` is the session's **reasoning effort**, applied after the model and before the seat's\nfirst turn, so a seat never serves a turn at an effort nobody chose. A persona's `variant:` is the\ndefault and `--variant` overrides it, the same way `model:` and `--model` work:\n\n```bash\ncotal spawn --agent jcode --model gpt-5.6-sol --variant high\n```\n\nWhich tiers exist depends on the provider **and** model. The connector does not carry a copy of\nthose ladders: it passes the requested tier to Jcode, which validates it against the active model's\nladder. A rejected tier, or a model with no reasoning-effort surface, ends the launch rather than\nquietly starting the seat at another effort. The external observer/UI receives only the requested\ntier, effective model, fixed `invalid_request` provider code, and an accepted-tier ladder when it\ncan be safely parsed; arbitrary provider rejection text stays private. Omit `--variant` to keep\nJcode's configured default.\n\nIf the mandatory readiness turn receives a provider `invalid_request` refusal for a model id or\nreasoning-effort value, the launch diagnostic names only the provider error code and rejected\nvalue. Other provider response text remains scrubbed, so an external observer/UI can correct\nconnector-visible input without exposing private harness output.\n\nThe following fail loud before a new session is provisioned where the manager can preflight them,\nor at connector launch as a backstop:\n\n- **Resume /continuation:** a Cotal seat owns a new private Jcode instance. Reusing a session from\n an operator or another seat would violate that ownership boundary.\n- **Tool sharing:** Jcode resolves its MCP configuration from several global and project sources.\n The connector owns a private configuration containing only `cotal`, rather than claim a chosen\n subset can be safely merged.\n- **Events:** Jcode's Harness API does not provide the durable structured rollout surface required\n by Cotal's event plane.\n- **Launch options:** the connector does not map arbitrary flags/config into the Harness API.\n- **Containers:** the current deploy image does not bundle Jcode, so there is no containerized Jcode connector today.\n\n## Security limits\n\nThe private home protects against accidental sharing and stale session selection; it is not an\nOS-user isolation boundary. A hostile process running as the same user can still read that user's\nfiles or inspect another same-user process. Use OS/container isolation where peers must be mutually\nhostile.\n\nThe model can receive remote peer messages and Jcode is an autonomous coding harness. Treat its\nprovider credentials, filesystem access, and network capability as the privileges of the OS user\nrunning the seat. Cotal's spawn capability governs who may create a seat; it is not a sandbox for\nwhat a model can be persuaded to do after creation.\n" + "body": "# Connect Jcode (beta)\n\n> **Guide** (informative) · **For:** operators · **Prereqs:** [Quickstart](getting-started.md)\n\n[Jcode](https://github.com/1jehuang/jcode) joins a Cotal mesh as a lateral peer. The connector\ncreates one private Jcode Harness API instance per seat, one Jcode session inside it, and exposes\nthe normal `cotal_*` tool surface through Jcode's documented stdio MCP configuration.\n\n**Beta** means the supported path is deliberately narrow: a fresh private session, prompt\ninjection, presence, managed start/stop, requested reasoning effort, and an attached TUI work.\nFeatures that do not preserve that private session's mesh surface fail loud: `--resume`,\nexact-session continuation, `--share-tools`, `--events`, and connector `--opt` values are not\nsupported.\n\n## Install\n\nThe connector is seeded with the Cotal CLI. It currently supports **macOS and Linux only**:\nJcode's released Harness API bridge is a Unix-socket surface. Install Jcode 0.78.1 or later from\nits GitHub release and make the binary available as `jcode` on `PATH`:\n\n```bash\njcode version --json\ncotal spawn --agent jcode\n```\n\nIf an older Cotal installation is missing the connector, run `cotal ext seed --repair` (or\n`cotal ext add @cotal-ai/connector-jcode`). This connector intentionally uses the released\nbinary's `api-bridge` command; it does not require a Rust checkout.\n\n## Spawn it\n\n```bash\ncotal spawn --agent jcode\ncotal spawn reviewer --agent jcode -d\ncotal spawn --agent jcode --model gpt-5.6-sol --prompt \"Review the current change.\"\nCOTAL_DEFAULT_AGENT=jcode cotal spawn\n```\n\nA detached seat is managed normally: `cotal ps`, `cotal attach`, and `cotal stop` control the\nsame process the connector starts. In a terminal, Jcode opens on the managed session. With piped\noutput it stays headless; set `COTAL_JCODE_TUI=1` or `COTAL_JCODE_TUI=0` in the environment of the\nprocess building the launch to override that choice. For a detached spawn, that is the manager's\nenvironment.\n\n## How it binds\n\nJcode's stable integration surface is the **Harness API**: protocol-v1 NDJSON over a Unix socket.\nThe connector launches a **private instance** with `@1jehuang/jcode-sdk`'s `launchInstance()` and\nattaches only to that instance's own socket:\n\n- `launchInstance()` starts a private `JCODE_HOME`, runtime directory, daemon, and `api-bridge`;\n the connector holds the process handle first-hand and closes that instance with the Cotal seat.\n This gives each managed Cotal peer one owned session and prevents it from seeing or changing\n the operator's live Jcode sessions.\n- Attaching to an **operator-run** `jcode api-bridge` shares the operator's live session\n inventory. That is appropriate for a dashboard or editor integration, but not a managed Cotal\n seat: stop, prompt injection, and session selection could act on the operator's work. The\n connector never attaches to an operator bridge.\n- A managed seat **never updates its own binary**. Jcode's background updater restarts the\n process tree when it lands a release; that restart drops the seat's TUI, which is the only\n connection the Jcode server counts as a client, and nothing re-attaches, so the server's idle\n reaper takes the seat down five minutes later in the middle of a turn. The seat's version is\n whatever is on `PATH` when you spawn it, and it stays that version for the seat's life. Update\n deliberately, between seats, not under a running agent.\n\nOn a graceful stop **and** on a startup failure, the connector proves the private daemon tree is\nactually gone rather than trusting the SDK's registry-keyed stop (which is a silent no-op when the\n`servers.json` socket path does not match verbatim): it reads the PIDs the private home itself\nrecords, sends a bounded SIGTERM, escalates survivors to an exact-PID SIGKILL, and reports a\nfailed stop instead of a clean one if any recorded process survives. It never signals by name, so\nteardown can only ever reach the seat's own tree.\n\nThe private Jcode home lives under `/.cotal/jcode/`. It is unique per\nspace/name and is owner-only. Jcode's own credential inheritance is used for the private instance,\nso provider logins work without copying its transcript/config tree into the seat. The spawned\nJcode process does not inherit `COTAL_*` values or the Cotal launch-material pointer.\n\nIf a provider failure closes the private Harness API connection during a mesh-driven turn, the\nconnector leaves that turn's inbox batch unacknowledged and makes one private replacement\nconnection to the same session. The seat reports `waiting` while it reconnects, then redrives that\nunacknowledged batch only after the session attaches. A failed replacement, or a second disconnect,\nends the seat rather than silently retrying bridges without bound.\n\nJcode currently supports **stdio** MCP servers. The connector writes only its own `cotal` entry to\nthe private `JCODE_HOME/mcp.json`; it starts a stdio MCP bridge for that entry and relays its calls\nto the host's one `MeshAgent`. The Jcode/MCP child receives a per-launch relay capability, but not\nthe Cotal broker credential or its launch-material pointer. Jcode also overlays project\n`.jcode/mcp.json`, `.mcp.json`, and `.claude/mcp.json`; a managed launch **refuses** a workspace\ncontaining any of those files, because one could replace the `cotal` bridge or add tools that were\nnot explicitly shared. Operator MCP configuration is isolated in the private home and project MCP\nconfiguration is not supported yet.\n\nBefore the seat joins the mesh, the host runs a mandatory Jcode turn that calls\n`cotal_orientation`. Jcode loads MCP tools asynchronously; its first turn can use the pre-MCP tool\nsnapshot immediately before Jcode rebuilds that snapshot. The host repeats the identical proof once\nin that case. A second absence fails the launch, so a bridge that never comes up remains a loud\nfailure rather than an agent that is present but mute. A managed Jcode seat has a **three-minute\nbounded readiness window**: first boot can download model material, start the MCP bridge, and wait\nthrough the provider-backed readiness turns. If that window expires, the launch is `uncertain`, not\na failed or cleanup verdict; use `cotal attach ` or `cotal ps` to inspect it and do not stop\nit solely because the window elapsed. The host then waits for the mesh connection and presence bind\nto complete before it adds a no-reply notice that the bootstrap orientation predates the join and\nthat a new orientation is live context. During a broker outage, it stays waiting and sends no\nconnected notice.\n\nFor a foreground launch, the TUI opens as soon as the session is ready, before the readiness turn,\nso it streams boot activity instead of leaving the terminal blank. Presence still begins only after\nthe readiness proof passes. An inbound peer message then wakes a Harness API turn. A directed message\nthat arrives while this Cotal-owned turn is active enters Jcode's session-owned soft-interrupt queue,\nwhich incorporates it at a safe provider or tool boundary. Ambient channel traffic stays buffered for\nthe next turn. The host marks presence working while the turn runs and acknowledges every initial or\nsoft-interrupted inbox id only after that containing turn succeeds. A failed turn or private Harness\nreplacement leaves the ids unacknowledged for mesh redelivery. `cotal_inbox` pulls only buffered quiet\nambient from that host-owned queue; its shared optional `peek` argument is supported, so `peek: true`\nshows those messages without clearing them.\n\n## Model limits\n\n`--model` is passed to Jcode's session-level Harness API model selector. Jcode validates the model\nagainst the active provider, then the connector reads runtime identity back and refuses startup if\nit is not the requested model; a seat is never allowed to join under a model label it did not\nreceive.\n\n`cotal models --agent jcode` reads the declared catalog from the operator Jcode home's\n`config.toml`: each provider with `model_catalog = true`, its `[[providers..models]]` ids,\nand any declared `reasoning_efforts`. This is the same config Jcode copies into a private managed\ninstance. The command fails loud when the file is unreadable, malformed, or enables a catalog\nwithout model entries.\n\nThe listed effort tiers are declarations, not provider-verified capabilities. `cotal models` prints\nthat caveat inline as `variants (declared, not provider-verified)` beside each configured tier list,\nso it cannot be missed by reading only the model rows. Providers can reject a tier the file names,\nso launch remains the authority: Jcode applies the requested value and a provider rejection ends the\nlaunch. `--refresh` does not turn this local declaration into a live probe.\n\nThe Harness API can set a requested effort but cannot read an effective effort back. Its runtime\nidentity reports provider, model, and routes only; no reply or event carries the applied tier. Cotal\ntherefore records the accepted request and does not relabel it as an observed effect.\n\n`--variant` is the session's **reasoning effort**, applied after the model and before the seat's\nfirst turn, so a seat never serves a turn at an effort nobody chose. A persona's `variant:` is the\ndefault and `--variant` overrides it, the same way `model:` and `--model` work:\n\n```bash\ncotal spawn --agent jcode --model gpt-5.6-sol --variant high\n```\n\nWhich tiers exist depends on the provider **and** model. The connector does not carry a copy of\nthose ladders: it passes the requested tier to Jcode, which validates it against the active model's\nladder. A rejected tier, or a model with no reasoning-effort surface, ends the launch rather than\nquietly starting the seat at another effort. The external observer/UI receives only the requested\ntier, effective model, fixed `invalid_request` provider code, and an accepted-tier ladder when it\ncan be safely parsed; arbitrary provider rejection text stays private. Omit `--variant` to keep\nJcode's configured default.\n\nIf the mandatory readiness turn receives a provider `invalid_request` refusal for a model id or\nreasoning-effort value, the launch diagnostic names only the provider error code and rejected\nvalue. Other provider response text remains scrubbed, so an external observer/UI can correct\nconnector-visible input without exposing private harness output.\n\nThe following fail loud before a new session is provisioned where the manager can preflight them,\nor at connector launch as a backstop:\n\n- **Resume /continuation:** a Cotal seat owns a new private Jcode instance. Reusing a session from\n an operator or another seat would violate that ownership boundary.\n- **Tool sharing:** Jcode resolves its MCP configuration from several global and project sources.\n The connector owns a private configuration containing only `cotal`, rather than claim a chosen\n subset can be safely merged.\n- **Events:** Jcode's Harness API does not provide the durable structured rollout surface required\n by Cotal's event plane.\n- **Launch options:** the connector does not map arbitrary flags/config into the Harness API.\n- **Containers:** the current deploy image does not bundle Jcode, so there is no containerized Jcode connector today.\n\n## Security limits\n\nThe private home protects against accidental sharing and stale session selection; it is not an\nOS-user isolation boundary. A hostile process running as the same user can still read that user's\nfiles or inspect another same-user process. Use OS/container isolation where peers must be mutually\nhostile.\n\nThe model can receive remote peer messages and Jcode is an autonomous coding harness. Treat its\nprovider credentials, filesystem access, and network capability as the privileges of the OS user\nrunning the seat. Cotal's spawn capability governs who may create a seat; it is not a sandbox for\nwhat a model can be persuaded to do after creation.\n" }, { "slug": "connect-opencode", @@ -264,7 +264,7 @@ export const DOCS_BUNDLE: DocsBundle = { "title": "Watch a mesh", "kind": "Guide (informative)", "summary": "A running mesh is a stream of live activity: who is present, what they are doing, what they are saying to each other.", - "body": "# Watch a mesh\n\n> **Guide** (informative) · **For:** operators · **Prereqs:** [Quickstart](getting-started.md)\n\nA running mesh is a stream of live activity: who is present, what they are doing, what they\nare saying to each other. Cotal gives you three read-only surfaces onto one space. All three\nrender the *same* observer model ([`MeshView`](mesh-view.md)); none opens its own connection or\nre-implements the wire. Pick by where you are:\n\n| Surface | Command | Use it to |\n|---|---|---|\n| **console (TUI)** | `cotal console` | drive it interactively in the terminal: drill into agents, channels, DMs |\n| **stream** | `cotal console --plain`, or any pipe | tail a passive line log: grep it, pipe it, watch it in CI |\n| **web dashboard** | `cotal web` | a god-view browser dashboard: see at a glance what needs a human |\n\nThe console ships with the CLI; the web dashboard is an extension (`cotal setup` installs it).\n\n## Terminal console\n\n`cotal console` auto-selects its renderer: a real TTY gets the lazygit-style Ink TUI; a pipe or\n`--plain` gets the line stream. Both read from one invisible observer over the space.\n\n```bash\ncotal console --space main # the TUI for one space\ncotal console --plain # the passive line stream (also the default when piped)\ncotal console # no --space on an open mesh → the admin overview first\n```\n\n![The cotal console: a live roster of agents and their all-activity feed in a terminal TUI](../assets/quickstart.gif)\n\n**Admin overview.** On an open mesh, `cotal console` with **no `--space`** opens a space picker:\nevery space on the server (enumerated from its `CHAT_*` streams and presence buckets) with its\nagents, channels, and message counts. Pick one to drop into its console; `b` returns to the\noverview. `--space X` skips the picker. Under auth a server hosts a single space, so the console\nenters it directly (no overview).\n\n**Lenses and keys** (TUI). The layout is a roster, a live feed, per-channel tabs, a golden-signal\ntiles strip, and toggleable lenses:\n\n| Key | Does |\n|---|---|\n| `1`–`9`, `[` `]` | select a channel tab |\n| `n` | the NEEDS-YOU rail: agents currently blocked or waiting |\n| `d` | the DM lens: per-peer roll-up and threads (god-view only; shows \"DMs hidden\" under chat-only creds) |\n| `t`, then `v` / `1`–`3` | the topology lens: who-talks-to-whom, as a swimlane, a heat matrix, or a ring map |\n| `/` | search / filter the feed |\n| `:` | the command palette |\n| arrows / `h` `l` | move focus; select a row for its detail card |\n| `?` · `b` · `q` | help · back to overview · quit |\n\nThe stream is line-oriented, so the signals stay out of it; it is just a timestamped log of\npresence changes and messages, ready for `grep`.\n\n## Web dashboard\n\nThe dashboard ships inside `cotal-ai` as the `@cotal-ai/web` extension and is seeded automatically on\nfirst run (like the built-in connectors), so `cotal web` is there out of the box and tracks your CLI\nversion on upgrade. If a seeded copy is damaged, `cotal ext seed --repair` restores it.\n\n![The web dashboard: roster, all-activity feed, golden-signal tiles, and the NEEDS-YOU lane](../assets/dashboard.png)\n\n```bash\ncotal web --space main # opens http://cotal.localhost:7799/\ncotal web --space main --detach # background; stop with cotal down web\ncotal web --space main --port 8080 --no-open\ncotal web --space main --host 192.0.2.10 # explicit remote bind and browser address\ncotal web --space main --creds ./admin.creds # use a cred you minted yourself\n```\n\nFlags: `--space` (default `main`), `--server` (the mesh's broker, resolved from the registry),\n`--host` (HTTP bind and browser host, default `127.0.0.1`), `--port` (default `7799`), `--detach`\n(run in the background), `--no-open` (skip auto-launching the browser), `--creds` (override the\nself-minted cred). Remote exposure requires an explicit concrete `--host`; wildcard addresses\n`0.0.0.0` and `::` are refused because neither is a browser destination. Detached mode waits for\nthe real HTTP server at the selected host before returning, logs to `/.cotal/web.log`, and is stopped by\n`cotal down web` or bare `cotal down`. It requires a recorded mesh root; after `cotal up` records the\nmesh, it can be launched from any directory. The branded URL `http://cotal.localhost:7799/` resolves\nto loopback with no DNS setup in Chrome, Firefox, and Edge; Safari may not resolve `*.localhost`,\nso use `http://127.0.0.1:7799`. A custom `--port` uses the plain loopback address. An explicit\n`--host` is also the advertised address and the only allowed browser Origin for that process.\n\n**The link is single-use, and the surface authenticates the caller.** Starting the dashboard prints a\nURL carrying a one-time token; opening it exchanges the token for a session cookie and the token is\nthen spent. Binding loopback keeps other *hosts* out, but it never kept out other *processes* on your\nmachine, nor a page in your own browser posting to `http://127.0.0.1:7799`, so the token is what\nmakes the session yours. Requests without it are refused with the reason named (`unauthenticated`,\n`launch-token-already-used`, or `cross-origin`) rather than silently returning nothing.\n\nPractical consequences: open the printed link in the browser you want to use it in, because the\ntoken is spent on first use. Re-opening it **in another browser or profile** is refused with\n`launch-token-already-used`. (In the browser that already holds the session, re-opening the link\nstill works: the session is checked before the spent token, so the page loads on the session you\nalready have.) If you lose the line, the link is also written to `/.cotal/web.session`,\nmode `0600` on every write. The session is bound to the origin you opened, so one started on\n`http://cotal.localhost` does not carry over to `http://127.0.0.1`. Restarting `cotal web` mints a\nfresh link and invalidates every earlier session.\n\n**A god-view, minimal privilege.** The dashboard is always the full god-view; there is no\nread-only viewer mode. In auth mode it self-mints its own **admin** read cred (the scope that lets\nit tap DMs and anycast), then *drops the space signing seed* so a dashboard compromise can't mint\nidentities; it keeps only one narrow cred for its single write path. In open mode it connects bare.\nPass `--creds` to use a cred you minted yourself instead. On a per-user-auth mesh there is nothing\nto mint: the dashboard rides the read-only admin view over your login, and the channel-delete\nwrite path asks for its own channel-purger view per click (both need ledger scope `admin`;\n[identity & auth](identity-and-auth.md)).\n\nThe dashboard is read-only except that one write path: **deleting a channel and its content**\n(a filtered history purge plus the channel-registry key), which is POST-gated and confirm-guarded\nin the UI.\n\n**The views.** Every view keeps the same skeleton: navigation on the left (roster, channels,\nDMs), the selected content in the centre, the NEEDS-YOU lane always on the right.\n\n- **Monitor**: the all-activity feed (two-line messages with a delivery-mode badge, per-mode\n filter chips, and pause), the roster (status as shape *and* colour, role, a one-line activity,\n and the agent's harness: claude / opencode / hermes), and the golden-signal tiles\n (working / waiting / idle / offline / oldest-unattended).\n- **Channel view**: one channel's message list, members shown in the header.\n- **Direct messages**: a per-peer roll-up (one row per peer, not the n² pair list); expand a peer\n for its conversations.\n- **Agent Detail.** A per-agent drill-down rendered from the peer's card: name, role, the harness\n and model, capabilities, and what it's working on or blocked on.\n- **Graph view** (`/graph`, linked from the Monitor header): the same feed as a live\n force-directed constellation. Channels and agents are both nodes; a wire is drawn per\n **membership** (a spoke to every channel an agent subscribes to) and glows when a message flows.\n Membership is **broker-sourced and authoritative**, reconstructed by the delivery daemon from\n the broker's connection view unioned with the durable-members registry, so *silent* subscribers\n show too. A header pill reports the feed as *live*, *stale*, *traffic-only* (no daemon, e.g.\n open mode, where the graph degrades to traffic-derived spokes), or *unreadable*. The last\n meaning the read itself did not answer, which is a fact about the viewer rather than about the\n mesh, and is kept distinct from *traffic-only* for that reason. A **hide-offline** control\n collapses durable-but-away members. The live feed opens as the page loads rather than after it, so\n the pill reports the connection honestly from the first moment instead of sitting in its down\n state for as long as the first read takes. What the feed says outranks the page's own startup reads: a\n read issued before a live update cannot overwrite it when it lands afterwards, whether it answers\n or refuses, so a slow link cannot make the pill contradict what the feed already reported.\n Broker-sourced membership needs the delivery daemon (auth mode) and is provisioned on a fresh\n `cotal up`.\n\n**When a read does not land.** A poll that fails never blanks the page. The dashboard keeps the\nlast values it actually read and marks them stale in the header, naming which source is stale and\nwhy (`stale: peers, activity`, with the server's own reason on hover); the next successful read\nreplaces the data and clears the mark.\n\n**When the observer itself goes deaf.** Presence liveness is derived from heartbeat timestamps, so\na watch that hears nothing for longer than the TTL used to flip every peer `offline` at once. The\nsidebar is an online-only list, so the page emptied while the browser's connection pill stayed\nlive — that pill is the local SSE link, not the observer's upstream. Whole-bucket silence past\nTTL is now a fact about the *view*: the header says `stale: roster` (`observer presence watch\nsilent since T`) and the last-known online list stays on screen until the watch delivers again.\nA single peer whose own heartbeat lapses while the watch is live still drops out. A stall\nshorter than TCP-level detection never reconnects, which is why this is a freshness gate on the\nwatch rather than a `connection` event.\n\nThe all-activity read is bounded, so on a slow link it can\ncome back SHORT rather than late: the header then says `partial: activity`, and the page reports how\nmany sources answered out of how many were asked and names the ones that did not. A short page and a\ncomplete one are never the same bytes. On a link too slow to finish anything the honest answer is\nzero sources answered, and you keep looking at the last good data with the marker up.\n\nThe open channel's own history read is bounded by the same deadline. It is a single read, so there\nis no short page to serve: it either produced the messages or it refuses, naming the channel and the\nbound it exceeded, and the view keeps the messages it already had rather than emptying. A sparse\nchannel (fewer messages than the page) is bounded by that channel's own first and last matching\nsequences, not by walking the stream back to sequence 1. Every one of\nthese routes takes an optional `limit`, and a value that is not a whole number is refused outright\nrather than guessed at. The same holds for the channel name in the URL: an escape the decoder cannot\nread is the caller's typo, not a broken server. Either way a malformed request is answered as a bad\nrequest and never as the dashboard having broken.\n\nA refusal names the value it received, and it renders that value so you can read it. Characters that\nwould otherwise be invisible, rearrange the text around them, or mark part of it as an annotation\ncome back as their escape in both the response and the line printed in the terminal, so what you\nread is what was actually sent. Ordinary text, accents and non-Latin scripts included, is left\nalone: a character that renders as itself is left as itself.\n\nA channel name has to be the name the mesh actually uses: dotted segments of letters, digits, `_`\nand `-`, or a `*` or `>` where the mesh reads a whole subtree. Anything else is refused rather than\nquietly rewritten, because the wire rewrites what it cannot use and two different names would then\nbe one channel. That matters most on the delete button: a name that had to be rewritten would have\npurged a channel you did not name, while the answer showed you the name you typed. Delete takes no\nwildcard at all, so the one destructive control names one channel.\n\nThe delete request itself is capped at 8 KiB, which is far more than a channel name can be and far\nless than a machine can spend. A larger body is refused with a `413` naming the limit, the server\nstops reading it rather than taking it all in first and complaining afterwards, and the connection\nthat body arrived on is closed so the rest of it cannot be sent. It is never shortened to fit: a\ntrimmed name is a name you did not type, which is the thing the paragraph above exists to prevent.\nOrdinary requests keep their connection as usual.\n\n**Message bodies render Markdown** (headings, lists, **bold**, `code`, blockquotes, links) across\nthe Monitor, channel, and DM views, parsed and sanitized client-side. Agent text is untrusted, so\nraw HTML is stripped and only http(s)/mailto links survive. Long bodies still clamp to a few lines\nwith a per-message *show more*; a channel-wide **expand / collapse all** in the header opens or\ncloses every message at once.\n\nAppend `?demo` (`http://127.0.0.1:7799/?demo`) to render the design reference as a static\nshowcase with no mesh, including forward-looking elements that have no protocol backing yet\n(intent badges, approval requests, task-failed alerts). Live mode renders only what the god-view\ncan actually read.\n\n## What each surface can see\n\nEvery surface is a read-only observer; what it *sees* depends on its credential:\n\n- **console TUI** and **web** self-mint an **admin** god-view cred under auth, so both show the\n whole space: chat, DMs, and anycast (`dmVisible: true`).\n- **`console --plain`** deliberately narrows to the chat subtree, so DMs and anycast stay\n confidential in a line log even under an admin cred.\n- An explicit **`--creds`** limits each surface to the credential's grants; a chat-only\n observer cred hides the DM lens.\n\nSee [identity and auth](identity-and-auth.md) for the observer vs admin scopes, and\n[MeshView](mesh-view.md) for the shared model behind all three surfaces. Normative delivery and\nvisibility rules live in the [SPEC](../SPEC.md).\n" + "body": "# Watch a mesh\n\n> **Guide** (informative) · **For:** operators · **Prereqs:** [Quickstart](getting-started.md)\n\nA running mesh is a stream of live activity: who is present, what they are doing, what they\nare saying to each other. Cotal gives you three read-only surfaces onto one space. All three\nrender the *same* observer model ([`MeshView`](mesh-view.md)); none opens its own connection or\nre-implements the wire. Pick by where you are:\n\n| Surface | Command | Use it to |\n|---|---|---|\n| **console (TUI)** | `cotal console` | drive it interactively in the terminal: drill into agents, channels, DMs |\n| **stream** | `cotal console --plain`, or any pipe | tail a passive line log: grep it, pipe it, watch it in CI |\n| **web dashboard** | `cotal web` | a god-view browser dashboard: see at a glance what needs a human |\n\nThe console ships with the CLI; the web dashboard is an extension (`cotal setup` installs it).\n\n## Terminal console\n\n`cotal console` auto-selects its renderer: a real TTY gets the lazygit-style Ink TUI; a pipe or\n`--plain` gets the line stream. Both read from one invisible observer over the space.\n\n```bash\ncotal console --space main # the TUI for one space\ncotal console --plain # the passive line stream (also the default when piped)\ncotal console # no --space on an open mesh → the admin overview first\n```\n\n![The cotal console: a live roster of agents and their all-activity feed in a terminal TUI](../assets/quickstart.gif)\n\n**Admin overview.** On an open mesh, `cotal console` with **no `--space`** opens a space picker:\nevery space on the server (enumerated from its `CHAT_*` streams and presence buckets) with its\nagents, channels, and message counts. Pick one to drop into its console; `b` returns to the\noverview. `--space X` skips the picker. Under auth a server hosts a single space, so the console\nenters it directly (no overview).\n\n**Lenses and keys** (TUI). The layout is a roster, a live feed, per-channel tabs, a golden-signal\ntiles strip, and toggleable lenses:\n\n| Key | Does |\n|---|---|\n| `1`–`9`, `[` `]` | select a channel tab |\n| `n` | the NEEDS-YOU rail: agents currently blocked or waiting |\n| `d` | the DM lens: per-peer roll-up and threads (god-view only; shows \"DMs hidden\" under chat-only creds) |\n| `t`, then `v` / `1`–`3` | the topology lens: who-talks-to-whom, as a swimlane, a heat matrix, or a ring map |\n| `/` | search / filter the feed |\n| `:` | the command palette |\n| arrows / `h` `l` | move focus; select a row for its detail card |\n| `?` · `b` · `q` | help · back to overview · quit |\n\nThe stream is line-oriented, so the signals stay out of it; it is just a timestamped log of\npresence changes and messages, ready for `grep`.\n\n## Web dashboard\n\nThe dashboard ships inside `cotal-ai` as the `@cotal-ai/web` extension and is seeded automatically on\nfirst run (like the built-in connectors), so `cotal web` is there out of the box and tracks your CLI\nversion on upgrade. If a seeded copy is damaged, `cotal ext seed --repair` restores it.\n\n![The web dashboard: roster, all-activity feed, golden-signal tiles, and the NEEDS-YOU lane](../assets/dashboard.png)\n\n```bash\ncotal web --space main # opens http://cotal.localhost:7799/\ncotal web --space main --detach # background; stop with cotal down web\ncotal web --space main --port 8080 --no-open\ncotal web --space main --host 192.0.2.10 # explicit remote bind and browser address\ncotal web --space main --creds ./admin.creds # use a cred you minted yourself\n```\n\nFlags: `--space` (default `main`), `--server` (the mesh's broker, resolved from the registry),\n`--host` (HTTP bind and browser host, default `127.0.0.1`), `--port` (default `7799`), `--detach`\n(run in the background), `--no-open` (skip auto-launching the browser), `--creds` (override the\nself-minted cred). Remote exposure requires an explicit concrete `--host`; wildcard addresses\n`0.0.0.0` and `::` are refused because neither is a browser destination. Detached mode waits for\nthe real HTTP server at the selected host before returning, logs to `/.cotal/web.log`, and is stopped by\n`cotal down web` or bare `cotal down`. It requires a recorded mesh root; after `cotal up` records the\nmesh, it can be launched from any directory. The branded URL `http://cotal.localhost:7799/` resolves\nto loopback with no DNS setup in Chrome, Firefox, and Edge; Safari may not resolve `*.localhost`,\nso use `http://127.0.0.1:7799`. A custom `--port` uses the plain loopback address. An explicit\n`--host` is also the advertised address and the only allowed browser Origin for that process.\n\n**The link is single-use, and the surface authenticates the caller.** Starting the dashboard prints a\nURL carrying a one-time token; opening it exchanges the token for a session cookie and the token is\nthen spent. Binding loopback keeps other *hosts* out, but it never kept out other *processes* on your\nmachine, nor a page in your own browser posting to `http://127.0.0.1:7799`, so the token is what\nmakes the session yours. Requests without it are refused with the reason named (`unauthenticated`,\n`launch-token-already-used`, or `cross-origin`) rather than silently returning nothing.\n\nPractical consequences: open the printed link in the browser you want to use it in, because the\ntoken is spent on first use. Re-opening it **in another browser or profile** is refused with\n`launch-token-already-used`. (In the browser that already holds the session, re-opening the link\nstill works: the session is checked before the spent token, so the page loads on the session you\nalready have.) If you lose the line, the link is also written to `/.cotal/web.session`,\nmode `0600` on every write. The session is bound to the origin you opened, so one started on\n`http://cotal.localhost` does not carry over to `http://127.0.0.1`. Restarting `cotal web` mints a\nfresh link and invalidates every earlier session.\n\n**A god-view, minimal privilege.** The dashboard is always the full god-view; there is no\nread-only viewer mode. In auth mode it self-mints its own **admin** read cred (the scope that lets\nit tap DMs and anycast), then *drops the space signing seed* so a dashboard compromise can't mint\nidentities; it keeps only one narrow cred for its single write path. In open mode it connects bare.\nPass `--creds` to use a cred you minted yourself instead. On a per-user-auth mesh there is nothing\nto mint: the dashboard rides the read-only admin view over your login, and the channel-delete\nwrite path asks for its own channel-purger view per click (both need ledger scope `admin`;\n[identity & auth](identity-and-auth.md)).\n\nThe dashboard is read-only except that one write path: **deleting a channel and its content**\n(a filtered history purge plus the channel-registry key), which is POST-gated and confirm-guarded\nin the UI.\n\n**The views.** Every view keeps the same skeleton: navigation on the left (roster, channels,\nDMs), the selected content in the centre, the NEEDS-YOU lane always on the right.\n\n- **Monitor**: the all-activity feed (two-line messages with a delivery-mode badge, per-mode\n filter chips, and pause), the roster (status as shape *and* colour, role, a one-line activity,\n and the agent's harness: claude / opencode / hermes), and the golden-signal tiles\n (working / waiting / idle / offline / oldest-unattended).\n- **Channel view**: one channel's message list, members shown in the header.\n- **Direct messages**: a per-peer roll-up (one row per peer, not the n² pair list); expand a peer\n for its conversations.\n- **Agent Detail.** A per-agent drill-down rendered from the peer's card: name, role, the harness\n and model, capabilities, and what it's working on or blocked on.\n- **Graph view** (`/graph`, linked from the Monitor header): the same feed as a live\n force-directed constellation. Channels and agents are both nodes; a wire is drawn per\n **membership** (a spoke to every channel an agent subscribes to) and glows when a message flows.\n Membership is **broker-sourced and authoritative**, reconstructed by the delivery daemon from\n the broker's connection view unioned with the durable-members registry, so *silent* subscribers\n show too. A header pill reports the feed as *live*, *stale*, *traffic-only* (no daemon, e.g.\n open mode, where the graph degrades to traffic-derived spokes), or *unreadable*. The last\n meaning the read itself did not answer, which is a fact about the viewer rather than about the\n mesh, and is kept distinct from *traffic-only* for that reason. A **hide-offline** control\n collapses durable-but-away members. The live feed opens as the page loads rather than after it, so\n the pill reports the connection honestly from the first moment instead of sitting in its down\n state for as long as the first read takes. What the feed says outranks the page's own startup reads: a\n read issued before a live update cannot overwrite it when it lands afterwards, whether it answers\n or refuses, so a slow link cannot make the pill contradict what the feed already reported.\n Broker-sourced membership needs the delivery daemon (auth mode) and is provisioned on a fresh\n `cotal up`.\n\n**When a read does not land.** A poll that fails never blanks the page. The dashboard keeps the\nlast values it actually read and marks them stale in the header, naming which source is stale and\nwhy (`stale: peers, activity`, with the server's own reason on hover); the next successful read\nreplaces the data and clears the mark.\n\n**When the observer itself goes deaf.** Presence liveness is derived from heartbeat timestamps, so\na watch that hears nothing for longer than the TTL used to flip every peer `offline` at once. The\nsidebar is an online-only list, so the page emptied while the browser's connection pill stayed\nlive: that pill is the local SSE link, not the observer's upstream. Whole-bucket silence past\nTTL is now a fact about the *view*: the header says `stale: roster` (`observer presence watch\nsilent since T`) and the last-known online list stays on screen until the watch delivers again.\nA single peer whose own heartbeat lapses while the watch is live still drops out. A stall\nshorter than TCP-level detection never reconnects, which is why this is a freshness gate on the\nwatch rather than a `connection` event.\n\nThe all-activity read is bounded, so on a slow link it can\ncome back SHORT rather than late: the header then says `partial: activity`, and the page reports how\nmany sources answered out of how many were asked and names the ones that did not. A short page and a\ncomplete one are never the same bytes. On a link too slow to finish anything the honest answer is\nzero sources answered, and you keep looking at the last good data with the marker up.\n\nThe open channel's own history read is bounded by the same deadline. It is a single read, so there\nis no short page to serve: it either produced the messages or it refuses, naming the channel and the\nbound it exceeded, and the view keeps the messages it already had rather than emptying. A sparse\nchannel (fewer messages than the page) is bounded by that channel's own first and last matching\nsequences, not by walking the stream back to sequence 1. Every one of\nthese routes takes an optional `limit`, and a value that is not a whole number is refused outright\nrather than guessed at. The same holds for the channel name in the URL: an escape the decoder cannot\nread is the caller's typo, not a broken server. Either way a malformed request is answered as a bad\nrequest and never as the dashboard having broken.\n\nA refusal names the value it received, and it renders that value so you can read it. Characters that\nwould otherwise be invisible, rearrange the text around them, or mark part of it as an annotation\ncome back as their escape in both the response and the line printed in the terminal, so what you\nread is what was actually sent. Ordinary text, accents and non-Latin scripts included, is left\nalone: a character that renders as itself is left as itself.\n\nA channel name has to be the name the mesh actually uses: dotted segments of letters, digits, `_`\nand `-`, or a `*` or `>` where the mesh reads a whole subtree. Anything else is refused rather than\nquietly rewritten, because the wire rewrites what it cannot use and two different names would then\nbe one channel. That matters most on the delete button: a name that had to be rewritten would have\npurged a channel you did not name, while the answer showed you the name you typed. Delete takes no\nwildcard at all, so the one destructive control names one channel.\n\nThe delete request itself is capped at 8 KiB, which is far more than a channel name can be and far\nless than a machine can spend. A larger body is refused with a `413` naming the limit, the server\nstops reading it rather than taking it all in first and complaining afterwards, and the connection\nthat body arrived on is closed so the rest of it cannot be sent. It is never shortened to fit: a\ntrimmed name is a name you did not type, which is the thing the paragraph above exists to prevent.\nOrdinary requests keep their connection as usual.\n\n**Message bodies render Markdown** (headings, lists, **bold**, `code`, blockquotes, links) across\nthe Monitor, channel, and DM views, parsed and sanitized client-side. Agent text is untrusted, so\nraw HTML is stripped and only http(s)/mailto links survive. Long bodies still clamp to a few lines\nwith a per-message *show more*; a channel-wide **expand / collapse all** in the header opens or\ncloses every message at once.\n\nAppend `?demo` (`http://127.0.0.1:7799/?demo`) to render the design reference as a static\nshowcase with no mesh, including forward-looking elements that have no protocol backing yet\n(intent badges, approval requests, task-failed alerts). Live mode renders only what the god-view\ncan actually read.\n\n## What each surface can see\n\nEvery surface is a read-only observer; what it *sees* depends on its credential:\n\n- **console TUI** and **web** self-mint an **admin** god-view cred under auth, so both show the\n whole space: chat, DMs, and anycast (`dmVisible: true`).\n- **`console --plain`** deliberately narrows to the chat subtree, so DMs and anycast stay\n confidential in a line log even under an admin cred.\n- An explicit **`--creds`** limits each surface to the credential's grants; a chat-only\n observer cred hides the DM lens.\n\nSee [identity and auth](identity-and-auth.md) for the observer vs admin scopes, and\n[MeshView](mesh-view.md) for the shared model behind all three surfaces. Normative delivery and\nvisibility rules live in the [SPEC](../SPEC.md).\n" }, { "slug": "workflows", diff --git a/extensions/connector-jcode/smoke/fake-jcode.mjs b/extensions/connector-jcode/smoke/fake-jcode.mjs index 761c4045b..51371573d 100644 --- a/extensions/connector-jcode/smoke/fake-jcode.mjs +++ b/extensions/connector-jcode/smoke/fake-jcode.mjs @@ -175,6 +175,9 @@ const server = createServer((socket) => { reply({ ev: "ok" }); } break; + case "soft_interrupt": + reply({ ev: "ok" }); + break; case "get_runtime_info": reply({ ev: "runtime_info", session_id: frame.session_id, model: "fake-model", routes: [] }); break; diff --git a/extensions/connector-jcode/smoke/jcode-mid-turn-delivery.smoke.ts b/extensions/connector-jcode/smoke/jcode-mid-turn-delivery.smoke.ts new file mode 100644 index 000000000..aa0e2537f --- /dev/null +++ b/extensions/connector-jcode/smoke/jcode-mid-turn-delivery.smoke.ts @@ -0,0 +1,269 @@ +/** + * Jcode mid-turn DM delivery regression (#910). + * + * The exact production shape is a healthy, working seat whose automatic inbox count grows while a + * long Harness turn is open. `cotal_dm` reports success because JetStream accepted the publish, but + * the Jcode host leaves the DM parked in MeshAgent until that turn ends. Jcode v0.81.1 already has a + * session-owned `soft_interrupt` queue for this purpose, including busy-agent and persisted fallback + * paths; the connector simply did not use it. + * + * This spins up a real loopback broker and the shipped Jcode host. The fake is only the Harness API + * peer: it holds one turn open long enough to make the missing handoff observable. The decisive cell + * reads what the RECIPIENT session accepted (`soft_interrupt`), not whether the sender publish + * returned ok. Three sizes rule out the live specimen's tempting 4 KiB hypothesis: short, ~4 KiB, + * and ~64 KiB DMs must all reach the recipient session before the open turn finishes. + * + * Run: pnpm smoke:jcode-mid-turn-delivery + */ +import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { once } from "node:events"; +import { spawn, type ChildProcess } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { CotalEndpoint, isReachable, mintLifecycleUid, seedChannelRegistry } from "@cotal-ai/core"; +import { killAndAwaitExit, SMOKE_BROKER_TOKEN, teardownOnSignal } from "@cotal-ai/smoke-kit"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +async function freePort(): Promise { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as { port: number }).port; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} +async function waitFor(name: string, read: () => T | undefined, timeoutMs = 20_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = read(); + if (value !== undefined) return value; + if (Date.now() > deadline) throw new Error(`timed out waiting for ${name}`); + await sleep(50); + } +} + +type Entry = { ev: string; frame?: { req?: string; content?: string; no_reply?: boolean }; [key: string]: unknown }; + +const root = mkdtempSync(join(tmpdir(), `cotal-jcode-mid-turn-${SMOKE_BROKER_TOKEN}`)); +const port = await freePort(); +const servers = `nats://127.0.0.1:${port}`; +const fake = fileURLToPath(new URL("./fake-jcode.mjs", import.meta.url)); +const host = fileURLToPath(new URL("../src/host-main.ts", import.meta.url)); +const tsx = fileURLToPath(new URL("../node_modules/.bin/tsx", import.meta.url)); +const shimDir = join(root, "bin"); +const shim = join(shimDir, "jcode"); +const log = join(root, "fake.jsonl"); +const turnDelayMs = 8_000; +const sessionState = join(root, "fake-session.json"); +const lifecycleUid = mintLifecycleUid(); +const nats = spawn("nats-server", ["-js", "-p", String(port), "-sd", join(root, "js")], { stdio: "ignore" }); +const releaseBroker = teardownOnSignal(nats, root); +let child: ChildProcess | undefined; +let operator: CotalEndpoint | undefined; +let stderr = ""; +let pass = 0; +const check = (name: string, condition: boolean, actual?: unknown): void => { + assert.ok(condition, `${name}${actual === undefined ? "" : ` — ${JSON.stringify(actual)}`}`); + pass++; + console.log(` ✓ ${name}`); +}; +const entries = (): Entry[] => + existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line) as Entry) : []; +const turnRequests = (): Entry[] => entries().filter((entry) => entry.ev === "request" && entry.frame?.req === "send_message" && !entry.frame.no_reply); +const steerText = (): string => + entries() + .filter((entry) => entry.ev === "request" && entry.frame?.req === "soft_interrupt") + .map((entry) => String(entry.frame?.content ?? "")) + .join("\n"); + +try { + mkdirSync(shimDir, { recursive: true }); + writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "${fake}" "$@"\n`); + chmodSync(shim, 0o755); + for (let i = 0; i < 100 && !(await isReachable(servers)); i++) await sleep(50); + await seedChannelRegistry({ + servers, + space: "jcodemidturn", + file: { defaults: { replay: false }, channels: { team: { replay: false } } }, + }); + + operator = new CotalEndpoint({ + space: "jcodemidturn", + servers, + card: { name: "operator", kind: "agent", id: "operator" }, + channels: ["team"], + }); + operator.on("error", () => {}); + let peerId: string | undefined; + operator.on("presence", (event: { type: string; presence: { card: { id: string; name: string } } }) => { + if (event.type !== "offline" && event.presence.card.name === "jcodepeer") { + peerId = event.presence.card.id; + } + }); + await operator.start(); + + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(env)) if (key.startsWith("COTAL_")) delete env[key]; + const inheritedJcodeHome = join(root, "source-jcode"); + mkdirSync(inheritedJcodeHome, { recursive: true, mode: 0o700 }); + writeFileSync(join(inheritedJcodeHome, "auth.json"), "jcode-mid-turn-smoke-token", { mode: 0o600 }); + child = spawn(tsx, [host], { + cwd: root, + detached: true, + env: { + ...env, + PATH: `${shimDir}:${env.PATH ?? ""}`, + FAKE_JCODE_LOG: log, + FAKE_JCODE_TURN_DELAY_MS: String(turnDelayMs), + FAKE_JCODE_SESSION_STATE: sessionState, + JCODE_HOME: inheritedJcodeHome, + COTAL_SPACE: "jcodemidturn", + COTAL_NAME: "jcodepeer", + COTAL_ID: "jcodepeer", + COTAL_LIFECYCLE_UID: lifecycleUid, + COTAL_SERVERS: servers, + COTAL_SUBSCRIBE: "team", + COTAL_ALLOW_SUBSCRIBE: "team", + COTAL_ALLOW_PUBLISH: "team", + COTAL_JCODE_HOME: root, + COTAL_JCODE_TUI: "0", + COTAL_CONTROL_SOCKET: join(root, "control.sock"), + COTAL_CONTROL_TOKEN: "jcode-mid-turn-control-token", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + + await waitFor("mesh presence", () => peerId); + check("Jcode recipient is live before the delivery probe", Boolean(peerId)); + + await operator.unicast(peerId!, "OPEN_LONG_TURN"); + await waitFor("the recipient's long Harness turn", () => + turnRequests().find((entry) => String(entry.frame?.content).includes("OPEN_LONG_TURN")), + ); + + const short = "MID_SHORT_910"; + const fourK = `MID_4K_910:${"x".repeat(4 * 1024)}`; + const sixtyFourK = `MID_64K_910:${"y".repeat(64 * 1024)}`; + const sentAt = Date.now(); + await operator.unicast(peerId!, short); + await operator.unicast(peerId!, fourK); + await operator.unicast(peerId!, sixtyFourK); + + const observed = await waitFor( + "short, 4 KiB, and 64 KiB DMs reach the recipient session before the active turn ends (#910)", + () => { + const text = steerText(); + return text.includes(short) && text.includes("MID_4K_910:") && text.includes("MID_64K_910:") ? text : undefined; + }, + 3_000, + ); + check( + "short, 4 KiB, and 64 KiB DMs reach the recipient session before the active turn ends (#910)", + Date.now() - sentAt < turnDelayMs && observed.includes(short), + { elapsedMs: Date.now() - sentAt, steerBytes: Buffer.byteLength(observed) }, + ); + + // The soft-interrupt acceptance is not the commit boundary. Once the containing Harness turn + // completes cleanly, those exact Cotal deliveries are acked. If the accepted ids were omitted from + // that boundary ledger, the host starts a second recipient turn carrying the same batch immediately. + await sleep(turnDelayMs + 500); + const repeated = turnRequests().filter((entry) => String(entry.frame?.content).includes(short)); + check( + "the clean containing turn commits the steered DMs exactly once", + repeated.length === 0, + { repeatedTurns: repeated.length }, + ); + await operator.unicast(peerId!, "POST_BOUNDARY_910"); + const post = await waitFor("the post-boundary recipient turn", () => + turnRequests().find((entry) => String(entry.frame?.content).includes("POST_BOUNDARY_910")), + ); + const postText = String(post.frame?.content ?? ""); + check("the clean boundary leaves later turns free of the prior batch", !postText.includes(short), { postBytes: Buffer.byteLength(postText) }); + + // A host restart reuses the same wire principal and lifecycle durable, and resumes the stored + // Jcode session. The sender still has the old presence record in memory when the replacement comes + // up. Addressing by that principal must reach the replacement session, not disappear behind the + // predecessor's dead Harness tree. + child.kill("SIGKILL"); + await Promise.race([once(child, "exit"), sleep(5_000)]); + check( + "the predecessor Jcode host exits before replacement", + child.exitCode !== null || child.signalCode !== null, + { exitCode: child.exitCode, signalCode: child.signalCode }, + ); + // The production manager retires a dead child tree before replacement. This fixture's fake bridge + // is in the host's process group, so clean the controlled group boundary before reusing its socket. + if (child.pid !== undefined) { + try { process.kill(-child.pid, "SIGKILL"); } catch { /* already gone */ } + } + await sleep(200); + + const beforeRestart = turnRequests().length; + child = spawn(tsx, [host], { + cwd: root, + detached: true, + env: { + ...env, + PATH: `${shimDir}:${env.PATH ?? ""}`, + FAKE_JCODE_LOG: log, + FAKE_JCODE_TURN_DELAY_MS: "10", + FAKE_JCODE_SESSION_STATE: sessionState, + JCODE_HOME: inheritedJcodeHome, + COTAL_SPACE: "jcodemidturn", + COTAL_NAME: "jcodepeer", + COTAL_ID: "jcodepeer", + COTAL_LIFECYCLE_UID: lifecycleUid, + COTAL_SERVERS: servers, + COTAL_SUBSCRIBE: "team", + COTAL_ALLOW_SUBSCRIBE: "team", + COTAL_ALLOW_PUBLISH: "team", + COTAL_JCODE_HOME: root, + COTAL_JCODE_TUI: "0", + COTAL_CONTROL_SOCKET: join(root, "r.sock"), + COTAL_CONTROL_TOKEN: "jcode-mid-turn-replacement-token", + }, + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + await waitFor("the replacement to resume the prior session", () => + entries().find( + (entry) => entry.ev === "session_path" && entry.req === "attach_session" && entry.session_id === "fake-session", + ), + ); + // Send before the successor publishes presence. The sender still resolves the predecessor's + // lingering card, but both process incarnations bind the same lifecycle durable, so this DM must + // wait for the replacement rather than disappear below a fresh activation frontier. + await operator.unicast(peerId!, "POST_RESTART_910"); + await waitFor("the replacement to complete mesh readiness", () => + entries().filter((entry) => entry.ev === "orientation_done").length >= 2 ? true : undefined, + ); + const replacementTurn = await waitFor("the replacement recipient session to observe the DM", () => + turnRequests().slice(beforeRestart).find((entry) => String(entry.frame?.content).includes("POST_RESTART_910")), + ); + check( + "a DM addressed from stale pre-restart presence reaches the resumed replacement session (#910)", + String(replacementTurn.frame?.content).includes("POST_RESTART_910"), + replacementTurn, + ); + + console.log(`\nJCODE MID-TURN DELIVERY PASSED (${pass} checks)`); +} catch (error) { + if (child && (child.exitCode !== null || child.signalCode !== null)) + process.stderr.write( + `\nJCODE HOST STDERR:\nexit=${String(child.exitCode)} signal=${String(child.signalCode)}\n${stderr.slice(-8_000)}\n`, + ); + throw error; +} finally { + if (child && child.exitCode === null) { + child.kill("SIGTERM"); + await Promise.race([once(child, "exit"), sleep(15_000)]); + } + await operator?.stop().catch(() => {}); + await killAndAwaitExit(nats); + releaseBroker(); + rmSync(root, { recursive: true, force: true }); +} diff --git a/extensions/connector-jcode/smoke/mutations/mid-turn-delivery.json b/extensions/connector-jcode/smoke/mutations/mid-turn-delivery.json new file mode 100644 index 000000000..f348ec32d --- /dev/null +++ b/extensions/connector-jcode/smoke/mutations/mid-turn-delivery.json @@ -0,0 +1,36 @@ +{ + "suite": "extensions/connector-jcode/smoke/jcode-mid-turn-delivery.smoke.ts", + "guard": "a directed DM arriving during a live Jcode turn is accepted by the recipient session through its soft-interrupt queue, and the exact Cotal delivery is committed only at the clean containing-turn boundary", + "command": "pnpm smoke:jcode-mid-turn-delivery", + "progressPattern": "✓", + "minTicks": 4, + "proveWith": "node scripts/mutation-proof.mjs --config extensions/connector-jcode/smoke/mutations/mid-turn-delivery.json", + "why": [ + "The failure presents as success everywhere upstream: JetStream accepts the DM, presence says the", + "recipient is working, and the connector reports an automatic message queued. The fixture therefore", + "grades only recipient observation: the shipped host must submit soft_interrupt to the recipient's", + "Harness session before the already-open turn ends. Its size matrix rules out the live 4 KiB specimen", + "as a payload ceiling by requiring the same recipient observation at short, 4 KiB, and 64 KiB.", + "", + "M1 removes only the shipped handoff call. It is attributable to the production host because the", + "fixture's red assertion searches the fake Harness request log, not connector memory or sender ack.", + "M2 keeps the handoff but drops the accepted ids from the containing-turn ledger; the post-boundary", + "recipient turn then carries the same DM again, proving that queue acceptance alone is not the commit." + ], + "mutations": [ + { + "name": "M1 leave directed mid-turn DMs parked in the connector inbox", + "file": "extensions/connector-jcode/src/host.ts", + "find": " const current: JcodeClient = client;\n const request = current.softInterrupt(sessionId, injection, false);", + "replace": " const current: JcodeClient = client;\n return;", + "expectRed": "short, 4 KiB, and 64 KiB DMs reach the recipient session before the active turn ends (#910)" + }, + { + "name": "M2 accept the soft interrupt but commit only the turn's initial ids", + "file": "extensions/connector-jcode/src/host.ts", + "find": " await steerSettled;\n const committed = surfacedIds;", + "replace": " await steerSettled;\n const committed = ids;", + "expectRed": "the clean containing turn commits the steered DMs exactly once" + } + ] +} diff --git a/extensions/connector-jcode/src/host.ts b/extensions/connector-jcode/src/host.ts index c0f20b9aa..43fed0a94 100644 --- a/extensions/connector-jcode/src/host.ts +++ b/extensions/connector-jcode/src/host.ts @@ -258,6 +258,15 @@ export async function runJcodeHost(): Promise { let briefed = false; let initialized = false; let wakeQueued = false; + /** Exact Cotal deliveries the active Harness turn has accepted, either in its initial prompt or + * through Jcode's session-owned soft-interrupt queue. They commit only when that containing turn + * finishes cleanly. A soft_interrupt `ok` proves the live recipient session queued the text; it + * does not prove the model consumed it yet, so the turn boundary remains the sole ack site. */ + let surfacedIds: string[] = []; + let steering = false; + /** The last in-flight steer request. `drive()` waits for it before deciding which ids the clean + * boundary owns, so a soft_interrupt reply racing turn_done can never be recorded after the ack. */ + let steerSettled: Promise = Promise.resolve(); // The SDK trusts mutable servers.json PIDs and can signal a stale foreign process. Remove its // exit hook once the launch handle exists and own teardown here instead: every record is checked @@ -390,6 +399,7 @@ export async function runJcodeHost(): Promise { } driving = true; turnActive = true; + surfacedIds = [...ids]; void agent.setStatus("working").catch(() => {}); let turnClient: JcodeClient | undefined; try { @@ -400,12 +410,18 @@ export async function runJcodeHost(): Promise { // only safe outcome. The reconnect path redrives it after it reattaches the owned session. if (reconnecting || client !== turnClient) throw new Error("Jcode Harness connection closed during the turn; leaving the inbox batch unacknowledged"); - if (ids.length) agent.drainInboxDeliveries(ids); + // A directed DM can be accepted into Jcode's persistent soft-interrupt queue while this run is + // active. Wait for that request to settle before reading the exact containing-turn ledger. + await steerSettled; + const committed = surfacedIds; + surfacedIds = []; + if (committed.length) agent.drainInboxDeliveries(committed); // A turn that SUCCEEDS clears the backoff: the next failure starts from the short delay again // rather than inheriting a penalty the seat has already recovered from. errorRetryMs = ERROR_RETRY_INITIAL_MS; consecutiveFailures = 0; } catch (error) { + surfacedIds = []; consecutiveFailures++; process.stderr.write( `[cotal-jcode] turn failed (${consecutiveFailures} in a row): ${(error as Error).message}\n`, @@ -437,6 +453,39 @@ export async function runJcodeHost(): Promise { } }; + /** Queue directed automatic traffic in Jcode's live session while a turn owns the provider. Jcode + * incorporates soft interrupts at its documented safe points (after provider streaming / tools), + * including a persisted fallback across a private bridge replacement. Ambient channel chatter + * stays buffered for the next turn. Exact-id accounting makes the accepted set independent of + * physical inbox order, and the containing turn's clean completion remains the only commit. */ + const steerPending = async (): Promise => { + if (steering || !driving || !turnActive || !client || !sessionId) return; + steering = true; + try { + for (;;) { + const surfaced = new Set(surfacedIds); + const items = agent + .peekInbox("automatic") + .filter((item) => !surfaced.has(item.recvKey) && (item.kind !== "channel" || item.mentionsMe)); + if (!items.length || !turnActive) return; + const injection = formatInjection(items); + if (!injection) return; + const current: JcodeClient = client; + const request = current.softInterrupt(sessionId, injection, false); + steerSettled = request.catch(() => {}); + await request; + // If the turn closed or the client was replaced while acceptance was in flight, retain the + // inbox copy. Jcode may also have queued it, so this deliberately chooses at-least-once. + if (!turnActive || client !== current) return; + surfacedIds.push(...items.map((item) => item.recvKey)); + } + } catch (error) { + process.stderr.write(`[cotal-jcode] soft interrupt failed: ${(error as Error).message}\n`); + } finally { + steering = false; + } + }; + /** * A provider stall can take down Jcode's bridge while leaving the private session and its inbox * batch intact. Give that owned instance one clean replacement: the failed turn remains unacked, @@ -455,6 +504,7 @@ export async function runJcodeHost(): Promise { reconnecting = true; turnActive = false; driving = false; + surfacedIds = []; // deliberately unacked; the replacement redrives the durable inbox batch void agent.setStatus("waiting").catch(() => {}); try { // The connector owns the private instance. Stop the whole broken tree before replacing it: @@ -496,9 +546,15 @@ export async function runJcodeHost(): Promise { }; agent.on("incoming", (item: InboxItem) => { - void item; + const automatic = agent.inboxScope(item.recvKey) === "automatic"; + if (!automatic) return; wakeQueued = true; - void drive(); + const directed = item.kind !== "channel" || item.mentionsMe; + if (driving) { + if (directed) void steerPending(); + return; + } + if (directed || agent.attention === "open") void drive(); }); let startControl: ReturnType | undefined; diff --git a/package.json b/package.json index 03f80d434..ff85f50a2 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "smoke:jcode-lifecycle": "tsx extensions/connector-jcode/smoke/jcode-lifecycle.smoke.ts", "smoke:jcode-private-lifecycle": "tsx extensions/connector-jcode/smoke/private-lifecycle.smoke.ts", "smoke:jcode-provider-disconnect": "tsx extensions/connector-jcode/smoke/jcode-provider-disconnect.smoke.ts", + "smoke:jcode-mid-turn-delivery": "tsx extensions/connector-jcode/smoke/jcode-mid-turn-delivery.smoke.ts", "smoke:jcode-live": "tsx extensions/connector-jcode/smoke/jcode-live.smoke.ts", "smoke:view": "tsx implementations/cli/smoke/view.smoke.ts", "smoke:members": "tsx packages/core/smoke/members.smoke.ts",