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.
- package/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -1
- package/runtime/python/okstra_ctl/cmux.py +33 -2
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -1
- package/runtime/python/okstra_ctl/run.py +22 -0
- package/runtime/python/okstra_ctl/session_transcript.py +16 -8
package/docs/architecture.md
CHANGED
|
@@ -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`.
|
|
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
package/runtime/BUILD.json
CHANGED
|
@@ -93,7 +93,9 @@ class CodexExecution:
|
|
|
93
93
|
"""
|
|
94
94
|
|
|
95
95
|
def build_command(self, request: WorkerExecRequest) -> ExecCommand:
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
|
|
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:
|