okstra 0.195.4 → 0.196.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.
@@ -99,7 +99,7 @@ The host-native Okstra lead owns judgment policy and worker orchestration. okstr
99
99
 
100
100
  Canonical roles are `leader`, `analyser`, `critic`, `designer`, `planner`, `implementer`, `verifier`, `report-writer`, and `translator`. `lead` is a compatibility alias for `leader`. `executor` is a compatibility alias for `implementer`. New artifacts write only the canonical names.
101
101
 
102
- Selection is role-first: `--role-count <role>=<N>` creates `RoleInstance` ordinals, `--role-model <role>=<modelRef>` and `modelDefaults` feed `ModelPool`, and a pinned model is kept only when the host can bind it exactly. The resulting `RoleExecution` owns `Invocation` and `Attempt` rows plus the stored `executionLabel`. Pane titles use that label. Shared Git object stores and other-stage refs are observed-projection only; they are not an audit enforcement surface.
102
+ Selection is role-first: `--role-count <role>=<N>` creates `RoleInstance` ordinals, `--role-model <role>=<modelRef>` and `modelDefaults` feed `ModelPool`, and a pinned model is kept only when the host can bind it exactly. The resulting `RoleExecution` owns `Invocation` and `Attempt` rows plus the stored `executionLabel`. Worker pane titles use that label. The lead's own cmux surface is titled `<task-group>/<task-id>` by `prepare_task_bundle` (`run.py` `_title_lead_pane`, cmux backend only): prepare runs in the lead's pane, so the calling surface from `cmux identify` is the lead's. A rename cmux refuses is a stderr line, not a failed prepare. Shared Git object stores and other-stage refs are observed-projection only; they are not an audit enforcement surface.
103
103
 
104
104
  ## Runtime assets vs support assets
105
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.195.4",
3
+ "version": "0.196.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.195.4",
3
- "builtAt": "2026-09-09T19:01:57.276Z",
2
+ "package": "0.196.0",
3
+ "builtAt": "2026-09-09T20:48:12.483Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -93,7 +93,9 @@ class CodexExecution:
93
93
  """
94
94
 
95
95
  def build_command(self, request: WorkerExecRequest) -> ExecCommand:
96
- argv = ["codex", "exec", "-C", str(request.project_root)]
96
+ # 출력은 파이프로 수집하므로 auto 는 색상을 끈다. 화면 표시와 기록의
97
+ # 색상 제거 여부는 공통 세션 기록기가 목적지에 맞춰 결정한다.
98
+ argv = ["codex", "exec", "--color", "always", "-C", str(request.project_root)]
97
99
  for directory in request.policy.write_scope:
98
100
  if directory != request.project_root:
99
101
  argv += ["--add-dir", str(directory)]
@@ -421,7 +421,7 @@ def worker_command_line(
421
421
  """
422
422
  return (
423
423
  f"cd {shlex.quote(str(cwd))} && "
424
- f"PATH={shlex.quote(path_value)} exec {shlex.join(argv)}"
424
+ f"PATH={shlex.quote(path_value)} FORCE_COLOR=1 exec {shlex.join(argv)}"
425
425
  )
426
426
 
427
427
 
@@ -456,7 +456,7 @@ def spawn_worker_surface(
456
456
  )
457
457
  target = _pane_by_id(panes, placement.pane_id)
458
458
  surface_uuid = _open_worker_surface(workspace, placement, target)
459
- run_cmux(["rename-tab", "--surface", surface_uuid, "--title", title])
459
+ rename_surface(surface_uuid, title)
460
460
  _exec_worker(surface_uuid, cwd=cwd, command=command)
461
461
  owned = (*owned_surface_ids, surface_uuid)
462
462
  _size_lead_pane(workspace, owned)
@@ -464,6 +464,37 @@ def spawn_worker_surface(
464
464
  return surface_uuid
465
465
 
466
466
 
467
+ def rename_surface(surface: str, title: str) -> subprocess.CompletedProcess[str]:
468
+ """Set the tab title cmux shows for one surface.
469
+
470
+ `surface` is a UUID for the worker surfaces okstra opened and a short ref
471
+ (`surface:N`) for the caller's own surface; cmux accepts both.
472
+ """
473
+ return run_cmux(["rename-tab", "--surface", surface, "--title", title])
474
+
475
+
476
+ def rename_lead_surface(title: str) -> str:
477
+ """Title the surface this process runs in; "" on success, else the reason.
478
+
479
+ The lead is whatever pane invoked okstra, so the surface comes from
480
+ `identify` rather than from anything okstra recorded. A failure is returned
481
+ instead of raised: the title is a courtesy for the person watching the
482
+ workspace, and prepare has already written every manifest by the time it
483
+ is applied.
484
+ """
485
+ surface = identify_caller().get("surface_ref", "")
486
+ if not surface:
487
+ return "cmux could not identify the calling surface"
488
+ try:
489
+ result = rename_surface(surface, title)
490
+ except (OSError, subprocess.SubprocessError) as exc:
491
+ return f"cmux rename-tab failed: {exc}"
492
+ if result.returncode != 0:
493
+ detail = (result.stderr or result.stdout).strip() or f"exit {result.returncode}"
494
+ return f"cmux rename-tab failed: {detail}"
495
+ return ""
496
+
497
+
467
498
  def close_surface(surface_uuid: str) -> None:
468
499
  """Close an okstra-created surface, killing whatever still runs inside it."""
469
500
  try:
@@ -13,6 +13,7 @@ CLI 는 갈라 읽어야 한다.
13
13
  from __future__ import annotations
14
14
 
15
15
  import json
16
+ import re
16
17
  from dataclasses import dataclass, field
17
18
  from pathlib import Path
18
19
  from typing import Any, Callable, Literal, Mapping, Protocol, runtime_checkable
@@ -34,6 +35,11 @@ ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
34
35
  ObserveUsage = Callable[[Mapping[str, Any]], Mapping[str, Any] | None]
35
36
 
36
37
  WORKER = "worker"
38
+ _TERMINAL_COLORS = re.compile(r"\x1b\[[0-9;:]*m")
39
+
40
+
41
+ def strip_terminal_colors(text: str) -> str:
42
+ return _TERMINAL_COLORS.sub("", text)
37
43
 
38
44
 
39
45
  class TranscriptWriter(Protocol):
@@ -93,7 +99,7 @@ class SplitText:
93
99
  def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
94
100
  def result(line: str) -> str | None:
95
101
  writer.write(WORKER, line)
96
- return line
102
+ return strip_terminal_colors(line)
97
103
 
98
104
  def progress(line: str) -> str | None:
99
105
  writer.write(WORKER, line)
@@ -4563,6 +4563,25 @@ def _resolve_terminal_backend(project_root: Path, inp: PrepareInputs) -> str:
4563
4563
  ) if part))
4564
4564
 
4565
4565
 
4566
+ def lead_pane_title(task_group: str, task_id: str) -> str:
4567
+ """cmux 에서 리드 pane 에 붙는 제목. 사용자가 여러 task 의 pane 을 구분하는
4568
+ 이름이므로 slug 가 아니라 입력한 task-group / task-id 그대로 쓴다."""
4569
+ return f"{task_group}/{task_id}"
4570
+
4571
+
4572
+ def _title_lead_pane(inp: PrepareInputs) -> None:
4573
+ """리드 pane 제목을 `<task-group>/<task-id>` 로 바꾼다 (cmux 백엔드 전용).
4574
+
4575
+ prepare 는 리드 세션(또는 리드를 띄울 pane)에서 실행되므로 호출 surface 가
4576
+ 곧 리드 pane 이다. 제목은 화면 편의라 실패해도 run 을 막지 않지만, 무엇이
4577
+ 막았는지는 stderr 에 남긴다 — codex 샌드박스가 cmux 소켓을 EPERM 으로 막는
4578
+ 경우가 실제로 있다(`_resolve_terminal_backend` 참조).
4579
+ """
4580
+ reason = cmux.rename_lead_surface(lead_pane_title(inp.task_group, inp.task_id))
4581
+ if reason:
4582
+ print(f"okstra: lead pane title not applied — {reason}", file=sys.stderr)
4583
+
4584
+
4566
4585
  def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4567
4586
  """Produce a complete okstra task bundle on disk. See module docstring."""
4568
4587
  workspace_root = Path(inp.workspace_root)
@@ -4872,6 +4891,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4872
4891
 
4873
4892
  _record_run_in_central_index(inp, ctx, workspace_root, run_seq_override)
4874
4893
 
4894
+ if terminal_backend == BACKEND_CMUX_PANE:
4895
+ _title_lead_pane(inp)
4896
+
4875
4897
  if not inp.render_only:
4876
4898
  _provision_settings_symlink(inp)
4877
4899
 
@@ -11,8 +11,9 @@ from datetime import datetime
11
11
  from pathlib import Path
12
12
  from typing import Callable
13
13
 
14
+ from .domain.worker_presentation import strip_terminal_colors
15
+
14
16
  OKSTRA = "okstra"
15
- _SPEAKER_WIDTH = 14
16
17
  _RESET = "\x1b[0m"
17
18
  _MUTED = "\x1b[90m"
18
19
  _LIVE_COLORS = (
@@ -50,11 +51,20 @@ class SessionTranscript:
50
51
  path.parent.mkdir(parents=True, exist_ok=True)
51
52
  self._file = path.open("w", encoding="utf-8")
52
53
  self._live = live
54
+ # cmux 워커는 색상 사용을 명시한다. 리드에서 상속한 비대화형 출력
55
+ # 설정이 워커 터미널의 색상까지 끄지 않도록 명시적 요청을 우선한다.
56
+ force_color = os.environ.get("FORCE_COLOR", "")
53
57
  self._color = (
54
58
  live
55
59
  and sys.stdout.isatty()
56
- and not os.environ.get("NO_COLOR")
57
- and os.environ.get("TERM") != "dumb"
60
+ and (
61
+ force_color not in ("", "0")
62
+ or (
63
+ force_color != "0"
64
+ and not os.environ.get("NO_COLOR")
65
+ and os.environ.get("TERM") != "dumb"
66
+ )
67
+ )
58
68
  )
59
69
  self._clock = clock
60
70
  self._archived = 0
@@ -89,9 +99,7 @@ class SessionTranscript:
89
99
  self._keep(self._row(speaker, line), capped=True)
90
100
 
91
101
  def _row(self, speaker: str, line: str) -> str:
92
- # 패딩이 본문 앞 공백을 겸한다. 뒤에 공백을 한 칸 더 넣으면
93
- # `[worker:grok]`(13칸) 줄이 두 칸이 되고 `[okstra]` 정렬이 깨진다.
94
- label = f"[{speaker}]".ljust(_SPEAKER_WIDTH)
102
+ label = f"[{speaker}] "
95
103
  return f"{self._clock()} {label}{line}".rstrip()
96
104
 
97
105
  def _show(self, row: str, line: str) -> None:
@@ -110,7 +118,7 @@ class SessionTranscript:
110
118
  f"{_MUTED}{row[:prefix_size]}{_RESET}"
111
119
  f"{color}{row[prefix_size:]}{_RESET}"
112
120
  )
113
- print(row, flush=True)
121
+ print(row if self._color else strip_terminal_colors(row), flush=True)
114
122
 
115
123
  def _keep(self, row: str, *, capped: bool) -> None:
116
124
  if not capped:
@@ -136,7 +144,7 @@ class SessionTranscript:
136
144
  self._file.close()
137
145
 
138
146
  def _append(self, row: str) -> None:
139
- self._file.write(row + "\n")
147
+ self._file.write(strip_terminal_colors(row) + "\n")
140
148
  self._file.flush()
141
149
 
142
150
  def _note_elision(self) -> None: