@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.
Files changed (111) hide show
  1. package/AGENTS.md +85 -190
  2. package/BASELINE.md +6 -3
  3. package/CHANGELOG.md +473 -14
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +332 -60
  6. package/README.md +100 -22
  7. package/VERIFY.md +93 -7
  8. package/docs/acp-backend-rail.md +0 -1
  9. package/docs/external-mcp-host.md +64 -33
  10. package/docs/setup-clean-host.md +151 -17
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
  28. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
  29. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  30. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
  31. package/mcp/entwurf-bridge/src/index.ts +154 -116
  32. package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
  33. package/package.json +13 -4
  34. package/pi-extensions/entwurf-control.ts +71 -19
  35. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  36. package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
  37. package/pi-extensions/lib/compaction-send-guard.ts +80 -0
  38. package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
  39. package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
  41. package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
  42. package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
  43. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  44. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  45. package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
  46. package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
  47. package/pi-extensions/lib/meta-sender-identity.ts +305 -0
  48. package/pi-extensions/lib/mux-fresh-call.ts +233 -29
  49. package/pi-extensions/lib/native-push/adapter.ts +21 -24
  50. package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
  51. package/pi-extensions/lib/native-push/register.ts +7 -9
  52. package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
  53. package/run.sh +342 -28
  54. package/scripts/check-agy-sender-identity.ts +1 -1
  55. package/scripts/check-codex-app-server-launch.ts +445 -0
  56. package/scripts/check-codex-birth-hook.ts +264 -0
  57. package/scripts/check-codex-bridge-identity.ts +179 -0
  58. package/scripts/check-codex-native-push.ts +386 -0
  59. package/scripts/check-codex-sender-identity.ts +495 -0
  60. package/scripts/check-compaction-send-guard.ts +130 -0
  61. package/scripts/check-copilot-receive-arm.ts +4 -1
  62. package/scripts/check-entwurf-fact-provider.ts +38 -0
  63. package/scripts/check-entwurf-peers-surface.ts +13 -1
  64. package/scripts/check-entwurf-self-address.ts +15 -16
  65. package/scripts/check-entwurf-v2-contract.ts +4 -3
  66. package/scripts/check-entwurf-v2-decider.ts +7 -5
  67. package/scripts/check-entwurf-v2-native-push.ts +35 -7
  68. package/scripts/check-entwurf-v2-production.ts +245 -12
  69. package/scripts/check-entwurf-v2-runner.ts +1 -1
  70. package/scripts/check-entwurf-v2-send.ts +26 -7
  71. package/scripts/check-entwurf-v2-surface.ts +1 -1
  72. package/scripts/check-gate-qualification.ts +8 -3
  73. package/scripts/check-harness-admission-parity.ts +0 -1
  74. package/scripts/check-mux-launch-tmux.ts +345 -4
  75. package/scripts/check-native-push-adapter.ts +20 -16
  76. package/scripts/check-native-push-register.ts +5 -1
  77. package/scripts/check-release-gate-outcomes.ts +47 -1
  78. package/scripts/check-setup-qualification.sh +3 -1
  79. package/scripts/codex-app-server-launch.sh +275 -0
  80. package/scripts/codex-birth-doctor.sh +276 -0
  81. package/scripts/codex-birth-install.sh +414 -0
  82. package/scripts/codex-birth-uninstall.sh +170 -0
  83. package/scripts/codex-mcp-config.py +435 -0
  84. package/scripts/codex-socket-path.ts +33 -0
  85. package/scripts/codex-statusline-config.py +434 -0
  86. package/scripts/codex-terminal-title-config.py +500 -0
  87. package/scripts/codex_toml_io.py +653 -0
  88. package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
  89. package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
  90. package/scripts/lib/launch-receipt-windows.ts +46 -0
  91. package/scripts/lib/tmux-coordinate-row.ts +58 -0
  92. package/scripts/meta-bridge-fresh-cut.ts +6 -1
  93. package/scripts/mutants/codex-app-server-launch.json +157 -0
  94. package/scripts/mutants/codex-caller-seat.json +336 -0
  95. package/scripts/mutants/codex-native.json +838 -0
  96. package/scripts/mutants/compaction-send-guard.json +103 -0
  97. package/scripts/mutants/entwurf-peers.json +19 -0
  98. package/scripts/mutants/mux-fresh-call.json +93 -10
  99. package/scripts/mutants/omp-fresh.json +6 -4
  100. package/scripts/mutants/release-gate.json +13 -0
  101. package/scripts/mutants/v2-surface.json +75 -1
  102. package/scripts/raw-async-delivery/README.md +2 -1
  103. package/scripts/raw-codex-measure/README.md +114 -46
  104. package/scripts/smoke-agy-native-push-live.ts +3 -1
  105. package/scripts/smoke-codex-birth.sh +347 -0
  106. package/scripts/smoke-codex-config-state.sh +700 -0
  107. package/scripts/smoke-codex-fresh-live.ts +1426 -0
  108. package/scripts/smoke-codex-native-push-live.ts +75 -0
  109. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  110. package/scripts/smoke-setup-verdict.sh +125 -10
  111. 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)