okstra 0.160.0 → 0.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -147,6 +147,7 @@ Runtime entry points are consolidated in Python packages. Bash and skills only c
147
147
 
148
148
  - [`prompts/lead/okstra-lead-contract.md`](../prompts/lead/okstra-lead-contract.md) is the runtime-neutral lifecycle core: phase boundaries, artifacts, convergence, report ownership, and persistence semantics.
149
149
  - `prompts/lead/adapters/claude-code.md`, `prompts/lead/adapters/codex.md`, `prompts/lead/adapters/antigravity.md`, and `prompts/lead/adapters/external.md` map the same semantic operations to one selected host runtime. The generated launch prompt exposes the core path plus exactly one adapter path.
150
+ - `prompts/lead/adapters/cmux.md` is selected by environment rather than by runtime: when the run manifest's `terminalBackend` is `cmux-pane`, every lead runtime resolves to it and dispatches through `okstra team`, because okstra owns the worker panes on that path instead of the host. It overrides only the adapter and the dispatch mode; the lead's agent, role, and session accounting still come from its own runtime.
150
151
  - Runtime metadata and role assignments are persisted separately, but the lead provider is derived from the host: Claude Code maps to Claude, Codex maps to Codex, and Antigravity CLI maps to Antigravity. New runs persist `hostRuntime`, `leadAssignment`, and `workerAssignments[]`; each assignment records its provider, model, execution value, and resolved `native-session` or `cli-wrapper` runner. `lead-execution-prompt.md` is canonical, while `claude-execution-prompt.md` is a byte-identical compatibility alias for historical consumers.
151
152
  - Provider registry and front-door separation are implemented: the active Claude Code, Codex, or Antigravity host owns the native lead session, while non-host providers run through their registered CLI wrappers.
152
153
  - [`skills/okstra-setup/SKILL.md`](../skills/okstra-setup/SKILL.md) — **first-run bootstrap**. Runs `okstra install` and creates `project.json`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.160.0",
3
+ "version": "0.161.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.160.0",
3
- "builtAt": "2026-08-08T09:56:46.059Z",
2
+ "package": "0.161.0",
3
+ "builtAt": "2026-08-08T16:14:31.788Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -0,0 +1,67 @@
1
+ # cmux Lead Runtime Adapter
2
+
3
+ ## Scope
4
+
5
+ This adapter maps the neutral Okstra lead operations to a cmux session, where Okstra owns the worker panes regardless of which model is leading. Read it only when the rendered launch prompt selects it; the run manifest's `terminalBackend` is `cmux-pane` for exactly those runs.
6
+
7
+ It replaces the per-runtime adapter, not the lead contract. Your own runtime still decides how you read files, ask the user, and record your session — this file only decides how workers are started, awaited, and reclaimed.
8
+
9
+ ## Capability declaration
10
+
11
+ | Field | Value |
12
+ |---|---|
13
+ | `runtime` | environment-selected — any lead runtime resolves to this adapter under cmux |
14
+ | `leadRoleLabel` | `Okstra lead` |
15
+ | `userPromptMode` | `host-text` |
16
+ | `workerDispatchBackend` | `cmux-pane` |
17
+ | `initialPromptDeliveryMode` | `lazy-path-reference` |
18
+ | `sessionAccounting` | unchanged — keep your own runtime's accounting |
19
+ | `resumeMode` | `artifact-checkpoint` |
20
+ | `teardownMode` | `pane-teardown` |
21
+ | `leadEventSource` | `lead-events-jsonl` |
22
+
23
+ ## Semantic operation mapping
24
+
25
+ | Operation | Mapping |
26
+ |---|---|
27
+ | `read_artifacts` | Read the manifest-provided paths through the current host's file or shell interface. |
28
+ | `write_artifact` | Write only core-authorized `.okstra/` artifacts and preserve their schemas. |
29
+ | `prompt_user` | Ask through the host text/question interface and require an explicit approval or clarification response. |
30
+ | `dispatch_worker` | Run `okstra team dispatch --project-root <root> --run-manifest <path>`; use `--dry-run` first when the core requires a dispatch preview. |
31
+ | `await_workers` | Run `okstra team await --project-root <root> --run-manifest <path>` through the host's asynchronous shell facility. |
32
+ | `redispatch_worker` | Create the core-specified fresh jobs file and dispatch it with a new `dispatchKind`; never reuse a live worker conversation. |
33
+ | `shutdown_workers` | Run `okstra team teardown --project-root <root> --run-manifest <path>` only after the user-approved cleanup gate. |
34
+ | `record_lead_event` | Append the required structured event to the manifest-provided `leadEventsPath`; emit the matching user-facing `PROGRESS:` line. |
35
+ | `collect_usage` | Collect artifact/CLI-log-backed usage through the existing Okstra token-usage path; never substitute another runtime's session log. |
36
+
37
+ ## Pane placement is not yours to compute
38
+
39
+ Okstra creates, sizes, labels, and closes every worker pane. Do not issue terminal-multiplexer commands of any kind — not to place a worker, not to resize the lead, not to reclaim a finished round. Pane geometry depends on the display, and a lead that recomputes it per run gets it wrong differently on every host.
40
+
41
+ Concretely: you never choose a split direction, a pane width, a surface id, or a title. `okstra team dispatch` does all of it and records what it created.
42
+
43
+ ## Watching a worker is not the same as judging it
44
+
45
+ Workers run beside you, so you can read their screens. That is a diagnostic channel and nothing more.
46
+
47
+ - A worker is finished when its dispatch record reaches a terminal status and its required Result Paths exist. Nothing you see on a screen changes that verdict.
48
+ - Never parse a pane's contents into a result. Terminal output is a rendered grid — wrapped to the pane's width, with history truncated — so a path or a number read off it may be silently incomplete.
49
+ - Never send input to a running worker. Workers are one-shot sessions whose prompt is already delivered; retries and re-verification always create a fresh session, and interrupting a live worker contaminates exactly the context that rule protects.
50
+
51
+ Use the screen to tell "still working" from "stuck", and to see at a glance which worker failed. Use the artifacts for everything else.
52
+
53
+ ## cmux dispatch details
54
+
55
+ - For convergence reverify, consume the persisted round plan exactly. This adapter may map and transport each returned batch, but it cannot change batch membership and does not classify findings or branch on task type, provider, or model identity.
56
+ - Do not invoke Claude Code team tools or `okstra codex-dispatch`. Under cmux every lead dispatches through `okstra team`, including a Claude Code lead.
57
+ - Worker completion is valid only from `workerDispatches[]`, terminal status sidecars, and required Result Paths. Pane creation alone is not completion.
58
+ - Reverify uses a fresh jobs file at `runs/<task-type>/state/reverify-jobs-r<N>-<task-type>-<seq>.json`, sets `dispatchKind: "reverify-r<N>"`, and dispatches with `okstra team dispatch --project-root <root> --run-manifest <path> --dispatch-kind reverify-r<N> --jobs-file <jobs-file>`.
59
+ - Report-writer uses a fresh one-job jobs file with `dispatchKind: "report-writer"` and the same schema, then dispatches through `okstra team dispatch --project-root <root> --run-manifest <path> --jobs-file <jobs-file>`.
60
+ - Every reverify or report-writer jobs file carries `workerId`, `provider`, `role`, `modelExecutionValue`, `promptPath`, `resultPath`, `workerResultPath`, and `completionPaths`. For reverify, set `role` to `worker-reverify-r<N>` for the pane title. The report-writer completion paths include both data.json and the worker-results audit file.
61
+ - After either dispatch, run `okstra team await --project-root <root> --run-manifest <path>` before evaluating terminal status or completion paths.
62
+
63
+ ## Completion, cleanup, and resume
64
+
65
+ - Await through `okstra team await`; raw Result Path polling is forbidden for this backend.
66
+ - Reclaim each round's panes at the round boundary through `okstra team teardown`, before the next round's dispatch. Finished workers leave their panes behind on purpose — the screen survives the process so you can still read a failure — so an unreclaimed round keeps shrinking the space the next one gets.
67
+ - Resume from run artifacts and lead-events checkpoints. After usage collection, persistence, and the core user-approval gate, run `okstra team teardown --project-root <root> --run-manifest <path>` and tear down only Okstra-owned panes recorded for the run.
@@ -0,0 +1,531 @@
1
+ """cmux command helpers for okstra-owned worker surfaces.
2
+
3
+ The tmux sibling of this module is `tmux.py`. cmux is a GUI app, so there is no
4
+ detached-server equivalent here — every surface okstra creates lands in the
5
+ workspace the user is already looking at.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shlex
12
+ import shutil
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Sequence
17
+
18
+ PING_OK = "PONG"
19
+
20
+ # cmux answers over a local unix socket, so a probe that has not returned in a
21
+ # few seconds means the app is wedged or gone — not that it is being slow.
22
+ PROBE_TIMEOUT_SECONDS = 5
23
+
24
+ # A login shell sources the user's whole rc chain, which can be slow on a
25
+ # developer machine, so this gets far more room than a socket probe.
26
+ LOGIN_SHELL_TIMEOUT_SECONDS = 15
27
+
28
+ # cmux plants a per-surface directory of CLI shims on PATH; every entry under it
29
+ # re-enters cmux's own agent wrapper instead of the real CLI.
30
+ SHIM_DIR_MARKER = "cmux-cli-shims"
31
+
32
+ # Workers fill two columns and then grow downward. A third column would cost
33
+ # width, and width is the dimension a horizontal split cannot give back.
34
+ GRID_COLUMNS = 2
35
+
36
+ # Measured against a Claude Code worker: 67 columns renders losslessly, 33 drops
37
+ # content off the right edge. Below this floor okstra stacks a tab instead of
38
+ # splitting, because an unreadable pane costs the lead its view of that worker.
39
+ WORKER_MIN_COLUMNS = 60
40
+
41
+ # What the lead keeps for itself once workers arrive.
42
+ LEAD_TARGET_COLUMNS = 80
43
+
44
+ # Sidebar entries are keyed by source so tools do not overwrite each other's.
45
+ SIDEBAR_SOURCE = "okstra"
46
+
47
+
48
+ def cmux_cli_path() -> str:
49
+ """Absolute path to the cmux CLI, or "" when cmux is not installed.
50
+
51
+ The bundled CLI wins over PATH: cmux documents the /usr/local/bin/cmux
52
+ symlink as a manual step, so a working install may leave PATH untouched.
53
+ """
54
+ bundled = os.environ.get("CMUX_BUNDLED_CLI_PATH", "")
55
+ if bundled and os.access(bundled, os.X_OK):
56
+ return bundled
57
+ return shutil.which("cmux") or ""
58
+
59
+
60
+ def run_cmux(
61
+ args: Sequence[str], *, timeout: int = PROBE_TIMEOUT_SECONDS
62
+ ) -> subprocess.CompletedProcess[str]:
63
+ cli = cmux_cli_path()
64
+ if not cli:
65
+ raise FileNotFoundError(
66
+ "cmux CLI not found: CMUX_BUNDLED_CLI_PATH unset and no cmux on PATH"
67
+ )
68
+ return subprocess.run(
69
+ [cli, *args],
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=timeout,
73
+ check=False,
74
+ )
75
+
76
+
77
+ def cmux_available() -> bool:
78
+ """True only when okstra can drive cmux *and* knows where the lead sits.
79
+
80
+ Being able to drive cmux is not sufficient. Workers attach to the lead's
81
+ workspace, so a run whose lead location is unresolvable would open panes on
82
+ a screen nobody is watching; degrading to the blocking wrapper is the better
83
+ failure. Nested tmux is exactly that case — see `resolve_lead_workspace`.
84
+ """
85
+ if not cmux_cli_path():
86
+ return False
87
+ # Cheapest discriminator first: no workspace in the environment means this
88
+ # is not a cmux-hosted session, and the probes below would only confirm that
89
+ # the app happens to be installed.
90
+ if not _lead_workspace_env():
91
+ return False
92
+ if not _ping_answers():
93
+ return False
94
+ return bool(resolve_lead_workspace())
95
+
96
+
97
+ def resolve_lead_workspace() -> str:
98
+ """The lead's workspace UUID, or "" when it cannot be resolved.
99
+
100
+ The UUID is read from the environment rather than from `identify`, whose
101
+ refs are positional and shift as surfaces open and close. `identify` is
102
+ consulted only to prove the environment is current: a tmux server freezes
103
+ the CMUX_* block it was launched with, so those variables can outlive the
104
+ surface they name.
105
+ """
106
+ workspace = _lead_workspace_env()
107
+ if not workspace:
108
+ return ""
109
+ if not identify_caller():
110
+ return ""
111
+ return workspace
112
+
113
+
114
+ def _lead_workspace_env() -> str:
115
+ return os.environ.get("CMUX_WORKSPACE_ID", "").strip()
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class PaneGeometry:
120
+ """One pane as `pane.list` reports it.
121
+
122
+ `columns` and `rows` come straight from cmux rather than being derived from
123
+ the container frame, so a display with a different cell size needs no
124
+ conversion here.
125
+ """
126
+
127
+ pane_id: str
128
+ surface_ids: tuple[str, ...]
129
+ columns: int
130
+ rows: int
131
+ x: int
132
+ y: int
133
+ cell_width_points: int
134
+ ref: str = ""
135
+ selected_surface_id: str = ""
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class Placement:
140
+ """Where the next worker goes: split `pane_id`, or stack a tab into it."""
141
+
142
+ pane_id: str
143
+ direction: str
144
+ stack_as_tab: bool
145
+
146
+
147
+ def plan_worker_placement(
148
+ panes: Sequence[PaneGeometry], *, lead_pane_id: str, min_columns: int
149
+ ) -> Placement:
150
+ """Pick the next worker slot from the workspace's current geometry.
151
+
152
+ Stateless by design: okstra records surface UUIDs, never a layout, so a
153
+ resumed or crashed run cannot carry a layout model that no longer matches
154
+ the screen. Every dispatch re-reads the panes and derives the next slot.
155
+ """
156
+ workers = [pane for pane in panes if pane.pane_id != lead_pane_id]
157
+ if not workers:
158
+ return Placement(pane_id=lead_pane_id, direction="right", stack_as_tab=False)
159
+
160
+ columns = _panes_by_column(workers)
161
+ if len(columns) < GRID_COLUMNS:
162
+ return _widen_the_grid(workers, min_columns=min_columns)
163
+ return _extend_the_shortest_column(columns)
164
+
165
+
166
+ def lead_shrink_points(lead: PaneGeometry, *, target_columns: int) -> int:
167
+ """How far to push the lead's right border, in the points `pane.resize` takes.
168
+
169
+ The API's `amount` is points, not cells — measured at this pane's own
170
+ `cell_width_points`, so passing a column count shrinks by an eighth of the
171
+ intent on a typical display.
172
+ """
173
+ surplus = lead.columns - target_columns
174
+ if surplus <= 0:
175
+ return 0
176
+ return surplus * lead.cell_width_points
177
+
178
+
179
+ def _widen_the_grid(
180
+ workers: Sequence[PaneGeometry], *, min_columns: int
181
+ ) -> Placement:
182
+ rightmost = max(workers, key=lambda pane: pane.x)
183
+ if rightmost.columns // GRID_COLUMNS >= min_columns:
184
+ return Placement(pane_id=rightmost.pane_id, direction="right", stack_as_tab=False)
185
+ roomiest = min(workers, key=lambda pane: (len(pane.surface_ids), pane.x))
186
+ return Placement(pane_id=roomiest.pane_id, direction="", stack_as_tab=True)
187
+
188
+
189
+ def _extend_the_shortest_column(
190
+ columns: dict[int, list[PaneGeometry]]
191
+ ) -> Placement:
192
+ shortest = min(columns.values(), key=lambda group: (len(group), group[0].x))
193
+ bottom = max(shortest, key=lambda pane: pane.y)
194
+ return Placement(pane_id=bottom.pane_id, direction="down", stack_as_tab=False)
195
+
196
+
197
+ def _panes_by_column(
198
+ workers: Sequence[PaneGeometry],
199
+ ) -> dict[int, list[PaneGeometry]]:
200
+ columns: dict[int, list[PaneGeometry]] = {}
201
+ for pane in workers:
202
+ columns.setdefault(pane.x, []).append(pane)
203
+ return columns
204
+
205
+
206
+ def shim_free_login_path() -> str:
207
+ """The user's login PATH with cmux's per-surface CLI shims removed.
208
+
209
+ A cmux pane execs its command through `login … bash --noprofile --norc`, so
210
+ none of the user's shell rc runs and PATH is cmux's own short list. What that
211
+ list does hold is a shim directory bound to the new surface, where `claude`
212
+ and `codex` are wrappers into cmux's agent lifecycle rather than the real
213
+ CLIs — and where a provider cmux does not know has no entry at all, which is
214
+ a plain exit 127 inside the worker wrapper.
215
+
216
+ Restoring the login shell's PATH and dropping the shim entries gives the
217
+ wrapper what it would see in an ordinary terminal, which is what its own
218
+ `command -v <cli>` check expects. Nothing here is provider-specific, so
219
+ adding a provider does not touch this path.
220
+ """
221
+ entries = _login_shell_path().split(os.pathsep)
222
+ return os.pathsep.join(
223
+ entry for entry in entries if entry and SHIM_DIR_MARKER not in entry
224
+ )
225
+
226
+
227
+ def worker_command_line(
228
+ *, cwd: Path, argv: Sequence[str], path_value: str
229
+ ) -> str:
230
+ """The single shell line a cmux pane execs to run one worker.
231
+
232
+ Every part is shell-quoted. PATH entries routinely contain spaces — macOS
233
+ ships `/Applications/VMware Fusion.app/Contents/Public` on any machine with
234
+ VMware — and an unquoted assignment stops at the first space, then hands the
235
+ remainder to the shell as a command name.
236
+
237
+ The `cd` is part of the command because neither `new-split` nor
238
+ `respawn-pane` accepts a working directory the way `tmux split-window -c`
239
+ does.
240
+ """
241
+ return (
242
+ f"cd {shlex.quote(str(cwd))} && "
243
+ f"PATH={shlex.quote(path_value)} exec {shlex.join(argv)}"
244
+ )
245
+
246
+
247
+ def list_panes(workspace: str) -> list[PaneGeometry]:
248
+ payload = rpc("pane.list", {"workspace_id": workspace})
249
+ return [_pane_geometry(entry) for entry in payload.get("panes", [])]
250
+
251
+
252
+ def spawn_worker_surface(
253
+ *, workspace: str, cwd: Path, command: Sequence[str], title: str
254
+ ) -> str:
255
+ """Start one worker beside the lead and return its surface UUID.
256
+
257
+ The UUID is what okstra records and later closes by. Positional refs cannot
258
+ serve that purpose: cmux renumbers them as surfaces open and close, so a
259
+ close by ref can land on a pane okstra never created.
260
+ """
261
+ panes = list_panes(workspace)
262
+ lead = _lead_pane(panes)
263
+ placement = plan_worker_placement(
264
+ panes, lead_pane_id=lead.pane_id, min_columns=WORKER_MIN_COLUMNS
265
+ )
266
+ target = _pane_by_id(panes, placement.pane_id)
267
+ surface_uuid = _open_worker_surface(workspace, placement, target)
268
+ run_cmux(["rename-tab", "--surface", surface_uuid, "--title", title])
269
+ _exec_worker(surface_uuid, cwd=cwd, command=command)
270
+ _shrink_lead_pane(workspace)
271
+ return surface_uuid
272
+
273
+
274
+ def close_surface(surface_uuid: str) -> None:
275
+ """Close an okstra-created surface, killing whatever still runs inside it."""
276
+ try:
277
+ run_cmux(["close-surface", "--surface", surface_uuid])
278
+ except (OSError, subprocess.SubprocessError):
279
+ return
280
+
281
+
282
+ def capture_surface(surface_uuid: str, *, last_lines: int = 200) -> str:
283
+ """What the worker's screen shows — for the lead to look at, never to parse.
284
+
285
+ cmux hands back a rendered grid: wrapped to the pane's width with a finite
286
+ history, so a path or a count read off it can be silently truncated.
287
+ """
288
+ try:
289
+ result = run_cmux(
290
+ [
291
+ "capture-pane",
292
+ "--surface",
293
+ surface_uuid,
294
+ "--scrollback",
295
+ "--lines",
296
+ str(last_lines),
297
+ ]
298
+ )
299
+ except (OSError, subprocess.SubprocessError):
300
+ return ""
301
+ return result.stdout if result.returncode == 0 else ""
302
+
303
+
304
+ def sidebar_log(workspace: str, message: str, *, level: str = "info") -> None:
305
+ """Append one line to the workspace sidebar's log."""
306
+ _sidebar_call(
307
+ workspace,
308
+ ["log", "--workspace", workspace, "--level", level,
309
+ "--source", SIDEBAR_SOURCE, "--", message],
310
+ )
311
+
312
+
313
+ def sidebar_notify(workspace: str, *, title: str, body: str) -> None:
314
+ """Raise a notification, which badges the tab even from another workspace."""
315
+ _sidebar_call(
316
+ workspace,
317
+ ["notify", "--workspace", workspace, "--title", title, "--body", body],
318
+ )
319
+
320
+
321
+ def sidebar_progress(workspace: str, done: int, total: int, *, label: str) -> None:
322
+ """Show `done`/`total` as the sidebar's progress bar.
323
+
324
+ An empty roster reports nothing rather than zero percent: no workers is not
325
+ the same claim as no progress.
326
+ """
327
+ if total <= 0:
328
+ return
329
+ fraction = min(1.0, done / total)
330
+ _sidebar_call(
331
+ workspace,
332
+ ["set-progress", str(round(fraction, 2)), "--workspace", workspace,
333
+ "--label", label],
334
+ )
335
+
336
+
337
+ def _sidebar_call(workspace: str, args: Sequence[str]) -> None:
338
+ """Best-effort sidebar write.
339
+
340
+ The sidebar is a view of the run, not part of it, so a wedged or closed cmux
341
+ must never take a dispatch down. The workspace is always explicit: letting
342
+ cmux choose would drop one run's noise into whichever workspace happens to
343
+ be focused, which on a machine running two okstra tasks is the other one.
344
+ """
345
+ if not workspace:
346
+ return
347
+ try:
348
+ run_cmux(args)
349
+ except (OSError, subprocess.SubprocessError):
350
+ return
351
+
352
+
353
+ def rpc(method: str, params: dict[str, Any]) -> dict[str, Any]:
354
+ """Call a cmux rpc method.
355
+
356
+ The rpc surface is used rather than the same-named CLI verbs wherever both
357
+ exist, because the CLI swallows failures — `resize-pane` reports success on
358
+ a rejected resize, while `pane.resize` returns the reason.
359
+ """
360
+ result = run_cmux(["rpc", method, json.dumps(params)])
361
+ if result.returncode != 0:
362
+ raise RuntimeError(result.stderr.strip() or f"cmux {method} failed")
363
+ try:
364
+ payload = json.loads(result.stdout)
365
+ except ValueError as exc:
366
+ raise RuntimeError(f"cmux {method} returned non-JSON output") from exc
367
+ return payload if isinstance(payload, dict) else {}
368
+
369
+
370
+ def _open_worker_surface(
371
+ workspace: str, placement: Placement, target: PaneGeometry
372
+ ) -> str:
373
+ before = _surface_ids(workspace)
374
+ _create_surface(workspace, placement, target)
375
+ new_ids = _surface_ids(workspace) - before
376
+ if len(new_ids) != 1:
377
+ raise RuntimeError(
378
+ f"cmux opened {len(new_ids)} surfaces where exactly one was expected"
379
+ )
380
+ return new_ids.pop()
381
+
382
+
383
+ def _surface_ids(workspace: str) -> set[str]:
384
+ """Every surface UUID in the workspace.
385
+
386
+ The new surface is identified by diffing this set rather than by translating
387
+ the `OK surface:N` echo, because `list-pane-surfaces` reports only the
388
+ focused pane unless given a `--pane`, and the new pane is not focused.
389
+ """
390
+ return {
391
+ surface_id
392
+ for pane in list_panes(workspace)
393
+ for surface_id in pane.surface_ids
394
+ }
395
+
396
+
397
+ def _create_surface(
398
+ workspace: str, placement: Placement, target: PaneGeometry
399
+ ) -> None:
400
+ if placement.stack_as_tab:
401
+ created = run_cmux(
402
+ ["new-surface", "--workspace", workspace, "--pane", target.pane_id]
403
+ )
404
+ else:
405
+ created = run_cmux(
406
+ [
407
+ "new-split",
408
+ placement.direction,
409
+ "--workspace",
410
+ workspace,
411
+ "--surface",
412
+ target.selected_surface_id or target.surface_ids[0],
413
+ ]
414
+ )
415
+ if created.returncode != 0:
416
+ raise RuntimeError(created.stderr.strip() or "cmux could not open a pane")
417
+
418
+
419
+ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> None:
420
+ line = worker_command_line(
421
+ cwd=cwd, argv=command, path_value=shim_free_login_path()
422
+ )
423
+ started = run_cmux(["respawn-pane", "--surface", surface_uuid, "--command", line])
424
+ if started.returncode != 0:
425
+ raise RuntimeError(started.stderr.strip() or "cmux could not start the worker")
426
+
427
+
428
+ def _shrink_lead_pane(workspace: str) -> None:
429
+ """Give the lead's width to the workers by pushing its right border left.
430
+
431
+ The lead cannot shrink itself: `pane.resize` moves the named pane's border,
432
+ so asking the leftmost pane to move `left` fails with no adjacent border and
433
+ `right` widens it. The neighbour on its right carries the request instead.
434
+ """
435
+ panes = list_panes(workspace)
436
+ lead = _lead_pane(panes)
437
+ amount = lead_shrink_points(lead, target_columns=LEAD_TARGET_COLUMNS)
438
+ if amount <= 0:
439
+ return
440
+ neighbours = [pane for pane in panes if pane.x > lead.x]
441
+ if not neighbours:
442
+ return
443
+ rpc(
444
+ "pane.resize",
445
+ {
446
+ "workspace_id": workspace,
447
+ "pane_id": min(neighbours, key=lambda pane: pane.x).pane_id,
448
+ "direction": "left",
449
+ "amount": amount,
450
+ },
451
+ )
452
+
453
+
454
+ def _lead_pane(panes: Sequence[PaneGeometry]) -> PaneGeometry:
455
+ caller_pane_ref = identify_caller().get("pane_ref", "")
456
+ for pane in panes:
457
+ if caller_pane_ref and pane.ref == caller_pane_ref:
458
+ return pane
459
+ raise RuntimeError("cmux could not locate the lead's pane in its workspace")
460
+
461
+
462
+ def _pane_by_id(panes: Sequence[PaneGeometry], pane_id: str) -> PaneGeometry:
463
+ for pane in panes:
464
+ if pane.pane_id == pane_id:
465
+ return pane
466
+ raise RuntimeError(f"cmux pane {pane_id} disappeared while placing a worker")
467
+
468
+
469
+ def _pane_geometry(entry: dict[str, Any]) -> PaneGeometry:
470
+ frame = entry.get("pixel_frame") or {}
471
+ return PaneGeometry(
472
+ pane_id=str(entry.get("id", "")),
473
+ surface_ids=tuple(str(s) for s in entry.get("surface_ids") or ()),
474
+ columns=int(entry.get("columns", 0)),
475
+ rows=int(entry.get("rows", 0)),
476
+ x=int(frame.get("x", 0)),
477
+ y=int(frame.get("y", 0)),
478
+ cell_width_points=int(entry.get("cell_width_points", 0)),
479
+ ref=str(entry.get("ref", "")),
480
+ selected_surface_id=str(entry.get("selected_surface_id", "")),
481
+ )
482
+
483
+
484
+ def _login_shell_path() -> str:
485
+ """PATH as the user's own login shell resolves it.
486
+
487
+ Falls back to the inherited PATH: a worker started with a shim-filtered
488
+ inherited PATH is still better than one started with cmux's bare list.
489
+ """
490
+ shell = os.environ.get("SHELL", "") or "/bin/sh"
491
+ try:
492
+ result = subprocess.run(
493
+ [shell, "-lc", 'printf %s "$PATH"'],
494
+ capture_output=True,
495
+ text=True,
496
+ timeout=LOGIN_SHELL_TIMEOUT_SECONDS,
497
+ check=False,
498
+ )
499
+ except (OSError, subprocess.SubprocessError):
500
+ return os.environ.get("PATH", "")
501
+ if result.returncode != 0 or not result.stdout.strip():
502
+ return os.environ.get("PATH", "")
503
+ return result.stdout.strip()
504
+
505
+
506
+ def identify_caller() -> dict[str, Any]:
507
+ """The caller's ref bundle, or {} when cmux cannot resolve it.
508
+
509
+ `identify` exits 0 even for an id it cannot resolve, so the exit code says
510
+ nothing; a null `caller` is the only signal that the lookup failed.
511
+ """
512
+ try:
513
+ result = run_cmux(["identify"])
514
+ except (OSError, subprocess.SubprocessError):
515
+ return {}
516
+ if result.returncode != 0:
517
+ return {}
518
+ try:
519
+ payload = json.loads(result.stdout)
520
+ except ValueError:
521
+ return {}
522
+ caller = payload.get("caller")
523
+ return caller if isinstance(caller, dict) else {}
524
+
525
+
526
+ def _ping_answers() -> bool:
527
+ try:
528
+ result = run_cmux(["ping"])
529
+ except (OSError, subprocess.SubprocessError):
530
+ return False
531
+ return result.returncode == 0 and result.stdout.strip() == PING_OK
@@ -10,9 +10,11 @@ from datetime import datetime, timezone
10
10
  from pathlib import Path
11
11
  from typing import Any, Mapping, Sequence
12
12
 
13
+ from . import cmux
13
14
  from . import tmux
14
15
  from .dispatch_state import (
15
16
  BACKEND_CLI_WRAPPER,
17
+ BACKEND_CMUX_PANE,
16
18
  BACKEND_MIXED,
17
19
  BACKEND_TMUX_PANE,
18
20
  DispatchError,
@@ -185,11 +187,19 @@ def build_dispatch_plan(
185
187
 
186
188
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
187
189
  if wait:
188
- if any(job.backend == BACKEND_TMUX_PANE for job in plan.jobs):
190
+ pane_backends = sorted(
191
+ {
192
+ job.backend
193
+ for job in plan.jobs
194
+ if job.backend in (BACKEND_TMUX_PANE, BACKEND_CMUX_PANE)
195
+ }
196
+ )
197
+ if pane_backends:
189
198
  raise DispatchError(
190
- "wait=True dispatch does not support tmux-pane workers: "
191
- "the per-job blocking loop would serialize panes instead of "
192
- "running them concurrently; dispatch tmux panes with wait=False"
199
+ f"wait=True dispatch does not support {'/'.join(pane_backends)} "
200
+ "workers: the per-job blocking loop would serialize panes "
201
+ "instead of running them concurrently; dispatch panes with "
202
+ "wait=False"
193
203
  )
194
204
  _set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.jobs))
195
205
  for job in plan.jobs:
@@ -427,9 +437,29 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
427
437
  return _run_cli_wrapper(plan, job, "")
428
438
  if job.backend == BACKEND_TMUX_PANE:
429
439
  return _start_tmux_or_degrade(plan, job)
440
+ if job.backend == BACKEND_CMUX_PANE:
441
+ return _start_cmux_or_degrade(plan, job)
430
442
  raise DispatchError(f"unsupported worker backend: {job.backend}")
431
443
 
432
444
 
445
+ def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
446
+ workspace = cmux.resolve_lead_workspace()
447
+ if not workspace:
448
+ return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
449
+ try:
450
+ surface_id = cmux.spawn_worker_surface(
451
+ workspace=workspace,
452
+ cwd=plan.project_root,
453
+ command=job.command,
454
+ title=f"{job.worker_id}-worker",
455
+ )
456
+ except (RuntimeError, OSError, subprocess.SubprocessError):
457
+ return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
458
+ return WorkerHandle(
459
+ job, surface_id, None, status_path_for_prompt(job.prompt_path), ""
460
+ )
461
+
462
+
433
463
  def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
434
464
  lead_pane = tmux.resolve_caller_pane()
435
465
  if not lead_pane:
@@ -561,7 +591,7 @@ def _dispatch_record(
561
591
 
562
592
 
563
593
  def _liveness_mode(backend: str) -> str:
564
- if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE):
594
+ if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE, BACKEND_CMUX_PANE):
565
595
  return LIVENESS_WRAPPER_STATUS
566
596
  return LIVENESS_AUDIT_HEARTBEAT
567
597
 
@@ -615,7 +645,44 @@ def _skip_reasons(
615
645
  return {w: "skipped by worker dispatch default: worker is not supported by this dispatcher" for w in _string_list(manifest.get("recommendedWorkers")) if w not in set(selected) and w not in supported}
616
646
 
617
647
 
648
+ _SIDEBAR_LEVELS = {
649
+ "worker-dispatched": "progress",
650
+ "worker-result-collected": "success",
651
+ "worker-retry-scheduled": "warning",
652
+ "worker-failed": "error",
653
+ }
654
+
655
+
656
+ def _relay_to_sidebar(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
657
+ """Mirror a dispatch event onto the cmux sidebar.
658
+
659
+ A long run is mostly silence, and the sidebar is the one surface still
660
+ visible after the user scrolls away or switches workspaces. A failed worker
661
+ additionally raises a notification, because that is the event whose cost
662
+ grows the longer it goes unnoticed.
663
+ """
664
+ # The plan's backend, not the manifest field it was resolved from: a job
665
+ # that degraded to the blocking wrapper is still part of a cmux run, and
666
+ # that degradation is exactly what the sidebar should keep showing.
667
+ if plan.default_backend != BACKEND_CMUX_PANE:
668
+ return
669
+ workspace = cmux.resolve_lead_workspace()
670
+ worker_id = str(details.get("workerId", "") or "worker")
671
+ cmux.sidebar_log(
672
+ workspace,
673
+ f"{worker_id}: {event_type.removeprefix('worker-')}",
674
+ level=_SIDEBAR_LEVELS.get(event_type, "info"),
675
+ )
676
+ if event_type == "worker-failed":
677
+ cmux.sidebar_notify(
678
+ workspace,
679
+ title=f"okstra — {_require_string(plan.manifest, 'taskType')}",
680
+ body=f"{worker_id} failed: {details.get('reason', 'no reason recorded')}",
681
+ )
682
+
683
+
618
684
  def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
685
+ _relay_to_sidebar(plan, event_type, details)
619
686
  append_lead_event(
620
687
  plan.lead_events_path,
621
688
  LeadEvent(
@@ -24,6 +24,7 @@ from datetime import datetime, timezone
24
24
  from pathlib import Path
25
25
  from typing import Any, Callable, Mapping, Sequence
26
26
 
27
+ from . import cmux
27
28
  from .worker_prompt_contract import (
28
29
  PromptRecord,
29
30
  validate_initial_prompt_records,
@@ -32,8 +33,23 @@ from .worker_prompt_contract import (
32
33
 
33
34
  BACKEND_CLI_WRAPPER = "cli-wrapper"
34
35
  BACKEND_TMUX_PANE = "tmux-pane"
36
+ BACKEND_CMUX_PANE = "cmux-pane"
35
37
  BACKEND_MIXED = "mixed"
36
38
 
39
+
40
+ def detect_terminal_backend() -> str:
41
+ """Which pane backend this run gets. Called once, by prepare.
42
+
43
+ cmux wins wherever it is usable: the only thing a tmux session buys a lead
44
+ is Claude Code's AgentTeam, and the cmux path deliberately does not use it.
45
+ Everywhere else this answers `tmux-pane`, which is what every run got before
46
+ cmux existed. The answer is written to the run manifest and read back from
47
+ there — consumers must not re-detect, or two phases of one run can disagree.
48
+ """
49
+ if cmux.cmux_available():
50
+ return BACKEND_CMUX_PANE
51
+ return BACKEND_TMUX_PANE
52
+
37
53
  # `livenessMode` picks which artifact answers "is this worker still alive": the
38
54
  # in-process worker's audit sidecar heartbeat, or the CLI wrapper's status
39
55
  # sidecar. Both dispatchers write it and `worker_liveness` reads it, so the
@@ -1,7 +1,7 @@
1
1
  """Lead runtime metadata shared by render and prepare paths."""
2
2
  from __future__ import annotations
3
3
 
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, replace
5
5
 
6
6
 
7
7
  @dataclass(frozen=True)
@@ -24,9 +24,14 @@ class LeadRuntimeInfo:
24
24
  }
25
25
 
26
26
 
27
- ALLOWED_LEAD_RUNTIMES = ("claude-code", "codex", "antigravity", "external")
28
27
  ARTIFACT_ONLY_LEAD_RUNTIMES = frozenset({"codex", "antigravity", "external"})
29
28
 
29
+ # Not a lead runtime — an adapter selected by the environment, so it is absent
30
+ # from ALLOWED_LEAD_RUNTIMES and from _LEAD_RUNTIMES on purpose. `--lead-runtime
31
+ # cmux` is not a thing a user can ask for; see `with_cmux_dispatch`.
32
+ CMUX_ADAPTER_NAME = "cmux"
33
+ CMUX_ADAPTER_RELATIVE_PATH = "lead/adapters/cmux.md"
34
+
30
35
  _LEAD_RUNTIMES = {
31
36
  "claude-code": LeadRuntimeInfo(
32
37
  runtime="claude-code",
@@ -75,6 +80,11 @@ _LEAD_RUNTIMES = {
75
80
  }
76
81
 
77
82
 
83
+ # Derived rather than restated: a hand-written copy of the registry's own keys
84
+ # is a list that can disagree with the thing it describes.
85
+ ALLOWED_LEAD_RUNTIMES = tuple(_LEAD_RUNTIMES)
86
+
87
+
78
88
  def lead_runtime_info(runtime: str) -> LeadRuntimeInfo:
79
89
  try:
80
90
  return _LEAD_RUNTIMES[runtime]
@@ -85,3 +95,21 @@ def lead_runtime_info(runtime: str) -> LeadRuntimeInfo:
85
95
 
86
96
  def is_artifact_only_runtime(runtime: str) -> bool:
87
97
  return runtime in ARTIFACT_ONLY_LEAD_RUNTIMES
98
+
99
+
100
+ def with_cmux_dispatch(info: LeadRuntimeInfo) -> LeadRuntimeInfo:
101
+ """The same lead, minus the two fields cmux takes over.
102
+
103
+ On the cmux path okstra owns the worker panes rather than the host, so
104
+ "which adapter" and "does this lead dispatch or only render" stop depending
105
+ on who the lead is — they are the same for every runtime. Overriding the two
106
+ fields keeps that answer in one adapter file rather than repeating it in
107
+ four. Everything else (agent, label, role, session accounting) still
108
+ describes the lead itself and is left untouched.
109
+ """
110
+ return replace(
111
+ info,
112
+ adapter_name=CMUX_ADAPTER_NAME,
113
+ adapter_dispatch_mode="team",
114
+ adapter_contract_relative_path=CMUX_ADAPTER_RELATIVE_PATH,
115
+ )
@@ -31,7 +31,8 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
31
31
  from . import fix_cycles
32
32
  from .analysis_inputs import ANALYSIS_TASK_TYPES
33
33
  from .paths import okstra_home
34
- from .lead_runtime import lead_runtime_info
34
+ from .dispatch_state import BACKEND_CMUX_PANE
35
+ from .lead_runtime import lead_runtime_info, with_cmux_dispatch
35
36
  from .models import UnknownProviderError, provider_ids, provider_spec
36
37
  from .runner_resolution import native_provider_for_host
37
38
  from .path_hints import compact_active_run_context, hydrate_run_context
@@ -80,7 +81,10 @@ def _lead_runtime(ctx: dict) -> str:
80
81
 
81
82
 
82
83
  def _lead_info(ctx: dict):
83
- return lead_runtime_info(_lead_runtime(ctx))
84
+ info = lead_runtime_info(_lead_runtime(ctx))
85
+ if ctx.get("TERMINAL_BACKEND") == BACKEND_CMUX_PANE:
86
+ return with_cmux_dispatch(info)
87
+ return info
84
88
 
85
89
 
86
90
  def _lead_agent(ctx: dict) -> str:
@@ -1444,6 +1448,7 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1444
1448
  "hostRuntime": ctx.get("HOST_RUNTIME", "") or _lead_runtime(ctx),
1445
1449
  "leadRuntime": _lead_runtime(ctx),
1446
1450
  "leadRuntimeRequest": ctx.get("LEAD_RUNTIME_REQUEST", "") or _lead_runtime(ctx),
1451
+ "terminalBackend": ctx.get("TERMINAL_BACKEND", ""),
1447
1452
  "runtimeResolution": _runtime_resolution(ctx),
1448
1453
  "leadAssignment": _lead_assignment(ctx),
1449
1454
  "workerAssignments": _worker_assignments(ctx),
@@ -96,6 +96,7 @@ from .render import (
96
96
  )
97
97
  from okstra_project.dirs import okstra_home
98
98
 
99
+ from .dispatch_state import BACKEND_CMUX_PANE, detect_terminal_backend
99
100
  from .run_context import (
100
101
  compute_and_write_run_context,
101
102
  refresh_run_context_snapshot,
@@ -2394,7 +2395,17 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2394
2395
  json.loads(runtime_resolution_json or "{}")
2395
2396
  except json.JSONDecodeError as exc:
2396
2397
  raise PrepareError(f"invalid --runtime-resolution-json: {exc}") from exc
2397
- if lead_runtime != "claude-code" and not inp.render_only:
2398
+ # Probed once here and reused below, so the gate and the manifest cannot
2399
+ # disagree about which backend this run is on.
2400
+ terminal_backend = detect_terminal_backend()
2401
+ # Outside cmux only claude-code has a dispatch backend of its own. Under
2402
+ # cmux okstra owns the panes for every lead, so the render-only restriction
2403
+ # no longer applies to any runtime.
2404
+ if (
2405
+ lead_runtime != "claude-code"
2406
+ and terminal_backend != BACKEND_CMUX_PANE
2407
+ and not inp.render_only
2408
+ ):
2398
2409
  raise PrepareError(
2399
2410
  f"lead runtime `{lead_runtime}` is currently render-only; "
2400
2411
  "use --render-only until a dispatch backend is enabled for this lead runtime."
@@ -2495,6 +2506,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2495
2506
  )
2496
2507
 
2497
2508
  ctx.update({
2509
+ "TERMINAL_BACKEND": terminal_backend,
2498
2510
  "EXECUTOR_WORKTREE_PATH": worktree.path,
2499
2511
  "EXECUTOR_WORKTREE_BRANCH": worktree.branch,
2500
2512
  "EXECUTOR_WORKTREE_BASE_REF": worktree.base_ref,
@@ -1,4 +1,9 @@
1
- """Neutral okstra team CLI for external tmux-pane worker dispatch."""
1
+ """Neutral okstra team CLI for pane-backed worker dispatch.
2
+
3
+ Outside cmux this is the external lead's door onto tmux panes. Under cmux it
4
+ is every lead's door onto cmux surfaces, because okstra owns the panes there
5
+ rather than the host. Which backend a run uses is read from its run manifest.
6
+ """
2
7
  from __future__ import annotations
3
8
 
4
9
  import argparse
@@ -7,8 +12,10 @@ import sys
7
12
  from pathlib import Path
8
13
  from typing import Any, Mapping, Sequence
9
14
 
15
+ from . import cmux
10
16
  from . import tmux
11
17
  from .dispatch_core import (
18
+ BACKEND_CMUX_PANE,
12
19
  BACKEND_TMUX_PANE,
13
20
  DispatchError,
14
21
  DispatchPlan,
@@ -51,7 +58,7 @@ def _parser() -> argparse.ArgumentParser:
51
58
 
52
59
 
53
60
  def _add_dispatch_parser(sub) -> None:
54
- parser = sub.add_parser("dispatch", help="dispatch tmux-pane workers")
61
+ parser = sub.add_parser("dispatch", help="dispatch pane-backed workers")
55
62
  _add_run_args(parser)
56
63
  parser.add_argument("--workers", default="")
57
64
  parser.add_argument("--jobs-file", default="")
@@ -61,7 +68,7 @@ def _add_dispatch_parser(sub) -> None:
61
68
 
62
69
 
63
70
  def _add_await_parser(sub) -> None:
64
- parser = sub.add_parser("await", help="wait for tmux-pane workers")
71
+ parser = sub.add_parser("await", help="wait for pane-backed workers")
65
72
  _add_run_args(parser)
66
73
  parser.add_argument("--poll-interval-seconds", type=int, default=5)
67
74
  parser.add_argument("--timeout-seconds", type=int, default=None)
@@ -70,7 +77,7 @@ def _add_await_parser(sub) -> None:
70
77
 
71
78
 
72
79
  def _add_teardown_parser(sub) -> None:
73
- parser = sub.add_parser("teardown", help="kill tmux-pane workers")
80
+ parser = sub.add_parser("teardown", help="reclaim this run's worker panes")
74
81
  _add_run_args(parser)
75
82
  parser.add_argument("--dry-run", action="store_true")
76
83
  parser.add_argument("--json", action="store_true")
@@ -94,8 +101,8 @@ def _dispatch(args) -> int:
94
101
  okstra_bin=Path(args.okstra_bin),
95
102
  requested_workers=requested,
96
103
  idle_timeout_seconds=args.idle_timeout_seconds,
97
- required_lead_runtime="external",
98
- default_backend=BACKEND_TMUX_PANE,
104
+ required_lead_runtime=None if _is_cmux_run(manifest) else "external",
105
+ default_backend=_manifest_backend(manifest),
99
106
  supported_worker_wrappers=_SUPPORTED_WRAPPERS,
100
107
  unsupported_worker_label="external lead",
101
108
  dispatch_kind=args.dispatch_kind,
@@ -133,13 +140,13 @@ def _teardown(args) -> int:
133
140
  team_state_path = _resolve_project_path(project_root, _require_string(manifest, "teamStatePath"))
134
141
  team_state = _load_json(team_state_path, "team-state")
135
142
  run_dir = _resolve_project_path(project_root, _require_string(manifest, "runDirectoryPath"))
136
- lead_pane = tmux.resolve_caller_pane()
137
- panes = _teardown_panes(team_state, run_dir, lead_pane)
143
+ panes = _reclaimable_panes(manifest, team_state, run_dir)
138
144
  if args.dry_run:
139
145
  _emit_teardown(args.json, panes)
140
146
  return 0
147
+ reclaim = cmux.close_surface if _is_cmux_run(manifest) else tmux.kill_pane
141
148
  for pane in panes:
142
- tmux.kill_pane(pane["paneId"])
149
+ reclaim(pane["paneId"])
143
150
  _mark_teardown_errors(team_state_path)
144
151
  _emit_teardown(args.json, panes)
145
152
  return 0
@@ -158,7 +165,7 @@ def _plan_for_existing(
158
165
  lead_events_path=_resolve_project_path(root, _require_string(manifest, "leadEventsPath")),
159
166
  manifest=manifest,
160
167
  jobs=(),
161
- default_backend=BACKEND_TMUX_PANE,
168
+ default_backend=_manifest_backend(manifest),
162
169
  )
163
170
 
164
171
 
@@ -174,12 +181,25 @@ def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
174
181
  }
175
182
 
176
183
 
177
- def _teardown_panes(team_state: Mapping[str, Any], run_dir: Path, lead_pane: str) -> list[dict[str, str]]:
184
+ def _reclaimable_panes(
185
+ manifest: Mapping[str, Any], team_state: Mapping[str, Any], run_dir: Path
186
+ ) -> list[dict[str, str]]:
187
+ """Everything this run owns and may close.
188
+
189
+ Under cmux the recorded ids are the whole list. There is no per-pane tag API
190
+ to sweep with, and scanning by title would be worse than nothing: cmux labels
191
+ its own agent surfaces with the same glyph okstra's tmux cleanup treats as a
192
+ teammate marker, so a sweep could close the lead. Only surfaces okstra
193
+ created are recorded, so only those can be closed.
194
+ """
178
195
  seen: set[str] = set()
179
196
  panes: list[dict[str, str]] = []
180
197
  for record in team_state.get("workerDispatches", []):
181
198
  if isinstance(record, dict):
182
199
  _append_pane(panes, seen, str(record.get("paneId", "")), "worker")
200
+ if _is_cmux_run(manifest):
201
+ return panes
202
+ lead_pane = tmux.resolve_caller_pane()
183
203
  for pane in tmux.list_run_panes(run_dir, lead_pane=lead_pane):
184
204
  _append_pane(panes, seen, pane.pane_id, pane.kind)
185
205
  return panes
@@ -208,10 +228,29 @@ def _emit_teardown(as_json: bool, panes: list[dict[str, str]]) -> None:
208
228
  print(f"{pane['paneId']}\t{pane['kind']}")
209
229
 
210
230
 
231
+ def _manifest_backend(manifest: Mapping[str, Any]) -> str:
232
+ """The backend prepare recorded for this run.
233
+
234
+ Deliberately not a flag on this command: the manifest already answers it,
235
+ and a flag would be a second answer free to disagree. A manifest written
236
+ before the field existed reads as tmux, which is what those runs used.
237
+ """
238
+ return str(manifest.get("terminalBackend") or "") or BACKEND_TMUX_PANE
239
+
240
+
241
+ def _is_cmux_run(manifest: Mapping[str, Any]) -> bool:
242
+ return _manifest_backend(manifest) == BACKEND_CMUX_PANE
243
+
244
+
211
245
  def _validate_external_manifest(manifest: Mapping[str, Any]) -> None:
212
246
  runtime = manifest.get("leadRuntime")
213
247
  if runtime == "external":
214
248
  return
249
+ if _is_cmux_run(manifest):
250
+ # Under cmux okstra owns the panes for every lead, so this command is no
251
+ # longer the external lead's private door and the advice below no longer
252
+ # applies — there is nowhere else for a codex or Claude Code lead to go.
253
+ return
215
254
  if runtime == "codex":
216
255
  raise DispatchError("use okstra codex-dispatch for leadRuntime=codex")
217
256
  if runtime == "claude-code":
@@ -39,6 +39,12 @@ SECONDARY_BRIEF_FILENAME="validation-brief-secondary.md"
39
39
  export OKSTRA_SKIP_INSTALL_CHECK="${OKSTRA_SKIP_INSTALL_CHECK:-1}"
40
40
  export OKSTRA_CTL_SKIP_RECONCILE="${OKSTRA_CTL_SKIP_RECONCILE:-1}"
41
41
  export OKSTRA_CTL_SKIP_BACKFILL="${OKSTRA_CTL_SKIP_BACKFILL:-1}"
42
+ # Same reason as the three above: the synthetic run must render the same way on
43
+ # every machine. cmux outranks tmux when present, so a maintainer running this
44
+ # from inside cmux would otherwise get the cmux adapter and a lead-session
45
+ # requirement this fixture never simulates. The cmux path has its own coverage
46
+ # in tests/run/test_cmux*.py and tests/contract/test_validate_session_conformance.py.
47
+ export CMUX_WORKSPACE_ID=""
42
48
 
43
49
  # shellcheck source=lib/common.sh
44
50
  source "$SCRIPT_DIR/lib/common.sh"
@@ -112,9 +112,19 @@ _ENTRY_GUARD_READS = (
112
112
  ),
113
113
  )
114
114
 
115
+ # 환경으로 선택되는 어댑터라 lead 의 런타임이 이 파일을 가르쳐 주지 않는다.
116
+ # 읽지 않은 lead 는 자기 런타임이 아는 방식 — cmux 경로에서는 okstra 가 소유한
117
+ # 디스패치를 host 네이티브로 가로채는 방식 — 으로 되돌아간다.
118
+ CMUX_ADAPTER_BASENAME = "cmux.md"
119
+ CMUX_ADAPTER_NAME = "cmux"
120
+ _CMUX_ADAPTER_CITE = "prompts/lead/adapters/cmux.md"
121
+
115
122
  # Read 증거는 basename 으로 거른다 — 절대 경로는 레이어(repo / runtime / 설치본)
116
123
  # 마다 다르지만 basename 은 동일하다. 목록이 갈리지 않도록 기대치에서 파생한다.
117
- _ENTRY_GUARD_BASENAMES = tuple(row.basename for row in _ENTRY_GUARD_READS)
124
+ _TRACKED_READ_BASENAMES = (
125
+ *(row.basename for row in _ENTRY_GUARD_READS),
126
+ CMUX_ADAPTER_BASENAME,
127
+ )
118
128
 
119
129
 
120
130
  @dataclass
@@ -208,7 +218,7 @@ def _scan_one_jsonl(
208
218
  progress.append((ts, m.group("phase"), line))
209
219
  elif block.get("type") == "tool_use" and block.get("name") == "Read":
210
220
  base = Path(str((block.get("input") or {}).get("file_path") or "")).name
211
- if base in _ENTRY_GUARD_BASENAMES:
221
+ if base in _TRACKED_READ_BASENAMES:
212
222
  reads.setdefault(base, []).append(ts)
213
223
  return progress, reads, agent_name
214
224
 
@@ -334,7 +344,7 @@ def _sidecar_read_from_event(event) -> tuple[str, str] | None:
334
344
  if not isinstance(basename, str) or not basename:
335
345
  raw_path = details.get("path") or details.get("filePath")
336
346
  basename = Path(str(raw_path or "")).name
337
- if basename not in _ENTRY_GUARD_BASENAMES:
347
+ if basename not in _TRACKED_READ_BASENAMES:
338
348
  return None
339
349
  return (basename, event.timestamp)
340
350
 
@@ -752,6 +762,29 @@ def _instruction_set_dir(run_dir: Path, suffix: str | None, project_root: Path)
752
762
  return path if path.is_dir() else None
753
763
 
754
764
 
765
+ def _check_cmux_adapter_read(
766
+ evidence: _LeadEvidence, team_state: dict, errors: list[str]
767
+ ) -> None:
768
+ """검사 4 — cmux 어댑터 읽음. 모든 task-type 에 적용된다.
769
+
770
+ 다른 어댑터는 lead 의 런타임이 고르지만 이것은 환경이 고른다. 그래서 읽지
771
+ 않은 lead 에게는 이 경로가 존재한다는 사실 자체가 닿지 않고, 자기 런타임이
772
+ 아는 host 네이티브 디스패치로 되돌아간다 — cmux 경로에서 okstra 가 소유한
773
+ 바로 그 일이다."""
774
+ adapter = team_state.get("leadAdapter")
775
+ name = str(adapter.get("name", "")).strip() if isinstance(adapter, dict) else ""
776
+ if name != CMUX_ADAPTER_NAME:
777
+ return
778
+ if evidence.sidecar_reads.get(CMUX_ADAPTER_BASENAME):
779
+ return
780
+ errors.append(
781
+ f"cmux adapter: no `Read` of `{CMUX_ADAPTER_BASENAME}` found in the "
782
+ "selected adapter evidence source within this run's window — the cmux "
783
+ "adapter is selected by environment, not by lead runtime, so it MUST be "
784
+ f"read before dispatch ({_CMUX_ADAPTER_CITE})."
785
+ )
786
+
787
+
755
788
  def _check_implementation_entry_guard(
756
789
  evidence: _LeadEvidence, errors: list[str], instruction_set: Path | None
757
790
  ) -> None:
@@ -837,6 +870,7 @@ def validate_session_conformance(
837
870
  result.errors.append(error)
838
871
  return result
839
872
  _check_progress_checkpoints(evidence, team_state, run_dir, suffix, result.errors)
873
+ _check_cmux_adapter_read(evidence, team_state, result.errors)
840
874
  if task_type == "implementation":
841
875
  _check_implementation_entry_guard(
842
876
  evidence,