Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/shy-geese-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cotal-ai/cli": minor
"@cotal-ai/connector-core": minor
"@cotal-ai/connector-jcode": minor
---

Show each managed seat's model and requested variant in the default `cotal ps` view, and expose Jcode's declared local model catalog without presenting configured effort tiers as provider-verified capabilities.
3 changes: 3 additions & 0 deletions bin/smoke/ci-suites.txt
Original file line number Diff line number Diff line change
Expand Up @@ -615,3 +615,6 @@ smoke:web-remote-bind
# closure from built dist and require every external package in it to be installed by a production
# dependency field. Appended so every existing shard assignment remains unchanged.
smoke:manager-runtime-deps
# Seat provenance rows must stay correct across the three ps presentations; appended so
# every existing shard assignment remains unchanged.
smoke:ps-provenance
54 changes: 54 additions & 0 deletions bin/smoke/mutations/ps-provenance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"suite": "implementations/cli/smoke/agents-provenance.smoke.ts",
"guard": "default cotal ps treats the model and requested variant as seat identity while keeping an omitted request absent",
"command": "pnpm smoke:ps-provenance",
"completionMarker": "PS PROVENANCE SMOKE PASSED",
"proveWith": "node scripts/mutation-proof.mjs --config bin/smoke/mutations/ps-provenance.json",
"why": [
"The helper under test is the same expression printAgentRow feeds into the default human row, so these cells grade the real renderer seam rather than rebuilding its inputs in a parallel formatter.",
"The positive and absent variant cells are separate because omission is meaningful provenance: one seat requested high and another requested nothing. A default tier is never inferred.",
"The variant-without-model cell preserves the independent rendering invariant already held by --wide and --json."
],
"mutations": [
{
"name": "the default identity drops the model back behind --wide",
"file": "implementations/cli/src/commands/agents.ts",
"find": " if (r.model) parts.push(`${r.model}${r.variant ? ` (${r.variant})` : \"\"}`);",
"replace": " if (r.model) void r.model;",
"expectRed": "default ps identity includes the model and requested variant",
"cell": "default ps identity includes the model and requested variant"
},
{
"name": "the default identity hides the requested variant",
"file": "implementations/cli/src/commands/agents.ts",
"find": " if (r.model) parts.push(`${r.model}${r.variant ? ` (${r.variant})` : \"\"}`);",
"replace": " if (r.model) parts.push(r.model);",
"expectRed": "default ps identity includes the model and requested variant",
"cell": "default ps identity includes the model and requested variant"
},
{
"name": "a requested variant without a model is silently discarded",
"file": "implementations/cli/src/commands/agents.ts",
"find": " else if (r.variant) parts.push(`variant ${r.variant}`);",
"replace": " else if (r.variant) void r.variant;",
"expectRed": "default ps identity preserves a requested variant without a model",
"cell": "default ps identity preserves a requested variant without a model"
},
{
"name": "wide output repeats provenance already shown in the compact identity",
"file": "implementations/cli/src/commands/agents.ts",
"find": " const facts: string[] = [];",
"replace": " const facts: string[] = [\"model duplicated (variant duplicated)\"];",
"expectRed": "wide facts do not repeat model or requested variant from the identity row",
"cell": "wide facts do not repeat model or requested variant from the identity row"
},
{
"name": "the human model catalog drops the inline declared-tier caveat",
"file": "implementations/cli/src/commands/models.ts",
"find": " const label = declared ? \"variants (declared, not provider-verified)\" : \"variants\";",
"replace": " const label = \"variants\";",
"expectRed": "declared Jcode caveat appears where variants print",
"cell": "declared Jcode caveat appears where variants print"
}
]
}
25 changes: 12 additions & 13 deletions bin/smoke/spawn-detach-live.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,38 +193,37 @@ try {
const psOut = await capture(() => run("ps", ["--space", SPACE]));
ok("ps lists the detached agent under its OVERRIDDEN identity", /bard/.test(psOut) && !/poet/.test(psOut), psOut);

// B2 (#651): the same rows, three presentations. BARE is exactly today's output: one line per
// seat, NO facts line (a wide line leaking into the default would be a regression, not
// enrichment). WIDE adds the dim facts line with the facts the launch actually pinned (model +
// variant rode part A's flags; pid is a real number; the uid/instance/host attribute the seat).
// B2 (#651, #905): the same rows, three presentations. BARE is one compact identity line per
// seat, including model and optional requested variant. WIDE adds one dim line of EXTRA operational
// facts without repeating that identity; pid is real and uid/instance/host attribute the seat.
// JSON is the manager's row verbatim, one line, parseable, fields equal to what the launch sent.
const psBare2 = await capture(() => run("ps", ["--space", SPACE]));
ok("bare ps stays compact: one line per seat, no facts line", psBare2.trim().split("\n").length === 1 && !/model /.test(psBare2), psBare2);
ok("bare ps stays compact and includes model plus requested variant identity",
psBare2.trim().split("\n").length === 1 && /e2e · fancy \(high\) · pty/.test(psBare2), psBare2);
const psWide = await capture(() => run("ps", ["--space", SPACE, "--wide"]));
ok("ps --wide adds one facts line with the recorded per-seat facts",
/model fancy \(high\)/.test(psWide) && /pid \d+/.test(psWide) && /uid [a-z0-9]{26,}/.test(psWide) && /instance [a-z0-9]{26,}/.test(psWide) && /host /.test(psWide), psWide);
ok("ps --wide adds operational facts without duplicating model or requested variant",
/e2e · fancy \(high\) · pty/.test(psWide) && !/\n[^\n]*model fancy \(high\)/.test(psWide) && /pid \d+/.test(psWide) && /uid [a-z0-9]{26,}/.test(psWide) && /instance [a-z0-9]{26,}/.test(psWide) && /host /.test(psWide), psWide);
const psJson = await capture(() => run("ps", ["--space", SPACE, "--json"]));
let jsonRow: Record<string, unknown> | undefined;
try { jsonRow = JSON.parse(psJson.trim().split("\n").find((l) => l.includes("bard")) ?? ""); } catch { /* graded below */ }
ok("ps --json emits the manager's row verbatim, one JSON line",
jsonRow !== undefined && jsonRow.name === "bard" && jsonRow.model === "fancy" && jsonRow.variant === "high" && typeof jsonRow.pid === "number" && typeof jsonRow.lifecycleUid === "string" && typeof jsonRow.cwd === "string", psJson);

// B3 (#651 fix): a variant WITHOUT a model survives to --wide. A persona that pins only a variant
// records it independently (no model), and the --wide render must show it standalone rather than
// drop it because a model is absent. Before the fix, printWideFacts nested variant inside
// `if (r.model)`, so --json carried the variant but --wide silently lost it - a recorded fact gone.
// B3 (#651, #905): a variant WITHOUT a model survives in the compact identity and JSON. The wide
// continuation must not repeat it now that provenance lives in the identity row.
writeFileSync(join(workspaceRoot, ".cotal", "agents", "lutist.md"), "---\nname: lutist\nrole: writer\nvariant: high\n---\nYou play.\n");
await capture(() => run("spawn", ["lutist", "--detach", "--agent", "e2e", "--space", SPACE, "--name", "lutist"]));
let lutWide = "";
for (let i = 0; i < 40 && !/lutist/.test(lutWide); i++) {
lutWide = await capture(() => run("ps", ["--space", SPACE, "--wide"]));
if (!/lutist/.test(lutWide)) await sleep(250);
}
// The wide facts render on the DIM CONTINUATION line right after the seat's name line.
// The identity is on the seat line; operational wide facts are on the continuation immediately after.
const lutLines = lutWide.split("\n");
const lutIdx = lutLines.findIndex((l) => /lutist/.test(l));
const lutFacts = lutIdx >= 0 ? (lutLines[lutIdx + 1] ?? "") : "";
ok("ps --wide shows a variant-without-model as a standalone variant fact", /variant high/.test(lutFacts) && !/model /.test(lutFacts), lutWide);
ok("ps --wide keeps a variant-without-model in identity and out of operational facts",
lutIdx >= 0 && /e2e · variant high · pty/.test(lutLines[lutIdx] ?? "") && !/variant high|model /.test(lutFacts), lutWide);
const lutJsonOut = await capture(() => run("ps", ["--space", SPACE, "--json"]));
let lutJson: Record<string, unknown> | undefined;
try { lutJson = JSON.parse(lutJsonOut.trim().split("\n").find((l) => l.includes("lutist")) ?? ""); } catch { /* graded below */ }
Expand Down
33 changes: 23 additions & 10 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,8 +572,14 @@ cotal models [--agent <connector>] [--refresh]
| `--refresh` | off | Ask the connector to refresh its provider cache |

Asks the running manager for each connector's model catalog (model ids plus their variants)
for connectors that expose one (OpenCode today; a connector without a catalog says so). Pick a
result with `cotal spawn --model <provider/model> --variant <v>`.
for connectors that expose one. OpenCode and Codex query harness/provider surfaces; Jcode reads
providers that enable `model_catalog = true` in the operator Jcode `config.toml`. Jcode's listed
effort tiers render as `variants (declared, not provider-verified)`, and launch can still refuse one.
A connector without a catalog says so. Pick a result with `cotal spawn --model <id> --variant <v>`,
where `<id>` is the model id as the catalog printed it. OpenCode and Codex ids are the full
`provider/model`; Jcode ids are bare (`opus-5`, not `cliproxy/opus-5`), because the provider is
selected by the operator's Jcode config and a prefixed id is refused at launch with the bare form
named.

## endpoints

Expand Down Expand Up @@ -619,17 +625,24 @@ cotal attach --name <n> [--on <instance>] [--no-reconnect] [--space <s>]
| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |
| `--name <n>` | none | Managed agent to stop / attach (required) |
| `--on <instance>` | class anycast (`ps`: class scatter) | Pin to one manager instance id (multi-manager space); takes the whole id as `ps` prints it, not a prefix. An empty value (`--on ""`, an unset shell variable) is refused, never treated as absent |
| `--wide` (`ps`) | off | After each seat's compact row, print the per-seat facts the manager already records: model pin (and variant), `cwd`, `pid`, spawner, lifecycle uid, and the owning manager's instance id and host. A fact the manager did not record (no model pinned, or a runtime that owns no real process) prints nothing, never a placeholder |
| `--wide` (`ps`) | off | After each seat's compact row, print extra operational facts the manager records: `cwd`, `pid`, spawner, lifecycle uid, and the owning manager's instance id and host. Model and requested variant stay in the identity row rather than printing twice. A fact the manager did not record (for example a runtime with no real process) prints nothing, never a placeholder |
| `--json` (`ps`) | off | Machine-readable: one JSON object per seat per line, copied unchanged from the manager row. Instance headers and errors go to stderr, so stdout contains only rows. Mutually exclusive with `--wide` |
| `--no-reconnect` (`attach`) | off | End the attach when its session ends, instead of re-establishing it. For scripts that want one run and one exit code |

These are operator clients over the running manager's control plane. `ps` prints two facts per
managed agent, because they answer different questions: the process fact from the manager's own
runtime handle (`running` with its uptime, or `exited` with how long it ran), and the mesh fact from
the roster (`idle` / `working` / `waiting` / `mesh offline`, or `not in roster` when the seat has no
presence row at all: a seat that has not joined yet, or one that never did). A seat can be `running` and `mesh offline` at once: the process is alive and
its presence has lapsed. On a user-auth mesh `ps` also renders each managed agent's last
credential-refresh outcome, fail-closed.
The human `ps` row is presentation text and is not a stable parsing target. Scripts use `--json`,
which is the machine-readable row contract.

These are operator clients over the running manager's control plane. The default row includes the
connector, model pin, optional requested variant, and runtime as operational descriptors for the
managed row. They do not make a shared display name a unique protocol identity; use `--json` when
unambiguous owner+actor attribution is required. An omitted variant means no override was requested;
Cotal does not invent an effective provider default it cannot observe. `ps` also prints two state
facts per managed agent, because they answer different questions: the process fact from the manager's
own runtime handle (`running` with its uptime, or `exited` with how long it ran), and the mesh fact
from the roster (`idle` / `working` / `waiting` / `mesh offline`, or `not in roster` when the seat has
no presence row at all: a seat that has not joined yet, or one that never did). A seat can be
`running` and `mesh offline` at once: the process is alive and its presence has lapsed. On a user-auth
mesh `ps` also renders each managed agent's last credential-refresh outcome, fail-closed.

**Mode split (chosen up front, never try-scatter-then-degrade):**

Expand Down
26 changes: 21 additions & 5 deletions docs/connect-jcode.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ creates one private Jcode Harness API instance per seat, one Jcode session insid
the normal `cotal_*` tool surface through Jcode's documented stdio MCP configuration.

**Beta** means the supported path is deliberately narrow: a fresh private session, prompt
injection, presence, managed start/stop, and an attached TUI work. Features that do not preserve
that private session's mesh surface fail loud: `--resume`, exact-session continuation,
`--variant`, `--share-tools`, `--events`, and connector `--opt` values are not supported.
injection, presence, managed start/stop, requested reasoning effort, and an attached TUI work.
Features that do not preserve that private session's mesh surface fail loud: `--resume`,
exact-session continuation, `--share-tools`, `--events`, and connector `--opt` values are not
supported.

## Install

Expand Down Expand Up @@ -117,8 +118,23 @@ quiet ambient from that host-owned queue; its shared optional `peek` argument is
`--model` is passed to Jcode's session-level Harness API model selector. Jcode validates the model
against the active provider, then the connector reads runtime identity back and refuses startup if
it is not the requested model; a seat is never allowed to join under a model label it did not
receive. The connector does not currently offer a Cotal model catalog because the Harness API's
`listModels()` is session-scoped and provider-specific.
receive.

`cotal models --agent jcode` reads the declared catalog from the operator Jcode home's
`config.toml`: each provider with `model_catalog = true`, its `[[providers.<name>.models]]` ids,
and any declared `reasoning_efforts`. This is the same config Jcode copies into a private managed
instance. The command fails loud when the file is unreadable, malformed, or enables a catalog
without model entries.

The listed effort tiers are declarations, not provider-verified capabilities. `cotal models` prints
that caveat inline as `variants (declared, not provider-verified)` beside each configured tier list,
so it cannot be missed by reading only the model rows. Providers can reject a tier the file names,
so launch remains the authority: Jcode applies the requested value and a provider rejection ends the
launch. `--refresh` does not turn this local declaration into a live probe.

The Harness API can set a requested effort but cannot read an effective effort back. Its runtime
identity reports provider, model, and routes only; no reply or event carries the applied tier. Cotal
therefore records the accepted request and does not relabel it as an observed effect.

`--variant` is the session's **reasoning effort**, applied after the model and before the seat's
first turn, so a seat never serves a turn at an effort nobody chose. A persona's `variant:` is the
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ Ask the manager to start a new peer endpoint in your space. It joins the mesh as
|---|---|---|---|
| `name` | string | yes | Which persona to spawn: the persona FILENAME in .cotal/agents (e.g. `review-critic`), without the .md. The new peer joins under the persona's own `name:` (auto-numbered with an underscore, e.g. socrates_2, if that's taken). Fails if no such persona file exists; spawn an existing persona, don't invent a name. |
| `role` | string | no | Optional role for the new peer (e.g. worker, reviewer); overrides the persona file's role. |
| `agent` | string | no | Optional harness the new peer runs on: the agent/connector type (claude, opencode, hermes), NOT the persona to spawn (that's `name`). Defaults to the manager's COTAL_DEFAULT_AGENT, else Claude. |
| `agent` | string | no | Optional harness the new peer runs on: the agent/connector type (claude, jcode, opencode, hermes), NOT the persona to spawn (that's `name`). Resolution order: this explicit agent > the persona's agent: pin > the caller's COTAL_DEFAULT_AGENT > the manager's COTAL_DEFAULT_AGENT > the product default (Claude). |
| `model` | string | no | Optional model override (e.g. opus, sonnet); it wins over the persona file's model:. |
| `variant` | string | no | Optional model variant override (connector-defined; for OpenCode, a model variant such as high/max/low). |
| `launchOptions` | record | no | Optional connector-specific launch options: an opaque key→value map the chosen connector forwards raw to its own host form (claude CLI flags, OpenCode agent config); a connector with no option surface (Hermes) rejects any, and malformed keys are refused. |
Expand Down
5 changes: 3 additions & 2 deletions docs/run-a-mesh.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ How a spawn resolves:
picks `.cotal/agents/<name>.md`; `--config` takes an explicit ref or path. Set
`COTAL_DEFAULT_PERSONA=<name-or-path>` to change the fallback. Fields and format:
[agent files](agent-files.md).
- **Harness.** Claude by default; `--agent opencode` / `--agent hermes` / `--agent pi` per
spawn, or `COTAL_DEFAULT_AGENT` to change the default. Compared in
- **Harness.** Resolution order is an explicit `--agent` or `cotal_spawn` `agent` argument,
then the persona file's `agent:` pin, then the invoking caller's `COTAL_DEFAULT_AGENT`,
then the manager's `COTAL_DEFAULT_AGENT`, then the product default (Claude). Compared in
[Connectors](connectors.md); per-connector guides:
[Claude](connect-claude.md) · [OpenCode](connect-opencode.md) ·
[Hermes](connect-hermes.md) · [pi](connect-pi.md).
Expand Down
8 changes: 4 additions & 4 deletions extensions/connector-core/src/docs-bundle.generated.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion extensions/connector-core/src/tool-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1000,7 +1000,7 @@ export function cotalToolSpecs(config: AgentConfig, source = "connector"): Cotal
agent: z
.string()
.optional()
.describe("Optional harness the new peer runs on: the agent/connector type (claude, opencode, hermes), NOT the persona to spawn (that's `name`). Defaults to the manager's COTAL_DEFAULT_AGENT, else Claude."),
.describe("Optional harness the new peer runs on: the agent/connector type (claude, jcode, opencode, hermes), NOT the persona to spawn (that's `name`). Resolution order: this explicit agent > the persona's agent: pin > the caller's COTAL_DEFAULT_AGENT > the manager's COTAL_DEFAULT_AGENT > the product default (Claude)."),
model: z
.string()
.optional()
Expand Down
3 changes: 2 additions & 1 deletion extensions/connector-jcode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
},
"dependencies": {
"@1jehuang/jcode-sdk": "^1.1.0",
"@modelcontextprotocol/sdk": "^1.29.0"
"@modelcontextprotocol/sdk": "^1.29.0",
"smol-toml": "^1.8.0"
},
"devDependencies": {
"@cotal-ai/connector-core": "workspace:*",
Expand Down
Loading
Loading