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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@cotal-ai/delivery",
"@cotal-ai/cmux",
"@cotal-ai/tmux",
"@cotal-ai/zellij",
"@cotal-ai/connector-core",
"@cotal-ai/connector-claude-code",
"@cotal-ai/connector-hermes",
Expand Down
23 changes: 23 additions & 0 deletions .changeset/zellij-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@cotal-ai/zellij": minor
---

feat: @cotal-ai/zellij — a zellij Runtime and TerminalLayout extension

Spawns each agent into its own **tab** in a shared per-space background zellij session (the human
attaches with `zellij attach <session>`), with P3 `env -i` isolation. A `TerminalLayout` provider
lets `cotal setup` open and close zellij tabs from the ambient `$ZELLIJ_SESSION_NAME` session.
Self-registers on import (`import "@cotal-ai/zellij"`), exactly like `@cotal-ai/tmux` and
`@cotal-ai/cmux`; select it with `--runtime zellij`, which fails loud if the extension isn't
imported (no silent fallback to pty). Lifecycle keys off the stable numeric tab id `new-tab`
returns. Because zellij takes the launch command structurally as argv over its control socket
(not a rendered command line), secret env values never appear in `dump-layout`/`ps` — so no
launcher-script indirection is needed.

Adds **per-agent placement**: `Runtime.spawn` takes an optional `placement` (target tab by name,
created on demand; stacked/floating/split), so a wave lands each agent as a pane in a named lane
tab of the shared session. Only zellij reads it; the other runtimes accept and ignore it. A pure
`layout-map` module (`seedFromDump`/`generateKdl`) turns a `dump-layout` into a full-session KDL
that boots via top-level `zellij --layout`, for fresh-boot wave restart. Per-agent `placement`
threads through the manifest → resolve → spawn chain (`runtime: zellij` + `placement` on an agent
entry), and `--runtime zellij` is selectable from both runtime allow-lists.
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,28 @@ jobs:
run: echo "$HOME/nats-bin" >> "$GITHUB_PATH"
- name: Install tmux
run: tmux -V || (sudo apt-get update && sudo apt-get install -y tmux)
- name: Cache zellij
id: zellij
uses: actions/cache@v4
with:
path: ~/zellij-bin
key: zellij-v0.44.3-${{ runner.os }}
- name: Install zellij
if: steps.zellij.outputs.cache-hit != 'true'
run: |
set -euo pipefail
ZELLIJ_VERSION=0.44.3
ZELLIJ_SHA256=0f7c346788627f506c0a28296517768633cff24fc822a739f8264b640ecad751
curl -fsSL -o /tmp/zellij.tar.gz \
"https://github.com/zellij-org/zellij/releases/download/v${ZELLIJ_VERSION}/zellij-x86_64-unknown-linux-musl.tar.gz"
echo "${ZELLIJ_SHA256} /tmp/zellij.tar.gz" | sha256sum -c -
tar -xzf /tmp/zellij.tar.gz -C /tmp
mkdir -p ~/zellij-bin
install /tmp/zellij ~/zellij-bin/zellij
- name: zellij on PATH
run: |
echo "$HOME/zellij-bin" >> "$GITHUB_PATH"
"$HOME/zellij-bin/zellij" --version
- name: Build
run: pnpm build
# Broker-free boundary/preflight guards + the live target-resolution / spawn-from-anywhere /
Expand All @@ -114,6 +136,12 @@ jobs:
run: pnpm smoke:lifecycle-e2e
- name: Runtime smoke tests (tmux)
run: pnpm smoke:runtime

- name: Runtime smoke tests (zellij)
# Exercises the zellij Runtime end to end: pure layout-map (seedFromDump/generateKdl), and the
# live placement path (pane-into-named-tab, per-pane teardown) which needs an attached client —
# the smoke forks one under `script` (util-linux, present on the runner). zellij installed above.
run: pnpm smoke:zellij
- name: cmux launcher secret-hygiene smoke
run: pnpm smoke:cmux
- name: OpenCode cooperative-stop smoke test
Expand Down
1 change: 1 addition & 0 deletions bin/cotal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import "@cotal-ai/connector-opencode"; // registers the `opencode` connector (na
import "@cotal-ai/connector-hermes"; // registers the `hermes` connector (Nous Research gateway as a mesh peer)
import "@cotal-ai/cmux"; // opt into the cmux integration — registers the `cmux` runtime + TerminalLayout providers
import "@cotal-ai/tmux"; // opt into the tmux integration — registers the `tmux` runtime + TerminalLayout providers
import "@cotal-ai/zellij"; // opt into the zellij integration — registers the `zellij` runtime + TerminalLayout providers
import { claudeConnector } from "@cotal-ai/connector-claude-code";
import { registry } from "@cotal-ai/core";

Expand Down
1 change: 1 addition & 0 deletions bin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@cotal-ai/cli": "workspace:*",
"@cotal-ai/cmux": "workspace:*",
"@cotal-ai/tmux": "workspace:*",
"@cotal-ai/zellij": "workspace:*",
"@cotal-ai/connector-claude-code": "workspace:*",
"@cotal-ai/connector-hermes": "workspace:*",
"@cotal-ai/connector-opencode": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion extensions/cmux/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type AgentHandle,
type LaunchSpec,
type Pane,
type Placement,
type Runtime,
type RuntimeProvider,
type Tab,
Expand Down Expand Up @@ -76,7 +77,7 @@ function cmuxLayout(label: string, tab: Tab): string {
export class CmuxRuntime implements Runtime {
readonly kind = "cmux";

spawn(name: string, spec: LaunchSpec, cwd: string): AgentHandle {
spawn(name: string, spec: LaunchSpec, cwd: string, _placement?: Placement): AgentHandle {
// `name` becomes a temp-script key and a `cotal-<name>` tab id — keep it a bare token
// so it can't traverse paths or break the workspace label.
if (!/^[A-Za-z0-9_.-]+$/.test(name))
Expand Down
3 changes: 2 additions & 1 deletion extensions/tmux/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
registry,
type AgentHandle,
type LaunchSpec,
type Placement,
type Runtime,
type RuntimeProvider,
type Tab,
Expand Down Expand Up @@ -38,7 +39,7 @@ export class TmuxRuntime implements Runtime {

constructor(private readonly session: string) {}

spawn(name: string, spec: LaunchSpec, cwd: string): AgentHandle {
spawn(name: string, spec: LaunchSpec, cwd: string, _placement?: Placement): AgentHandle {
if (!/^[A-Za-z0-9_.-]+$/.test(name))
throw new Error(
`tmux runtime: unsafe agent name ${JSON.stringify(name)} (allowed: letters, digits, _ . -)`,
Expand Down
43 changes: 43 additions & 0 deletions extensions/zellij/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# @cotal-ai/zellij

The zellij integration: a thin driver over the zellij CLI (open/close a tab, send keys) plus a
self-registering `zellij` `Runtime` and `TerminalLayout` provider. Importing it registers both
with the core `Registry`, so the manager can spawn agents into zellij tabs without depending
on this package.

**Tier:** `extensions/`. Peer-depends [`@cotal-ai/core`](../../packages/core); self-registers on
import.

## What it does

- **`Runtime` (`zellij`)** — each agent gets its own **tab** in a shared per-space zellij session
(`cotal-<space>`), created as a background session the human attaches to (`zellij attach
<session>`). Spawned unfocused; switch to the agent's tab to watch it. Env is isolated (`env -i`)
so the zellij server's environment doesn't reach agents. Graceful stop focuses the tab, types
`/exit`, then closes it; hard stop closes immediately. The tab's stable numeric id (returned by
`new-tab`) keys the whole lifecycle, surviving tab renames.

- **`TerminalLayout` (`zellij`)** — opens/closes zellij tabs for host-side orchestration
(e.g. `cotal setup`). Detects the current session from `$ZELLIJ_SESSION_NAME`; must be called
from inside a zellij session. Supports multi-pane tabs via `new-pane`.

## Usage

```ts
import "@cotal-ai/zellij"; // self-registers; no other setup needed
```

Then select via the manager: `cotal supervise --runtime zellij`.

## Differences from `@cotal-ai/tmux`

Both spawn each agent into its own native surface in a shared per-space session and are
native-watch (no PTY streaming). tmux addresses a **window** by its stable `@N` id for every
operation, and sends keys to a specific window target. zellij only returns a tab's stable numeric
id at `new-tab` time and exposes just names thereafter — so the runtime keys lifecycle off that id
(spawn holds it), while `write`/`write-chars` target the *focused* pane, so a per-tab send focuses
the tab by name first. Like tmux, no `cli.ts` helper is included — zellij is always on PATH and
needs no bundled binary path.

See [docs/architecture.md](../../docs/architecture.md) (*Manager*) and the
[root AGENTS.md](../../AGENTS.md) for the tier rules.
34 changes: 34 additions & 0 deletions extensions/zellij/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@cotal-ai/zellij",
"description": "Cotal zellij integration: a zellij Runtime and TerminalLayout provider for spawning agents into zellij tabs.",
"version": "0.8.1",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/Cotal-AI/Cotal.git",
"directory": "extensions/zellij"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"build": "tsc -p tsconfig.json",
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"@cotal-ai/core": "workspace:*"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
}
}
Loading
Loading