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
@@ -32,6 +32,7 @@ from .convergence_store import (
32
32
  write_final_state_atomic,
33
33
  write_json_atomic,
34
34
  )
35
+ from .verdict_blocks import FindingVote, VerdictBlockError, parse_finding_votes
35
36
 
36
37
 
37
38
  _EVIDENCE_ARTIFACT = {
@@ -110,6 +111,41 @@ _EXAMPLES: dict[str, dict[str, Any]] = {
110
111
  }
111
112
  ],
112
113
  },
114
+ "coverage-batch": {
115
+ "schemaVersion": "1.0",
116
+ "taskKey": "example/group/task",
117
+ "mode": "coverage",
118
+ # The critic's own provider never votes on its gaps, so `provider` here
119
+ # is deliberately absent from `dispatches[]`.
120
+ "provider": "antigravity",
121
+ "modelExecutionValue": "gemini-3.1-pro",
122
+ "dispatches": [
123
+ {"worker": "claude-worker", "status": "completed", "durationMs": 1250},
124
+ {"worker": "codex-worker", "status": "completed", "durationMs": 980},
125
+ ],
126
+ "gaps": [
127
+ {
128
+ "gapId": "G-001",
129
+ "summary": "The rollback path has no live-data verification",
130
+ "category": "missing",
131
+ "ticketIds": ["TASK-001"],
132
+ "originEvidence": "No rollback query result is present",
133
+ "evidenceArtifacts": [_EVIDENCE_ARTIFACT],
134
+ "votes": {
135
+ "claude-worker": {
136
+ "verdict": "agree",
137
+ "disagreeBasis": None,
138
+ "explanation": "The plan cites no rollback verification step",
139
+ },
140
+ "codex-worker": {
141
+ "verdict": "agree",
142
+ "disagreeBasis": None,
143
+ "explanation": "No rollback assertion appears in the stage map",
144
+ },
145
+ },
146
+ }
147
+ ],
148
+ },
113
149
  }
114
150
 
115
151
 
@@ -131,21 +167,22 @@ def _parser() -> argparse.ArgumentParser:
131
167
  help="print a deterministic input artifact example",
132
168
  description=(
133
169
  "Print one deterministic valid example as JSON.\n\n"
134
- "`groups` feeds `seed --groups` and `round-results` feeds "
135
- "`apply-round --results`, but `critic-results` is the critic "
136
- "worker's own result document — NOT the `apply-critic-gaps "
137
- "--results` input. That input is the coverage batch the lead "
138
- "assembles from those candidates plus each analyser's vote "
139
- '({schemaVersion, taskKey, mode: "coverage", provider, '
140
- "modelExecutionValue, dispatches[], gaps[]}); see "
141
- 'prompts/lead/convergence.md §"Coverage critic". Feeding this '
142
- "example straight into `apply-critic-gaps` is rejected, by design."
170
+ "Each kind feeds one command:\n"
171
+ " groups -> seed --groups\n"
172
+ " round-results -> apply-round --results\n"
173
+ " coverage-batch -> apply-critic-gaps --results\n\n"
174
+ "`critic-results` feeds nothing: it is the critic worker's own "
175
+ "result document, NOT the `apply-critic-gaps --results` input. "
176
+ "Feeding it straight in is rejected, by design — that reducer takes "
177
+ "the coverage batch the lead assembles from those candidates plus "
178
+ "each analyser's vote, which is what `--kind coverage-batch` prints. "
179
+ 'See prompts/lead/convergence.md §"Coverage critic".'
143
180
  ),
144
181
  formatter_class=argparse.RawDescriptionHelpFormatter,
145
182
  )
146
183
  example.add_argument(
147
184
  "--kind",
148
- choices=("groups", "round-results", "critic-results"),
185
+ choices=("groups", "round-results", "critic-results", "coverage-batch"),
149
186
  required=True,
150
187
  )
151
188
 
@@ -160,6 +197,23 @@ def _parser() -> argparse.ArgumentParser:
160
197
  plan.add_argument("--work-state", type=Path, required=True)
161
198
  plan.add_argument("--plan", type=Path, required=True)
162
199
 
200
+ collect = subparsers.add_parser(
201
+ "collect-results",
202
+ help="read one round's worker responses into the apply-round input",
203
+ )
204
+ collect.add_argument("--plan", type=Path, required=True)
205
+ collect.add_argument("--mode", choices=("adversarial", "collaborative"),
206
+ required=True,
207
+ help="which reverify prompt this round used; the two "
208
+ "carry different verdict vocabularies")
209
+ collect.add_argument("--result", action="append", default=[],
210
+ metavar="<worker>=<path>",
211
+ help="one worker's reverify result file (repeatable)")
212
+ collect.add_argument("--dispatch", action="append", default=[], required=True,
213
+ metavar="<worker>=<status>:<durationMs>",
214
+ help="terminal status per dispatched worker (repeatable)")
215
+ collect.add_argument("--output", type=Path, required=True)
216
+
163
217
  apply = subparsers.add_parser("apply-round", help="apply structured round results")
164
218
  apply.add_argument("--work-state", type=Path, required=True)
165
219
  apply.add_argument("--plan", type=Path, required=True)
@@ -348,10 +402,112 @@ def _validate(args: argparse.Namespace) -> tuple[str, Path]:
348
402
  return "valid", args.state
349
403
 
350
404
 
405
+ def _split_pair(raw: str, label: str) -> tuple[str, str]:
406
+ key, separator, value = raw.partition("=")
407
+ if not separator or not key.strip() or not value.strip():
408
+ raise ConvergenceContractError(f"{label} must be <worker>=<value>, got: {raw}")
409
+ return key.strip(), value.strip()
410
+
411
+
412
+ def _dispatch_rows(raw_dispatches: list[str]) -> list[dict[str, Any]]:
413
+ """`dispatches[]`, from what only the lead knows.
414
+
415
+ Terminal status and duration are not in the worker's response; they belong
416
+ to the dispatch. Deriving them from the presence of a result file would call
417
+ a timed-out worker `completed` whenever it managed to write something.
418
+ """
419
+ rows: list[dict[str, Any]] = []
420
+ for raw in raw_dispatches:
421
+ worker, value = _split_pair(raw, "--dispatch")
422
+ status, _, duration = value.partition(":")
423
+ if not duration.isdigit():
424
+ raise ConvergenceContractError(
425
+ f"--dispatch for `{worker}` must end in :<durationMs>, got: {value}"
426
+ )
427
+ rows.append({
428
+ "worker": worker, "status": status, "durationMs": int(duration)
429
+ })
430
+ return rows
431
+
432
+
433
+ def _planned_finding_ids(plan: dict[str, Any]) -> dict[str, list[str]]:
434
+ dispatches = plan.get("dispatches")
435
+ if not isinstance(dispatches, list):
436
+ raise ConvergenceContractError("round plan has no `dispatches` array")
437
+ planned: dict[str, list[str]] = {}
438
+ for row in dispatches:
439
+ worker = row.get("worker") if isinstance(row, dict) else None
440
+ if not isinstance(worker, str) or not worker:
441
+ raise ConvergenceContractError("every round plan dispatch needs a `worker`")
442
+ planned[worker] = list(row.get("findingIds") or [])
443
+ return planned
444
+
445
+
446
+ def _worker_votes(
447
+ raw_results: list[str], planned: dict[str, list[str]], adversarial: bool
448
+ ) -> dict[str, dict[str, FindingVote]]:
449
+ votes: dict[str, dict[str, FindingVote]] = {}
450
+ for raw in raw_results:
451
+ worker, path = _split_pair(raw, "--result")
452
+ if worker not in planned:
453
+ raise ConvergenceContractError(
454
+ f"`{worker}` returned a result but the round plan did not dispatch it"
455
+ )
456
+ parsed = parse_finding_votes(
457
+ Path(path).read_text(encoding="utf-8"), adversarial=adversarial
458
+ )
459
+ assigned = set(planned[worker])
460
+ missing = sorted(assigned - set(parsed))
461
+ if missing:
462
+ raise ConvergenceContractError(
463
+ f"`{worker}` was dispatched {len(assigned)} findings but returned "
464
+ f"no verdict for {missing} — an unanswered finding cannot be "
465
+ f"classified, and dropping it silently makes the round look complete"
466
+ )
467
+ unknown = sorted(set(parsed) - assigned)
468
+ if unknown:
469
+ raise ConvergenceContractError(
470
+ f"`{worker}` returned verdicts for {unknown}, which this round's "
471
+ f"plan did not dispatch to it"
472
+ )
473
+ votes[worker] = parsed
474
+ return votes
475
+
476
+
477
+ def _collect_results(args: argparse.Namespace) -> tuple[str, Path]:
478
+ plan = load_json_object(args.plan)
479
+ planned = _planned_finding_ids(plan)
480
+ dispatches = _dispatch_rows(args.dispatch)
481
+ undispatched = sorted(set(planned) - {row["worker"] for row in dispatches})
482
+ if undispatched:
483
+ raise ConvergenceContractError(
484
+ f"the round plan dispatched {undispatched} but no --dispatch status "
485
+ f"was given for them; a planned worker with no recorded outcome is "
486
+ f"indistinguishable from one that was never asked"
487
+ )
488
+ votes = _worker_votes(args.result, planned, args.mode == "adversarial")
489
+ by_finding: dict[str, dict[str, Any]] = {}
490
+ for worker, parsed in votes.items():
491
+ for finding_id, vote in parsed.items():
492
+ by_finding.setdefault(finding_id, {})[worker] = {
493
+ "verdict": vote.verdict,
494
+ "disagreeBasis": vote.disagree_basis,
495
+ "explanation": vote.explanation,
496
+ }
497
+ write_json_atomic(args.output, {
498
+ "schemaVersion": "1.0",
499
+ "round": plan.get("round"),
500
+ "dispatches": dispatches,
501
+ "votesByFinding": by_finding,
502
+ })
503
+ return "collected", args.output
504
+
505
+
351
506
  def _execute(args: argparse.Namespace) -> tuple[str, Path]:
352
507
  operations: dict[str, Any] = {
353
508
  "seed": _seed,
354
509
  "plan-round": _plan_round,
510
+ "collect-results": _collect_results,
355
511
  "apply-round": _apply_round,
356
512
  "apply-critic-gaps": _apply_critic_gaps,
357
513
  "finalize": _finalize,
@@ -374,7 +530,8 @@ def main(argv: list[str] | None = None) -> int:
374
530
  return 0
375
531
  try:
376
532
  action, path = _execute(args)
377
- except (ConvergenceContractError, json.JSONDecodeError, ValueError) as exc:
533
+ except (ConvergenceContractError, VerdictBlockError,
534
+ json.JSONDecodeError, ValueError) as exc:
378
535
  print(f"error: {exc}", file=sys.stderr)
379
536
  return 2
380
537
  except OSError as exc:
@@ -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:
@@ -259,7 +269,9 @@ def _jobs_from_roster(
259
269
  prompt_path,
260
270
  options,
261
271
  )
262
- for fact, prompt_path in zip(worker_facts, prompt_paths, strict=True)
272
+ for fact, prompt_path in (
273
+ (fact, prompt_paths[fact.worker_id]) for fact in worker_facts
274
+ )
263
275
  ]
264
276
 
265
277
 
@@ -327,7 +339,7 @@ def _materialize_roster_prompts(
327
339
  team_state: Mapping[str, Any],
328
340
  selected: Sequence[str],
329
341
  options: _BuildOptions,
330
- ) -> tuple[tuple[_RosterWorkerFacts, ...], tuple[Path, ...]]:
342
+ ) -> tuple[tuple[_RosterWorkerFacts, ...], dict[str, Path]]:
331
343
  worker_facts = tuple(
332
344
  _roster_worker_facts(team_state, worker_id)
333
345
  for worker_id in selected
@@ -425,9 +437,29 @@ def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
425
437
  return _run_cli_wrapper(plan, job, "")
426
438
  if job.backend == BACKEND_TMUX_PANE:
427
439
  return _start_tmux_or_degrade(plan, job)
440
+ if job.backend == BACKEND_CMUX_PANE:
441
+ return _start_cmux_or_degrade(plan, job)
428
442
  raise DispatchError(f"unsupported worker backend: {job.backend}")
429
443
 
430
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
+
431
463
  def _start_tmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
432
464
  lead_pane = tmux.resolve_caller_pane()
433
465
  if not lead_pane:
@@ -559,7 +591,7 @@ def _dispatch_record(
559
591
 
560
592
 
561
593
  def _liveness_mode(backend: str) -> str:
562
- if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE):
594
+ if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE, BACKEND_CMUX_PANE):
563
595
  return LIVENESS_WRAPPER_STATUS
564
596
  return LIVENESS_AUDIT_HEARTBEAT
565
597
 
@@ -613,7 +645,44 @@ def _skip_reasons(
613
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}
614
646
 
615
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
+
616
684
  def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
685
+ _relay_to_sidebar(plan, event_type, details)
617
686
  append_lead_event(
618
687
  plan.lead_events_path,
619
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