okstra 0.159.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.
- package/README.md +1 -1
- package/docs/architecture/storage-model.md +2 -0
- package/docs/architecture.md +2 -1
- package/docs/cli.md +8 -3
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-inspect.md +3 -0
- package/docs/for-ai/skills/okstra-run.md +2 -1
- package/docs/for-ai/skills/okstra-user-response.md +5 -5
- package/docs/project-structure-overview.md +5 -1
- package/docs/task-process/implementation.md +28 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-claude-exec.sh +4 -1
- package/runtime/prompts/host-orchestration/README.md +18 -0
- package/runtime/prompts/host-orchestration/implementation.md +57 -0
- package/runtime/prompts/launch.template.md +10 -1
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +67 -0
- package/runtime/prompts/lead/context-loader.md +5 -2
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/plan-body-verification.md +21 -2
- package/runtime/prompts/lead/team-contract.md +2 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
- package/runtime/prompts/profiles/_common-contract.md +3 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +3 -0
- package/runtime/python/okstra_ctl/clarification_items.py +9 -0
- package/runtime/python/okstra_ctl/cmux.py +531 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
- package/runtime/python/okstra_ctl/convergence.py +168 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +76 -7
- package/runtime/python/okstra_ctl/dispatch_state.py +16 -0
- package/runtime/python/okstra_ctl/error_issue.py +640 -0
- package/runtime/python/okstra_ctl/error_report.py +56 -0
- package/runtime/python/okstra_ctl/error_zip.py +23 -10
- package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
- package/runtime/python/okstra_ctl/issue_signals.py +186 -0
- package/runtime/python/okstra_ctl/lead_runtime.py +30 -2
- package/runtime/python/okstra_ctl/paths.py +38 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
- package/runtime/python/okstra_ctl/profile_show.py +134 -0
- package/runtime/python/okstra_ctl/recap.py +63 -0
- package/runtime/python/okstra_ctl/render.py +7 -2
- package/runtime/python/okstra_ctl/render_final_report.py +7 -22
- package/runtime/python/okstra_ctl/report_translation.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +7 -3
- package/runtime/python/okstra_ctl/run.py +54 -3
- package/runtime/python/okstra_ctl/run_audit.py +477 -0
- package/runtime/python/okstra_ctl/team.py +50 -11
- package/runtime/python/okstra_ctl/user_response.py +25 -10
- package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
- package/runtime/python/okstra_ctl/wizard.py +64 -10
- package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
- package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
- package/runtime/schemas/final-report-v1.0.schema.json +14 -0
- package/runtime/schemas/final-report-v2.0.schema.json +51 -1
- package/runtime/skills/okstra-inspect/SKILL.md +3 -1
- package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
- package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
- package/runtime/skills/okstra-run/SKILL.md +28 -10
- package/runtime/skills/okstra-user-response/SKILL.md +18 -18
- package/runtime/templates/reports/final-report.template.md +4 -0
- package/runtime/templates/reports/html/i18n/en.json +5 -1
- package/runtime/templates/reports/html/i18n/ko.json +5 -1
- package/runtime/templates/reports/html/macros/forms.html +15 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
- package/runtime/templates/reports/i18n/en.json +2 -0
- package/runtime/validators/validate-run.py +267 -208
- package/runtime/validators/validate-workflow.sh +6 -0
- package/runtime/validators/validate_session_conformance.py +135 -31
- package/src/cli-registry.mjs +34 -0
- package/src/commands/execute/incremental-scope.mjs +10 -0
- package/src/commands/execute/worker-audit-check.mjs +35 -0
- package/src/commands/inspect/error-issue.mjs +27 -0
- package/src/commands/inspect/profile-show.mjs +29 -0
- package/src/commands/inspect/run-audit.mjs +26 -0
|
@@ -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
|
|
@@ -283,7 +283,7 @@ def _build_selected_worker_dispatches(
|
|
|
283
283
|
idle_timeout_seconds: int,
|
|
284
284
|
selected_workers: Sequence[str],
|
|
285
285
|
worker_requests: Sequence[InitialPromptWorkerRequest],
|
|
286
|
-
prompt_paths:
|
|
286
|
+
prompt_paths: Mapping[str, Path],
|
|
287
287
|
) -> tuple[WorkerJob, ...]:
|
|
288
288
|
return tuple(
|
|
289
289
|
_build_worker_dispatch(
|
|
@@ -298,11 +298,11 @@ def _build_selected_worker_dispatches(
|
|
|
298
298
|
model_execution_value=worker_request.model,
|
|
299
299
|
prompt_path=prompt_path,
|
|
300
300
|
)
|
|
301
|
-
for worker_id, worker_request, prompt_path in
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
301
|
+
for worker_id, worker_request, prompt_path in (
|
|
302
|
+
(worker_id, worker_request, prompt_paths[worker_id])
|
|
303
|
+
for worker_id, worker_request in zip(
|
|
304
|
+
selected_workers, worker_requests, strict=True
|
|
305
|
+
)
|
|
306
306
|
)
|
|
307
307
|
)
|
|
308
308
|
|