Skip to content

Skip the Codex update prompt by starting the TUI with an initial prompt (#110) - #111

Merged
Simon-AI-coding merged 4 commits into
mainfrom
codex-initial-prompt
Aug 26, 2026
Merged

Simon-AI-coding merged 4 commits into
mainfrom
codex-initial-prompt

Conversation

@Simon-AI-coding

Copy link
Copy Markdown
Owner

Closes #110. Part of #67. Harness only — no product code, no version pin.

What changed

journey.CODEX.arguments becomes ("--sandbox", "workspace-write", ACKNOWLEDGE.words). The prompt is positional (codex [OPTIONS] [PROMPT], verified against 0.150.0 --help), so it goes last, after #105's sandbox pin. The measurement behind it is recorded in hand_started's module docstring, beside the other three launch facts, and the decision in docs/acceptance-design.md § Decided at build time (#110), beside TrustGate.

Why a prompt and not a config key

A non-empty PROMPT makes the TUI skip the update prompt outright — tui/src/lib.rs:995-996, byte-identical at 0.149.1 and 0.150.0 (#107 Q6):

let skip_update_prompt = cli.prompt.as_ref().is_some_and(|p| !p.is_empty());

Nothing of the user's is edited, version.json is not written, and there is nothing to revoke afterwards. That is what closes #105's open question — "may the harness arrange the update prompt like TrustGate" — without arranging anything.

The route not taken is recorded as a trap: -c check_for_update_on_startup=false suppresses the prompt and makes the TUI refuse the shared daemon (can_reuse_implicit_local_daemon requires cli_kv_overrides.is_empty(), tui/src/lib.rs:919-921). It would silently destroy the attachment the run exists to measure.

Two consequences, recorded rather than discovered

Both are in the code comment and the design doc; the advisor asked for them here too.

  1. The rollout now exists before roster runs. Codex writes its rollout when the first turn starts, so this lane's ground truth carries a real session_id at the first read instead of "". The pid join underneath is unchanged; the evidence line roster prints changes shape.
  2. The ephemeral title thread moves earlier. At 0.150.0 the first user message spawns a title-generation thread (Probe codex 0.150.0: what changed on the surfaces we ride, and is there a simpler official route #107, recommendation 2), so that phantom row — if it is one — appears during boot rather than during stable name. roster asserts only that this Session has a row, so an extra row does not fail it. Counting the rows stays Build 6/7 — Child-process rule: seen in the roster, never spoken to, never announced #79's observation item, one step earlier than it was.

It is an extra turn, not a replacement: stable name still types ACKNOWLEDGE itself, because it needs a name held across a Stop it drives and stop notice marks the chat immediately before that turn — a Stop crossed at launch would predate the mark and its notice would be unfindable. The boot turn uses no tools, so it cannot raise a permission before approval is there to answer one, and it leaves codex_turn_policy reading the same ground (the sandbox is this lane's pin; the turn_context that step grades is relay's, the last one written).

Verification

Pending — to be added as a comment on this PR: a TUI started this way registers on the shared daemon and version.json is not written by us. What that run cannot prove is stated up front: on 2026-08-27 this machine's version.json read latest_version 0.150.0 against a 0.150.0 CLI, so get_upgrade_version returns None and the prompt is not armed. The skip itself stands on the source at both tags and will be observed for real on the first run after 0.151.0 ships.

Legacy

No citation, checked and empty. Gen 1's Codex path forwarded the user's own argv verbatim (legacy@1d32845:bridge/codex.py:1971-1975) and nowhere reads or writes the update cache; it had no automated real-environment acceptance to hit the gate from. ADR 0010's rule, answered in the negative.

1496 tests pass; ruff clean; swift format lint clean.

🤖 Generated with Claude Code

Simon-AI-coding and others added 3 commits August 27, 2026 10:14
…pt (#110)

`~/.codex/version.json` learns of a release, every hand-started `codex` stops at
`1. Update now / 2. Skip / 3. Skip until next version` **before the Session
registers** — no thread on the daemon, no rollout, indefinitely. One probe that
started two seconds before the check landed booted normally; the three after it
did not. The run that followed was green because Simon had updated the CLI by
hand, and the gate returns at 0.151.0. The open question it left was whether the
harness may arrange that gate the way it arranges `TrustGate`, by writing
`dismissed_version` into a file the user owns.

It may not, and it does not have to. A non-empty `PROMPT` argument makes the TUI
skip the prompt outright — `codex [OPTIONS] [PROMPT]`, decided at
`tui/src/lib.rs:995-996` and byte-identical at 0.149.1 and 0.150.0 (#107 Q6):

    let skip_update_prompt = cli.prompt.as_ref().is_some_and(|p| !p.is_empty());

So the Codex lane launches with the words its first turn would have typed
anyway. Nothing of the user's is edited, `version.json` is not written, and
there is nothing for the run to revoke afterwards — the gate is *skipped*, not
arranged, which is the one place this design takes the opposite decision to
`TrustGate`. The emptiness test is the mechanism, which is why the argument is a
real instruction rather than a placeholder that could quietly shrink to `""`.

**The one route not taken, and why it is written down as a trap.**
`-c check_for_update_on_startup=false` suppresses the prompt *and* makes the TUI
refuse the shared daemon — `can_reuse_implicit_local_daemon` requires
`cli_kv_overrides.is_empty()` (`tui/src/lib.rs:919-921`) — so it would silently
destroy the attachment this run exists to measure. A harness that reaches for
`-c` to solve a boot gate is measuring nothing.

**It is an extra turn, not a replacement, and two steps depend on that.**
`stable name` requires a name held across a Stop it drives itself, and
`stop notice` marks the chat immediately before that turn; a Stop crossed at
launch would predate the mark and its notice would be unfindable. So
`stable name` still types `ACKNOWLEDGE`, and the boot turn asks the Session
nothing the run does not already ask. It uses no tools, so it cannot raise a
permission before `approval` is there to answer one, and it changes nothing
`codex_turn_policy` reads: the sandbox is still the lane's `workspace-write`
pin, and the `turn_context` that step grades is `relay`'s, the last one written.

Two consequences recorded rather than left to be discovered. Codex writes its
rollout when the first *turn* starts, so the rollout now exists before `roster`
runs and this lane's ground truth carries a real `session_id` at the first read
instead of `""`; the pid join underneath is unchanged. And at 0.150.0 the first
user message spawns an ephemeral title-generation thread (#107, recommendation
2), so that phantom row — if it is one — appears during boot rather than during
`stable name`; `roster` asserts only that *this* Session has a row, so an extra
row does not fail it, and counting the rows stays #79's observation item, one
step earlier than it was.

What the verification run can prove is bounded, and the bound is stated in the
design rather than discovered later: on 2026-08-27 this machine's `version.json`
read `latest_version 0.150.0` against a 0.150.0 CLI, so `get_upgrade_version`
returns `None` and the prompt is **not armed**. The run proves that a TUI
started this way registers on the daemon and that `version.json` is not written
by us; the skip itself stands on the source at both tags and will be observed
for real on the first run after 0.151.0 ships.

Legacy: no citation, checked and empty. Gen 1's Codex path forwarded the user's
own argv verbatim (`legacy@1d32845:bridge/codex.py:1971-1975`) and nowhere reads
or writes the update cache — `check_for_update`, `version.json` and the prompt's
own words appear nowhere in that tree. It had no automated real-environment
acceptance to hit the gate from, so there is no prior implementation to port
(ADR 0010's rule, answered in the negative).

Harness only; no product code, and no version pin. 1496 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eview)

Both axes of the Codex review of the change before it found the same P1, and it
was a false green rather than a red. A prompt on the command line starts a turn
**asynchronously**: `session.start()` returns at once and nothing downstream
waited for it. `stable name` then takes `stop notice`'s chat mark and submits
the walk's first turn — so a boot turn still running at that moment puts its own
Stop Notice on the far side of the mark, and `stop notice` passes on the notice
for a turn nobody drove. The two turns carry the same words, so no reader of the
chat could tell them apart either. Neither reviewer had to run it to see it, and
neither would a Session that answered slowly.

`Walk.settle_boot_turn` waits the boot turn out before a word is typed or a mark
is taken, by the same rule `_drive_turn` uses — the agent's own record grew and
then stopped growing, extracted as `_turn_ended` so a boot turn cannot be judged
by a looser rule than the turns it runs ahead of. It is arranged, not graded
(`TrustGate`'s and `arm_switches`' class): it lands in `verdict.observations` as
`boot turn` with its seconds, and a lane still unsettled after twice the turn
figure is **blocked** rather than typed into. Twice, because the wait covers
boot *and* one turn, and boot alone has been measured at the whole figure on
this machine — a `codex` sat in `starting MCP servers` for a full 180s
ground-truth wait on 2026-08-26.

The second finding was the string: the standards axis read
`arguments=("--sandbox", "workspace-write", ACKNOWLEDGE.words)` as three
interchangeable words hiding three rules — must be last, must be non-empty,
starts a turn. So the boot prompt is now its own field on the lane
(`Lane.boot_prompt`), the argv is composed in one place that holds the two
command-line rules (`hand_started.launch_arguments`, which refuses an empty
prompt because emptiness is exactly what the update gate tests), and the third
rule is `settle_boot_turn`'s whole reason to exist. The Claude lane passes
`None` and is launched silent, as it was.

Exercised against fakes before committing: a boot turn that grows and settles is
measured and observed, one that never stops growing blocks the lane with the
record size and the screen tail, and the Claude lane does nothing at all.

1496 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ff (#110 re-review)

The re-review retained its P1, and it was right. The wait added in the commit
before this one settled for nine seconds of an unchanging record — `_drive_turn`'s
rule — and a turn waiting on the model appends nothing either. Silence cannot
tell "finished" from "thinking", so the boot turn could still be called over
while it was running, and the false green the fix existed to prevent survived it.

**Codex says which it is.** Every turn is bracketed in the rollout by
`event_msg`/`task_started` and `event_msg`/`task_complete` — measured 2026-08-27
against six real rollouts on this machine (codex-cli 0.150.0): five with balanced
counts read *over*, and one live session mid-turn read *in flight*, which is the
case the silence rule got wrong. `hand_started.codex_turn_over` counts both and
answers only when every turn that started has finished. `_drive_turn` keeps its
own rule, unchanged: for a graded turn the ambiguity costs a slow reading, and
changing what "over" means for the five turns the walk drives is not this
ticket's to do.

**And the wait now runs before `arm_switches`, which closes the other half.**
Even a perfect end-of-turn reading leaves the product free to publish the boot
Stop *after* the mark, because it observes Stops on its own cadence. With the
outlets still off for the whole boot turn there is no notice to publish: a fresh
engine answers `duty off, message off, voice off` (#60), and Duty coming on
reports only what is still actionable (#80) — which an idle Session is not. Both
sides are needed and both are now there.

The boot prompt and its end-of-turn reading are one value (`hand_started.
BootPrompt`), because a lane that gained the prompt without the reading is
exactly the defect the review found, and a type that cannot express that is
cheaper than a comment asking nobody to.

Exercised before committing: `codex_turn_over` against the six real rollouts and
against seven synthetic ones — no record, session-but-no-turn, a turn in flight,
a turn in flight that has gone quiet, one turn done, a second turn in flight,
both done — plus a half-written trailing line, which it tolerates because it is
reading a file another process is appending to. `settle_boot_turn` measured and
observed a boot turn that completes, blocked the lane on one that does not, and
did nothing at all on the Claude lane.

1496 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Simon-AI-coding

Copy link
Copy Markdown
Owner Author

Verification run — 2026-08-27, on Simon's machine, with his per-run word

One throwaway workspace, one codex --sandbox workspace-write "<ACKNOWLEDGE>" in a pty with the Claude Code environment scrubbed, codex-cli 0.150.0 over the LaunchAgent's 0.149.1 managed daemon. No engine, no bot, no Telegram — this ticket's claim needs none of them. The daemon was joined read-only and never spawned, restarted or booted out (ADR 0012). Trust was granted and revoked as support.TrustGate does it, and config.toml came back byte for byte.

1. It registers on the daemon, read through the product's own discovery.

codex:01a0403a-0b5d-7242-af18-ca696a6af2fb:43261
  name      gvc-110-probe.c45yj3u_ · Reply with the single word READY. Do
  state     running
  workspace /private/var/folders/…/gvc-110-probe.c45yj3u_

The lane's degraded note rode along — "the Codex CLI is '0.150.0' and the running app-server is '0.149.1' — a Session started by this CLI will not join that daemon" — and the join happened anyway. That is #105's measurement seen a second time: the note is a caveat, not a refusal.

2. version.json is untouched. Identical sha256 303d6026…, identical 102 bytes, identical mtime, before and after:

{"latest_version":"0.150.0","last_checked_at":"2026-08-26T21:07:20.526819Z","dismissed_version":null}

3. The boot turn is bracketed exactly as settle_boot_turn assumes. One event_msg/task_started, one event_msg/task_complete, and turn_context reading sandbox 'workspace-write' — the lane's pin, on the boot turn too.

What this run does not prove, stated so nobody claims more. version.json's latest_version is 0.150.0 and the CLI is 0.150.0, so get_upgrade_version returns None and the update prompt was not armed today. The run proves registration and non-interference. The skip itself stands on tui/src/lib.rs:995-996, byte-identical at 0.149.1 and 0.150.0 (#107 Q6), and will be observed for real on the first run after 0.151.0 ships.

Review

Codex lane, both axes, then a re-review, then a fresh lineage on the fix. Both axes found the same P1 on the first commit and it was a false green: a prompt on the command line starts a turn asynchronously, so the boot turn could still be running when stable name takes stop notice's mark, and that step would then pass on the notice for a turn nobody drove — same words in both turns, so nothing downstream could tell them apart.

Fixed from two sides. settle_boot_turn runs before arm_switches, so every outlet is off for the whole boot turn and no notice for it can exist (a fresh engine answers duty off, message off, voice off — #60; Duty coming on reports only what is still actionable — #80). And it waits on Codex's own turn boundary rather than on the record going quiet: my first fix used _drive_turn's nine-seconds-of-silence rule, the re-review refused it, and it was right — a turn waiting on the model appends nothing either. Measured across six real rollouts on this machine: five with balanced task_started/task_complete counts read over, one live mid-turn session read in flight, which is the case silence got wrong.

_drive_turn keeps its own rule, unchanged. For a graded turn that ambiguity costs a slow reading rather than the run's meaning, and changing what "over" means for the five turns the walk drives is a bigger change than this ticket asked for — left for another ticket, deliberately.

Also observed

The run saw the phantom roster row #107's recommendation 2 predicted: a second, pidless, rollout-less thread in the same workspace, which the daemon describes as ephemeral: true, threadSource: "system", name: null — and which outlives the TUI, so the roster keeps a row for a Session that has ended. Nothing is filtered here, per the advisor's ruling; the measurement is written up on #79, which owns it.

@Simon-AI-coding

Copy link
Copy Markdown
Owner Author

Round 3 of the Codex spec review, and what I did with it

The fresh-lineage review of the fix commit returned one P1: the boot Stop can still pollute stop notice. It has two halves and they did not fare the same, so both are recorded here.

The half I accepted — the timing. The rollout's task_complete and the engine's own observation of the Stop are two unsynchronised paths. An engine that observes it after arm_switches escalates it straight out, and stop notice's mark is taken later — so a green there could rest on that message losing a race. Narrow (the status is a push on the daemon connection, not the 5s poll, and it has to beat arm_switches plus the whole roster step plus three name reads), but narrow is not closed, and a green that rests on winning a race is what this harness exists to refuse.

Closed by drain_boot_notice: a mark taken behind the boot turn, waited on until the notice arrives or telegram_round_trip_seconds passes — the same window stop notice trusts for its own. Anything after stable name's later mark is then provably not the boot turn's.

The half that hurt nothing — the retention. The finding is right that a notice with no route open is retained rather than dropped (core/escalation.py:230-232) and that flip_switch(on=True) sweeps the retained ones (core/bridge.py:361-372). But that sweep happens inside arm_switches, which runs between the settle and the drain — so a held boot notice is delivered early, before stable name's mark, not late. The outlets-off half of the arrangement stands as written.

It records and never asserts, and that is not a hedge. A Stop is raised only on a transition out of active; the first idle a thread reports is it sitting there having done nothing (adapters/agent/codex/adapter.py:965-985). So an engine that first saw this thread already idle raised nothing for the boot turn and there is nothing to drain — a legitimate outcome, which is also the one that pays the worst case: one 90s wait per Codex run. Stated in docs/acceptance-design.md beside the boot turn, with that reason.

The single boot turn observation now carries both halves: the turn's seconds, whether its notice was drained and which chat message it was, and what engine.log's Session stopped lines say — the same attribution stop notice makes for its own.

Exercised against fakes, all four paths: notice arrives → drained and named with the log line behind it; notice never comes → recorded as the legitimate case; boot turn never ends → lane still blocked, no mark taken and none spent; Claude lane → nothing at all.

No fourth round, on the advisor's ruling: the drain is the direct answer to a finding the reviewer had already made precisely, in ~25 harness lines that reuse stop notice's own attribution.

…ater one (#110 review)

Third review round, and the half of its P1 that was right: the rollout's
`task_complete` and the engine's own observation of the Stop are two
unsynchronised paths, so an engine that observes it *after* `arm_switches`
escalates it straight out — and `stop notice`, whose mark is taken later, could
then pass on a notice for a turn nobody drove. The commit before this one closed
that by argument (the window is narrow, and the status is a push rather than the
5s poll), and a green that rests on a message losing a race is the thing this
harness exists to refuse. Advisor's ruling, and the right one.

`drain_boot_notice` waits on a mark taken behind the boot turn until the notice
arrives or `telegram_round_trip_seconds` passes — the same window `stop notice`
trusts for its own — so anything after `stable name`'s later mark is provably not
the boot turn's. It **records and never asserts**, because *no notice* is a
legitimate answer: a Stop is raised only on a transition out of `active`, and the
first `idle` a thread reports is it sitting there having done nothing
(`adapters/agent/codex/adapter.py:965-985`), so an engine that first saw this
thread already idle raised nothing to drain. That case pays the worst case — one
90s wait per Codex run — and it is stated in `docs/acceptance-design.md` beside
the boot turn with that reason.

**The other half of the finding hurt nothing, and the reason is worth keeping.**
A notice with no route open is retained rather than dropped
(`core/escalation.py:230-232`) and `flip_switch(on=True)` sweeps the retained
ones (`core/bridge.py:361-372`) — but that sweep happens *inside* `arm_switches`,
which runs between the settle and the drain, so a held boot notice is delivered
early, not late. The outlets-off half of the arrangement stands.

The one `boot turn` observation now carries both halves: the seconds the turn
took, whether its notice was drained and which chat message it was, and what
`engine.log`'s `Session stopped` lines say — the same attribution `stop notice`
makes for its own.

Exercised before committing, all four paths: a notice that arrives is drained and
named with the log line behind it; a notice that never comes is recorded as the
legitimate case it is; a boot turn that never ends still blocks the lane with no
mark taken and none spent; and the Claude lane does nothing at all.

1496 tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Simon-AI-coding
Simon-AI-coding merged commit f4f7d3b into main Aug 26, 2026
4 checks passed
@Simon-AI-coding
Simon-AI-coding deleted the codex-initial-prompt branch August 26, 2026 22:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant