@junghanacs/entwurf 0.20.1 → 0.22.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/AGENTS.md +85 -190
- package/BASELINE.md +6 -3
- package/CHANGELOG.md +473 -14
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +332 -60
- package/README.md +100 -22
- package/VERIFY.md +93 -7
- package/docs/acp-backend-rail.md +0 -1
- package/docs/external-mcp-host.md +64 -33
- package/docs/setup-clean-host.md +151 -17
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
- package/mcp/entwurf-bridge/src/index.ts +154 -116
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
- package/package.json +13 -4
- package/pi-extensions/entwurf-control.ts +71 -19
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
- package/pi-extensions/lib/compaction-send-guard.ts +80 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
- package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
- package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
- package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
- package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
- package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
- package/pi-extensions/lib/meta-sender-identity.ts +305 -0
- package/pi-extensions/lib/mux-fresh-call.ts +233 -29
- package/pi-extensions/lib/native-push/adapter.ts +21 -24
- package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
- package/pi-extensions/lib/native-push/register.ts +7 -9
- package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
- package/run.sh +342 -28
- package/scripts/check-agy-sender-identity.ts +1 -1
- package/scripts/check-codex-app-server-launch.ts +445 -0
- package/scripts/check-codex-birth-hook.ts +264 -0
- package/scripts/check-codex-bridge-identity.ts +179 -0
- package/scripts/check-codex-native-push.ts +386 -0
- package/scripts/check-codex-sender-identity.ts +495 -0
- package/scripts/check-compaction-send-guard.ts +130 -0
- package/scripts/check-copilot-receive-arm.ts +4 -1
- package/scripts/check-entwurf-fact-provider.ts +38 -0
- package/scripts/check-entwurf-peers-surface.ts +13 -1
- package/scripts/check-entwurf-self-address.ts +15 -16
- package/scripts/check-entwurf-v2-contract.ts +4 -3
- package/scripts/check-entwurf-v2-decider.ts +7 -5
- package/scripts/check-entwurf-v2-native-push.ts +35 -7
- package/scripts/check-entwurf-v2-production.ts +245 -12
- package/scripts/check-entwurf-v2-runner.ts +1 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-entwurf-v2-surface.ts +1 -1
- package/scripts/check-gate-qualification.ts +8 -3
- package/scripts/check-harness-admission-parity.ts +0 -1
- package/scripts/check-mux-launch-tmux.ts +345 -4
- package/scripts/check-native-push-adapter.ts +20 -16
- package/scripts/check-native-push-register.ts +5 -1
- package/scripts/check-release-gate-outcomes.ts +47 -1
- package/scripts/check-setup-qualification.sh +3 -1
- package/scripts/codex-app-server-launch.sh +275 -0
- package/scripts/codex-birth-doctor.sh +276 -0
- package/scripts/codex-birth-install.sh +414 -0
- package/scripts/codex-birth-uninstall.sh +170 -0
- package/scripts/codex-mcp-config.py +435 -0
- package/scripts/codex-socket-path.ts +33 -0
- package/scripts/codex-statusline-config.py +434 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +653 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
- package/scripts/lib/launch-receipt-windows.ts +46 -0
- package/scripts/lib/tmux-coordinate-row.ts +58 -0
- package/scripts/meta-bridge-fresh-cut.ts +6 -1
- package/scripts/mutants/codex-app-server-launch.json +157 -0
- package/scripts/mutants/codex-caller-seat.json +336 -0
- package/scripts/mutants/codex-native.json +838 -0
- package/scripts/mutants/compaction-send-guard.json +103 -0
- package/scripts/mutants/entwurf-peers.json +19 -0
- package/scripts/mutants/mux-fresh-call.json +93 -10
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/mutants/v2-surface.json +75 -1
- package/scripts/raw-async-delivery/README.md +2 -1
- package/scripts/raw-codex-measure/README.md +114 -46
- package/scripts/smoke-agy-native-push-live.ts +3 -1
- package/scripts/smoke-codex-birth.sh +347 -0
- package/scripts/smoke-codex-config-state.sh +700 -0
- package/scripts/smoke-codex-fresh-live.ts +1426 -0
- package/scripts/smoke-codex-native-push-live.ts +75 -0
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-setup-verdict.sh +125 -10
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codex-terminal-title-config — stateful `[tui].terminal_title` adapter; stdlib only.
|
|
3
|
+
|
|
4
|
+
One reversible atom in `$CODEX_HOME/config.toml`: ensure the TUI's closed-enum
|
|
5
|
+
TERMINAL TITLE list INCLUDES `thread-id`. That item renders the raw thread UUID
|
|
6
|
+
(`tui/src/bottom_pane/title_setup.rs:79-80` at rust-v0.153.4) into the OSC 0/2
|
|
7
|
+
title, which the multiplexer gives back as `#{pane_title}` — the ONE value a
|
|
8
|
+
`_meta.threadId` can be matched against to find the pane a Codex caller is
|
|
9
|
+
sitting in (#95 lane B). This is a PLACEMENT input only: the title is
|
|
10
|
+
operator-writable and forgeable, so identity and delivery keep the record +
|
|
11
|
+
`_meta` join (AGENTS.md Hard Rule 16).
|
|
12
|
+
|
|
13
|
+
Sibling of `codex-statusline-config.py` and deliberately its twin in shape: the
|
|
14
|
+
atom owns exactly the `thread-id` MEMBERSHIP, never the list. Operator items,
|
|
15
|
+
their order and their bytes survive untouched (a text insert right before the
|
|
16
|
+
closing bracket, not a re-serialization), and the inverse removes exactly the
|
|
17
|
+
snippet that was added.
|
|
18
|
+
|
|
19
|
+
TWO DIFFERENCES FROM THE STATUS-LINE ATOM, both measured, neither cosmetic:
|
|
20
|
+
|
|
21
|
+
1. The seed list is `["activity", "project-name", "thread-id"]`, not the bare
|
|
22
|
+
item. `activity` must lead because the herdr Codex detector keys on the
|
|
23
|
+
spinner tokens and the `[ ! ] Action Required` prefix
|
|
24
|
+
(herdr `src/detect/manifests/codex.toml`); seeding a title without it
|
|
25
|
+
would silently retire that detection on this host.
|
|
26
|
+
2. The item is APPENDED at the END of an existing operator list, where the
|
|
27
|
+
status-line atom prepends. Same reason: a prepend would put `thread-id`
|
|
28
|
+
ahead of `activity` on a host that already configured one.
|
|
29
|
+
|
|
30
|
+
Refuses, with the offending name in the message: a symlinked config (someone
|
|
31
|
+
else's SSOT), malformed TOML, a foreign install-state, a state retargeted at
|
|
32
|
+
another config path, and an explicit contrary operator value by name — a
|
|
33
|
+
`terminal_title` that is not an array of items is the operator's decision, not
|
|
34
|
+
drift to overwrite.
|
|
35
|
+
|
|
36
|
+
The doctor judges EFFECTIVE thread-id from the config alone, independent of the
|
|
37
|
+
state receipt (the operator may set it themselves — that is green, not drift).
|
|
38
|
+
Its subject is the REQUIRED caller-seat axis, so a present item is the only
|
|
39
|
+
green and every other effective token is red; the state never rescues one, it
|
|
40
|
+
only annotates ownership and adds `drift` when our receipt binds the file whose
|
|
41
|
+
item is gone.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
sys.dont_write_bytecode = True
|
|
50
|
+
|
|
51
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
52
|
+
from codex_toml_io import ( # noqa: E402
|
|
53
|
+
append_block,
|
|
54
|
+
array_append_point,
|
|
55
|
+
assert_blast_radius,
|
|
56
|
+
atomic_write,
|
|
57
|
+
atomic_write_state,
|
|
58
|
+
deep_get,
|
|
59
|
+
die,
|
|
60
|
+
find_direct_key,
|
|
61
|
+
find_table_family,
|
|
62
|
+
now,
|
|
63
|
+
parse_toml,
|
|
64
|
+
read_text,
|
|
65
|
+
render_file,
|
|
66
|
+
render_string_array,
|
|
67
|
+
scan_lines,
|
|
68
|
+
span_text,
|
|
69
|
+
span_value,
|
|
70
|
+
splice,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
STATE_SCHEMA_VERSION = 1
|
|
74
|
+
ATOM = "codex-terminal-title"
|
|
75
|
+
TUI_PATH = ("tui",)
|
|
76
|
+
TITLE_KEY = "terminal_title"
|
|
77
|
+
REQUIRED_ITEM = "thread-id"
|
|
78
|
+
# The seeded list when the operator has no `terminal_title` at all. `activity`
|
|
79
|
+
# leads for the herdr detector (see the module docstring); `project-name` is
|
|
80
|
+
# what makes a seated title readable to a human at a glance.
|
|
81
|
+
SEED_ITEMS = ("activity", "project-name", REQUIRED_ITEM)
|
|
82
|
+
STATE_REQUIRED = (
|
|
83
|
+
"managedConfigPath",
|
|
84
|
+
"atom",
|
|
85
|
+
"detectMode",
|
|
86
|
+
"configExistedBefore",
|
|
87
|
+
"tuiTableExisted",
|
|
88
|
+
"terminalTitleExisted",
|
|
89
|
+
"terminalTitlePreimage",
|
|
90
|
+
"postimageSpan",
|
|
91
|
+
"snippet",
|
|
92
|
+
"installedAt",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# detectMode → what the inverse takes back:
|
|
96
|
+
# created-new the whole file (we made it; only if nothing else remains)
|
|
97
|
+
# appended-table the [tui] block we appended (only if it is still only ours)
|
|
98
|
+
# inserted-key the `terminal_title = [...]` key lines we inserted
|
|
99
|
+
# merged-item the exact `, "thread-id"` snippet appended to the list
|
|
100
|
+
#
|
|
101
|
+
# There is deliberately NO `already-present` mode on disk. An operator who wrote
|
|
102
|
+
# `thread-id` themselves is UNOWNED, and recording a receipt over their bytes
|
|
103
|
+
# would claim an edit we never made — the doctor would then answer `owned`, and
|
|
104
|
+
# `drift` (its "repair in place" red) would point at an item that was never
|
|
105
|
+
# ours. It also carried a real inverse bug: with such a receipt parked, a later
|
|
106
|
+
# reinstall after the operator removed the item DOES splice ours in while the
|
|
107
|
+
# stale receipt still says `already-present`, so the inverse reports "nothing to
|
|
108
|
+
# take back" and leaves our bytes in their file for good. Writing nothing at all
|
|
109
|
+
# is what makes that second install a normal `merged-item` with an honest
|
|
110
|
+
# inverse. `[측정 2026-09-16]` the same shape is still live in the
|
|
111
|
+
# `codex-statusline` sibling; widening this repair to it is a separate decision.
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_state(state_path: str) -> dict | None:
|
|
115
|
+
raw = read_text(state_path, "install-state")
|
|
116
|
+
if raw is None:
|
|
117
|
+
return None
|
|
118
|
+
if not raw.strip():
|
|
119
|
+
die(4, f"codex-terminal-title: install-state {state_path} is empty — refusing to guess")
|
|
120
|
+
try:
|
|
121
|
+
state = json.loads(raw)
|
|
122
|
+
except ValueError as error:
|
|
123
|
+
die(4, f"codex-terminal-title: install-state {state_path} is not valid JSON: {error}")
|
|
124
|
+
if not isinstance(state, dict):
|
|
125
|
+
die(4, f"codex-terminal-title: install-state {state_path} top-level must be a JSON object")
|
|
126
|
+
return state
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def checked_state(state_path: str) -> dict:
|
|
130
|
+
state = load_state(state_path)
|
|
131
|
+
if state is None:
|
|
132
|
+
die(2, f"codex-terminal-title: no install-state at {state_path} — nothing to undo")
|
|
133
|
+
if state.get("schemaVersion") != STATE_SCHEMA_VERSION:
|
|
134
|
+
die(4, f"codex-terminal-title: install-state {state_path} is not schemaVersion {STATE_SCHEMA_VERSION} — refusing")
|
|
135
|
+
atom = state.get("atom")
|
|
136
|
+
if atom != ATOM:
|
|
137
|
+
die(4, f"codex-terminal-title: install-state {state_path} belongs to atom {atom!r}, not {ATOM!r} — refusing to touch it")
|
|
138
|
+
if any(key not in state for key in STATE_REQUIRED):
|
|
139
|
+
die(4, f"codex-terminal-title: install-state {state_path} has an unsupported shape — refusing")
|
|
140
|
+
path = state.get("managedConfigPath")
|
|
141
|
+
if not isinstance(path, str) or not os.path.isabs(path):
|
|
142
|
+
die(4, f"codex-terminal-title: install-state {state_path} has no absolute managedConfigPath")
|
|
143
|
+
return state
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def prior_state(state_path: str, config_path: str) -> dict | None:
|
|
147
|
+
state = load_state(state_path)
|
|
148
|
+
if state is None:
|
|
149
|
+
return None
|
|
150
|
+
if state.get("schemaVersion") != STATE_SCHEMA_VERSION or state.get("atom") != ATOM:
|
|
151
|
+
checked_state(state_path) # the honest, named refusal
|
|
152
|
+
if state.get("managedConfigPath") != os.path.abspath(config_path):
|
|
153
|
+
die(
|
|
154
|
+
3,
|
|
155
|
+
f"codex-terminal-title: install-state {state_path} is bound to {state.get('managedConfigPath')}, "
|
|
156
|
+
f"not {os.path.abspath(config_path)} — refusing to retarget an install onto another config",
|
|
157
|
+
)
|
|
158
|
+
return checked_state(state_path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def classify(parsed: dict) -> tuple[str, object]:
|
|
162
|
+
"""`(tui-shape, terminal_title-value)`; tui-shape is `table`, `refuse:<name>`,
|
|
163
|
+
or `not-line-shaped` only in combination with the span search."""
|
|
164
|
+
tui = deep_get(parsed, TUI_PATH)
|
|
165
|
+
if tui is None:
|
|
166
|
+
return "absent", None
|
|
167
|
+
if not isinstance(tui, dict):
|
|
168
|
+
return "refuse:tui", tui
|
|
169
|
+
value = tui.get(TITLE_KEY)
|
|
170
|
+
if value is None:
|
|
171
|
+
return "no-key", None
|
|
172
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
173
|
+
return "refuse:terminal_title", value
|
|
174
|
+
return "has-key", value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _append_item(span: str, config_path: str) -> tuple[str, str]:
|
|
178
|
+
"""`(merged_span, snippet)` — the span with our item appended at the END of
|
|
179
|
+
the operator's list, and the exact bytes that were added.
|
|
180
|
+
|
|
181
|
+
The position and the separator both come from `array_append_point`, not
|
|
182
|
+
from `rfind("]")`: a trailing comment, a `]` inside a string item, and the
|
|
183
|
+
multi-line style whose last item already carries a comma each send a naive
|
|
184
|
+
append to the wrong bytes (the last one emits `,,`). A span whose array
|
|
185
|
+
that helper cannot locate is a SHAPE this writer does not own — a named
|
|
186
|
+
refusal, not an internal error. The caller still re-parses the result and
|
|
187
|
+
compares it to the operator's items plus ours, so the position has an
|
|
188
|
+
independent oracle either way.
|
|
189
|
+
"""
|
|
190
|
+
point = array_append_point(span, span.index("=") + 1)
|
|
191
|
+
if point is None:
|
|
192
|
+
die(
|
|
193
|
+
4,
|
|
194
|
+
f"codex-terminal-title: `tui.{TITLE_KEY}` in {config_path} is a `key = value` line whose array this "
|
|
195
|
+
"writer cannot locate (unterminated, or closed past a comment) — it owns only line-shaped array "
|
|
196
|
+
"values and refuses to guess where the list ends",
|
|
197
|
+
)
|
|
198
|
+
at, tail = point
|
|
199
|
+
# `comma` keeps the operator's trailing-comma style by writing one of our
|
|
200
|
+
# own; the inverse removes this exact string, so the two stay symmetric.
|
|
201
|
+
snippet = {
|
|
202
|
+
"empty": f'"{REQUIRED_ITEM}"',
|
|
203
|
+
"item": f', "{REQUIRED_ITEM}"',
|
|
204
|
+
"comma": f' "{REQUIRED_ITEM}",',
|
|
205
|
+
}[tail]
|
|
206
|
+
return span[:at] + snippet + span[at:], snippet
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def install(config_path: str, state_path: str) -> None:
|
|
210
|
+
if os.path.islink(config_path):
|
|
211
|
+
die(3, f"codex-terminal-title: refusing to adopt {config_path} — symlink to {os.readlink(config_path)} (someone else's SSOT)")
|
|
212
|
+
if os.path.exists(config_path) and not os.path.isfile(config_path):
|
|
213
|
+
die(4, f"codex-terminal-title: refusing: {config_path} exists and is not a regular file")
|
|
214
|
+
|
|
215
|
+
text = read_text(config_path, "config")
|
|
216
|
+
parsed = parse_toml(text if text is not None else "", f"config {config_path}") if text is not None else {}
|
|
217
|
+
shape, value = classify(parsed)
|
|
218
|
+
if shape.startswith("refuse:"):
|
|
219
|
+
name = shape.split(":", 1)[1]
|
|
220
|
+
die(
|
|
221
|
+
3,
|
|
222
|
+
f"codex-terminal-title: refusing: {'.'.join(TUI_PATH)}{'.' + TITLE_KEY if name == TITLE_KEY else ''} in {config_path} "
|
|
223
|
+
f"is explicitly a {type(value).__name__}, not {'an array of terminal-title items' if name == TITLE_KEY else 'a table'}. "
|
|
224
|
+
"That is the operator's decision, not drift — entwurf will not overwrite it by name.",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
lines = (text if text is not None else "").split("\n")
|
|
228
|
+
infos = scan_lines(lines)
|
|
229
|
+
tui_family = find_table_family(lines, infos, TUI_PATH)
|
|
230
|
+
key_span = find_direct_key(lines, infos, tui_family[0], tui_family[1], TITLE_KEY) if tui_family is not None else None
|
|
231
|
+
if shape in ("has-key", "no-key") and tui_family is None:
|
|
232
|
+
die(
|
|
233
|
+
4,
|
|
234
|
+
f"codex-terminal-title: [tui] in {config_path} exists but not as a literal table block "
|
|
235
|
+
"(inline/dotted form) — this writer owns only line-shaped tables and refuses to touch it",
|
|
236
|
+
)
|
|
237
|
+
if shape == "has-key" and key_span is None:
|
|
238
|
+
die(
|
|
239
|
+
4,
|
|
240
|
+
f"codex-terminal-title: `tui.{TITLE_KEY}` in {config_path} exists but not as a literal `key = value` line "
|
|
241
|
+
"(dotted/inline form) — this writer owns only line-shaped keys and refuses to touch it",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
canonical_line = f"{TITLE_KEY} = {render_string_array(SEED_ITEMS)}"
|
|
245
|
+
# Read BEFORE the shape branch: whether a receipt already binds this file is what tells an
|
|
246
|
+
# operator-authored item apart from one of ours, and they get different answers below.
|
|
247
|
+
prior = prior_state(state_path, config_path)
|
|
248
|
+
|
|
249
|
+
if shape == "has-key":
|
|
250
|
+
if REQUIRED_ITEM in value:
|
|
251
|
+
if prior is None:
|
|
252
|
+
# The operator wrote it. Nothing is written ANYWHERE — not the config, not a
|
|
253
|
+
# receipt — so the doctor reads this host as `unowned`, which is what it is.
|
|
254
|
+
sys.stdout.write(f"already-present {os.path.abspath(config_path)}\n")
|
|
255
|
+
return
|
|
256
|
+
# Ours, already installed. The strongest idempotence is to leave both files exactly
|
|
257
|
+
# as they are, carrying the ORIGINAL receipt rather than re-deriving one.
|
|
258
|
+
mode = prior["detectMode"]
|
|
259
|
+
new_lines = lines
|
|
260
|
+
preimage = prior["terminalTitlePreimage"]
|
|
261
|
+
postimage_span = prior["postimageSpan"]
|
|
262
|
+
snippet = prior["snippet"]
|
|
263
|
+
else:
|
|
264
|
+
mode = "merged-item"
|
|
265
|
+
preimage = span_text(lines, key_span[0], key_span[1] + 1)
|
|
266
|
+
new_span, snippet = _append_item(preimage, config_path)
|
|
267
|
+
# The scanner's own receipt: the merged span must parse to exactly
|
|
268
|
+
# the old list with our item APPENDED, or we refuse to splice it.
|
|
269
|
+
merged_value = span_value(new_span, "merged terminal_title").get(TITLE_KEY)
|
|
270
|
+
if merged_value != list(value) + [REQUIRED_ITEM]:
|
|
271
|
+
die(7, "codex-terminal-title: internal refusal: merged terminal_title span does not re-parse to old items + thread-id")
|
|
272
|
+
postimage_span = new_span
|
|
273
|
+
new_lines = splice(lines, key_span[0], key_span[1] + 1, new_span.split("\n"))
|
|
274
|
+
elif shape == "no-key":
|
|
275
|
+
mode = "inserted-key"
|
|
276
|
+
preimage = None
|
|
277
|
+
postimage_span = canonical_line
|
|
278
|
+
snippet = None
|
|
279
|
+
new_lines = lines[: tui_family[0] + 1] + [canonical_line] + lines[tui_family[0] + 1:]
|
|
280
|
+
elif shape == "absent":
|
|
281
|
+
block = [f"[{TUI_PATH[0]}]", canonical_line]
|
|
282
|
+
if text is None:
|
|
283
|
+
mode = "created-new"
|
|
284
|
+
new_lines = list(block)
|
|
285
|
+
else:
|
|
286
|
+
mode = "appended-table"
|
|
287
|
+
new_lines = append_block(lines, block)
|
|
288
|
+
preimage = None
|
|
289
|
+
postimage_span = canonical_line
|
|
290
|
+
snippet = None
|
|
291
|
+
else: # pragma: no cover — classify() exhausted above
|
|
292
|
+
die(7, f"codex-terminal-title: internal: unhandled shape {shape!r}")
|
|
293
|
+
|
|
294
|
+
if prior is None:
|
|
295
|
+
state = {
|
|
296
|
+
"schemaVersion": STATE_SCHEMA_VERSION,
|
|
297
|
+
"managedConfigPath": os.path.abspath(config_path),
|
|
298
|
+
"atom": ATOM,
|
|
299
|
+
"detectMode": mode,
|
|
300
|
+
"configExistedBefore": text is not None,
|
|
301
|
+
"tuiTableExisted": shape in ("has-key", "no-key"),
|
|
302
|
+
"terminalTitleExisted": shape == "has-key",
|
|
303
|
+
# The FIRST preimage, captured once; a reinstall never re-captures.
|
|
304
|
+
"terminalTitlePreimage": preimage,
|
|
305
|
+
"postimageSpan": postimage_span,
|
|
306
|
+
"snippet": snippet,
|
|
307
|
+
"installedAt": now(),
|
|
308
|
+
}
|
|
309
|
+
else:
|
|
310
|
+
state = prior
|
|
311
|
+
|
|
312
|
+
candidate = render_file(new_lines)
|
|
313
|
+
_, after = assert_blast_radius(text, candidate, TUI_PATH + (TITLE_KEY,), f"config {config_path}")
|
|
314
|
+
effective = deep_get(after, TUI_PATH + (TITLE_KEY,))
|
|
315
|
+
if shape == "has-key":
|
|
316
|
+
expected = list(value) if REQUIRED_ITEM in value else list(value) + [REQUIRED_ITEM]
|
|
317
|
+
else:
|
|
318
|
+
expected = list(SEED_ITEMS)
|
|
319
|
+
if effective != expected:
|
|
320
|
+
die(7, "codex-terminal-title: internal refusal: candidate terminal_title is not the operator's items plus thread-id")
|
|
321
|
+
|
|
322
|
+
# The receipt is durable BEFORE the operator's config is ever touched: it
|
|
323
|
+
# already carries the exact preimage/snippet of the bytes about to be
|
|
324
|
+
# replaced (or a prior receipt's, on a re-run). A crash or write failure
|
|
325
|
+
# between here and the config write below leaves the operator's config
|
|
326
|
+
# untouched and the receipt correct — a retry resumes from it instead of
|
|
327
|
+
# mistaking our own canonical postimage for a fresh operator preimage.
|
|
328
|
+
atomic_write_state(state_path, state)
|
|
329
|
+
if candidate != (text if text is not None else ""):
|
|
330
|
+
atomic_write(config_path, candidate, prefix=".codex-tt-")
|
|
331
|
+
sys.stdout.write(f"{mode} {os.path.abspath(config_path)}\n")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _current_key_span(config_path: str, text: str):
|
|
335
|
+
"""Locate the CURRENT terminal_title key span (fresh bytes, never cached)
|
|
336
|
+
and its parsed value; `not-line-shaped` is refused here too."""
|
|
337
|
+
parsed = parse_toml(text, f"config {config_path}")
|
|
338
|
+
shape, value = classify(parsed)
|
|
339
|
+
if shape.startswith("refuse:"):
|
|
340
|
+
die(4, f"codex-terminal-title: {config_path} now carries an explicit contrary value under `tui`/`{TITLE_KEY}` — refusing to guess; fix it yourself")
|
|
341
|
+
lines = text.split("\n")
|
|
342
|
+
infos = scan_lines(lines)
|
|
343
|
+
tui_family = find_table_family(lines, infos, TUI_PATH)
|
|
344
|
+
key_span = find_direct_key(lines, infos, tui_family[0], tui_family[1], TITLE_KEY) if tui_family is not None else None
|
|
345
|
+
return parsed, shape, value, lines, infos, tui_family, key_span
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def uninstall(state_path: str) -> None:
|
|
349
|
+
state = checked_state(state_path)
|
|
350
|
+
config_path = state["managedConfigPath"]
|
|
351
|
+
mode = state["detectMode"]
|
|
352
|
+
if os.path.islink(config_path):
|
|
353
|
+
die(3, f"codex-terminal-title: refusing to uninstall — {config_path} became a symlink (someone else's SSOT)")
|
|
354
|
+
|
|
355
|
+
text = read_text(config_path, "config")
|
|
356
|
+
if text is None:
|
|
357
|
+
os.remove(state_path)
|
|
358
|
+
sys.stdout.write(f"uninstalled {config_path} (config already absent)\n")
|
|
359
|
+
return
|
|
360
|
+
parsed, shape, value, lines, infos, tui_family, key_span = _current_key_span(config_path, text)
|
|
361
|
+
|
|
362
|
+
if key_span is None:
|
|
363
|
+
if shape == "has-key" and REQUIRED_ITEM in value:
|
|
364
|
+
# `tui`/`terminal_title` is gone by NAME but the path still holds
|
|
365
|
+
# thread-id — reshaped into inline/dotted TOML. This writer cannot
|
|
366
|
+
# span-edit that shape; treating it as absent would silently drop
|
|
367
|
+
# the receipt over an item we never actually took back. Refuse and
|
|
368
|
+
# keep the receipt so the operator can repair it.
|
|
369
|
+
die(
|
|
370
|
+
3,
|
|
371
|
+
f"codex-terminal-title: refusing to uninstall — `tui.{TITLE_KEY}` in {config_path} exists but not "
|
|
372
|
+
"as a literal `key = value` line (dotted/inline form) — this writer owns only line-shaped keys "
|
|
373
|
+
"and refuses to guess; the receipt is retained",
|
|
374
|
+
)
|
|
375
|
+
# else: thread-id is genuinely absent — our membership was taken back
|
|
376
|
+
# by hand, or vanished along with the key/table itself.
|
|
377
|
+
os.remove(state_path)
|
|
378
|
+
sys.stdout.write(f"uninstalled {config_path} (terminal_title already absent)\n")
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
current_span = span_text(lines, key_span[0], key_span[1] + 1)
|
|
382
|
+
current_value = value if shape == "has-key" else None
|
|
383
|
+
|
|
384
|
+
if mode == "merged-item":
|
|
385
|
+
if current_span != state["postimageSpan"]:
|
|
386
|
+
if isinstance(current_value, list) and REQUIRED_ITEM not in current_value:
|
|
387
|
+
os.remove(state_path) # operator removed our item themselves
|
|
388
|
+
sys.stdout.write(f"uninstalled {config_path} (thread-id already removed)\n")
|
|
389
|
+
return
|
|
390
|
+
die(
|
|
391
|
+
6,
|
|
392
|
+
f"codex-terminal-title: refusing: `tui.{TITLE_KEY}` in {config_path} changed since install "
|
|
393
|
+
"(operator edits present). Remove 'thread-id' yourself if you want it gone.",
|
|
394
|
+
)
|
|
395
|
+
snippet = state["snippet"]
|
|
396
|
+
if not isinstance(snippet, str) or snippet not in current_span:
|
|
397
|
+
die(7, "codex-terminal-title: internal refusal: recorded snippet is not in the recorded span")
|
|
398
|
+
restored = current_span.replace(snippet, "", 1)
|
|
399
|
+
restored_value = span_value(restored, "restored terminal_title").get(TITLE_KEY)
|
|
400
|
+
preimage = state["terminalTitlePreimage"]
|
|
401
|
+
preimage_value = span_value(preimage, "preimage terminal_title").get(TITLE_KEY) if preimage else None
|
|
402
|
+
if restored_value != preimage_value:
|
|
403
|
+
die(7, "codex-terminal-title: internal refusal: snippet removal does not reproduce the preimage value")
|
|
404
|
+
candidate_lines = splice(lines, key_span[0], key_span[1] + 1, restored.split("\n"))
|
|
405
|
+
elif mode in ("inserted-key", "appended-table", "created-new"):
|
|
406
|
+
if current_span != state["postimageSpan"]:
|
|
407
|
+
if isinstance(current_value, list) and REQUIRED_ITEM not in current_value:
|
|
408
|
+
os.remove(state_path)
|
|
409
|
+
sys.stdout.write(f"uninstalled {config_path} (thread-id already removed)\n")
|
|
410
|
+
return
|
|
411
|
+
die(
|
|
412
|
+
6,
|
|
413
|
+
f"codex-terminal-title: refusing: `tui.{TITLE_KEY}` in {config_path} changed since install "
|
|
414
|
+
"(operator edits present). Remove it yourself if you want it gone.",
|
|
415
|
+
)
|
|
416
|
+
candidate_lines = splice(lines, key_span[0], key_span[1] + 1, [])
|
|
417
|
+
# A [tui] block that WE appended and that now holds nothing else goes
|
|
418
|
+
# away whole; an operator's [tui] header always stays.
|
|
419
|
+
if not state["tuiTableExisted"] and tui_family is not None:
|
|
420
|
+
rescanned = scan_lines(candidate_lines)
|
|
421
|
+
family = find_table_family(candidate_lines, rescanned, TUI_PATH)
|
|
422
|
+
if family is not None:
|
|
423
|
+
family_body = span_text(candidate_lines, family[0] + 1, family[1])
|
|
424
|
+
if not [line for line in family_body.split("\n") if line.strip()]:
|
|
425
|
+
candidate_lines = splice(candidate_lines, family[0], family[1], [])
|
|
426
|
+
else:
|
|
427
|
+
die(4, f"codex-terminal-title: install-state {state_path} has unknown detectMode {mode!r}")
|
|
428
|
+
|
|
429
|
+
candidate = render_file(candidate_lines)
|
|
430
|
+
assert_blast_radius(text, candidate, TUI_PATH + (TITLE_KEY,), f"config {config_path}")
|
|
431
|
+
if state["detectMode"] == "created-new" and state["configExistedBefore"] is False and not [l for l in candidate_lines if l.strip()]:
|
|
432
|
+
os.remove(config_path)
|
|
433
|
+
os.remove(state_path)
|
|
434
|
+
sys.stdout.write(f"uninstalled {config_path} (file removed)\n")
|
|
435
|
+
return
|
|
436
|
+
atomic_write(config_path, candidate, prefix=".codex-tt-")
|
|
437
|
+
os.remove(state_path) # the receipt is deleted LAST
|
|
438
|
+
sys.stdout.write(f"uninstalled {config_path}\n")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def effective_token(config_path: str) -> str:
|
|
442
|
+
"""The EFFECTIVE verdict, judged from the config alone — never the state."""
|
|
443
|
+
if os.path.islink(config_path):
|
|
444
|
+
return "symlink"
|
|
445
|
+
text = read_text(config_path, "config")
|
|
446
|
+
if text is None or not text.strip():
|
|
447
|
+
return "file-absent"
|
|
448
|
+
try:
|
|
449
|
+
parsed = parse_toml(text, f"config {config_path}")
|
|
450
|
+
except SystemExit:
|
|
451
|
+
return "invalid-toml"
|
|
452
|
+
shape, value = classify(parsed)
|
|
453
|
+
if shape in ("absent", "no-key"):
|
|
454
|
+
return "terminal-title-absent"
|
|
455
|
+
if shape.startswith("refuse:"):
|
|
456
|
+
return "malformed-value"
|
|
457
|
+
return "thread-id-present" if REQUIRED_ITEM in value else "thread-id-absent"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def doctor_static(config_path: str, state_path: str) -> None:
|
|
461
|
+
"""One line, two axes: `<effective> <ownership>[ drift]`. The effective
|
|
462
|
+
token is computed without ever reading the receipt; ownership annotates and
|
|
463
|
+
never rescues. GREEN (exit 0) is exactly `thread-id-present`, owned or
|
|
464
|
+
unowned — an unowned file that already carries the item is green, and every
|
|
465
|
+
other effective token is RED because this doctor's subject is the REQUIRED
|
|
466
|
+
caller-seat axis, not our bookkeeping. Ownership still distinguishes the two
|
|
467
|
+
reds an operator repairs differently: `drift` means our receipt binds this
|
|
468
|
+
file and our item is gone (repair in place), while a bare
|
|
469
|
+
`<effective> unowned` means the axis was never established here."""
|
|
470
|
+
effective = effective_token(config_path)
|
|
471
|
+
state = load_state(state_path)
|
|
472
|
+
owned = (
|
|
473
|
+
state is not None
|
|
474
|
+
and state.get("schemaVersion") == STATE_SCHEMA_VERSION
|
|
475
|
+
and state.get("atom") == ATOM
|
|
476
|
+
and state.get("managedConfigPath") == os.path.abspath(config_path)
|
|
477
|
+
)
|
|
478
|
+
ownership = "owned" if owned else "unowned"
|
|
479
|
+
if effective != "thread-id-present":
|
|
480
|
+
sys.stdout.write(f"{effective} {ownership}{' drift' if owned else ''}\n")
|
|
481
|
+
raise SystemExit(1)
|
|
482
|
+
sys.stdout.write(f"{effective} {ownership}\n")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def main(argv: list[str]) -> None:
|
|
486
|
+
if len(argv) < 2:
|
|
487
|
+
die(5, "usage: codex-terminal-title-config.py <install config state|uninstall state|doctor-static config state>")
|
|
488
|
+
verb = argv[1]
|
|
489
|
+
if verb == "install" and len(argv) == 4:
|
|
490
|
+
install(argv[2], argv[3])
|
|
491
|
+
elif verb == "uninstall" and len(argv) == 3:
|
|
492
|
+
uninstall(argv[2])
|
|
493
|
+
elif verb == "doctor-static" and len(argv) == 4:
|
|
494
|
+
doctor_static(argv[2], argv[3])
|
|
495
|
+
else:
|
|
496
|
+
die(5, "usage: codex-terminal-title-config.py <install config state|uninstall state|doctor-static config state>")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
if __name__ == "__main__":
|
|
500
|
+
main(sys.argv)
|