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.
Files changed (80) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture/storage-model.md +2 -0
  3. package/docs/architecture.md +2 -1
  4. package/docs/cli.md +8 -3
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-inspect.md +3 -0
  7. package/docs/for-ai/skills/okstra-run.md +2 -1
  8. package/docs/for-ai/skills/okstra-user-response.md +5 -5
  9. package/docs/project-structure-overview.md +5 -1
  10. package/docs/task-process/implementation.md +28 -0
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/bin/okstra-claude-exec.sh +4 -1
  14. package/runtime/prompts/host-orchestration/README.md +18 -0
  15. package/runtime/prompts/host-orchestration/implementation.md +57 -0
  16. package/runtime/prompts/launch.template.md +10 -1
  17. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  18. package/runtime/prompts/lead/adapters/cmux.md +67 -0
  19. package/runtime/prompts/lead/context-loader.md +5 -2
  20. package/runtime/prompts/lead/convergence.md +3 -1
  21. package/runtime/prompts/lead/plan-body-verification.md +21 -2
  22. package/runtime/prompts/lead/team-contract.md +2 -1
  23. package/runtime/prompts/profiles/_clarification-recommendation.md +11 -1
  24. package/runtime/prompts/profiles/_common-contract.md +3 -1
  25. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  26. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  27. package/runtime/prompts/wizard/prompts.ko.json +3 -0
  28. package/runtime/python/okstra_ctl/clarification_items.py +9 -0
  29. package/runtime/python/okstra_ctl/cmux.py +531 -0
  30. package/runtime/python/okstra_ctl/codex_dispatch.py +6 -6
  31. package/runtime/python/okstra_ctl/convergence.py +168 -11
  32. package/runtime/python/okstra_ctl/dispatch_core.py +76 -7
  33. package/runtime/python/okstra_ctl/dispatch_state.py +16 -0
  34. package/runtime/python/okstra_ctl/error_issue.py +640 -0
  35. package/runtime/python/okstra_ctl/error_report.py +56 -0
  36. package/runtime/python/okstra_ctl/error_zip.py +23 -10
  37. package/runtime/python/okstra_ctl/incremental_scope.py +159 -19
  38. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +18 -5
  39. package/runtime/python/okstra_ctl/issue_signals.py +186 -0
  40. package/runtime/python/okstra_ctl/lead_runtime.py +30 -2
  41. package/runtime/python/okstra_ctl/paths.py +38 -0
  42. package/runtime/python/okstra_ctl/plan_items_cli.py +167 -3
  43. package/runtime/python/okstra_ctl/profile_show.py +134 -0
  44. package/runtime/python/okstra_ctl/recap.py +63 -0
  45. package/runtime/python/okstra_ctl/render.py +7 -2
  46. package/runtime/python/okstra_ctl/render_final_report.py +7 -22
  47. package/runtime/python/okstra_ctl/report_translation.py +4 -0
  48. package/runtime/python/okstra_ctl/report_views.py +7 -3
  49. package/runtime/python/okstra_ctl/run.py +54 -3
  50. package/runtime/python/okstra_ctl/run_audit.py +477 -0
  51. package/runtime/python/okstra_ctl/team.py +50 -11
  52. package/runtime/python/okstra_ctl/user_response.py +25 -10
  53. package/runtime/python/okstra_ctl/verdict_blocks.py +183 -0
  54. package/runtime/python/okstra_ctl/wizard.py +64 -10
  55. package/runtime/python/okstra_ctl/worker_audit_check.py +44 -0
  56. package/runtime/python/okstra_ctl/worker_audit_ledger.py +207 -0
  57. package/runtime/python/okstra_ctl/worker_heartbeat.py +9 -3
  58. package/runtime/python/okstra_ctl/worker_liveness.py +81 -9
  59. package/runtime/schemas/final-report-v1.0.schema.json +14 -0
  60. package/runtime/schemas/final-report-v2.0.schema.json +51 -1
  61. package/runtime/skills/okstra-inspect/SKILL.md +3 -1
  62. package/runtime/skills/okstra-inspect/facets/error-issue.md +77 -0
  63. package/runtime/skills/okstra-inspect/facets/run-audit.md +34 -0
  64. package/runtime/skills/okstra-run/SKILL.md +28 -10
  65. package/runtime/skills/okstra-user-response/SKILL.md +18 -18
  66. package/runtime/templates/reports/final-report.template.md +4 -0
  67. package/runtime/templates/reports/html/i18n/en.json +5 -1
  68. package/runtime/templates/reports/html/i18n/ko.json +5 -1
  69. package/runtime/templates/reports/html/macros/forms.html +15 -0
  70. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -0
  71. package/runtime/templates/reports/i18n/en.json +2 -0
  72. package/runtime/validators/validate-run.py +267 -208
  73. package/runtime/validators/validate-workflow.sh +6 -0
  74. package/runtime/validators/validate_session_conformance.py +135 -31
  75. package/src/cli-registry.mjs +34 -0
  76. package/src/commands/execute/incremental-scope.mjs +10 -0
  77. package/src/commands/execute/worker-audit-check.mjs +35 -0
  78. package/src/commands/inspect/error-issue.mjs +27 -0
  79. package/src/commands/inspect/profile-show.mjs +29 -0
  80. 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: Sequence[Path],
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 zip(
302
- selected_workers,
303
- worker_requests,
304
- prompt_paths,
305
- strict=True,
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